Archive copy must succeed before a packet counts as filed (Gitea issue 57)
ci / frontend (pull_request) Successful in 1m0s
ci / server (pull_request) Successful in 13m45s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 5m49s

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:
2026-11-03 15:52:09 -07:00
co-authored by Claude Opus 5.5
parent 5f8165c833
commit 6adaf7bf25
11 changed files with 254 additions and 98 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ test.describe.serial("archive", () => {
expect((await packet(clara, p.id)).seal).toBeTruthy();
await closeAll(clara, wade, wes, andy, quinn);
// FILE asks the worker for a copy at once. The Admin section shows it done.
// FILE made the copy in the filing transaction (Gitea issue 57). The Admin section shows it done.
const ann = await admin(browser);
await expect
.poll(
+79 -40
View File
@@ -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