Skip to content

Commit c71b90d

Browse files
create: rebuild the files cache from an archive of the same group
When the local files cache is missing, borg rebuilds it by reading the archive this one continues from the repository. That archive was looked up by matching the series name only: archives = self.manifest.archives.list(match=[self.archive_name], ...) Archive series names are not unique across hosts, so in a repository shared by multiple machines or users this could pick a foreign archive: if host2 backed up its own "home" series after host1, host1 would rebuild its files cache from host2's archive. Almost nothing matches there, so borg reads and chunks everything again - the files cache silently stops working for everyone but the host that happened to write last. The lookup now matches the archive attributes given by the new --group-by option, defaulting to name,host. Valid keys are name, host and user; tags are not usable because a new archive is not known to belong to the tag group of an existing one, and an empty value is rejected because an archive must not continue an arbitrary unrelated archive. The host and user an archive gets stamped with now come from archive_hostname() / archive_username() in helpers, so the metadata written by create and the lookup done by the cache can not drift apart. Note that the local files cache file name is still derived from the series name alone. It lives on the client, so it is per host already, and keeping the name avoids invalidating everybody's files cache.
1 parent 35872ae commit c71b90d

8 files changed

Lines changed: 194 additions & 15 deletions

File tree

src/borg/archive.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from contextlib import contextmanager
1111
from datetime import timedelta
1212
from functools import partial
13-
from getpass import getuser
1413
from io import BytesIO
1514
from itertools import groupby, zip_longest
1615
from collections.abc import Iterator
@@ -32,6 +31,7 @@
3231
from .helpers import BackupSymlinkParentError, BackupPathTraversalError
3332
from .helpers import BackupOSError, BackupPermissionError, BackupFileNotFoundError, BackupIOError, BackupTimeoutError
3433
from .helpers import HardLinkManager
34+
from .helpers import archive_hostname, archive_username
3535
from .helpers import ChunkIteratorFileWrapper, open_item
3636
from .helpers import Error, IntegrityError, set_ec, sig_int
3737
from .platform import uid2user, user2uid, gid2group, group2gid, get_birthtime_ns
@@ -51,7 +51,6 @@
5151
from .manifest import Manifest
5252
from .patterns import PathPrefixPattern, FnmatchPattern, IECommand
5353
from .item import Item, ArchiveItem, ItemDiff
54-
from . import platform
5554
from .platform import acl_get, acl_set, set_flags, get_flags, set_times, swidth
5655
from .repository import Repository, NoManifestError
5756
from .repoobj import RepoObj
@@ -770,8 +769,8 @@ def save(self, name=None, comment=None, timestamp=None, stats=None, additional_m
770769
"item_ptrs": item_ptrs, # see #1473
771770
"command_line": join_cmd(sys.argv),
772771
"cwd": self.cwd,
773-
"hostname": os.environ.get("BORG_HOSTNAME") or platform.get_hostname(),
774-
"username": os.environ.get("BORG_USERNAME") or getuser(),
772+
"hostname": archive_hostname(),
773+
"username": archive_username(),
775774
"time": nominal.isoformat(timespec="microseconds"),
776775
"start": start.isoformat(timespec="microseconds"),
777776
"end": end.isoformat(timespec="microseconds"),

src/borg/archiver/create_cmd.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from ..constants import * # NOQA
1818
from ..helpers import comment_validator, ChunkerParams, FilesystemPathSpec, CompressionSpec
1919
from ..helpers import archivename_validator, DigestAlgos, FilesCacheMode, files_cache_mode_no_ctime
20+
from ..helpers import FilesCacheGroupBySpec
21+
from ..helpers.parseformat import FILES_CACHE_GROUP_BY_KEYS
2022
from ..helpers import octal_int, nonnegative_seconds
2123
from ..helpers import read_input_map
2224
from ..helpers import eval_escapes
@@ -313,7 +315,12 @@ def create_inner(archive, cache, fso):
313315
logger.info('Creating archive "%s" in repository %s' % (args.name, args.location.processed))
314316
if not dry_run:
315317
with Cache(
316-
repository, manifest, progress=args.progress, cache_mode=args.files_cache_mode, archive_name=args.name
318+
repository,
319+
manifest,
320+
progress=args.progress,
321+
cache_mode=args.files_cache_mode,
322+
archive_name=args.name,
323+
archive_group_by=tuple(args.group_by.split(",")),
317324
) as cache:
318325
archive = Archive(
319326
manifest,
@@ -791,6 +798,21 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser):
791798
done by comparing multiple file metadata values with previous values kept in
792799
the files cache.
793800
801+
The files cache is kept locally, one per archive series. If it is missing (e.g. on a
802+
fresh machine or after the local cache was removed), borg rebuilds it by reading the
803+
archive this one continues from the repository. That archive is the newest one having
804+
the same archive attributes as given by ``--group-by``, by default the same series
805+
name and the same host. Matching the series name alone would be wrong in a repository
806+
shared by multiple machines or users, because they may use the same series name for
807+
their own, unrelated data - borg would then rebuild the files cache from a foreign
808+
archive, where almost nothing matches, and read and chunk everything again.
809+
810+
Give ``--group-by name`` if the same series is written by different hosts on purpose
811+
and they see the same files, or add ``user`` if one host backs up the same series as
812+
different users. Beware of grouping by an attribute that is not stable over time: if
813+
e.g. the hostname changes for every backup run (as it might for containers), borg will
814+
never find an archive to rebuild the files cache from.
815+
794816
This comparison can operate in different modes as given by ``--files-cache``:
795817
796818
- ctime,size,inode (default on POSIX systems)
@@ -1221,6 +1243,18 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser):
12211243
help="operate files cache in MODE. default: %s (on Windows: %s, because ctime is "
12221244
"file creation time there)." % (FILES_CACHE_MODE_UI_DEFAULT_POSIX, FILES_CACHE_MODE_UI_DEFAULT_WIN32),
12231245
)
1246+
fs_group.add_argument(
1247+
"--group-by",
1248+
metavar="KEYS",
1249+
dest="group_by",
1250+
action=Highlander,
1251+
type=FilesCacheGroupBySpec,
1252+
default="name,host",
1253+
help="comma-separated list of archive attributes identifying the archives this archive "
1254+
"belongs to; the newest of them is the archive the files cache is rebuilt from, if the "
1255+
"local files cache is missing. valid keys are: {}; default is: "
1256+
"name,host".format(", ".join(FILES_CACHE_GROUP_BY_KEYS)),
1257+
)
12241258
fs_group.add_argument(
12251259
"--files-changed",
12261260
metavar="MODE",

src/borg/cache.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from .constants import CHUNKINDEX_SMALL_FRAGMENT_CAP, CHUNKINDEX_MERGE_ATTEMPTS, CHUNKINDEX_INVALID_SENTINEL
2727
from .hashindex import ChunkIndex, ChunkIndexEntry, ChunkIndexEntryFormat
2828
from .helpers import get_cache_dir
29+
from .helpers import archive_hostname, archive_username
2930
from .helpers import chunkit
3031
from .helpers import hex_to_bin, bin_to_hex, parse_stringified_list
3132
from .helpers import format_file_size, safe_encode
@@ -58,6 +59,27 @@ def files_cache_name(archive_name, files_cache_name="files"):
5859
return files_cache_name + "." + suffix
5960

6061

62+
def archive_group_patterns(archive_name, group_by):
63+
"""
64+
Build the match patterns selecting the archives belonging to the same group as a new archive.
65+
66+
The new archive is named *archive_name* and gets stamped with this host and this user, so the
67+
patterns describe the archives it continues, e.g. ["name:home", "host:myhost"] for the default
68+
grouping. See "borg help match-archives" for the pattern syntax.
69+
"""
70+
patterns = []
71+
for group_by_key in group_by:
72+
if group_by_key == "name":
73+
patterns.append(f"name:{archive_name}")
74+
elif group_by_key == "host":
75+
patterns.append(f"host:{archive_hostname()}")
76+
elif group_by_key == "user":
77+
patterns.append(f"user:{archive_username()}")
78+
else:
79+
raise ValueError(f"invalid group-by key: {group_by_key}")
80+
return patterns
81+
82+
6183
def discover_files_cache_names(path, files_cache_name="files"):
6284
"""
6385
Return a list of all files cache file names in the given directory.
@@ -200,6 +222,7 @@ def __new__(
200222
progress=False,
201223
cache_mode=FILES_CACHE_MODE_DISABLED,
202224
archive_name=None,
225+
archive_group_by=(),
203226
start_backup=None,
204227
):
205228
return AdHocWithFilesCache(
@@ -209,6 +232,7 @@ def __new__(
209232
progress=progress,
210233
cache_mode=cache_mode,
211234
archive_name=archive_name,
235+
archive_group_by=archive_group_by,
212236
start_backup=start_backup,
213237
)
214238

@@ -225,8 +249,9 @@ class FilesCacheMixin:
225249

226250
FILES_CACHE_NAME = "files"
227251

228-
def __init__(self, cache_mode, archive_name=None, start_backup=None):
252+
def __init__(self, cache_mode, archive_name=None, archive_group_by=(), start_backup=None):
229253
self.archive_name = archive_name # ideally a SERIES name
254+
self.archive_group_by = archive_group_by # archive attributes identifying the previous archive
230255
assert not ("c" in cache_mode and "m" in cache_mode)
231256
assert "d" in cache_mode or "c" in cache_mode or "m" in cache_mode
232257
self.cache_mode = cache_mode
@@ -294,9 +319,13 @@ def _build_files_cache(self):
294319

295320
from .archive import Archive
296321

297-
# get the latest archive with the IDENTICAL name, supporting archive series:
322+
# Get the latest archive of the same group, supporting archive series. Matching the name
323+
# alone is not enough in a repository shared by multiple hosts or users, because they may
324+
# use the same series name for their own, unrelated data - we would then build our files
325+
# cache from a foreign archive, which just wastes time as almost nothing would match.
326+
match = archive_group_patterns(self.archive_name, self.archive_group_by)
298327
try:
299-
archives = self.manifest.archives.list(match=[self.archive_name], sort_by=["ts"], last=1)
328+
archives = self.manifest.archives.list(match=match, sort_by=["ts"], last=1)
300329
except PermissionDenied: # maybe repo is in write-only mode?
301330
archives = None
302331
if not archives:
@@ -1203,13 +1232,14 @@ def __init__(
12031232
progress=False,
12041233
cache_mode=FILES_CACHE_MODE_DISABLED,
12051234
archive_name=None,
1235+
archive_group_by=(),
12061236
start_backup=None,
12071237
):
12081238
"""
12091239
:param warn_if_unencrypted: print warning if accessing unknown unencrypted repository
12101240
:param cache_mode: what shall be compared in the file stat infos vs. cached stat infos comparison
12111241
"""
1212-
FilesCacheMixin.__init__(self, cache_mode, archive_name, start_backup)
1242+
FilesCacheMixin.__init__(self, cache_mode, archive_name, archive_group_by, start_backup)
12131243
ChunksMixin.__init__(self)
12141244
assert isinstance(manifest, Manifest)
12151245
self.manifest = manifest

src/borg/helpers/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from .fs import O_, flags_dir, flags_dir_follow, flags_special_follow, flags_special
2929
from .fs import flags_base, flags_normal, flags_normal_follow, flags_noatime
3030
from .fs import HardLinkManager
31-
from .misc import sysinfo, log_multi, consume
31+
from .misc import sysinfo, log_multi, consume, archive_hostname, archive_username
3232
from .misc import ChunkIteratorFileWrapper, open_item, chunkit, iter_separated, ErrorIgnoringTextIOWrapper
3333
from .parseformat import octal_int, bin_to_hex, hex_to_bin, safe_encode, safe_decode
3434
from .parseformat import text_to_json, binary_to_json, remove_surrogates, join_cmd
@@ -39,6 +39,7 @@
3939
FilesystemDirSpec,
4040
SortBySpec,
4141
GroupBySpec,
42+
FilesCacheGroupBySpec,
4243
CompressionSpec,
4344
ChunkerParams,
4445
DigestAlgos,

src/borg/helpers/misc.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import platform # python stdlib import - if this fails, check that cwd != src/borg/
55
import sys
66
from collections import deque
7+
from getpass import getuser
78
from itertools import islice
89

910
from ..logger import create_logger
@@ -15,6 +16,18 @@
1516
from ..constants import ROBJ_FILE_STREAM
1617

1718

19+
def archive_hostname():
20+
"""Return the hostname a new archive is stamped with."""
21+
from ..platform import get_hostname
22+
23+
return os.environ.get("BORG_HOSTNAME") or get_hostname()
24+
25+
26+
def archive_username():
27+
"""Return the username a new archive is stamped with."""
28+
return os.environ.get("BORG_USERNAME") or getuser()
29+
30+
1831
def sysinfo():
1932
show_sysinfo = os.environ.get("BORG_SHOW_SYSINFO", "yes").lower()
2033
if show_sysinfo == "no":

src/borg/helpers/parseformat.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -582,24 +582,35 @@ def SortBySpec(text):
582582
return text.replace("timestamp", "ts").replace("archive", "name")
583583

584584

585-
def GroupBySpec(text):
585+
def GroupBySpec(text, valid_keys=None, allow_ungrouped=True):
586586
"""Validate a comma-separated list of group-by keys. "" and "none" mean: do not group."""
587587
from ..manifest import AI_GROUP_BY_KEYS
588588

589+
valid_keys = AI_GROUP_BY_KEYS if valid_keys is None else valid_keys
589590
if text in ("", "none"):
591+
if not allow_ungrouped:
592+
raise ArgumentTypeError("At least one group-by key is required (valid keys: %s)" % ", ".join(valid_keys))
590593
return "" # idempotency: the normalized value must pass validation again
591594
seen = set()
592595
for group_key in text.split(","):
593-
if group_key not in AI_GROUP_BY_KEYS:
594-
raise ArgumentTypeError(
595-
"Invalid group-by key: %s (valid keys: %s)" % (group_key, ", ".join(AI_GROUP_BY_KEYS))
596-
)
596+
if group_key not in valid_keys:
597+
raise ArgumentTypeError("Invalid group-by key: %s (valid keys: %s)" % (group_key, ", ".join(valid_keys)))
597598
if group_key in seen:
598599
raise ArgumentTypeError("Duplicate group-by key: %s" % group_key)
599600
seen.add(group_key)
600601
return text
601602

602603

604+
# A new archive is not known to belong to the tag group of an existing archive, and it must not
605+
# continue an arbitrary unrelated archive, so this grouping is more restricted than prune's.
606+
FILES_CACHE_GROUP_BY_KEYS = ["name", "host", "user"]
607+
608+
609+
def FilesCacheGroupBySpec(text):
610+
"""Validate the group-by keys usable for finding the archive a new archive continues."""
611+
return GroupBySpec(text, valid_keys=FILES_CACHE_GROUP_BY_KEYS, allow_ungrouped=False)
612+
613+
603614
SIZE_UNITS = ("si", "iec", "raw")
604615

605616
_warned_units: set[str] = set() # invalid BORG_UNITS values already complained about

src/borg/testsuite/archiver/create_cmd_test.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import socket
88
import stat
99
import subprocess
10+
from pathlib import Path
1011

1112
import pytest
1213
from blake3 import blake3
@@ -2035,3 +2036,62 @@ def no_close_time_write(self, now, force=False, clear=False):
20352036
with changedir("output"):
20362037
cmd(archiver, "extract", "test")
20372038
assert_dirs_equal("input", "output/input")
2039+
2040+
2041+
def _remove_files_cache(archiver, archive_name):
2042+
"""Remove the local files cache of an archive series, forcing a rebuild from the repository."""
2043+
from ...cache import files_cache_name
2044+
from ...helpers import get_cache_dir
2045+
2046+
repo_id = json.loads(cmd(archiver, "repo-info", "--json"))["repository"]["id"]
2047+
cache_file = Path(get_cache_dir(repo_id, create=False)) / files_cache_name(archive_name)
2048+
cache_file.unlink()
2049+
2050+
2051+
def test_files_cache_rebuild_ignores_other_hosts(archivers, request, monkeypatch):
2052+
"""The files cache must be rebuilt from an archive of the same host, not from a foreign one."""
2053+
archiver = request.getfixturevalue(archivers)
2054+
create_regular_file(archiver.input_path, "file1", size=1024 * 80)
2055+
cmd(archiver, "repo-create", RK_ENCRYPTION)
2056+
2057+
# host1 backs up its "home" series ...
2058+
monkeypatch.setenv("BORG_HOSTNAME", "host1")
2059+
cmd(archiver, "create", "home", "input")
2060+
host1_id = cmd(archiver, "repo-list", "--format={id}{NL}").strip()
2061+
2062+
# ... and afterwards host2 backs up its own, unrelated "home" series into the same repository,
2063+
# so the newest archive named "home" is not host1's any more.
2064+
monkeypatch.setenv("BORG_HOSTNAME", "host2")
2065+
cmd(archiver, "create", "home", "input")
2066+
2067+
# host1 lost its local files cache and has to rebuild it from the repository.
2068+
monkeypatch.setenv("BORG_HOSTNAME", "host1")
2069+
_remove_files_cache(archiver, "home")
2070+
output = cmd(archiver, "create", "--debug", "home", "input")
2071+
assert "Building files cache from" in output
2072+
assert host1_id in output # host2's archive would be useless here
2073+
2074+
2075+
def test_files_cache_rebuild_group_by_name_only(archivers, request, monkeypatch):
2076+
"""--group-by name restores the previous behaviour of matching the series name only."""
2077+
archiver = request.getfixturevalue(archivers)
2078+
create_regular_file(archiver.input_path, "file1", size=1024 * 80)
2079+
cmd(archiver, "repo-create", RK_ENCRYPTION)
2080+
2081+
monkeypatch.setenv("BORG_HOSTNAME", "host1")
2082+
cmd(archiver, "create", "home", "input")
2083+
monkeypatch.setenv("BORG_HOSTNAME", "host2")
2084+
cmd(archiver, "create", "home", "input")
2085+
host2_id = cmd(archiver, "repo-list", "--format={id}{NL}", "--last", "1").strip()
2086+
2087+
monkeypatch.setenv("BORG_HOSTNAME", "host1")
2088+
_remove_files_cache(archiver, "home")
2089+
output = cmd(archiver, "create", "--debug", "--group-by", "name", "home", "input")
2090+
assert host2_id in output # the newest archive of the series, whatever host made it
2091+
2092+
2093+
def test_files_cache_rebuild_group_by_invalid(archivers, request):
2094+
archiver = request.getfixturevalue(archivers)
2095+
cmd(archiver, "repo-create", RK_ENCRYPTION)
2096+
output = cmd(archiver, "create", "--group-by", "", "home", "input", exit_code=2)
2097+
assert "At least one group-by key is required" in output

src/borg/testsuite/cache_test.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,3 +678,34 @@ def test_files_cache_save_tolerates_missing_chunk(tmp_path, monkeypatch):
678678
finally:
679679
cache.close()
680680
repository.flush()
681+
682+
683+
def test_archive_group_patterns(monkeypatch):
684+
from ..cache import archive_group_patterns
685+
686+
monkeypatch.setenv("BORG_HOSTNAME", "myhost")
687+
monkeypatch.setenv("BORG_USERNAME", "myuser")
688+
assert archive_group_patterns("home", ()) == []
689+
assert archive_group_patterns("home", ("name",)) == ["name:home"]
690+
assert archive_group_patterns("home", ("name", "host")) == ["name:home", "host:myhost"]
691+
assert archive_group_patterns("home", ("name", "host", "user")) == ["name:home", "host:myhost", "user:myuser"]
692+
with pytest.raises(ValueError, match="invalid group-by key: tags"):
693+
archive_group_patterns("home", ("tags",))
694+
695+
696+
def test_files_cache_group_by_spec():
697+
from argparse import ArgumentTypeError
698+
699+
from ..helpers import FilesCacheGroupBySpec
700+
701+
assert FilesCacheGroupBySpec("name,host") == "name,host"
702+
assert FilesCacheGroupBySpec("name") == "name"
703+
# the parsed value is fed through the spec again by the argument parser, so it must be stable:
704+
assert FilesCacheGroupBySpec(FilesCacheGroupBySpec("name,host")) == FilesCacheGroupBySpec("name,host")
705+
# tags are not usable here: a new archive is not known to belong to the tag group of an existing one.
706+
with pytest.raises(ArgumentTypeError, match="Invalid group-by key: tags"):
707+
FilesCacheGroupBySpec("name,tags")
708+
# a new archive must not continue an arbitrary unrelated archive:
709+
for text in ("", "none"):
710+
with pytest.raises(ArgumentTypeError, match="At least one group-by key is required"):
711+
FilesCacheGroupBySpec(text)

0 commit comments

Comments
 (0)