fix(kanban): re-create the schema when a cached DB path loses it (#83445)
`connect()` caches every path it has initialized in the process-local `_INITIALIZED_PATHS` set and then skips all first-open work for it — header validation, integrity probe, `SCHEMA_SQL`, additive migrations. That cache is keyed on a path, but the schema it stands for lives in a file, and the two can drift apart: delete or replace `kanban.db` under a live gateway/dispatcher/dashboard process and the next `connect()` takes the fast path, lets SQLite create a fresh empty database, and hands back a connection with no tables in it. Nothing notices. Every query then fails with `no such table: tasks`, `plugin_api._conn()` logs its init warning and carries on, and the board renders empty. Because the cache entry survives, the process re-creates the same schema-less ~4 KB file on every restart of the desktop app in front of it — only killing the backing process clears it. Verify the sentinel table on the fast path and self-heal when it is gone: drop the stale cache entry and fall through to the existing init path, which re-runs the probes and the schema script under the cross-process init lock. The check is one `sqlite_master` lookup on the already-resident page 1, so the steady-state path stays lock-free (#36644) and does no schema work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+38
-1
@@ -2304,6 +2304,26 @@ def repair_db(
|
||||
)
|
||||
|
||||
|
||||
def _schema_is_present(conn: sqlite3.Connection) -> bool:
|
||||
"""Whether an open connection actually sees the kanban schema.
|
||||
|
||||
``tasks`` is the sentinel: :data:`SCHEMA_SQL` always creates it, and
|
||||
SQLite loses tables all-or-nothing (a file is either the one we
|
||||
initialized or a fresh one created by this very open), so one
|
||||
``sqlite_master`` lookup on the already-resident page 1 is enough. Cheap
|
||||
by design — it runs on every steady-state :func:`connect`.
|
||||
"""
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='tasks' LIMIT 1"
|
||||
).fetchone()
|
||||
except sqlite3.DatabaseError:
|
||||
# Unreadable schema table is not this guard's call — let the full init
|
||||
# path's header/integrity probes classify and quarantine it.
|
||||
return False
|
||||
return row is not None
|
||||
|
||||
|
||||
def connect(
|
||||
db_path: Optional[Path] = None,
|
||||
*,
|
||||
@@ -2361,10 +2381,27 @@ def connect(
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA secure_delete=ON")
|
||||
conn.execute("PRAGMA cell_size_check=ON")
|
||||
schema_present = _schema_is_present(conn)
|
||||
except Exception:
|
||||
conn.close()
|
||||
raise
|
||||
return conn
|
||||
if schema_present:
|
||||
return conn
|
||||
# The cache says "initialized", the file says otherwise: it was deleted
|
||||
# or replaced under a live process, and the open above silently
|
||||
# recreated an empty DB. Left alone, every query on this path fails
|
||||
# with "no such table: tasks" for the rest of the process's life and
|
||||
# the board just renders empty (#83445). Drop the stale cache entry and
|
||||
# fall through to the full init path, which re-runs the header and
|
||||
# integrity probes and the schema script under the cross-process lock.
|
||||
conn.close()
|
||||
with _INIT_LOCK:
|
||||
_INITIALIZED_PATHS.discard(resolved)
|
||||
_log.warning(
|
||||
"kanban DB %s lost its schema after this process initialized it "
|
||||
"(deleted or replaced externally); re-initializing.",
|
||||
path,
|
||||
)
|
||||
|
||||
with _cross_process_init_lock(path):
|
||||
# Read-only file/sidecar preflight (port of kilocode#12508) —
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -134,3 +135,103 @@ def test_unseen_events_for_sub_survives_migrated_db(tmp_path, monkeypatch):
|
||||
)
|
||||
assert isinstance(cursor, int)
|
||||
assert isinstance(events, list)
|
||||
|
||||
|
||||
def _default_board_db(tmp_path, monkeypatch) -> Path:
|
||||
"""Point the kanban root at a temp home and return the default board's DB
|
||||
(the back-compat top-level ``<root>/kanban.db`` #83445 reports on)."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
db_path = kb.kanban_db_path(board="default")
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
|
||||
return db_path
|
||||
|
||||
|
||||
def _tables(path: Path) -> set[str]:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
return {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_connect_reinitializes_schema_when_db_file_vanished(tmp_path, monkeypatch):
|
||||
"""#83445: the schema cache is process-local, but the schema is on disk.
|
||||
|
||||
A long-lived process (gateway, dispatcher, dashboard API) that already
|
||||
initialized a path keeps taking the ``_INITIALIZED_PATHS`` fast path after
|
||||
the file is deleted underneath it. SQLite recreates an empty DB on the next
|
||||
open, so every query then fails with ``no such table: tasks`` and the board
|
||||
renders empty until that process itself is restarted.
|
||||
"""
|
||||
db_path = _default_board_db(tmp_path, monkeypatch)
|
||||
|
||||
with kb.connect_closing(db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, title, status, created_at) VALUES ('t-1', 'T', 'ready', 1000)"
|
||||
)
|
||||
conn.commit()
|
||||
assert str(db_path.resolve()) in kb._INITIALIZED_PATHS
|
||||
|
||||
# External deletion (manual cleanup, restore, sync tool) while the process
|
||||
# that cached this path is still alive.
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
db_path.with_name(db_path.name + suffix).unlink(missing_ok=True)
|
||||
|
||||
with kb.connect_closing(db_path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 0
|
||||
assert "tasks" in _tables(db_path)
|
||||
|
||||
|
||||
def test_connect_reinitializes_schema_when_db_replaced_by_empty_file(tmp_path, monkeypatch):
|
||||
"""Same defect, restore shape: the file still exists and passes both the
|
||||
header and the integrity probes, but carries no schema at all."""
|
||||
db_path = _default_board_db(tmp_path, monkeypatch)
|
||||
|
||||
with kb.connect_closing(db_path):
|
||||
pass
|
||||
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
db_path.with_name(db_path.name + suffix).unlink(missing_ok=True)
|
||||
sqlite3.connect(str(db_path)).close()
|
||||
assert "tasks" not in _tables(db_path)
|
||||
|
||||
with kb.connect_closing(db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, title, status, created_at) VALUES ('t-2', 'T', 'ready', 1000)"
|
||||
)
|
||||
conn.commit()
|
||||
assert "tasks" in _tables(db_path)
|
||||
|
||||
|
||||
def test_healthy_fast_path_stays_lock_free(tmp_path, monkeypatch):
|
||||
"""The self-heal must cost nothing in steady state: an intact cached path
|
||||
still skips the cross-process init lock (#36644), and only pays for it when
|
||||
the schema is actually gone."""
|
||||
db_path = _default_board_db(tmp_path, monkeypatch)
|
||||
|
||||
with kb.connect_closing(db_path):
|
||||
pass
|
||||
|
||||
locks: list[Path] = []
|
||||
real_lock = kb._cross_process_init_lock
|
||||
|
||||
@contextlib.contextmanager
|
||||
def recording_lock(path):
|
||||
locks.append(path)
|
||||
with real_lock(path):
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(kb, "_cross_process_init_lock", recording_lock)
|
||||
|
||||
with kb.connect_closing(db_path):
|
||||
pass
|
||||
assert locks == []
|
||||
|
||||
db_path.unlink()
|
||||
with kb.connect_closing(db_path):
|
||||
pass
|
||||
assert len(locks) == 1
|
||||
|
||||
Reference in New Issue
Block a user