mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-23 00:22:20 +02:00
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:
co-authored by
Alexandre Teixeira
parent
1976fe1b60
commit
e0615cda47
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user