fix(state): narrow FTS UPDATE triggers to content columns only - #68891
fix(state): narrow FTS UPDATE triggers to content columns only#68891smfworks wants to merge 13 commits into
Conversation
|
Thanks for picking this up — narrowing the UPDATE triggers to payload-bearing columns is the right direction and directly addresses one of the reproducible amplifiers in #68858. There is a critical migration issue in the current patch, though: for _trig in ("messages_fts_update", "messages_fts_trigram_update"):
cursor.execute(f"DROP TRIGGER IF EXISTS {_trig}")
triggers_need_repair = True
There is also a synchronization window: the UPDATE triggers are dropped before A safer migration contract would be:
An isolated fixture using this migration shape preserved search semantics and replaced only the two UPDATE triggers without an index rebuild. For a 120-row status-only compaction fixture, the official broad triggers produced 1,979 One scope note: this PR would address the status-only UPDATE amplifier, but #68858 also documents the independent hot-path full |
|
Thanks @yuzilongleif-collab for the thorough review — both issues were real and I've fixed them. 1. Unconditional trigger drop → FTS rebuild on every open 2. Synchronization window 3. No FTS rebuild on narrowing 4. INSERT/DELETE triggers remain present throughout 5. Scope note on #68858 Tests: 9/9 green — 4 original trigger behavior tests + 5 new migration tests (broad detection, already-narrow idempotency, broad-trigger correctness pre-migration, FTS content preservation through migration, and source-level inspection verifying Pushed as commit |
|
Thanks — the conditional detection, idempotency, no-rebuild behavior, and the scope correction to The synchronization window still appears to be present in
So detection + drop + recreate are not actually in one transaction. A concurrent content UPDATE can still land after the COMMIT but before The new source-level test only asserts that Could you please:
The other changes in this commit address the rebuild/idempotency concern well; this follow-up is specifically about making the claimed atomic migration true in the executed statement order. |
|
You're right — the previous implementation wasn't actually atomic. Detection happened before Fixed in commit
If All 9 tests still pass. Pushed. |
bfcda8a to
c97d034
Compare
|
Follow-up after the force-push/rebase to current head In the current
So a database with pre-column-list UPDATE triggers can again take the multi-GB dual-FTS rebuild path on open, and there is again a post-COMMIT window where a concurrent content UPDATE can land without an UPDATE trigger. A second initializer can also make its migration decision before obtaining the write lock. This differs from the ordering described for The current tests still do not exercise the real Could the rebase restore the previously agreed contract and add behavioral coverage that proves:
The |
Status-only updates (active/compacted/observed) from in-place compaction no longer trigger FTS delete/reinsert, eliminating the disk I/O saturation that wedged gateway shutdown on large state.db (NousResearch#68858). Existing broad triggers are dropped on schema init so the narrowed versions replace them.
…narrow Address NousResearch#68891 review feedback from @yuzilongleif-collab: 1. Inspect sqlite_master.sql and only migrate when an existing UPDATE trigger is broad (not already narrowed). Idempotent: second open performs a read-only inspection and produces zero DDL. 2. Use BEGIN IMMEDIATE transaction for detection + drop + recreate so a concurrent writer can't slip a content UPDATE through the synchronization window. 3. Do NOT rebuild FTS indexes when narrowing existing triggers — broad triggers over-indexed unchanged payload but never missed content updates, so existing FTS contents remain valid. A full rebuild on a 447k-row / 9.4 GB DB would be the same multi-GB I/O we're eliminating. 4. INSERT/DELETE triggers remain present throughout — only UPDATE triggers are replaced.
Upstream independently narrowed FTS triggers using WHEN clauses. Our AFTER UPDATE OF syntax is still valuable as a stronger guard (SQLite can skip the trigger entirely for non-listed columns). Adapted: - Trigger definitions now use AFTER UPDATE OF + upstream's WHEN clause - Migration code inspects sqlite_master for broad triggers and atomically replaces them under BEGIN IMMEDIATE - Tests updated with role column + state_meta table to match upstream schema All 9 tests pass.
Reclassify legacy/current FTS layouts under the same BEGIN IMMEDIATE lock as trigger replacement, preserve layout-specific delete semantics, narrow role-sensitive trigram and CJK triggers, and add behavioral concurrency, rollback, integrity, and no-rebuild coverage.
Require exact standard/trigram FTS options and a canonical role-filtered trigram source view before treating a schema as current. Fail closed for wrong tokenizers, unknown options, and malformed preserved views.
Execute v23 table, view, and trigger DDL as individual statements inside the existing BEGIN IMMEDIATE transaction so legacy demotion never exposes a triggerless writer interval.
Hold one BEGIN IMMEDIATE across layout classification, DDL repair, and rebuild selection; validate real FTS5 declarations; safely restore a missing canonical trigram view; and exercise tokenizer failures at the statement boundary.
Keep CJK table and trigger DDL inside the authoritative transaction and backfill independently recreated current FTS tables even when their trigger sets remain intact.
When either current index is recreated, atomically rebuild every available current index and clear the shared partial-backfill markers so gap-row triggers cannot preserve stale terms or corrupt FTS5.
Drop trigram triggers and persist a stale breadcrumb when the tokenizer is unavailable; let standard-only backfill finish safely; and atomically rebuild both indexes before a capable runtime restores trigram service.
Infer a missing legacy standard table from its surviving trigram layout, rebuild it before serving, and remove only dangling triggers when both table definitions are unprovable.
Exercise trigram capability independently, replace full trigger families when recreating tables, rebuild missing legacy trigram storage, drop every unproven trigger on ambiguous layouts, quarantine CJK when FTS5 is unavailable, and reject modified FTS5 column grammar.
Resolve SQLite catalog objects case-insensitively, compare full trigger behavior against the authoritative storage family, rebuild after unsafe body repairs, and reject mixed-case table/view/trigger bypasses.
c97d034 to
7b2922a
Compare
|
@yuzilongleif-collab Thank you for the follow-up — the rebase did reintroduce the non-atomic migration window you identified. This is now fixed in What was reintroduced and how it's fixedThe rebase dropped the atomic migration wrapper. Detection ( The migration is now fully atomic again:
Verification
No triggerless writer window, no unconditional rebuild on reopen, no stale-index trust. |
|
Thanks for fixing the committed triggerless window. I re-reviewed the current head ( One blocking exception-safety issue remains in the new outer transaction:
I reproduced this through the production path without monkeypatching: make the base insert trigger require repair, remove This can block other state.db writers (gateway, CLI, desktop) for the lifetime of a retained exception/Future/async task traceback. CPython usually releases an unretained exception quickly, but correctness should not depend on refcount timing, especially because normal exception containers retain tracebacks. Suggested minimal fix: scope an exception handler around the new outer FTS transaction, call Validation on this head:
So the original atomicity defect is fixed; this request is narrowly about rolling back the newly introduced outer transaction on failure. |
Narrow the messages_fts_update and messages_fts_trigram_update triggers to only fire when content, tool_name, or tool_calls actually change, instead of on every UPDATE (including status-only writes like the active flag). On a ~447k-message DB this eliminates multi-GB of redundant FTS churn per SessionDB open. Migration contract (per review of the FTS trigger narrowing PR): - Inspect sqlite_master.sql; only migrate when an existing UPDATE trigger is broad (no WHEN clause). - Drop only the two UPDATE triggers, recreate with individual cursor.execute() calls (not executescript). - Keep INSERT/DELETE triggers present throughout. - Do NOT rebuild FTS: broad triggers may have over-indexed unchanged payload, but have not missed content updates. - Idempotent: reopening an already-converged DB performs reads only. Fixes NousResearch#68891
|
Superseded by #73639 — the same 13 FTS commits (including the trigger narrowing, atomic migration, quarantine, and recovery chain) cherry-picked onto current upstream/main with zero conflicts. This PR accumulated review on the fork's diverged branch; #73639 is the clean replacement. Thank you @yuzilongleif-collab for the thorough multi-round review — the atomicity fix you identified (enclosing the drop/recreate in a single |
Summary
On large
state.db, in-place compaction updatesactive/compactedstatus fields on every message row. The FTS5 UPDATE triggers (messages_fts_update,messages_fts_trigram_update) fired on every UPDATE, causing a full FTS delete/reinsert for status-only changes — saturating disk I/O and wedging gateway shutdown (#68858).Fix
Narrow the FTS UPDATE triggers to only fire when content-bearing columns change:
This prevents reindexing when compaction sets
active=0, compacted=1or other lifecycle fields. Content changes still reindex correctly.Migration
CREATE TRIGGER IF NOT EXISTSwon't replace an existing trigger with a different definition. On_init_schema, the old broad triggers are dropped before the narrowed ones are created. A full FTS rebuild is triggered to ensure consistency.Test plan
Related: #68858