Merge pull request #9751 from ThomasWaldmann/fix-9749
Some checks are pending
Lint / lint (push) Waiting to run
CI / lint (push) Waiting to run
CI / security (push) Waiting to run
CI / asan_ubsan (push) Blocked by required conditions
CI / native_tests (push) Blocked by required conditions
CI / vm_tests (NetBSD, false, netbsd, 10.1) (push) Blocked by required conditions
CI / vm_tests (OmniOS, false, omnios, r151056) (push) Blocked by required conditions
CI / vm_tests (OpenBSD, false, openbsd, 7.8) (push) Blocked by required conditions
CI / vm_tests (borg-freebsd-14-x86_64-gh, FreeBSD, true, freebsd, 14.3) (push) Blocked by required conditions
CI / windows_tests (push) Blocked by required conditions
CodeQL / Analyze (push) Waiting to run

files cache: don't empty cache on a no-change backup, #9749
This commit is contained in:
TW 2026-06-10 14:48:05 +02:00 committed by GitHub
commit 8509b3b7fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 33 additions and 4 deletions

View file

@ -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

View file

@ -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:

View file

@ -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."""