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
52 changes: 47 additions & 5 deletions planfile/core/store.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

import json
import logging
import math
import os
import re
import shutil
import sqlite3
import tempfile
import time
from contextlib import contextmanager
Expand Down Expand Up @@ -117,6 +119,7 @@ def _bind_base_dir(self, base_dir: Path) -> None:
self._forensic_log_receipt_path = self.base_dir / "events" / ".logs.dsl.v1"
self._evidence_dir = self.base_dir / "evidence"
self._ticket_index_path = self.base_dir / "index" / "tickets.sqlite3"
self._ticket_index_invalidation_path = self.base_dir / "index" / "tickets.invalidated"
self._ticket_index_rebuild_lock_path = self.base_dir / "index" / ".rebuild.lock"
self._history_locations_path = self.base_dir / "index" / "history-locations.yaml"
self._operational_receipt_path = self.base_dir / OPERATIONAL_RECEIPT_FILENAME
Expand Down Expand Up @@ -1854,8 +1857,16 @@ def _ticket_index_signature(self) -> tuple:
self.sprint_signature("all"),
self._evidence_revision(),
self._history_locations_signature(),
self._ticket_index_invalidation_revision(),
)

def _ticket_index_invalidation_revision(self) -> str | None:
"""Read the cross-process token without opening the contended database."""
try:
return self._ticket_index_invalidation_path.read_text(encoding="ascii")
except FileNotFoundError:
return None

@staticmethod
def _ticket_index_signature_cache_seconds() -> float:
"""Bound source-signature reuse during a concurrent API read burst."""
Expand Down Expand Up @@ -1901,6 +1912,7 @@ def _ticket_index_signature_cache_probe(self) -> tuple:
self.sprint_signature("current"),
evidence_dir_mtime,
self._history_locations_signature(),
self._ticket_index_invalidation_revision(),
)

def _invalidate_ticket_index_signature_cache(self) -> None:
Expand Down Expand Up @@ -2024,11 +2036,41 @@ def _finish_index_mutation(
self._ticket_index_record(ticket, sprint)
for ticket, sprint in (upserts or [])
]
self._sqlite_ticket_index().apply(
upserts=records,
deletes=deletes or [],
signature=self._ticket_index_signature(),
)
try:
self._sqlite_ticket_index().apply(
upserts=records,
deletes=deletes or [],
signature=self._ticket_index_signature(),
)
except sqlite3.OperationalError as exc:
# All callers have already committed authoritative files. Only
# BUSY/LOCKED in this disposable projection may be deferred; other
# errors and failures before the source write still propagate.
code = getattr(exc, "sqlite_errorcode", None)
if code is not None:
contention = code & 0xFF in (5, 6) # SQLITE_BUSY / SQLITE_LOCKED
else:
# Python 3.10 lacks sqlite_errorcode. Match SQLite's own
# messages exactly (or its table/schema-qualified variants).
message = str(exc).lower()
contention = message in {
"database is locked", "database table is locked", "database schema is locked"
} or message.startswith(("database table is locked: ", "database schema is locked: "))
if not contention:
raise
from planfile.core.fastio import _atomic_write_text

# A process-local cache reset alone leaves another worker's cached
# signature looking current until its TTL expires. This atomic
# token invalidates every worker, even for historical/evidence
# edits that do not change the cheap current-sprint probe. Never
# advance SQLite metadata or delete another writer's lock.
_atomic_write_text(self._ticket_index_invalidation_path, os.urandom(16).hex())
self._invalidate_ticket_index_signature_cache()
logging.getLogger(__name__).warning(
"ticket_index_update_deferred: durable mutation committed; "
"SQLite projection stale (%s)", exc,
)

def ensure_ticket_index(self, *, force: bool = False) -> dict:
"""Ensure the disposable SQLite projection matches durable sources."""
Expand Down
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,5 +159,6 @@ This file indexes governance tickets without taking ownership of
| **ticket-254** | [`README.md`](./ticket-254/README.md) | - | - | - | - | - |
| **ticket-256** | [`README.md`](./ticket-256/README.md) | - | - | - | - | - |
| **ticket-257** | [`README.md`](./ticket-257/README.md) | - | - | - | - | - |
| **ticket-258** | [`README.md`](./ticket-258/README.md) | - | - | - | - | - |
| **ticket-259** | [`README.md`](./ticket-259/README.md) | - | - | - | - | - |
<!-- AUTO:TICKET_INDEX:END -->
21 changes: 21 additions & 0 deletions project/ticket-258/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Ticket 258: Avoid ambiguous post-commit SQLite index contention

- **ID**: ticket-258
- **Owner**: agent:codex
- **Status**: IN_PROGRESS
- **Workflow state**: PUBLICATION
- **Created**: 2026-10-29

