Files
Baradb/docs/superpowers/specs/2026-07-30-engine-persistence-design.md
T

5.4 KiB

Engine Persistence (C1) — Design

Date: 2026-07-30 Status: Approved direction (user: "продължи"); implementation follows.

Problem

After a server restart, FTS indexes, HNSW vector indexes, and graph objects silently vanish while the underlying rows survive (LSM+WAL):

  • CREATE INDEX ... USING FTS builds an in-memory InvertedIndex only (ctx.ftsIndexes); the DDL is never persisted under _schema:. DML keeps updating it in memory (exec/dml.nim:81-89, 295-304), but nothing writes it down.
  • Same for HNSW (ctx.vectorIndexes) and graphs (ctx.graphs; only the backing <name>_nodes/_edges tables are durable).
  • restoreSchema (exec/schema.nim:167-231) restores tables, views, triggers, users, policies and rebuilds B-tree secondary indexes — but not these three engines. Their CREATE DDL is not even stored.
  • User-visible effect: hybrid search and graph queries return silently empty results after restart (guards in exec/eval.nim:103,150,968-984), and new inserts no longer update the lost indexes. This is a correctness bug, not a performance gap.

Goal

FTS indexes, HNSW vector indexes, and graphs survive a restart: after reopening the database, the same queries return the same results, and new DML keeps the indexes up to date.

Non-goals (later phases): snapshot-based persistence for fast startup (C2), columnar persistence (no ctx integration exists yet), Raft transport (C3).

Design

Follow the existing schema-durability pattern: DDL is serialized under stable _schema:<kind>:<name> keys in the LSM store and replayed at startup by restoreSchema.

1. Persist engine DDL

New key prefixes (mirroring SchemaTablePrefix etc. in exec/schema.nim:14-20):

  • _schema:ftsidx:<table>.<column> → the original CREATE INDEX ... USING FTS DDL
  • _schema:vecidx:<table>.<column> → the original CREATE INDEX ... USING HNSW/VECTOR DDL
  • _schema:graphs:<name> → the original CREATE GRAPH ... DDL

Written by the corresponding CREATE execution paths in the dispatcher (executeQueryImpl), deleted by DROP INDEX / DROP GRAPH, and cleaned up on DROP TABLE for keys belonging to that table (same sweep the current code does for _schema:tables: and secondary-index metadata — check and extend that path).

2. Restore on startup

restoreSchema runs after tables are restored and B-tree indexes rebuilt. New step, in this order:

  1. Scan _schema:ftsidx: / _schema:vecidx: keys, parse each DDL, and re-execute it through executeQuery (the same way table DDL is re-applied). The CREATE INDEX path already builds the in-memory index from a full table scan, so replay == rebuild, no new build logic.
  2. Scan _schema:graphs: keys and re-execute the CREATE GRAPH DDL. Caveat to resolve at implementation time: replaying CREATE GRAPH must not fail when the backing <name>_nodes/_edges tables already exist (they were restored as regular tables). If the CREATE GRAPH execution errors on existing tables, the restore path instead rebuilds the in-memory Graph object by scanning those tables (small new loader in the graph engine usage site — the engine has no table-scan loader today, only unused binary file save/load). Pick whichever is smaller and matches existing behavior; document the choice in the plan.

Failures during engine restore must not prevent startup: log a warning and continue (a corrupt engine DDL must not take the database down — matches the defensive style of restoreSchema).

3. Ordering constraints

  • Table restore + row data available BEFORE engine replay (indexes build from scans; graphs build from _nodes/_edges).
  • Engine replay runs before the context is served to connections (it is part of newExecutionContextrestoreSchema).

4. What does NOT change

  • In-memory update paths in exec/dml.nim (insert/update/delete keeping indexes current) — untouched; they work once the indexes exist again.
  • LSM/WAL mechanics; _schema table format; B-tree index rebuild.
  • Public API: none.

Testing

New suite in tests/test_schema_persist.nim (the existing persistence test file), TDD — each test fails before the fix:

  1. FTS: create table, insert docs, CREATE INDEX ... USING FTS, close DB, reopen, hybrid_search-style query / FTS MATCH query returns the doc (pre-fix: silently empty). Insert another doc after reopen and verify it is found too (index updates live again).
  2. Vector: same flow with an HNSW index and a vector search query.
  3. Graph: create graph, add nodes/edges, close, reopen, graph query returns the traversal (pre-fix: "[]").
  4. DROP INDEX removes the _schema:ftsidx:/:vecidx: key (no ghost rebuild after reopen). DROP TABLE removes engine keys for that table.
  5. Existing persistence tests keep passing (regression).

Verification gate: full nimble test green (currently 650 [OK]).

Risks

  • CREATE GRAPH replay semantics (see §2) — resolved during planning with a concrete read of the dispatcher's graph DDL path.
  • Startup cost: full-scan rebuilds are O(table size) per index, same as today's B-tree rebuild. Acceptable for C1; C2 (snapshots) addresses it.
  • Re-executing DDL through executeQuery inside restoreSchema — must not deadlock on ctx.sharedLock (DDL lock is taken by executeQuery; restoreSchema runs during context construction, before serving — verify no lock is held at that point).