Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion planfile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | - | - | - | - | - |
<!-- AUTO:TICKET_INDEX:END -->
25 changes: 25 additions & 0 deletions project/ticket-216/README.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 76 additions & 0 deletions project/ticket-216/intent.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
38 changes: 38 additions & 0 deletions tests/test_ticket_create_dedupe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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())
Loading