Skip to content

create: rebuild the files cache from an archive of the same group - #10292

Open
ThomasWaldmann wants to merge 2 commits into
borgbackup:masterfrom
ThomasWaldmann:create-files-cache-group-by
Open

create: rebuild the files cache from an archive of the same group#10292
ThomasWaldmann wants to merge 2 commits into
borgbackup:masterfrom
ThomasWaldmann:create-files-cache-group-by

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Aug 31, 2026

Copy link
Copy Markdown
Member

Rebased onto current master now that #10291 has merged, so this is a single commit again.

The bug

When the local files cache is missing — a fresh machine, a cleared cache dir — borg rebuilds it by
reading the archive this one continues from the repository. That archive was looked up by matching
the series name only:

# get the latest archive with the IDENTICAL name, supporting archive series:
archives = self.manifest.archives.list(match=[self.archive_name], sort_by=["ts"], last=1)

Archive series names are not unique across hosts. In a repository shared by several machines or
users, this picks whichever archive of that name was written last, no matter by whom: if host2 backs
up its own home series after host1, host1 rebuilds its files cache from host2's archive.
Almost nothing matches there, so borg reads and chunks everything again. Nothing errors — the files
cache just silently stops doing its job for every host except the one that happened to write last.

Same root cause as #10288 and #10291: the series name alone is not an identity in a shared
repository.

The change

The lookup now matches the archive attributes given by a new create --group-by option, default
name,host — the same default prune --group-by uses, so both commands agree on what an archive's
group is:

match = archive_group_patterns(self.archive_name, self.archive_group_by)
archives = self.manifest.archives.list(match=match, sort_by=["ts"], last=1)

Valid keys are name, host and user — a deliberately smaller set than prune's:

  • tags is excluded because a new archive is not known to belong to the tag group of an existing
    one, and -a tags: is a superset match rather than equality, so it would not express a group.
  • the empty value is rejected: for prune, "one group" is meaningful; here it would mean "continue an
    arbitrary unrelated archive", which is the bug generalized. --group-by name gives the old
    behaviour if someone wants it (e.g. several hosts deliberately backing up the same files under one
    series name).

--group-by name,host,user covers one host backing up the same series as different users.

Keeping the metadata and the lookup in sync

The host / user an archive is stamped with came from an expression open-coded in archive.py; the
cache now needs the same values, and a lookup that disagreed with what create writes would silently
never match. Both now go through archive_hostname() / archive_username() in helpers/misc.py,
so they cannot drift apart. This also honours BORG_HOSTNAME / BORG_USERNAME consistently on both
sides.

Unrelated but noticed while doing this: {user} as an archive-name placeholder uses
platform.getosusername() (uid → name) while the archive's username metadata uses
getpass.getuser() (env-based). These can disagree, e.g. under sudo. Left alone here since changing
it would change generated archive names, but it may be worth a look.

Compatibility

The local files cache file name is still derived from the series name alone. It lives on the client
and is therefore per host already, so it needs no host in its name — and keeping it avoids
invalidating everybody's files cache on upgrade.

Behaviour change: on a host whose hostname is not stable (containers with a random hostname each
run), the rebuild will now find no archive and start from an empty files cache instead of rebuilding
from a foreign one. That is the correct outcome — the foreign rebuild was near-useless work — but it
is worth knowing. The epilog warns about it, and this only affects the path where the local files
cache is missing.

Tests

test_files_cache_rebuild_ignores_other_hosts is the repro: host1 backs up home, then host2 backs
up its own home, then host1 loses its local files cache and backs up again. It asserts that the
debug log names host1's archive as the rebuild source. Verified that it fails without the fix
(both archiver and remote_archiver) and passes with it.

Also: test_files_cache_rebuild_group_by_name_only (opting back into the old behaviour),
test_files_cache_rebuild_group_by_invalid, and unit tests for archive_group_patterns and
FilesCacheGroupBySpec (rejected tags, rejected empty, idempotency).

