mirror of
https://github.com/borgbackup/borg.git
synced 2026-06-11 01:41:57 -04:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
5a9855dda3
commit
c08c2ca461
3 changed files with 33 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue