From c08c2ca461074207d944768d623c4ebf95380ed5 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 10 Jun 2026 12:39:36 +0200 Subject: [PATCH] files cache: don't empty cache on a no-change backup, #9749 FilesCacheMixin initialized _newest_cmtime to 0, but _write_files_cache() only treats None as "no file was chunked this run" (falling back to a max_time_ns cutoff that keeps all current entries). When a backup reuses all files from the cache without chunking anything, _newest_cmtime stayed at 0, so the race-protection cutoff became the unix epoch and every current (age == 0) entry was discarded. The next backup then had to re-read, chunk and hash all files again. Initialize _newest_cmtime to None to match the documented contract in _write_files_cache(), and make the comparisons in _build_files_cache() None-safe. Co-Authored-By: Claude Opus 4.8 --- docs/changes.rst | 1 + src/borg/cache.py | 6 +++--- src/borg/testsuite/cache_test.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 20533dd3f..8ec31bf3a 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -185,6 +185,7 @@ New features: Fixes: +- files cache: fix no-change backup emptying the files cache, #9749 - fix canonical_path() missing ':' before port number - fix: xattr xdg backup exclusion should be on 'false' - fix slashdot hack excluding source directory metadata, #9534 diff --git a/src/borg/cache.py b/src/borg/cache.py index b52fe31cb..992fda667 100644 --- a/src/borg/cache.py +++ b/src/borg/cache.py @@ -227,7 +227,7 @@ class FilesCacheMixin: assert "d" in cache_mode or "c" in cache_mode or "m" in cache_mode self.cache_mode = cache_mode self._files = None - self._newest_cmtime = 0 + self._newest_cmtime = None # None means "no file was chunked/seen yet", see _write_files_cache self._newest_path_hashes = set() self.start_backup = start_backup @@ -312,13 +312,13 @@ class FilesCacheMixin: path_hash = self.key.id_hash(safe_encode(item.path)) # keep track of the key(s) for the most recent timestamp(s): ctime_ns = item.ctime - if ctime_ns > self._newest_cmtime: + if self._newest_cmtime is None or ctime_ns > self._newest_cmtime: self._newest_cmtime = ctime_ns self._newest_path_hashes = {path_hash} elif ctime_ns == self._newest_cmtime: self._newest_path_hashes.add(path_hash) mtime_ns = item.mtime - if mtime_ns > self._newest_cmtime: + if self._newest_cmtime is None or mtime_ns > self._newest_cmtime: self._newest_cmtime = mtime_ns self._newest_path_hashes = {path_hash} elif mtime_ns == self._newest_cmtime: diff --git a/src/borg/testsuite/cache_test.py b/src/borg/testsuite/cache_test.py index 86a7d5b74..b50516f73 100644 --- a/src/borg/testsuite/cache_test.py +++ b/src/borg/testsuite/cache_test.py @@ -1,12 +1,15 @@ import os +import time import pytest from .hashindex_test import H from .crypto.key_test import TestKey from ..archive import Statistics -from ..cache import AdHocWithFilesCache, delete_chunkindex_cache, read_chunkindex_from_repo_cache +from ..cache import AdHocWithFilesCache, FileCacheEntry, delete_chunkindex_cache, read_chunkindex_from_repo_cache from ..crypto.key import AESOCBRepoKey +from ..helpers import safe_ns +from ..helpers.msgpack import int_to_timestamp from ..manifest import Manifest from ..repository import Repository @@ -54,6 +57,31 @@ class TestAdHocWithFilesCache: assert cache.cache_mode == "d" assert cache.files == {} + def test_no_change_backup_keeps_files_cache(self, repository, key, manifest): + # Regression test for #9749: a backup that does not chunk any new file leaves + # _newest_cmtime unset. The writer must treat "unset" as "keep everything" and + # must NOT discard the current (age == 0) entries with an epoch cutoff. + # "cis" == normalized form of the "borg create" default "ctime,size,inode" + cache = AdHocWithFilesCache(manifest, cache_mode="cis", archive_name="test") + # the chunk that our cached file references (needed so the entry can be (de)compressed): + cache.add_chunk(H(5), {}, b"5678", stats=Statistics()) + # a "current" files cache entry as left behind by a no-change backup: the file was found + # unchanged (so its age was reset to 0), but nothing got chunked, so memorize_file() was + # never called and _newest_cmtime stayed at its initial value. + now_ns = safe_ns(time.time_ns()) + entry = FileCacheEntry( + age=0, inode=1, size=4, ctime=int_to_timestamp(now_ns), mtime=int_to_timestamp(now_ns), chunks=[(H(5), 4)] + ) + path_hash = H(42) + files = {path_hash: cache.compress_entry(entry)} + assert cache._newest_cmtime is None # nothing was chunked in this backup + integrity_data = cache._write_files_cache(files) + cache.cache_config.integrity[cache.files_cache_name()] = integrity_data + # with the bug (initial value 0 instead of None) the cutoff would be the epoch and the + # entry would be dropped; after the fix the entry must still be there: + loaded = cache._read_files_cache() + assert path_hash in loaded + def test_delete_chunkindex_cache_missing(tmp_path): """delete_chunkindex_cache handles StoreObjectNotFound when cache entries do not exist."""