Full test suite: 2926 passed, 980 skipped. ruff check clean.

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.87234% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.64%. Comparing base (7004e87) to head (48ab3e6).
⚠️ Report is 3 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/borg/cache.py 96.29% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10292      +/-   ##
==========================================
+ Coverage   87.55%   87.64%   +0.09%     
==========================================
  Files         103      103              
  Lines       18676    18702      +26     
  Branches     2872     2877       +5     
==========================================
+ Hits        16352    16392      +40     
+ Misses       1622     1609      -13     
+ Partials      702      701       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann

Copy link
Copy Markdown
Member Author

Rebased onto the updated #10291 (defaultdict + prune --group-by now also defaults to name,host, see the discussion there). Nothing in this commit changed; the two commands now agree on what an archive's group is.

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.
@ThomasWaldmann
ThomasWaldmann force-pushed the create-files-cache-group-by branch from 6fa4d5c to c71b90d Compare August 31, 2026 17:44
_build_files_cache read item.ctime unconditionally, but an archive item does not
necessarily have a ctime:

- borg create --noctime omits it,
- on Windows it is never archived (st_ctime is the file creation time there and
  gets archived as birthtime, see borgbackup#8730),
- very old archives only have mtime, as the comment in stat_attrs() says.

Rebuilding the files cache from such an archive crashed:

    AttributeError: attribute ctime not found

That is reachable whenever the local files cache is missing while a previous
archive exists, i.e. after the cache directory was lost, or on a fresh machine -
always on Windows, and with --noctime everywhere.

Both timestamps are read with item.get() now. A timestamp the archive does not
have is cached as 0, which can not compare equal to the timestamp seen in the
file system, so the file is considered changed and gets chunked again if that
timestamp is part of the files cache mode. The tracking of the newest
ctime/mtime skips missing values instead of comparing them.
@ThomasWaldmann

Copy link
Copy Markdown
Member Author

windows_tests failed, and it found a real pre-existing bug rather than a problem with this change.
Pushed a second commit fixing it.

Both failures were the new tests, with:

src/borg/cache.py:347: in _build_files_cache
    ctime_ns = item.ctime
E   AttributeError: attribute ctime not found. Did you mean: 'atime'?

_build_files_cache() reads item.ctime unconditionally, but an archive item does not necessarily
have a ctime. stat_attrs() only stores it conditionally:

if not self.noctime and not is_win32:
    # win32: st_ctime is the file creation time, that is archived as birthtime, see #8730.
    attrs["ctime"] = safe_ns(st.st_ctime_ns)

and the comment right above it even says borg "can work with archives only having mtime".

So rebuilding the files cache from a previous archive crashes with AttributeError always on
Windows
, and with --noctime on every platform. That is reachable whenever the local files cache
is missing while a previous archive exists — a fresh machine, or a lost cache directory. It is not
caused by this PR; the tests here are simply the first thing that exercises the repo-side rebuild
path on Windows CI.

It reproduces on Linux/macOS too, which is how I confirmed it is not Windows-specific:

borg create --noctime home input
rm <cache dir>/<repo id>/files.*
borg create --noctime home input   # AttributeError: attribute ctime not found

48ab3e601 reads both timestamps with item.get(). A timestamp the archive does not have is cached
as 0, which cannot compare equal to the timestamp seen in the file system — so if that timestamp is
part of the files cache mode, the file is considered changed and gets chunked again, which is the
safe outcome. The newest-ctime/mtime tracking skips missing values rather than comparing them (and
the duplicated block for the two timestamps collapsed into a loop).

The regression test uses --noctime rather than relying on Windows CI, so it covers this everywhere:
test_files_cache_rebuild_without_ctime. Verified it fails without the fix on archiver and
remote_archiver here.

Happy to split that commit into its own PR if you would rather have the fix separate — I kept it here
because without it this PR stays red on Windows.

Full test suite locally: 2928 passed, 981 skipped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant