Skip to content

fix(store): persistent WAL + version-gated migrations to stop multi-process SQLite corruption#613

Open
jarl9801 wants to merge 5 commits into
Gentleman-Programming:mainfrom
jarl9801:fix/wal-persist-and-migration-gate
Open

fix(store): persistent WAL + version-gated migrations to stop multi-process SQLite corruption#613
jarl9801 wants to merge 5 commits into
Gentleman-Programming:mainfrom
jarl9801:fix/wal-persist-and-migration-gate

Conversation

@jarl9801

@jarl9801 jarl9801 commented Jul 15, 2026

Copy link
Copy Markdown

Closes #477 · Fixes #559 · Addresses #571 (and the DB-lifecycle item in #508)

Problem

With one engram mcp per agent session plus engram serve and periodic CLI/sync runs, ~10 processes hold ~/.engram/engram.db open concurrently. Two corruption mechanisms have hit my store six times since April (details in #477):

  1. Startup migration race — every subcommand runs the full migrate() suite plus repairEnrolledProjectSyncMutations() with no inter-process exclusion. The destructive check-then-act rebuilds (migrateFTSTopicKey, migrateSyncChunksTable, migrateLegacyObservationsTable) can double-run under concurrent cold starts → cross-linked pages / database disk image is malformed (11).
  2. WAL/SHM unlink race — SQLite's last-closer deletes -wal/-shm, deciding "last" from its own SHM mapping. A process whose SHM is a deleted orphan cannot see live lockers, so a clean shutdown deletes the LIVE WAL/SHM and orphans the next generation of processes (observed live on 2026-07-15; forensic signature: lsof +L1 shows engram PIDs holding NLINK=0 -wal/-shm inodes).

Additionally journal_mode=WAL ran before busy_timeout was set, so concurrent cold starts die instantly on SQLITE_BUSY (#559).

Fix

  • Per-connection config via DSN _pragma params (busy_timeout(5000)journal_mode(WAL)synchronous(NORMAL)foreign_keys(1)): applied by the driver on every physical connection — including pool replacements after a context-interrupted query, which previously would have silently dropped the session config (modernc discards interrupted conns via ResetSession/IsValid).
  • Persistent WAL via a sqlite.RegisterConnectionHook calling FileControlPersistWAL("main", 1) on every new conn (strictly verified for the store's own DB in primeConnection; best-effort elsewhere). With persist-WAL, no closer ever unlinks -wal/-shm, eliminating mechanism 2 at the root.
  • PRAGMA user_version-gated migrations (schemaVersion = 1): fast read-only startup when current; when stale, an exclusive advisory file lock (<DataDir>/.migrate.lock, flock on unix / LockFileEx on Windows, non-blocking retries bounded at 60s with a clear diagnostic) serializes the whole migrate+repair block across processes, with a double-checked re-read under the lock. user_version is stamped only after success, and a newer stamp is warn-and-skip (mixed-version safety). repairEnrolledProjectSyncMutations still runs on every open (original self-heal semantics) but now inside the lock.
  • Cold-start WAL-switch retry: primeConnection retries the first pooled conn with bounded backoff, since DSN pragmas run inside driver.Open (fix(store): journal_mode=WAL switch dies instantly on concurrent cold start #559).

Tests

New internal/store/startup_gate_test.go (+ lock timeout test): persistent WAL survives Close; connection-replacement test (context-interrupted query → pool replaces conn → busy_timeout/journal_mode/foreign_keys/persist-WAL all still active, -wal survives close); concurrent cold start with 8 goroutines and with 3 real child processes (exactly one migration run, integrity_check ok); user_version gating (skip on second open, newer-stamp untouched); lock exclusion + timeout diagnostics. go test ./internal/store/ -race -count=3 green; cross-builds verified for windows/amd64 and linux/amd64.

Live smoke on a real workload (macOS ARM64): 4 concurrent cold-start save processes — all succeed, -wal persists after every exit, user_version=1, integrity_check ok. Running this build as my daily binary since 2026-07-15.

Notes for review

  • The advisory file lock was chosen over one wrapping BEGIN IMMEDIATE because migrate() helpers open their own transactions internally; each destructive rebuild keeps its per-step transaction for crash atomicity. The lock file is intentionally never unlinked.
  • schemaVersion starts at 1 (= the fully migrated v1.19.0 schema); bump it whenever migrate() grows. Existing databases stamp on their first patched open (idempotent — the rebuilds skip when the schema is current).
  • Happy to split into two PRs (WAL persistence vs migration gate) if you prefer — they're independent commits.

Summary by CodeRabbit

  • Bug Fixes

    • Improved database startup reliability when multiple application instances open the database simultaneously.
    • Prevented duplicate migrations and reduced risks of database corruption during interrupted or concurrent startups.
    • Improved recovery and consistency for SQLite write-ahead logging and interrupted queries.
    • Added safeguards for databases using newer schema versions, leaving them unchanged rather than applying incompatible migrations.
  • Tests

    • Expanded coverage for concurrent startup, migration locking, WAL persistence, schema compatibility, and connection recovery.

jarl9801 added 5 commits July 15, 2026 11:29
… WAL files

Cold-start ordering bug: journal_mode = WAL ran before busy_timeout was
set, so concurrent engram processes racing the rollback-journal-to-WAL
conversion failed immediately with SQLITE_BUSY (Gentleman-Programming#559). busy_timeout now
comes first and the WAL switch is wrapped in a bounded growing-backoff
retry (mirroring withSQLiteWriteRetry) for the lock paths busy_timeout
does not cover.

Enable SQLITE_FCNTL_PERSIST_WAL on the single pooled connection via
modernc.org/sqlite's FileControl so closing the last connection no longer
unlinks the -wal/-shm files. The last-closer unlink, combined with
divergent SHM views across process generations, allowed a process holding
an orphaned SHM to delete the live WAL on clean shutdown and corrupt the
database (Gentleman-Programming#477, Gentleman-Programming#571).

Both New and newWithoutRepair now share one newStore path so the pragma
setup can never diverge again.
… lock

Every engram subcommand ran the full migrate() suite plus the enrolled-
project sync repair unconditionally and without inter-process exclusion.
Three migrations are destructive check-then-act rebuilds (FTS topic_key,
sync_chunks, legacy observations): two processes racing a cold start could
both pass the check and double-run DROP/recreate, corrupting the database.

Startup now reads PRAGMA user_version and compares it to a new
schemaVersion constant (1 = the fully-migrated v1.19.0 schema):

- equal: skip migrate() and the startup repair entirely (fast read-only
  startup for every warm process).
- older: take an exclusive advisory file lock on <DataDir>/.migrate.lock,
  re-read user_version under the lock (another process may have finished
  first), run migrate() plus the repair, stamp user_version, unlock.
- newer: warn and skip — never run old migrations against a schema
  written by a newer engram.

A file lock (flock on unix, LockFileEx on Windows) was chosen over one
giant BEGIN IMMEDIATE transaction because migrate() executes dozens of
statements directly on s.db and its rebuild helpers open their own
transactions internally; wrapping the suite in a single SQL transaction
would nest BEGINs and require rewriting every helper. The per-step
transactions inside the destructive rebuilds keep crash atomicity; the
file lock adds the missing cross-process mutual exclusion.

The user_version stamp is written only after migrate() and the repair
both succeed, so a failed run is retried on the next startup.

Promotes golang.org/x/sys to a direct dependency for LockFileEx.
- persistent WAL: the -wal file must survive Close (a last-closer unlink
  here is the Gentleman-Programming#477/Gentleman-Programming#571 corruption mechanism).
- concurrent cold start: 8 goroutines and 3 child processes open the same
  fresh database simultaneously; all must succeed, the migration suite
  must run exactly once, and the result must pass integrity_check (Gentleman-Programming#559).
- user_version gate: a second open skips the migration suite (asserted
  via migrateRunCount), and a database stamped by a newer engram is
  opened without running this binary's migrations and without rewriting
  its user_version.
- acquireMigrationLock: a second acquire blocks until the first holder
  releases.
…lock wait

Three fixes from reliability review:

Connection replacement (critical). The session pragmas and persist-WAL
were applied once, to the first pooled connection only. database/sql
silently discards a modernc connection after a context-cancelled query
interrupts it (IsValid/ResetSession fail once sqlite3_is_interrupted) and
opens an unconfigured replacement — busy_timeout back to 0 (Gentleman-Programming#559 returns)
and persist-WAL unset (Gentleman-Programming#477 returns) for the rest of the process life.
Production paths pass cancellable request contexts into the pool (server
and MCP doctor handlers), so this was reachable. Now:
- the four pragmas travel in the DSN as _pragma query parameters, which
  the driver applies to every physical connection it opens (and it orders
  busy_timeout ahead of the WAL switch itself);
- persist-WAL is enabled by a driver-global RegisterConnectionHook
  (registered once via sync.Once, best-effort so exotic VFS opens never
  break) and strictly verified on the store's own database at startup;
- the cold-start SQLITE_BUSY retry now wraps the first connection open,
  because DSN pragmas run inside driver.Open and a lost WAL-switch race
  fails the open as a whole. Replacement opens re-run journal_mode(WAL)
  as a cheap no-op read once the database is in WAL mode.

Repair frequency. The user_version gate had reduced the enrolled-project
sync repair from every-open to once per schema generation, regressing its
self-healing contract. The repair runs on every open again, but inside
the same exclusive migration lock so N concurrent startups cannot storm
identical backfill writes. Exclusive (not shared) locking is correct
because the repair writes when a backfill is needed; the healthy-path
cost is one brief lock hold plus the repair's read-only fast path.

Lock robustness. The migration lock was a blocking flock: a hung holder
would silently stall every engram process on the machine forever. Both
platform paths now acquire non-blocking (LOCK_NB / LOCKFILE_FAIL_IMMEDIATELY)
inside a bounded retry loop (poll capped at 100ms, 60s total budget) and
fail with an error naming the lock file and pointing at a stuck engram
process. Shared loop lives in migration_lock.go; platform files provide
only try/unlock primitives.
- TestConnectionReplacementKeepsConfiguration: plants a session-scoped
  cache_size marker, interrupts a long query via context timeout so the
  pool discards the poisoned connection, proves the physical connection
  was replaced (marker gone), and asserts the replacement is fully
  configured: busy_timeout 5000, journal_mode wal, foreign_keys on, and
  persist-WAL mode 1 via the file-control query — then writes, closes,
  and checks the -wal file still exists.
- TestAcquireMigrationLockTimesOutWithDiagnostic: with a shortened
  timeout, a second acquire against a held lock fails after the budget
  with an error naming the lock file and a stuck engram process.
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

SQLite startup now configures persistent WAL on every physical connection, retries initial connection setup, and gates migrations with PRAGMA user_version under a cross-platform advisory lock. New tests cover WAL persistence, migration concurrency, connection replacement, schema versions, and lock behavior.

Changes

SQLite startup and migrations

Layer / File(s) Summary
Connection setup and persistent WAL
internal/store/store.go, internal/store/startup_gate_test.go
Store construction uses DSN pragmas, persistent-WAL connection hooks, and bounded startup retries; tests verify WAL persistence and replacement-connection configuration.
Cross-platform migration lock
internal/store/migration_lock.go, internal/store/migration_lock_unix.go, internal/store/migration_lock_windows.go, go.mod, internal/store/startup_gate_test.go
A timeout-bounded advisory lock is implemented with Unix flock and Windows LockFileEx, with tests for exclusion and timeout diagnostics.
Startup migration gating
internal/store/store.go, internal/store/startup_gate_test.go
Migrations read and update user_version while holding the migration lock; tests cover repeated opens, newer schema versions, goroutine concurrency, and cross-process cold starts.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StoreNew
  participant SQLitePool
  participant MigrationLock
  participant MigrationSuite
  StoreNew->>SQLitePool: open with DSN pragmas
  StoreNew->>SQLitePool: prime connection and enable persistent WAL
  StoreNew->>MigrationLock: acquire exclusive migration lock
  StoreNew->>SQLitePool: read user_version
  StoreNew->>MigrationSuite: run migrations when required
  MigrationSuite->>SQLitePool: persist schema changes
  StoreNew->>SQLitePool: stamp schemaVersion
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: persistent WAL handling and version-gated SQLite startup migrations to prevent multi-process corruption.
Linked Issues check ✅ Passed The PR adds WAL persistence, cold-start retrying, schema-version gating, and serialized startup migration/repair, covering the linked corruption and startup-race issues.
Out of Scope Changes check ✅ Passed The added lock helper, tests, and dependency adjustment are all directly related to the SQLite startup and corruption fixes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/store/startup_gate_test.go`:
- Around line 257-269: Update the cold-start child-process setup around
TestColdStartChildProcess so it uses explicit ready/release synchronization:
have each child signal readiness, wait until the parent confirms all children
are ready, then release every child immediately before New. Ensure the parent
waits for all readiness signals before releasing them, preserving deterministic
concurrent WAL and migration contention coverage.

In `@internal/store/store.go`:
- Around line 823-855: Update the migration flow around the migration-lock
acquisition to re-read user_version unconditionally after locking, including
when the initial version was current. If the re-read version exceeds
schemaVersion, return before running either s.migrate or
repairEnrolledProjectSyncMutations; otherwise preserve the existing migration
and repair behavior. Extend TestNewerSchemaVersionIsLeftUntouched to verify
repair is not invoked.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1bb9d49e-37d6-4814-808c-3aa48da07de8

📥 Commits

Reviewing files that changed from the base of the PR and between be4b613 and a5cd6d8.

📒 Files selected for processing (6)
  • go.mod
  • internal/store/migration_lock.go
  • internal/store/migration_lock_unix.go
  • internal/store/migration_lock_windows.go
  • internal/store/startup_gate_test.go
  • internal/store/store.go

Comment on lines +257 to +269
cmds := make([]*exec.Cmd, procs)
outputs := make([]*strings.Builder, procs)
for i := range cmds {
outputs[i] = &strings.Builder{}
cmd := exec.Command(exe, "-test.run", "^TestColdStartChildProcess$", "-test.v", "-test.timeout", "60s")
cmd.Env = append(os.Environ(), coldStartChildEnv+"="+dir)
cmd.Stdout = outputs[i]
cmd.Stderr = outputs[i]
if err := cmd.Start(); err != nil {
t.Fatalf("start child %d: %v", i, err)
}
cmds[i] = cmd
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Synchronize the child processes before cold start.

cmd.Start() is not a concurrency barrier; an early child can complete New before later children reach it, allowing this test to pass without exercising cross-process WAL and migration contention. Add ready/release signaling so every child waits immediately before New.

As per path instructions, “Tests must be deterministic. Behavior changes without tests should be blocked.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/startup_gate_test.go` around lines 257 - 269, Update the
cold-start child-process setup around TestColdStartChildProcess so it uses
explicit ready/release synchronization: have each child signal readiness, wait
until the parent confirms all children are ready, then release every child
immediately before New. Ensure the parent waits for all readiness signals before
releasing them, preserving deterministic concurrent WAL and migration contention
coverage.

Source: Path instructions

Comment thread internal/store/store.go
Comment on lines +823 to 855
skipMigrate := shouldSkipMigrations(current)
if skipMigrate && !withRepair {
return nil // schema current and no repair requested: nothing to do
}

dbPath := filepath.Join(cfg.DataDir, "engram.db")
db, err := openDB("sqlite", dbPath)
unlock, err := acquireMigrationLock(filepath.Join(s.cfg.DataDir, ".migrate.lock"))
if err != nil {
return nil, fmt.Errorf("engram: open database: %w", err)
return fmt.Errorf("engram: acquire migration lock: %w", err)
}
defer unlock()

ranMigrate := false
if !skipMigrate {
// Double-check under the lock: another process may have completed
// the migration while this one was waiting on the lock.
current, err = s.readUserVersion()
if err != nil {
return fmt.Errorf("engram: re-read user_version: %w", err)
}
if !shouldSkipMigrations(current) {
migrateRunCount.Add(1)
if err := s.migrate(); err != nil {
return fmt.Errorf("engram: migration: %w", err)
}
ranMigrate = true
}
}
db.SetMaxOpenConns(1)

pragmas := []string{
"PRAGMA journal_mode = WAL",
"PRAGMA busy_timeout = 5000",
"PRAGMA synchronous = NORMAL",
"PRAGMA foreign_keys = ON",
if withRepair {
if err := s.repairEnrolledProjectSyncMutations(); err != nil {
return fmt.Errorf("engram: repair enrolled sync journal: %w", err)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not run the old repair against a newer schema.

shouldSkipMigrations returns true for both current and future versions, but withRepair still executes repairEnrolledProjectSyncMutations. An old binary can therefore write to a newer schema. Additionally, when the initial version is current, it is not rechecked after waiting for the lock.

Re-read user_version unconditionally under the lock and return before any migration or repair when it exceeds schemaVersion. Extend TestNewerSchemaVersionIsLeftUntouched to assert the repair is not invoked.

Also applies to: 871-879

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/store.go` around lines 823 - 855, Update the migration flow
around the migration-lock acquisition to re-read user_version unconditionally
after locking, including when the initial version was current. If the re-read
version exceeds schemaVersion, return before running either s.migrate or
repairEnrolledProjectSyncMutations; otherwise preserve the existing migration
and repair behavior. Extend TestNewerSchemaVersionIsLeftUntouched to verify
repair is not invoked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant