Archive copy must succeed before a packet counts as filed (Gitea issue 57)
FILE checks the archive folder and copies the packet inside the filing transaction. A failed check or copy rolls the filing back, so the packet stays in its previous stage; the person filing gets 503 archive_failed, and in its own transaction the server writes an archive.filing_blocked audit row and raises the critical archive_failed condition to Admin for that packet (who tried, folder, error). The next successful filing clears it. The archive-copy job now only catches up filings made while the archive was off. PLAN question 53, CHANGELOG, FEED and SECURITY updated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""The archive location (Gitea issue 7): the path rules, the check, the File locations API, the archive-copy job
|
||||
(copy, idempotence, failure and backoff, the Admin condition, old seals queued) and filing with the archive off or
|
||||
broken. Synthetic data only; the archive root is a temp folder."""
|
||||
"""The archive location (Gitea issue 7): the path rules, the check, the File locations API, the copy FILE makes in
|
||||
its own transaction, filing refused when that copy fails (Gitea issue 57), and the archive-copy job (idempotence,
|
||||
failure and backoff, the Admin condition, old seals queued). Synthetic data only; the archive root is a temp
|
||||
folder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from closehelpers import file_packet, filed, q1, qall, rich_complete, vclose
|
||||
from closehelpers import at_complete, file_packet, filed, q1, qall, rich_complete, vclose
|
||||
from fakedir import FakeDirectory
|
||||
from pshelpers import make_settings, needs_pg
|
||||
from shophelpers import Shop, ok, open_shop, refused, run
|
||||
@@ -283,12 +284,9 @@ def test_copy_job(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run(s, "UPDATE bom_shorts SET closed_at = now() WHERE packet_id = %s", (pid,))
|
||||
out = ok(file_packet(shop, vclose(shop, pkt)))
|
||||
assert out["status"] == "filed"
|
||||
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy' AND claimed_at IS NULL")[0] == 1
|
||||
# FILE made the copy itself, in the filing transaction: nothing waits for the worker
|
||||
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 0
|
||||
seal_id, book_id = q1(s, "SELECT id, databook_asset_id FROM packet_seals WHERE packet_id = %s", (pid,))
|
||||
|
||||
row = run_copy(s)
|
||||
assert row["status"] == "ok", row
|
||||
assert row["trigger"] == "request" and row["counts"]["queued"] == 1 and row["counts"]["copied"] == 1
|
||||
((sid, status, attempts, err, nxt, folder),) = copies(s)
|
||||
assert (sid, status, attempts, err, nxt) == (seal_id, "done", 1, None, None)
|
||||
project, unit = q1(
|
||||
@@ -315,11 +313,13 @@ def test_copy_job(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert q1(s, "SELECT manifest_sha256 FROM archive_copies")[0] == sha(where / "manifest.json")
|
||||
(done,) = audits(s, "archive.copied")
|
||||
assert done["seal_id"] == seal_id and done["folder"] == folder and done["files"] == len(names)
|
||||
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
|
||||
assert done["source"] == "filing"
|
||||
|
||||
# idempotent: a second run copies nothing; a row set back to pending finds the identical files and is done
|
||||
# idempotent: a run copies nothing; a row set back to pending finds the identical files and is done
|
||||
row = run_copy(s)
|
||||
assert row["status"] == "ok" and row["counts"]["queued"] == 0 and row["counts"]["copied"] == 0, row
|
||||
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
|
||||
events.clear()
|
||||
assert run_copy(s)["counts"]["copied"] == 0
|
||||
run(s, "UPDATE archive_copies SET status = 'pending'")
|
||||
before = {p.name: p.stat().st_mtime_ns for p in where.iterdir()}
|
||||
assert run_copy(s)["counts"]["copied"] == 1
|
||||
@@ -390,50 +390,89 @@ def test_old_seals_are_queued_when_the_archive_is_turned_on(off_shop: Shop, tmp_
|
||||
assert copies(s)[0][:2] == (seal, "done")
|
||||
|
||||
|
||||
def open_filing_events(settings: Settings, pid: int) -> list[Any]:
|
||||
return qall(
|
||||
settings,
|
||||
"SELECT title, body, detail, severity, raised_count FROM notification_events"
|
||||
" WHERE event_type = 'archive_failed' AND subject = %s AND resolved_at IS NULL",
|
||||
(f"filing:{pid}",),
|
||||
)
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_filing_succeeds_with_the_archive_broken(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_filing_is_refused_when_the_archive_folder_is_gone(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
root = Path(str(s.archive_root))
|
||||
events: list[tuple[str, str, dict[str, Any]]] = []
|
||||
monkeypatch.setattr(jobs, "raise_event", lambda conn, t, **kw: events.append(("raise", t, kw)))
|
||||
monkeypatch.setattr(jobs, "resolve_event", lambda conn, t, **kw: events.append(("resolve", t, kw)))
|
||||
save_dir(s, "gone") # saved, then the folder went away
|
||||
pkt = filed(shop)
|
||||
assert pkt["status"] == "filed"
|
||||
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 1
|
||||
pkt = vclose(shop, at_complete(shop))
|
||||
pid = pkt["id"]
|
||||
|
||||
row = run_copy(s)
|
||||
assert row["status"] == "degraded" and "does not exist" in row["counts"]["check"]
|
||||
body = refused(file_packet(shop, pkt), 503, "archive_failed")
|
||||
assert body["detail"].startswith("The packet could not be copied to the archive: The archive folder check failed:")
|
||||
assert "does not exist" in body["detail"] and "Nothing was filed, and Admin has been told." in body["detail"]
|
||||
# the whole filing rolled back: still complete, no seal, no data book, no copy row
|
||||
view = ok(shop("clerk.clara").get(f"/api/packets/{pid}"))
|
||||
assert view["status"] == "complete" and view["seal"] is None
|
||||
assert q1(s, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
|
||||
assert q1(s, "SELECT databook_at FROM packets WHERE id = %s", (pid,))[0] is None
|
||||
assert copies(s) == []
|
||||
((kind, etype, kw),) = events
|
||||
assert (kind, etype, kw["subject"], kw["condition"]) == ("raise", "archive_failed", "archive", True)
|
||||
checks = audits(s, "archive.checked")
|
||||
assert [(c["source"], c["ok"]) for c in checks] == [("job", False)]
|
||||
run_copy(s)
|
||||
assert len(audits(s, "archive.checked")) == 1 # the same result again is not audited again
|
||||
# recorded on its own: the audit row and one open Admin condition with the details
|
||||
(blocked,) = audits(s, "archive.filing_blocked")
|
||||
assert blocked["packet_id"] == pid and blocked["archive_dir"] == "gone" and blocked["by"] == "Clara Clerk"
|
||||
assert blocked["status"] == "complete" and "does not exist" in blocked["error"]
|
||||
((title, text, detail, severity, raised),) = open_filing_events(s, pid)
|
||||
assert title.startswith("Filing blocked: archive copy failed: ") and blocked["packet_label"] in title
|
||||
assert "Clara Clerk tried to file" in text and "Archive folder: gone" in text and "does not exist" in text
|
||||
assert severity == "critical" and raised == 1 and detail["packet_id"] == pid
|
||||
|
||||
# the folder comes back: the copy is made and the condition cleared
|
||||
# a second try fails the same way: the open condition only counts it
|
||||
refused(file_packet(shop, pkt), 503, "archive_failed")
|
||||
assert open_filing_events(s, pid)[0][4] == 2 and len(audits(s, "archive.filing_blocked")) == 2
|
||||
|
||||
# the folder comes back: the filing goes through, the copy is done and the condition is cleared
|
||||
(root / "gone").mkdir()
|
||||
events.clear()
|
||||
row = run_copy(s)
|
||||
assert row["status"] == "ok" and row["counts"]["copied"] == 1
|
||||
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
|
||||
assert [(c["source"], c["ok"]) for c in audits(s, "archive.checked")] == [("job", False), ("job", True)]
|
||||
out = ok(file_packet(shop, pkt))
|
||||
assert out["status"] == "filed"
|
||||
((_, status, attempts, err, _, folder),) = copies(s)
|
||||
assert (status, attempts, err) == ("done", 1, None) and folder.startswith("gone/")
|
||||
assert open_filing_events(s, pid) == []
|
||||
cleared = q1(
|
||||
s,
|
||||
"SELECT title, body FROM notification_events WHERE event_type = 'archive_failed' AND subject = %s"
|
||||
" AND recovery_of IS NOT NULL",
|
||||
(f"filing:{pid}",),
|
||||
)
|
||||
assert cleared[0].startswith("Cleared: Filing blocked") and cleared[1] == "The packet was filed and copied."
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_filing_succeeds_when_the_request_fails(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_filing_is_refused_when_the_copy_fails(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = shop.settings
|
||||
save_dir(s)
|
||||
|
||||
def boom(*a: Any, **k: Any) -> None:
|
||||
raise RuntimeError("no requests today")
|
||||
def boom(*a: Any, **k: Any) -> Any:
|
||||
raise archive.CopyError("A different file is already at Data_Book.pdf in the archive; it was not overwritten.")
|
||||
|
||||
monkeypatch.setattr(archive, "request_copy", boom)
|
||||
monkeypatch.setattr(archive, "copy_seal", boom)
|
||||
pkt = vclose(shop, at_complete(shop))
|
||||
body = refused(file_packet(shop, pkt), 503, "archive_failed")
|
||||
assert "it was not overwritten. Nothing was filed" in body["detail"]
|
||||
assert q1(s, "SELECT status FROM packets WHERE id = %s", (pkt["id"],))[0] == "complete"
|
||||
assert q1(s, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pkt["id"],))[0] == 0
|
||||
((_, text, detail, _, _),) = open_filing_events(s, pkt["id"])
|
||||
assert "not overwritten" in text and detail["archive_dir"] == "packets"
|
||||
|
||||
# anything unexpected blocks the filing the same way
|
||||
monkeypatch.setattr(archive, "copy_seal", lambda *a, **k: 1 / 0)
|
||||
body = refused(file_packet(shop, pkt), 503, "archive_failed")
|
||||
assert "ZeroDivisionError" in body["detail"]
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_filing_with_no_folder_saved_copies_nothing(shop: Shop) -> None:
|
||||
# ARCHIVE_ROOT is set but the Admin has not saved a folder: the archive is not in use yet, so filing goes ahead
|
||||
pkt = filed(shop)
|
||||
assert pkt["status"] == "filed"
|
||||
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 0
|
||||
assert run_copy(s)["counts"]["copied"] == 1 # the scheduled run picks it up
|
||||
assert pkt["status"] == "filed" and copies(shop.settings) == []
|
||||
|
||||
|
||||
@needs_pg
|
||||
|
||||
Reference in New Issue
Block a user