## Goal and scope

SESSION_EXECUTION_AUTHORIZATION: user requested continued autonomous repair, tests and protected publication ("kontynuuj, scalaj, testuj"). Continue parent PLF-20347 / Willman PLF-20340 and existing Planfile PLF-20365 / https://github.com/semcod/planfile/issues/316.

A durable ticket write must not report HTTP500 solely because the disposable SQLite index is busy/locked afterward. Record an atomic invalidation token visible to other workers, retain typed stale reads and normal rebuild recovery. Preserve CAS, source serialization and idempotent evidence. Propagate non-contention and pre-write errors. Existing issue313 owns rebuild liveness; issue317 owns qualified native runtime adoption. Preserve foreign governance worktree255.

## Acceptance criteria

- [x] AC-01: Real SQLite contention after authoritative upsert/delete/evidence write acknowledges success, another process rejects stale projection and a later rebuild recovers.
- [x] AC-02: Evidence replay, stale CAS, pre-write errors and unrelated SQLite errors retain their semantics.
- [ ] AC-03: Scoped checks and governance pass; protected current-base/head OneDev and independent publication are verified.

Validation: 142 scoped tests passed with Testless outcome accounting and coverage, including28 new regression cases. Ruff and managed governance passed. Real baseline/candidate canary: HTTP500 versus200 with the same committed value. Independent publication remains pending.
96 changes: 96 additions & 0 deletions project/ticket-258/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
{
"schema": "new-project.intent/v3",
"ticket": "ticket-258",
"summary": "Avoid ambiguous post-commit SQLite index contention (issue316)",
"workstream": "application",
"classification": {
"kind": "BUG",
"priority": "P1",
"origin": "health"
},
"allowedPaths": [
"project/ticket-258/**",
"TODO.md",
"project/TICKETS.md",
"planfile/core/store.py",
"tests/test_sqlite_ticket_index.py"
],
"forbiddenPaths": [
"project/ticket-*/user-*.md"
],
"stacks": [
"python"
],
"dependsOn": [],
"conflictsWith": [],
"integrationTicket": null,
"delivery": {
"acceptedBaseSha": "ee8d113322ffa8cf58f96cd676aec14c38258cdc",
"targetBranch": "main",
"outcome": "A durable ticket mutation succeeds despite SQLite BUSY/LOCKED during projection update; a durable invalidation token prevents stale caches being current in another process. Preserve CAS and idempotency and propagate unrelated failures.",
"nonGoals": [
"No pre-write lock retry, live lock deletion, runtime hotpatch, managed adoption changes, or other retained worktree takeover.",
"Index rebuild liveness and native deployment remain with existing owner issues313/317."
],
"complexity": "S",
"estimatedMinutes": 60,
"budgets": {
"maxImplementationFiles": 2,
"maxAffectedComponents": 1,
"maxPublicInterfaceChanges": 0,
"maxRuntimeDependencies": 0
},
"architecture": {
"status": "accepted",
"decision": "Handle contention at the existing post-commit projection boundary. Use a SQLite-independent atomic invalidation token in full and cached-signature probes; the normal rebuild reconciles it.",
"components": [
{
"name": "ticket-store-index",
"paths": [
"planfile/core/store.py",
"tests/test_sqlite_ticket_index.py"
]
}
],
"responsibilityChanges": false,
"interfaceChanges": [],
"dataChanges": [
{
"kind": "component-local-state",
"component": "ticket-store-index",
"description": "Disposable index signature includes an atomic invalidation token; authoritative ticket format remains compatible."
}
],
"ui": {
"impact": "none",
"states": [],
"evidence": []
},
"rollback": "Revert the source slice through protected publication; index is disposable and rebuilt from authoritative tickets. Reinstate the prior immutable runtime if a qualified rollout fails."
},
"runtimeDependencies": [],
"validation": [
{
"criterion": "AC-01",
"commands": [
"python -m pytest -q tests/test_sqlite_ticket_index.py"
],
"evidence": "Real SQLite writer contention, API acknowledgement, cross-process stale guard, eventual rebuild and upsert/delete paths."
},
{
"criterion": "AC-02",
"commands": [
"python -m pytest -q tests/test_store_lock_timeout.py tests/test_store_concurrency.py tests/test_ticket_api_events.py"
],
"evidence": "Pre-write failures, unrelated index errors, CAS and evidence replay remain correct."
},
{
"criterion": "AC-03",
"commands": [
"./project/governance-check.sh"
],
"evidence": "Scoped Testless, protected exact-head/current-base OneDev and independent Validator publication; runtime deployment is a separate receipt."
}
]
}
}
Loading
Loading