fix(state): do not stamp empty FTS after interrupted optimize-storage demote

Demote wrote the empty v23 schema via executescript inside BEGIN IMMEDIATE,
which commits early and can leave trash + empty indexes without rebuild
markers. Re-run then tore down trash and stamped fts_storage_version with
docsize=0, permanently losing historical session search.

Stage markers with the demote, create schema only after they are durable,
heal empty-index bookkeeping on resume, and refuse settle until the base
index is populated. Settle refusal returns ok=False instead of raising,
and resume fails fast if the base v23 table cannot be re-created.

Orphan-marker repair only resets a missing fts_rebuild_progress to 0 once
the index is known empty: the chunk worker replays its whole selected id
range without an anti-join, so a partially indexed DB that lost only its
progress key is first reset to a known-empty surface, then rebuilt.

Ported onto the SessionDB mixin split (hermes_state_search.py /
hermes_state_schema.py).
This commit is contained in:
Adolanium
2026-07-30 17:50:48 +03:00
committed by kshitij
parent 4e0a775580
commit b2d5995fc6
3 changed files with 589 additions and 21 deletions

View File

@@ -730,7 +730,8 @@ class SessionSchemaMixin:
# FTS_STORAGE_VERSION; a legacy DB is left at whatever it had
# (absent/0) until `optimize-storage` runs. An INTERRUPTED
# optimize (legacy vtables already demoted, but rebuild markers
# or demoted trash tables still present) is NOT stamped either —
# or demoted trash tables still present, or an empty external
# index against non-empty messages) is NOT stamped either —
# the marker is the source of truth for "fully optimized", and
# `fts_optimize_available()` keeps offering the resume until the
# transition actually completes.
@@ -742,6 +743,7 @@ class SessionSchemaMixin:
"WHERE key = 'fts_rebuild_high_water' LIMIT 1"
).fetchone() is None
and not self._has_fts_trash(cursor)
and not self._fts_external_index_empty_with_messages(cursor)
):
self.set_meta(
"fts_storage_version", str(FTS_STORAGE_VERSION), cursor=cursor

View File

@@ -356,13 +356,169 @@ class SessionSearchMixin:
self._ensure_fts_cjk_schema(self._conn)
self._conn.commit()
def _fts_external_index_empty_with_messages(self, conn) -> bool:
"""True when the base FTS table exists but indexes nothing while
``messages`` has rows. Caller must hold ``self._lock``.
This is the post-demote empty-index shape: external-content FTS with
zero ``messages_fts_docsize`` rows against a non-empty messages table.
Healthy installs (and mid-backfill installs that still hold markers)
never match.
"""
try:
n_msg = conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
if int(n_msg) <= 0:
return False
# docsize is the authoritative "is this rowid indexed" surface for
# external-content FTS5; COUNT(*) on the virtual table itself is
# not reliable across SQLite builds.
n_fts = conn.execute(
"SELECT COUNT(*) FROM messages_fts_docsize"
).fetchone()[0]
return int(n_fts) == 0
except sqlite3.OperationalError:
# Table absent / FTS disabled mid-init — not this failure class.
return False
def _fts_index_known_empty(self, conn) -> bool:
"""True when the base external-content index holds no rows.
A missing table counts as empty: the schema ensure that follows
creates it fresh.
"""
try:
n = conn.execute(
"SELECT COUNT(*) FROM messages_fts_docsize"
).fetchone()[0]
return int(n) == 0
except sqlite3.OperationalError:
return True
def _reset_fts_index_to_empty(self, conn) -> None:
"""Delete every indexed row from the v23 external-content tables.
FTS5 supports a no-WHERE DELETE on external-content tables as an
efficient drop-all. The backfill chunk worker replays its whole
selected id range with no anti-join, so a replay from zero is only
safe once the index is known empty — this is how a partially indexed
DB gets there.
"""
for tbl in ("messages_fts", "messages_fts_trigram"):
try:
conn.execute(f"DELETE FROM {tbl}")
except sqlite3.OperationalError:
pass # table absent — already an empty surface
def _seed_fts_rebuild_markers(self, conn, *, force: bool = False) -> int:
"""Write ``fts_rebuild_high_water`` / ``fts_rebuild_progress`` for a
full backfill. Returns the high-water id.
When ``force`` is False and high_water is already set, only repairs a
missing progress key (stuck no-op when high_water exists alone), and
only after the index is known empty: the chunk worker replays its
whole selected id range without an anti-join, so a partially indexed
DB is first reset to a known-empty surface rather than rebuilt from
zero on top of surviving rows. Caller must hold the write
transaction / lock as appropriate.
"""
existing_hw = conn.execute(
"SELECT value FROM state_meta WHERE key = 'fts_rebuild_high_water'"
).fetchone()
if existing_hw is not None and not force:
hw = int(existing_hw[0])
progress = conn.execute(
"SELECT value FROM state_meta WHERE key = 'fts_rebuild_progress'"
).fetchone()
if progress is None:
# high_water without progress: fts_rebuild_step treats missing
# progress as "done by another process" and optimize would
# no-op then stamp. Re-seed progress so the chunk loop runs.
if not self._fts_index_known_empty(conn):
self._reset_fts_index_to_empty(conn)
conn.execute(
"INSERT INTO state_meta (key, value) VALUES "
"('fts_rebuild_progress', '0') "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
)
return hw
hw = conn.execute(
"SELECT COALESCE(MAX(id), 0) FROM messages"
).fetchone()[0]
for k, v in (
("fts_rebuild_high_water", str(hw)),
("fts_rebuild_progress", "0"),
):
conn.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(k, v),
)
return int(hw)
def _repair_optimize_bookkeeping(self) -> None:
"""Heal interrupted demote/backfill bookkeeping before optimize runs.
Covers two post-#65798 failure classes:
1. Empty external-content index with messages present and no rebuild
markers (demote crash window after empty v23 tables landed but
before markers, or settle that stamped without backfill). Seed a
full backfill.
2. ``fts_rebuild_high_water`` present without ``fts_rebuild_progress``
(partial meta) — seed progress so the chunk loop is not a no-op,
resetting a partially populated index to a known-empty surface
first so the anti-join-free chunk replay cannot duplicate rows.
Must not invent markers on a still-legacy inline DB: that would make
``optimize_fts_storage`` skip demote (``legacy and not pending``) and
attempt v23-shaped INSERTs against the inline table forever.
"""
def _do(conn):
existing_hw = conn.execute(
"SELECT value FROM state_meta "
"WHERE key = 'fts_rebuild_high_water'"
).fetchone()
if existing_hw is not None:
# Repair orphan high_water-without-progress only. Never
# invent a fresh claim on a healthy complete index.
progress = conn.execute(
"SELECT 1 FROM state_meta "
"WHERE key = 'fts_rebuild_progress'"
).fetchone()
if progress is None:
if not self._fts_index_known_empty(conn):
self._reset_fts_index_to_empty(conn)
conn.execute(
"INSERT INTO state_meta (key, value) VALUES "
"('fts_rebuild_progress', '0') "
"ON CONFLICT(key) DO UPDATE SET value = '0'"
)
return
# No markers. On a still-legacy DB demote owns marker creation.
if self._db_has_legacy_inline_fts(conn):
return
# Non-legacy empty external index (demote crash window / premature
# stamp): seed a full backfill claim.
if self._fts_external_index_empty_with_messages(conn):
conn.execute(
"DELETE FROM state_meta WHERE key = 'fts_storage_version'"
)
self._seed_fts_rebuild_markers(conn, force=True)
self._execute_write(_do)
def fts_optimize_available(self) -> bool:
"""True when `optimize_fts_storage()` has work to do: either this DB
is a legacy inline-FTS install that can be optimized to the v23
external-content schema, or a previous optimize run was interrupted
(legacy vtables already demoted, but backfill markers and/or trash
tables remain) and re-running would resume it, or the CJK-bigram
index needs a backfill/rebuild on this tokenizer-capable host.
index needs a backfill/rebuild on this tokenizer-capable host, or
a prior demote left an empty external-content index without markers
(healable on re-run).
False for fresh and fully-optimized installs (and when FTS5 is
unavailable)."""
if not self._fts_enabled or self.read_only:
@@ -388,14 +544,27 @@ class SessionSearchMixin:
f"('fts_cjk_rebuild_high_water', '{FTS_CJK_STALE_KEY}') LIMIT 1"
).fetchone():
return True
return self._has_fts_trash(self._conn)
if self._has_fts_trash(self._conn):
return True
# Pre-fix crash window: empty external-content index with
# messages still present, no markers, no trash (teardown already
# finished or never needed). Re-run seeds markers and backfills.
return self._fts_external_index_empty_with_messages(self._conn)
def _demote_legacy_fts_to_trash(self) -> int:
"""Demote the legacy inline FTS vtables and stage their shadow tables
for chunked teardown. Returns MAX(messages.id) as the rebuild high
water. O(1) schema surgery — the heavy delete is deferred to the
chunked teardown, exactly as the validated auto path did."""
def _do(conn):
chunked teardown, exactly as the validated auto path did.
Markers are written in the same BEGIN IMMEDIATE as the demote, *before*
the empty v23 schema is created. Schema creation uses
``executescript`` and therefore cannot run inside that transaction
(it issues an implicit COMMIT — see the CJK recreate path). Creating
the empty schema only after markers are durable closes the crash
window where trash + empty v23 tables exist with no backfill claim.
"""
def _stage(conn):
self._drop_fts_triggers(conn)
conn.execute("DROP VIEW IF EXISTS messages_fts_trigram_src")
had = bool(conn.execute(
@@ -420,22 +589,35 @@ class SessionSearchMixin:
]
for sh in shadows:
conn.execute(f"ALTER TABLE {sh} RENAME TO fts_v22_trash_{sh}")
# Create the new v23 empty schema + set the backfill markers.
self._ensure_fts_schema(conn, "messages_fts", FTS_SQL)
self._ensure_fts_schema(conn, "messages_fts_trigram", FTS_TRIGRAM_SQL)
hw = conn.execute("SELECT COALESCE(MAX(id), 0) FROM messages").fetchone()[0]
for k, v in (
("fts_rebuild_high_water", str(hw)),
("fts_rebuild_progress", "0"),
):
conn.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(k, v),
)
conn.execute("DELETE FROM state_meta WHERE key = 'fts_optimize_available'")
# Claim the backfill *before* empty v23 tables exist. A crash
# between this commit and schema ensure still leaves markers, so
# optimize-storage resumes instead of tearing down trash and
# stamping an empty index as complete.
hw = self._seed_fts_rebuild_markers(conn, force=True)
conn.execute(
"DELETE FROM state_meta WHERE key = 'fts_optimize_available'"
)
return hw
return int(self._execute_write(_do))
hw = int(self._execute_write(_stage))
# Create the empty v23 schema outside the write transaction —
# ``_ensure_fts_schema`` uses executescript(), which implicitly
# commits any pending transaction and must not run inside
# ``_execute_write``'s BEGIN IMMEDIATE (same rule as the CJK recreate
# path above). Markers are already durable.
with self._lock:
base_ok = self._ensure_fts_schema(self._conn, "messages_fts", FTS_SQL)
trigram_ok = self._ensure_fts_schema(
self._conn, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
self._trigram_available = bool(trigram_ok)
if not base_ok:
raise sqlite3.OperationalError(
"failed to create v23 messages_fts during optimize-storage demote"
)
self._conn.commit()
return hw
def optimize_fts_storage(
self,
@@ -458,6 +640,12 @@ class SessionSearchMixin:
if self.read_only:
return {"ok": False, "reason": "read_only"}
# Heal empty-index / orphan-marker bookkeeping from an interrupted
# demote *before* deciding whether to demote again. This re-seeds
# markers when trash was already staged (or torn down) without a
# backfill claim so the phases below actually run.
self._repair_optimize_bookkeeping()
# Only demote if we're actually still on the legacy shape. If a prior
# run already demoted (markers/trash present), skip straight to
# finishing the backfill + teardown — this is what makes re-running
@@ -467,6 +655,26 @@ class SessionSearchMixin:
pending = self.get_meta("fts_rebuild_high_water") is not None
if legacy and not pending:
self._demote_legacy_fts_to_trash()
elif pending and not legacy:
# Resume mid-demote: markers exist, empty v23 tables may still be
# missing if the process died between the staged demote commit and
# schema ensure. Re-ensure is IF NOT EXISTS and cheap.
with self._lock:
base_ok = self._ensure_fts_schema(
self._conn, "messages_fts", FTS_SQL
)
trigram_ok = self._ensure_fts_schema(
self._conn, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
self._trigram_available = bool(trigram_ok)
if not base_ok:
# Fail fast: without the base table the backfill loop
# below would retry "no such table" errors forever.
raise sqlite3.OperationalError(
"failed to re-create v23 messages_fts "
"on optimize-storage resume"
)
self._conn.commit()
# A stale CJK index (triggers dropped by a tokenizer-less process)
# can only be recovered from scratch — reset it now so the cjk
@@ -536,6 +744,29 @@ class SessionSearchMixin:
_emit("teardown")
_pause(time.monotonic() - _t0)
# Refuse to stamp "optimized" while work remains or the base index is
# still empty against a non-empty messages table. Pre-fix code could
# tear down trash and settle after a no-op backfill when markers were
# missing — permanent search-index loss for historical rows.
with self._lock:
still_pending = self._conn.execute(
"SELECT 1 FROM state_meta "
"WHERE key = 'fts_rebuild_high_water' LIMIT 1"
).fetchone() is not None
still_trash = self._has_fts_trash(self._conn)
empty_index = self._fts_external_index_empty_with_messages(self._conn)
if still_pending or still_trash or empty_index:
reason = (
"backfill_incomplete" if still_pending or empty_index
else "teardown_incomplete"
)
logger.warning(
"FTS storage optimization did not settle (%s): "
"pending=%s trash=%s empty_index=%s",
reason, still_pending, still_trash, empty_index,
)
return {"ok": False, "reason": reason, "vacuumed": None}
# Phase 3: reclaim freed pages to the OS.
vacuum_ok = None
if vacuum:
@@ -572,6 +803,18 @@ class SessionSearchMixin:
# DB opened only by pre-decoupling code still settles). The FTS-layout
# marker is the source of truth for "is this DB optimized".
def _settle(conn):
# Re-check inside the write transaction so a concurrent writer
# cannot race a stamp past incomplete work. Returns a refusal
# reason (stamping nothing) or None once the stamp is written.
if conn.execute(
"SELECT 1 FROM state_meta "
"WHERE key = 'fts_rebuild_high_water' LIMIT 1"
).fetchone() is not None:
return "backfill_incomplete"
if self._has_fts_trash(conn):
return "teardown_incomplete"
if self._fts_external_index_empty_with_messages(conn):
return "backfill_incomplete"
conn.execute(
"INSERT INTO state_meta (key, value) VALUES ('fts_storage_version', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -582,7 +825,17 @@ class SessionSearchMixin:
"UPDATE schema_version SET version = ? WHERE version < ?",
(SCHEMA_VERSION, SCHEMA_VERSION),
)
self._execute_write(_settle)
return None
refusal = self._execute_write(_settle)
if refusal is not None:
# A concurrent process re-seeded markers, left trash, or emptied
# the index between the pre-vacuum check above and this write
# transaction. Nothing was stamped. Report the failure instead of
# crashing the CLI with a traceback; a re-run can still settle.
logger.warning(
"FTS storage optimization settle refused (%s)", refusal
)
return {"ok": False, "reason": refusal, "vacuumed": vacuum_ok}
_emit("done")
logger.info(
"FTS storage optimization complete (layout v%d).", FTS_STORAGE_VERSION

View File

@@ -2319,6 +2319,319 @@ class TestFTSExternalContentMigration:
def _simulate_pre_fix_demote_crash_window(self, db):
"""Replay the pre-fix demote crash window: trash + empty v23 schema,
no rebuild markers (executescript committed mid-demote before markers).
Mirrors what happened when ``_ensure_fts_schema`` ran inside
``_execute_write`` and the process died before the marker writes.
"""
from hermes_state import FTS_SQL, FTS_TRIGRAM_SQL
conn = db._conn
db._drop_fts_triggers(conn)
conn.execute("DROP VIEW IF EXISTS messages_fts_trigram_src")
had = bool(conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name IN ('messages_fts', 'messages_fts_trigram') "
"AND sql LIKE 'CREATE VIRTUAL TABLE%' LIMIT 1"
).fetchone())
assert had, "sanity: expected legacy/virtual FTS tables to demote"
conn.execute("PRAGMA writable_schema=ON")
conn.execute(
"DELETE FROM sqlite_master WHERE type = 'table' "
"AND name IN ('messages_fts', 'messages_fts_trigram') "
"AND sql LIKE 'CREATE VIRTUAL TABLE%'"
)
conn.execute("PRAGMA writable_schema=RESET")
shadows = [
r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND (name LIKE 'messages_fts_%' ESCAPE '\\' "
"OR name LIKE 'messages_fts_trigram_%' ESCAPE '\\')"
).fetchall()
]
for sh in shadows:
conn.execute(f"ALTER TABLE {sh} RENAME TO fts_v22_trash_{sh}")
# executescript commits — empty v23 tables without markers.
conn.executescript(FTS_SQL)
try:
conn.executescript(FTS_TRIGRAM_SQL)
except sqlite3.OperationalError:
pass
# Intentionally leave fts_rebuild_* unset (the crash window).
def test_optimize_resume_after_demote_crash_window_restores_search(
self, tmp_path
):
"""Pre-fix: demote crash left trash + empty v23 index, no markers.
Re-run tore down trash and stamped optimized with docsize=0 — permanent
search loss for historical rows. Re-run must backfill and restore."""
db_path = tmp_path / "v22.db"
self._build_v22_db(db_path)
db = SessionDB(db_path=db_path)
try:
assert len(db.search_messages("deployment")) == 1
self._simulate_pre_fix_demote_crash_window(db)
# Crash window shape: no markers, trash present, empty index.
assert db.get_meta("fts_rebuild_high_water") is None
assert db.get_meta("fts_rebuild_progress") is None
assert db._has_fts_trash(db._conn) is True
assert db._conn.execute(
"SELECT COUNT(*) FROM messages_fts_docsize"
).fetchone()[0] == 0
assert len(db.search_messages("deployment")) == 0
# Still offered (trash and/or empty-index heal).
assert db.fts_optimize_available() is True
result = db.optimize_fts_storage(vacuum=False)
assert result["ok"] is True
assert db.fts_rebuild_status() is None
assert db.fts_optimize_available() is False
assert db.get_meta("fts_storage_version") == str(
hermes_state.FTS_STORAGE_VERSION
)
assert db._conn.execute(
"SELECT name FROM sqlite_master WHERE name LIKE '%_v22_trash%'"
).fetchall() == []
# Historical rows searchable again; index fully populated.
assert len(db.search_messages("deployment")) == 1
assert len(db.search_messages("TOOLBLOB")) == 1
n_msg = db._conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
n_fts = db._conn.execute(
"SELECT COUNT(*) FROM messages_fts_docsize"
).fetchone()[0]
assert n_fts == n_msg
db._conn.execute(
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
)
finally:
db.close()
def test_optimize_heals_premature_stamp_with_empty_index(self, tmp_path):
"""Pre-fix settle could stamp fts_storage_version after tearing down
trash with an empty index and no markers. Re-run must clear the stamp,
backfill, and re-earn the layout version."""
db_path = tmp_path / "v22.db"
self._build_v22_db(db_path)
db = SessionDB(db_path=db_path)
try:
self._simulate_pre_fix_demote_crash_window(db)
# Simulate the bad resume: trash already gone, empty index stamped.
trash = [
r[0] for r in db._conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name LIKE 'fts\\_v22\\_trash\\_%' ESCAPE '\\'"
).fetchall()
]
for tbl in trash:
db._conn.execute(f"DROP TABLE IF EXISTS {tbl}")
db._conn.execute(
"INSERT INTO state_meta (key, value) VALUES "
"('fts_storage_version', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(str(hermes_state.FTS_STORAGE_VERSION),),
)
db._conn.commit()
assert db.get_meta("fts_rebuild_high_water") is None
assert db._has_fts_trash(db._conn) is False
assert db._fts_external_index_empty_with_messages(db._conn) is True
# Must still be offered despite the premature stamp.
assert db.fts_optimize_available() is True
assert len(db.search_messages("deployment")) == 0
result = db.optimize_fts_storage(vacuum=False)
assert result["ok"] is True
assert len(db.search_messages("deployment")) == 1
assert db.get_meta("fts_storage_version") == str(
hermes_state.FTS_STORAGE_VERSION
)
assert db.fts_optimize_available() is False
finally:
db.close()
def test_optimize_heals_high_water_without_progress(self, tmp_path):
"""high_water without progress used to make fts_rebuild_step return
False immediately (treated as finished by another process), then
settle stamped success while the marker remained. Re-seed progress
and complete the empty-index backfill."""
db_path = tmp_path / "v22.db"
self._build_v22_db(db_path)
db = SessionDB(db_path=db_path)
try:
self._simulate_pre_fix_demote_crash_window(db)
hw = db._conn.execute(
"SELECT COALESCE(MAX(id), 0) FROM messages"
).fetchone()[0]
# Orphan shape: high_water alone on an empty external index.
db.set_meta("fts_rebuild_high_water", str(hw))
db._conn.execute(
"DELETE FROM state_meta WHERE key = ?", ("fts_rebuild_progress",)
)
db._conn.commit()
assert db.get_meta("fts_rebuild_progress") is None
assert db.fts_optimize_available() is True
# Empty index: base FTS MATCH finds nothing (gap LIKE may still
# supplement when high_water is set — that is intentional).
assert db._conn.execute(
"SELECT COUNT(*) FROM messages_fts_docsize"
).fetchone()[0] == 0
result = db.optimize_fts_storage(vacuum=False)
assert result["ok"] is True
assert db.get_meta("fts_rebuild_high_water") is None
assert db.get_meta("fts_rebuild_progress") is None
n_msg = db._conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
n_fts = db._conn.execute(
"SELECT COUNT(*) FROM messages_fts_docsize"
).fetchone()[0]
assert n_fts == n_msg
assert len(db.search_messages("deployment")) == 1
assert db.fts_optimize_available() is False
finally:
db.close()
def test_repair_rebuilds_partial_index_without_duplicates(self, tmp_path):
"""high_water without progress on a PARTIALLY indexed DB must not
replay the backfill from zero on top of surviving rows: the chunk
worker inserts its whole id range with no anti-join, so replay
duplicates every already-indexed row. Recovery must reset the index
to a known-empty surface first, then rebuild."""
db_path = tmp_path / "v22.db"
self._build_v22_db(db_path)
db = SessionDB(db_path=db_path)
try:
self._simulate_pre_fix_demote_crash_window(db)
hw = db._conn.execute(
"SELECT COALESCE(MAX(id), 0) FROM messages"
).fetchone()[0]
db.set_meta("fts_rebuild_high_water", str(hw))
db._conn.execute(
"DELETE FROM state_meta WHERE key = ?", ("fts_rebuild_progress",)
)
# Partial index: one row survived from an interrupted backfill.
db._conn.execute(
"INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) "
"SELECT id, content, tool_name, tool_calls FROM messages "
"WHERE id = 1"
)
db._conn.commit()
assert db._conn.execute(
"SELECT COUNT(*) FROM messages_fts_docsize"
).fetchone()[0] == 1
result = db.optimize_fts_storage(vacuum=False)
assert result["ok"] is True
n_msg = db._conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
n_fts = db._conn.execute(
"SELECT COUNT(*) FROM messages_fts_docsize"
).fetchone()[0]
# Exactly one index entry per message: no replay duplicates.
assert n_fts == n_msg
assert len(db.search_messages("deployment")) == 1
db._conn.execute(
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
)
finally:
db.close()
def test_repair_bookkeeping_reseeds_missing_progress(self, tmp_path):
"""Unit: high_water without progress gets progress='0' without
forcing a full marker reset when a real backfill is already claimed."""
db = SessionDB(db_path=tmp_path / "fresh.db")
try:
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="bookkeeping needle")
db.set_meta("fts_rebuild_high_water", "42")
db._conn.execute(
"DELETE FROM state_meta WHERE key = ?", ("fts_rebuild_progress",)
)
db._conn.commit()
db._repair_optimize_bookkeeping()
assert db.get_meta("fts_rebuild_high_water") == "42"
assert db.get_meta("fts_rebuild_progress") == "0"
finally:
db.close()
def test_demote_writes_markers_before_empty_schema(self, tmp_path):
"""Demote must commit rebuild markers before createscript builds the
empty v23 tables — so a crash between stage and ensure still leaves
a resumable claim rather than an unmarked empty index."""
db_path = tmp_path / "v22.db"
self._build_v22_db(db_path)
db = SessionDB(db_path=db_path)
try:
# Patch ensure to fail *after* the staged write commits, simulating
# death mid schema-create. Markers must already be durable.
orig_ensure = db._ensure_fts_schema
calls = {"n": 0}
def boom(cursor, table_name, ddl):
calls["n"] += 1
if table_name == "messages_fts":
# Markers must already be on disk from the staged write.
row = db._conn.execute(
"SELECT value FROM state_meta "
"WHERE key = 'fts_rebuild_high_water'"
).fetchone()
assert row is not None, (
"markers must be committed before empty v23 schema create"
)
progress = db._conn.execute(
"SELECT value FROM state_meta "
"WHERE key = 'fts_rebuild_progress'"
).fetchone()
assert progress is not None and progress[0] == "0"
raise sqlite3.OperationalError("simulated crash mid-ensure")
return orig_ensure(cursor, table_name, ddl)
db._ensure_fts_schema = boom # type: ignore[method-assign]
try:
db._demote_legacy_fts_to_trash()
raise AssertionError("demote should have raised")
except sqlite3.OperationalError as exc:
assert "simulated crash" in str(exc)
# Staged demote survived: markers + trash, no successful stamp.
assert db.get_meta("fts_rebuild_high_water") is not None
assert db.get_meta("fts_rebuild_progress") == "0"
assert db._has_fts_trash(db._conn) is True
assert db.get_meta("fts_storage_version") is None
# Restore ensure and resume — full optimize completes.
db._ensure_fts_schema = orig_ensure # type: ignore[method-assign]
result = db.optimize_fts_storage(vacuum=False)
assert result["ok"] is True
assert len(db.search_messages("deployment")) == 1
assert db.fts_optimize_available() is False
finally:
db.close()
def test_optimize_settle_refuses_pending_backfill(self, tmp_path):
"""Settle must not stamp while high_water markers remain."""
db = SessionDB(db_path=tmp_path / "fresh.db")
try:
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="settle guard needle")
# Plant markers without going through demote.
db.set_meta("fts_rebuild_high_water", "1")
db.set_meta("fts_rebuild_progress", "0")
# The public contract: optimize returns ok=False when still
# pending. Simulate an unfinishable backfill by stubbing the
# chunk step to a no-op while markers stay.
db.fts_rebuild_step = lambda: False # type: ignore[method-assign]
result = db.optimize_fts_storage(vacuum=False)
assert result["ok"] is False
assert result.get("reason") == "backfill_incomplete"
assert db.get_meta("fts_storage_version") is None
assert db.get_meta("fts_rebuild_high_water") is not None
finally:
db.close()
def test_v23_fresh_db_born_optimized(self, tmp_path):
"""A brand-new DB is born on v23 — no legacy layout, no opt-in flag,
no pending rebuild."""