Fix to snapshot both parent and child volumes/filesystems instead of just the parent (#1156).

This commit is contained in:
Dan Helfman
2025-10-04 14:35:42 -07:00
parent 498d662b3d
commit 173ba00caa
5 changed files with 180 additions and 31 deletions
+3
View File
@@ -8,6 +8,9 @@
* #1151: Fix snapshotting in the ZFS, Btrfs, and LVM hooks to play nicely with the Borg 1.4+
"slashdot" hack within source directory paths.
* #1152: Fix a regression in the Loki monitoring hook in which log messages weren't sending.
* #1156: Fix snapshotting in the ZFS, Btrfs, and LVM hooks to snapshot both parent and child
volumes/filesystems instead of just the parent. As part of this fix, borgmatic no longer
deduplicates patterns except for those containing the borgmatic runtime directory.
* Fix a traceback (TypeError) regression in the "spot" check when the "local_path" option isn't
set.
+2
View File
@@ -54,6 +54,7 @@ def run_create(
pattern.collect_patterns(config),
config,
working_directory,
borgmatic_runtime_directory,
)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
@@ -80,6 +81,7 @@ def run_create(
patterns,
config,
working_directory,
borgmatic_runtime_directory,
skip_expand_paths=config_paths,
)
stream_processes = [process for processes in active_dumps.values() for process in processes]
+39 -16
View File
@@ -227,23 +227,37 @@ def device_map_patterns(patterns, working_directory=None):
)
def deduplicate_patterns(patterns, config):
def deduplicate_runtime_directory_patterns(patterns, config, borgmatic_runtime_directory=None):
'''
Given a sequence of borgmatic.borg.pattern.Pattern instances and a configuration dict, return
them with all duplicate root child patterns removed. For instance, if two root patterns are
given with paths "/foo" and "/foo/bar", return just the one with "/foo". Non-root patterns are
passed through without modification.
Given a sequence of borgmatic.borg.pattern.Pattern instances, the borgmatic runtime directory,
and a configuration dict, return them without any duplicate root child patterns that contain the
runtime directory. For instance, if two root patterns are given with paths "/foo" and
"/foo/bar", and the runtime directory is "/foo/bar", return just the "/foo" pattern. Non-root
patterns and patterns not containing the runtime directory are passed through without
modification.
The one exception to deduplication is if two paths are on different filesystems (devices) and
One exception to deduplication is if two paths are on different filesystems (devices) and
"one_file_system" is True in the given configuration. In that case, the paths won't get
deduplicated, because Borg won't cross filesystem boundaries when "one_file_system" is True.
The idea is that if Borg is given a root parent pattern, then it doesn't also need to be given
child patterns, because it will naturally spider the contents of the parent pattern's path. And
there are cases where Borg coming across the same file twice will result in duplicate reads and
even hangs, e.g. when a database hook is using a named pipe for streaming database dumps to
Borg.
The idea is that if Borg is given a root parent pattern containing the borgmatic runtime
directory, then Borg doesn't also need to be given child patterns, because it will naturally
spider the contents of the parent pattern's path. Additionally, there are cases where Borg
coming across the same file twice will result in duplicate reads and even hangs, e.g. when a
database hook in the borgmatic runtime directory is using a named pipe for streaming database
dumps to Borg.
This deduplication is limited to the borgmatic runtime directory (where borgmatic's named pipes
exist), because there are other legitimate use cases for parent and child patterns to both exist
in patterns. For instance, with some snapshotted filesystems, snapshots don't traverse from a
parent filesystem to a child and therefore both need to remain in patterns.
And for the case of named pipes outside of the borgmatic runtime directory, there is code
elsewhere (in the "create" action) that auto-excludes special files to prevent Borg hangs.
'''
if borgmatic_runtime_directory is None:
return patterns
deduplicated = {} # Use just the keys as an ordered set.
for pattern in patterns:
@@ -262,6 +276,8 @@ def deduplicate_patterns(patterns, config):
if any(
pathlib.PurePath(other_pattern.path) == parent
and pathlib.PurePosixPath(other_pattern.path)
in pathlib.PurePath(borgmatic_runtime_directory).parents
and pattern.device is not None
and (
other_pattern.device == pattern.device
@@ -276,16 +292,22 @@ def deduplicate_patterns(patterns, config):
return tuple(deduplicated.keys())
def process_patterns(patterns, config, working_directory, skip_expand_paths=None):
def process_patterns(
patterns, config, working_directory, borgmatic_runtime_directory=None, skip_expand_paths=None
):
'''
Given a sequence of Borg patterns, a configuration dict, a configured working directory, and a
sequence of paths to skip path expansion for, expand and deduplicate any "root" patterns,
returning the resulting root and non-root patterns as a list.
Given a sequence of Borg patterns, a configuration dict, a configured working directory, the
borgmatic runtime directory, and a sequence of paths to skip path expansion for, expand and
deduplicate any "root" patterns, returning the resulting root and non-root patterns as a list.
If the borgmatic runtime directory is None, then don't deduplicate patterns. Deduplication is
really only necessary for the "create" action when the runtime directory might contain named
pipes for database dumps.
'''
skip_paths = set(skip_expand_paths or ())
return list(
deduplicate_patterns(
deduplicate_runtime_directory_patterns(
device_map_patterns(
expand_patterns(
patterns,
@@ -294,5 +316,6 @@ def process_patterns(patterns, config, working_directory, skip_expand_paths=None
),
),
config,
borgmatic_runtime_directory,
),
)
+1 -1
View File
@@ -62,7 +62,7 @@ def validate_planned_backup_paths(
excluded.
Raise ValueError if the runtime directory has been excluded via "exclude_patterns" or similar,
because any features that rely on the runtime directory getting backed up will break. For
because any features that rely on the runtime directory getting backed up will break. For
instance, without the runtime directory, Borg can't consume any database dumps and borgmatic may
hang waiting for them to be consumed.
'''
+135 -14
View File
@@ -375,43 +375,97 @@ def test_device_map_patterns_with_existing_device_id_does_not_overwrite_it():
@pytest.mark.parametrize(
'patterns,expected_patterns,one_file_system',
'patterns,borgmatic_runtime_directory,expected_patterns,one_file_system',
(
((Pattern('/', device=1), Pattern('/root', device=1)), (Pattern('/', device=1),), False),
((Pattern('/', device=1), Pattern('/root/', device=1)), (Pattern('/', device=1),), False),
(
(Pattern('/', device=1), Pattern('/root', device=2)),
(Pattern('/', device=1), Pattern('/root', device=1)),
'/root',
(Pattern('/', device=1),),
False,
),
((Pattern('/root', device=1), Pattern('/', device=1)), (Pattern('/', device=1),), False),
# No deduplication is expected when borgmatic runtime directory is None.
(
(Pattern('/', device=1), Pattern('/root', device=1)),
None,
(Pattern('/', device=1), Pattern('/root', device=1)),
False,
),
(
(Pattern('/', device=1), Pattern('/root/', device=1)),
'/root',
(Pattern('/', device=1),),
False,
),
(
(Pattern('/', device=1), Pattern('/root', device=2)),
'/root',
(Pattern('/', device=1),),
False,
),
(
(Pattern('/', device=1), Pattern('/root', device=2)),
None,
(Pattern('/', device=1), Pattern('/root', device=2)),
False,
),
(
(Pattern('/root', device=1), Pattern('/', device=1)),
'/root',
(Pattern('/', device=1),),
False,
),
(
(Pattern('/root', device=1), Pattern('/', device=1)),
None,
(Pattern('/root', device=1), Pattern('/', device=1)),
False,
),
(
(Pattern('/root', device=1), Pattern('/root/foo', device=1)),
'/root/foo',
(Pattern('/root', device=1),),
False,
),
(
(Pattern('/root', device=1), Pattern('/root/foo', device=1)),
None,
(Pattern('/root', device=1), Pattern('/root/foo', device=1)),
False,
),
# No deduplication is expected when the runtime directory doesn't match the patterns.
(
(Pattern('/root', device=1), Pattern('/root/foo', device=1)),
'/other',
(Pattern('/root', device=1), Pattern('/root/foo', device=1)),
False,
),
(
(Pattern('/root/', device=1), Pattern('/root/foo', device=1)),
'/root/foo',
(Pattern('/root/', device=1),),
False,
),
(
(Pattern('/root', device=1), Pattern('/root/foo/', device=1)),
'/root/foo',
(Pattern('/root', device=1),),
False,
),
(
(Pattern('/root', device=1), Pattern('/root/foo', device=2)),
'/root/foo',
(Pattern('/root', device=1),),
False,
),
(
(Pattern('/root/foo', device=1), Pattern('/root', device=1)),
'/root/foo',
(Pattern('/root', device=1),),
False,
),
(
(Pattern('/root', device=None), Pattern('/root/foo', device=None)),
'/root/foo',
(Pattern('/root'), Pattern('/root/foo')),
False,
),
@@ -421,6 +475,7 @@ def test_device_map_patterns_with_existing_device_id_does_not_overwrite_it():
Pattern('/etc', device=1),
Pattern('/root/foo/bar', device=1),
),
'/root/foo/bar',
(Pattern('/root', device=1), Pattern('/etc', device=1)),
False,
),
@@ -430,70 +485,126 @@ def test_device_map_patterns_with_existing_device_id_does_not_overwrite_it():
Pattern('/root/foo', device=1),
Pattern('/root/foo/bar', device=1),
),
'/root/foo/bar',
(Pattern('/root', device=1),),
False,
),
(
(
Pattern('/root', device=1),
Pattern('/root/foo', device=1),
Pattern('/root/foo/bar', device=1),
),
None,
(
Pattern('/root', device=1),
Pattern('/root/foo', device=1),
Pattern('/root/foo/bar', device=1),
),
False,
),
(
(
Pattern('/root', device=1),
Pattern('/root/foo', device=1),
Pattern('/root/foo/bar', device=1),
),
'/other',
(
Pattern('/root', device=1),
Pattern('/root/foo', device=1),
Pattern('/root/foo/bar', device=1),
),
False,
),
(
(Pattern('/dup', device=1), Pattern('/dup', device=1)),
'/dup',
(Pattern('/dup', device=1),),
False,
),
(
(Pattern('/foo', device=1), Pattern('/bar', device=1)),
'/bar',
(Pattern('/foo', device=1), Pattern('/bar', device=1)),
False,
),
(
(Pattern('/foo', device=1), Pattern('/bar', device=2)),
'/bar',
(Pattern('/foo', device=1), Pattern('/bar', device=2)),
False,
),
((Pattern('/root/foo', device=1),), (Pattern('/root/foo', device=1),), False),
((Pattern('/root/foo', device=1),), '/root/foo', (Pattern('/root/foo', device=1),), False),
(
(Pattern('/', device=1), Pattern('/root', Pattern_type.INCLUDE, device=1)),
'/root',
(Pattern('/', device=1), Pattern('/root', Pattern_type.INCLUDE, device=1)),
False,
),
(
(Pattern('/root', Pattern_type.INCLUDE, device=1), Pattern('/', device=1)),
'/root',
(Pattern('/root', Pattern_type.INCLUDE, device=1), Pattern('/', device=1)),
False,
),
((Pattern('/', device=1), Pattern('/root', device=1)), (Pattern('/', device=1),), True),
((Pattern('/', device=1), Pattern('/root/', device=1)), (Pattern('/', device=1),), True),
(
(Pattern('/', device=1), Pattern('/root', device=1)),
'/root',
(Pattern('/', device=1),),
True,
),
(
(Pattern('/', device=1), Pattern('/root/', device=1)),
'/root',
(Pattern('/', device=1),),
True,
),
(
(Pattern('/', device=1), Pattern('/root', device=2)),
'/root',
(Pattern('/', device=1), Pattern('/root', device=2)),
True,
),
((Pattern('/root', device=1), Pattern('/', device=1)), (Pattern('/', device=1),), True),
(
(Pattern('/root', device=1), Pattern('/', device=1)),
'/root',
(Pattern('/', device=1),),
True,
),
(
(Pattern('/root', device=1), Pattern('/root/foo', device=1)),
'/root/foo',
(Pattern('/root', device=1),),
True,
),
(
(Pattern('/root/', device=1), Pattern('/root/foo', device=1)),
'/root/foo',
(Pattern('/root/', device=1),),
True,
),
(
(Pattern('/root', device=1), Pattern('/root/foo/', device=1)),
'/root/foo',
(Pattern('/root', device=1),),
True,
),
(
(Pattern('/root', device=1), Pattern('/root/foo', device=2)),
'/root/foo',
(Pattern('/root', device=1), Pattern('/root/foo', device=2)),
True,
),
(
(Pattern('/root/foo', device=1), Pattern('/root', device=1)),
'/root/foo',
(Pattern('/root', device=1),),
True,
),
(
(Pattern('/root', device=None), Pattern('/root/foo', device=None)),
'/root/foo',
(Pattern('/root'), Pattern('/root/foo')),
True,
),
@@ -503,6 +614,7 @@ def test_device_map_patterns_with_existing_device_id_does_not_overwrite_it():
Pattern('/etc', device=1),
Pattern('/root/foo/bar', device=1),
),
'/root/foo/bar',
(Pattern('/root', device=1), Pattern('/etc', device=1)),
True,
),
@@ -512,50 +624,59 @@ def test_device_map_patterns_with_existing_device_id_does_not_overwrite_it():
Pattern('/root/foo', device=1),
Pattern('/root/foo/bar', device=1),
),
'/root/foo/bar',
(Pattern('/root', device=1),),
True,
),
(
(Pattern('/dup', device=1), Pattern('/dup', device=1)),
'/dup',
(Pattern('/dup', device=1),),
True,
),
(
(Pattern('/foo', device=1), Pattern('/bar', device=1)),
'/bar',
(Pattern('/foo', device=1), Pattern('/bar', device=1)),
True,
),
(
(Pattern('/foo', device=1), Pattern('/bar', device=2)),
'/bar',
(Pattern('/foo', device=1), Pattern('/bar', device=2)),
True,
),
((Pattern('/root/foo', device=1),), (Pattern('/root/foo', device=1),), True),
((Pattern('/root/foo', device=1),), '/root/foo', (Pattern('/root/foo', device=1),), True),
(
(Pattern('/', device=1), Pattern('/root', Pattern_type.INCLUDE, device=1)),
'/root',
(Pattern('/', device=1), Pattern('/root', Pattern_type.INCLUDE, device=1)),
True,
),
(
(Pattern('/root', Pattern_type.INCLUDE, device=1), Pattern('/', device=1)),
'/root',
(Pattern('/root', Pattern_type.INCLUDE, device=1), Pattern('/', device=1)),
True,
),
),
)
def test_deduplicate_patterns_omits_child_paths_based_on_device_and_one_file_system(
def test_deduplicate_runtime_directory_patterns_omits_child_paths_based_on_device_and_one_file_system(
patterns,
borgmatic_runtime_directory,
expected_patterns,
one_file_system,
):
assert (
module.deduplicate_patterns(patterns, {'one_file_system': one_file_system})
module.deduplicate_runtime_directory_patterns(
patterns, {'one_file_system': one_file_system}, borgmatic_runtime_directory
)
== expected_patterns
)
def test_process_patterns_includes_patterns():
flexmock(module).should_receive('deduplicate_patterns').and_return(
flexmock(module).should_receive('deduplicate_runtime_directory_patterns').and_return(
(Pattern('foo'), Pattern('bar')),
)
flexmock(module).should_receive('device_map_patterns').and_return({})
@@ -574,7 +695,7 @@ def test_process_patterns_includes_patterns():
def test_process_patterns_skips_expand_for_requested_paths():
skip_paths = {flexmock()}
flexmock(module).should_receive('deduplicate_patterns').and_return(
flexmock(module).should_receive('deduplicate_runtime_directory_patterns').and_return(
(Pattern('foo'), Pattern('bar')),
)
flexmock(module).should_receive('device_map_patterns').and_return({})