From f451283276fc39b7ae3cff0c980457af4e754c1a Mon Sep 17 00:00:00 2001 From: Red Queen Date: Wed, 26 Aug 2026 10:48:10 +0000 Subject: [PATCH] fix(tolaria): allow propagate_commit resume from PROPAGATING_TOLARIA crash-window C5F canary commit parked in PROPAGATING_TOLARIA with objects already written to Tolaria (real write before crash). C5EEngine.replay() delegates the pending case to propagate_commit, whose entry guard only accepted READY/RETRY_PENDING, so the commit could never resume past the crash window and the ALREADY_AT_TARGET idempotency (pre_write_drift_check) was never reached. Minimal fix: accept PROPAGATING_TOLARIA as a resume entry state (idempotency still determined per-object via pre_write_drift_check -> no double write; read-back verify() remains the mandatory gate) and skip the READY->PROPAGATING_TOLARIA transition on resume (no self-transition entry exists in _ALLOWED_TRANSITIONS). Adds 2 regression tests covering the crash-window resume (already-at-target and pending-create). Full C5A-E suite: 190 tests, 0 failures. --- tolaria/c5-sync-service/rq_c5c.py | 15 +++++++--- tolaria/c5-sync-service/test_c5c.py | 46 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/tolaria/c5-sync-service/rq_c5c.py b/tolaria/c5-sync-service/rq_c5c.py index a5c471e..73b2c7d 100644 --- a/tolaria/c5-sync-service/rq_c5c.py +++ b/tolaria/c5-sync-service/rq_c5c.py @@ -584,13 +584,20 @@ class C5CPropagator: "reason_code": RC_UNKNOWN_OBJECT_ID} cur = commit.get("status") - # Nur aus READY oder RETRY_PENDING propagieren - if cur not in (ST_READY, ST_RETRY_PENDING): + # Resume-Einstieg: READY / RETRY_PENDING (Erst-/Retry-Propagation) UND + # PROPAGATING_TOLARIA (Crash-Window-Resume: Commit steckte waehrend der + # Propagation fest, Objekte evtl. teilweise propagiert). Idempotenz wird + # pro Objekt via pre_write_drift_check (ALREADY_AT_TARGET) bestimmt, + # kein Doppel-Write. Read-Back bleibt zwingend (verify / content_equal). + if cur not in (ST_READY, ST_RETRY_PENDING, ST_PROPAGATING_TOLARIA): return {"commit_sha": commit_sha, "status": cur, "message": "Commit nicht im propagierbaren Zustand"} - # READY -> PROPAGATING_TOLARIA - self.store.transition_commit(commit_sha, ST_PROPAGATING_TOLARIA) + # READY -> PROPAGATING_TOLARIA (nur beim Erst-/Retry-Einstieg; beim + # PROPAGATING_TOLARIA-Resume ist der State bereits gesetzt, kein + # Self-Transition-Entry noetig, der nicht in _ALLOWED_TRANSITIONS ist) + if cur != ST_PROPAGATING_TOLARIA: + self.store.transition_commit(commit_sha, ST_PROPAGATING_TOLARIA) parent_sha = commit.get("parent_sha") objs = self.store.list_object_changes(commit_sha) diff --git a/tolaria/c5-sync-service/test_c5c.py b/tolaria/c5-sync-service/test_c5c.py index 7a97ee0..b0ec51c 100644 --- a/tolaria/c5-sync-service/test_c5c.py +++ b/tolaria/c5-sync-service/test_c5c.py @@ -832,6 +832,52 @@ class TestCommitAtomicity(unittest.TestCase): self.assertEqual(res2["status"], ST_UPDATING_SEARCH) self.assertEqual(self.fake.write_count, writes_after_first) # kein Doppel-Write + def test_resume_from_propagating_tolaria_idempotent(self): + # Crash-Window-Resume (C5E ยง4): Commit steckte in PROPAGATING_TOLARIA fest, + # die Objekte sind bereits (vom echten Write vor dem Crash) in Tolaria am + # Ziel. propagate_commit MUSS den Einstieg aus PROPAGATING_TOLARIA erlauben, + # via pre_write_drift_check ALREADY_AT_TARGET erkennen (kein Doppel-Write) + # und deterministisch VERIFYING_TOLARIA -> UPDATING_SEARCH erreichen. + content = _fm(UUID_A, state="current") + "body" + self.repo.write("modul-09.md", content) + sha = self.repo.commit("create") + _seed_commit(self.store, sha, None, [{ + "object_id": UUID_A, "operation": OP_CREATE, + "path_before": None, "path_after": "modul-09.md", + "content_hash_after": content_hash(parse_frontmatter(content)[1]), + }]) + # Crash-Window simuliert: Commit bereits in PROPAGATING_TOLARIA, Objekt + # bereits (vom echten Write vor dem Crash) am Ziel in Tolaria. + self.store.transition_commit(sha, ST_PROPAGATING_TOLARIA) + self.fake.vault["/app/vault/modul-09.md"] = content + writes_before = self.fake.write_count + res = self.prop.propagate_commit(sha) + self.assertEqual(res["status"], ST_UPDATING_SEARCH, + "Resume aus PROPAGATING_TOLARIA muss zum Suchschritt weiterlaufen") + self.assertEqual(self.fake.write_count, writes_before, "kein Doppel-Write im Crash-Window") + self.assertEqual(self.store.commit_status(sha), ST_UPDATING_SEARCH) + + def test_resume_from_propagating_tolaria_with_pending_create(self): + # Crash-Window-Resume mit noch NICHT geschriebenem Objekt: Commit in + # PROPAGATING_TOLARIA, Objekt fehlt in Tolaria (CREATE-Fall) -> WRITE_ALLOWED, + # wird nachgeholt, danach Read-Back -> VERIFYING_TOLARIA -> UPDATING_SEARCH. + content = _fm(UUID_A, state="current") + "body" + self.repo.write("modul-09.md", content) + sha = self.repo.commit("create") + _seed_commit(self.store, sha, None, [{ + "object_id": UUID_A, "operation": OP_CREATE, + "path_before": None, "path_after": "modul-09.md", + "content_hash_after": content_hash(parse_frontmatter(content)[1]), + }]) + self.store.transition_commit(sha, ST_PROPAGATING_TOLARIA) + # Tolaria enthaelt das Objekt noch NICHT (CREATE pending). + writes_before = self.fake.write_count + res = self.prop.propagate_commit(sha) + self.assertEqual(res["status"], ST_UPDATING_SEARCH, + "Resume mit pending CREATE muss nachpropagieren und abschliessen") + self.assertEqual(self.fake.write_count, writes_before + 1, "pending Objekt wird nachgeschrieben") + self.assertEqual(self.fake.vault["/app/vault/modul-09.md"], content) + class TestGuarantees(unittest.TestCase): """No-Search / No-Master-Write Guarantee (statisch)."""