fix(upload): recover backups after same-timestamp corruption (#5860)

* fix(upload): harden index cache recovery

* fix: retry upload index loads across replacement

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
This commit is contained in:
RaresKeY
2026-08-12 03:22:31 +01:00
committed by GitHub
co-authored by Alexandre Teixeira
parent 1976fe1b60
commit e0615cda47
2 changed files with 182 additions and 46 deletions
+77 -8
View File
@@ -15,6 +15,7 @@ These tests exercise:
* Smoke tests: normal upload, duplicate detection, info lookup after
a backup-recovery scenario.
"""
import builtins
import concurrent.futures
import io
import json
@@ -59,6 +60,16 @@ def _db_path(handler: UploadHandler) -> str:
return os.path.join(handler.upload_dir, "uploads.json")
def _truncate_without_newer_mtime(path: str) -> None:
"""Model a filesystem where a torn write shares the cached timestamp."""
before = os.stat(path)
with open(path, "rb") as f:
full = f.read()
with open(path, "wb") as f:
f.write(full[: max(1, len(full) // 2)])
os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns))
def _seed_entry(owner: str, file_hash: str, file_id: str) -> dict:
return {
"id": file_id,
@@ -246,10 +257,7 @@ def test_partial_write_recovery_via_bak(tmp_path):
"Production _atomic_write_json must create a .bak sibling on subsequent writes."
)
full = open(db_path, "rb").read()
truncated_len = max(1, len(full) // 2)
with open(db_path, "wb") as f:
f.write(full[:truncated_len])
_truncate_without_newer_mtime(db_path)
recovered = handler._load_upload_index()
missing = [k for k in original if k not in recovered]
@@ -259,6 +267,69 @@ def test_partial_write_recovery_via_bak(tmp_path):
)
def test_partial_write_recovery_via_bak_after_restart(tmp_path):
"""A fresh handler must recover the previous snapshot from ``.bak``."""
handler = _make_handler(tmp_path)
db_path = _db_path(handler)
original = {
f"owner:hash_{i}": _seed_entry("owner", f"hash_{i}", f"id_{i}")
for i in range(3)
}
handler._atomic_write_json(db_path, original)
handler._atomic_write_json(db_path, {"latest": True})
_truncate_without_newer_mtime(db_path)
restarted_handler = UploadHandler(
base_dir=handler.base_dir,
upload_dir=handler.upload_dir,
)
assert restarted_handler._load_upload_index() == original
def test_unchanged_upload_index_uses_cache(tmp_path, monkeypatch):
"""The stronger file signature must preserve the unchanged-index fast path."""
handler = _make_handler(tmp_path)
original = {"owner:hash": _seed_entry("owner", "hash", "id")}
handler._atomic_write_json(_db_path(handler), original)
def fail_if_parsed(_file):
raise AssertionError("unchanged upload index should be served from cache")
monkeypatch.setattr(json, "load", fail_if_parsed)
assert handler._load_upload_index() == original
def test_upload_index_retries_when_replaced_during_read(tmp_path, monkeypatch):
"""Do not cache old JSON under the signature of a newer atomic replace."""
handler = _make_handler(tmp_path)
db_path = _db_path(handler)
old_index = {"owner:old": _seed_entry("owner", "old", "old_id")}
new_index = {"owner:new": _seed_entry("owner", "new", "new_id")}
handler._atomic_write_json(db_path, old_index)
handler._index_cache = None
handler._index_signature = None
real_open = builtins.open
replaced = False
def racing_open(file, mode="r", *args, **kwargs):
nonlocal replaced
handle = real_open(file, mode, *args, **kwargs)
if os.fspath(file) == db_path and "r" in mode and not replaced:
replaced = True
replacement = db_path + ".replacement"
with real_open(replacement, "w", encoding="utf-8") as out:
json.dump(new_index, out)
os.replace(replacement, db_path)
return handle
monkeypatch.setattr(builtins, "open", racing_open)
assert handler._load_upload_index() == new_index
# ---------------------------------------------------------------------------
# Atomicity primitive audit on the production module.
# ---------------------------------------------------------------------------
@@ -390,10 +461,8 @@ def test_smoke_info_lookup_after_bak_recovery(tmp_path):
handler._atomic_write_json(db_path, {"sentinel": True})
assert os.path.exists(db_path + ".bak")
# Truncate the live file.
full = open(db_path, "rb").read()
with open(db_path, "wb") as f:
f.write(full[: max(1, len(full) // 2)])
# Truncate the live file without assuming the filesystem advances mtime.
_truncate_without_newer_mtime(db_path)
info = handler.get_upload_info(first["id"])
assert info is not None, "Info lookup must succeed after .bak recovery."