diff --git a/planfile/__init__.py b/planfile/__init__.py index b326c2b..1677ede 100644 --- a/planfile/__init__.py +++ b/planfile/__init__.py @@ -141,7 +141,11 @@ def create_ticket_deduplicated(self, name: str, **kwargs) -> tuple[Ticket, bool] if dedupe_label: if live: oldest = min(live, key=lambda record: str(record.get("created_at") or "")) - existing = self.store.get_ticket(str(oldest.get("id") or "")) + # Rebuilding a stale projection here would decode every archive + # while holding mutation_lock. Use the durable-source fallback. + existing = self.store.get_ticket( + str(oldest.get("id") or ""), repair_index=False, + ) if existing: if dedupe_note and dedupe_note.strip(): outputs = existing.outputs.model_dump(mode="json") if existing.outputs else {} diff --git a/project/TICKETS.md b/project/TICKETS.md index 32fbcb1..7764f02 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -125,4 +125,5 @@ This file indexes governance tickets without taking ownership of | **ticket-213** | [`README.md`](./ticket-213/README.md) | - | - | - | - | - | | **ticket-214** | [`README.md`](./ticket-214/README.md) | - | - | - | - | - | | **ticket-215** | [`README.md`](./ticket-215/README.md) | - | - | - | - | - | +| **ticket-216** | [`README.md`](./ticket-216/README.md) | - | - | - | - | - | diff --git a/project/ticket-216/README.md b/project/ticket-216/README.md new file mode 100644 index 0000000..0f624e0 --- /dev/null +++ b/project/ticket-216/README.md @@ -0,0 +1,25 @@ +# Ticket 216: Avoid SQLite projection rebuild under deduplication lock + +- **ID**: ticket-216 +- **Owner**: agent:codex +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Planfile**: PLF-18410 + +## Authorization + +SESSION_EXECUTION_AUTHORIZATION: continue repairs, tests, protected merges. Previous ticket215 is merged/deployed; this bounded followup addresses first reuse latency. A stale/missing SQLite projection currently triggers full archive reconstruction while holding mutation_lock. + +## Acceptance criteria + +- AC-01: Dedupe reuse must not rebuild a stale or missing SQLite projection under the mutation lock. +- AC-02: Current and archived tickets, occurrence notes and terminal key release retain authoritative semantics; normal index maintenance remains available. +- AC-03: Regression tests, full suite, governance and protected exact-head review pass. + +## Validation + +Deterministic stale/missing-index tests and full pytest; isolated snapshot measurement if useful. Never mutate production tickets for benchmarking. + +## Local results + +All eight new regression cases failed before the change because dedupe invoked full projection repair. After the change: 45 focused tests passed; full suite 1273 passed, 6 skipped (110.94s); Ruff and governance passed. Isolated HTTP measurement running; protected CI and independent Validator required. diff --git a/project/ticket-216/intent.json b/project/ticket-216/intent.json new file mode 100644 index 0000000..fe47678 --- /dev/null +++ b/project/ticket-216/intent.json @@ -0,0 +1,76 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-216", + "summary": "Avoid SQLite projection rebuild inside deduplication mutation lock", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-216/**", + "TODO.md", + "project/TICKETS.md", + "planfile/__init__.py", + "tests/test_ticket_create_dedupe.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "python" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "ca3f35c934f3b2cd06b369adf44c6863ac8f4712", + "targetBranch": "main", + "outcome": "Avoid unrelated full SQLite projection reconstruction under deduplication mutation lock", + "nonGoals": [ + "No changes to index maintenance, storage schema or deduplication key semantics" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Use existing nonrepairing lookup with authoritative-source fallback during dedupe", + "components": [ + { + "name": "deduplication", + "paths": [ + "planfile/__init__.py", + "tests/test_ticket_create_dedupe.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert lookup mode change; no data migration" + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-04", + "commands": [ + "python -m pytest -q", + "./project/governance-check.sh" + ], + "evidence": "Full suite and independent protected CI receipts" + } + ] + } +} diff --git a/tests/test_ticket_create_dedupe.py b/tests/test_ticket_create_dedupe.py index a49925c..acd3ef4 100644 --- a/tests/test_ticket_create_dedupe.py +++ b/tests/test_ticket_create_dedupe.py @@ -68,6 +68,7 @@ def test_ordinary_tickets_are_not_deduplicated(pf): def test_http_reuse_returns_200_without_second_ticket(pf, monkeypatch): pytest.importorskip("fastapi.testclient") from fastapi.testclient import TestClient + from planfile.api import server monkeypatch.setattr(server, "get_planfile", lambda: pf) @@ -84,3 +85,40 @@ def test_http_reuse_returns_200_without_second_ticket(pf, monkeypatch): assert first.status_code == 201 assert second.status_code == 200 assert second.json()["id"] == first.json()["id"] + + +@pytest.mark.parametrize("projection", ["missing", "stale"]) +@pytest.mark.parametrize("archived", [False, True]) +@pytest.mark.parametrize("note", [None, "another occurrence"]) +def test_reuse_does_not_rebuild_projection_under_mutation_lock( + pf, monkeypatch, projection, archived, note, +): + pf.store.configure_ticket_index(True) + first = pf.create_ticket("first", dedupe_key="shared") + sprint = "archive" if archived else "current" + if archived: + with pf.store.mutation_lock(): + data = pf.store.load_sprint("current") + raw = data["tickets"].pop(first.id) + pf.store._write_yaml_atomic(pf.store._sprint_file("current"), data) + pf.store._write_yaml_atomic( + pf.store._sprint_file("archive"), {"tickets": {first.id: raw}}, + ) + pf.store.ensure_ticket_index() + if projection == "missing": + pf.store._ticket_index_path.unlink() + else: + path = pf.store._sprint_file(sprint) + path.write_text(path.read_text() + "\n# projection is stale\n") + + def forbidden_rebuild(*args, **kwargs): + pytest.fail("dedupe must not repair the full SQLite projection") + + monkeypatch.setattr(pf.store, "ensure_ticket_index", forbidden_rebuild) + reused, created = pf.create_ticket_deduplicated( + "again", dedupe_key="shared", dedupe_note=note, + ) + assert not created and reused.id == first.id + if note: + assert reused.outputs.notes == [note] + assert not pf.store._sqlite_ticket_index().is_current(pf.store._ticket_index_signature())