Skip to content

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

Merged
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:create-files-cache-group-by
Sep 1, 2026
Merged

create: rebuild the files cache from an archive of the same group#10292
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:create-files-cache-group-by

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Aug 31, 2026

Copy link
Copy Markdown
Member

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: 2928 passed, 981 skipped. ruff check clean.

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.65%. Comparing base (0a31ddc) to head (0adbb7a).
⚠️ Report is 7 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10292      +/-   ##
==========================================
+ Coverage   87.61%   87.65%   +0.03%     
==========================================
  Files         103      103              
  Lines       18674    18702      +28     
  Branches     2872     2877       +5     
==========================================
+ Hits        16362    16393      +31     
+ Misses       1611     1609       -2     
+ Partials      701      700       -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.

@ThomasWaldmann
ThomasWaldmann force-pushed the create-files-cache-group-by branch from 6fa4d5c to c71b90d Compare August 31, 2026 17:44
@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.

@ThomasWaldmann

Copy link
Copy Markdown
Member Author

Split the ctime fix out into #10299 as requested.

This PR is now rebased on top of that branch, so it shows two commits until #10299 merges, then drops back to its own one. It has to be stacked rather than independent: the _remove_files_cache() test helper lives in #10299 (its test needs it too) and both PRs use it, so having them side by side on master would just guarantee a conflict in create_cmd_test.py.

The commit here is unchanged apart from no longer carrying the fix, the helper and the ctime test.

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 a6415b7 to 0adbb7a Compare September 1, 2026 08:02
@ThomasWaldmann
ThomasWaldmann merged commit 5baae43 into borgbackup:master Sep 1, 2026
25 of 26 checks passed
@ThomasWaldmann
ThomasWaldmann deleted the create-files-cache-group-by branch September 1, 2026 08:35
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