Skip to content

fix(sqlite): move the non-daemon stores off WAL to rollback journalling (#356) - #977

Open
ziomik wants to merge 4 commits into
bradbrok:mainfrom
ziomik:fix/356-stores-rollback-stacked
Open

fix(sqlite): move the non-daemon stores off WAL to rollback journalling (#356)#977
ziomik wants to merge 4 commits into
bradbrok:mainfrom
ziomik:fix/356-stores-rollback-stacked

Conversation

@ziomik

@ziomik ziomik commented Aug 2, 2026

Copy link
Copy Markdown

Stacked on #968 — merge that first. This branch is based on #968's head, not on main, because it imports pinky_daemon.sqlite_journal, which #968 introduces and main does not yet have. Until #968 merges, the diff below shows its three commits too; the only commit belonging to this PR is the last one. Rebase is not needed after #968 merges — GitHub will narrow the diff on its own.

Closes the SQLite reliability thread opened on 2026-08-01. b5fad85, 87e2a60 and 77f0662 converted the daemon stores; this converts the five that live outside pinky_daemon and were explicitly left out of scope there.

What changes

PRAGMA journal_mode=WALconfigure_rollback_journal() in:

  • pinky_federation.state.FederationStateStore
  • pinky_hub.hub_store.HubStore
  • pinky_identity.bearer_tokens.BearerTokenStore
  • pinky_identity.signer_store.EncryptedSignerStore
  • pinky_memory.store.ReflectionStore — both the constructor and reopen(), which built a second connection and would have silently reverted that file to WAL

These import the daemon's helper rather than growing four copies of it. signer_store's security-notes docstring advertised WAL and now points at sqlite_journal instead. ReflectionStore's two explicit busy_timeout=5000 pragmas are dropped for the helper's 30000, matching #942.

Why this is not the low-priority cleanup it was filed as

Checked what is actually on disk in production right now. Four of the five stores have no database yetdata/federation/state.db, data/hub.db and both data/identity/*.db are absent; those subsystems aren't in use. The fifth is very much in use:

All nine per-agent memory.db files are on WAL with live -wal/-shm sidecars — aiena, fixer, sentinel, engineer, satoshi, alter-ego, writer, seo-pro, segugio. Orphaned -wal under a running daemon is the exact signature of the 2026-08-01 incident, and this is the same database that needed a hot-patch on 2026-08-02 for a poisoned row. So in practice this PR is a pinky_memory fix that also future-proofs four dormant stores.

Deploy note — please read before restarting prod

configure_rollback_journal() checkpoints any hot WAL and then raises SqliteJournalConfigError rather than silently staying on WAL (sqlite_journal.py:85-100). That is the right behaviour, but it means the first restart after this deploy converts nine live memory.db files in place.

pinky_memory runs as a per-agent MCP subprocess. If a subprocess from the old build still holds the WAL lock on a file the new one is converting, the checkpoint fails, the store raises, and that agent comes up with no memory instead of degrading quietly.

Mitigation: make sure the old MCP subprocesses are fully gone before the new ones start — a clean stop/start, not an overlapping rolling restart.

Testing

  • tests/test_non_daemon_stores_no_wal.py (new) — pins all five stores plus reopen(), asserts no -wal/-shm sidecars appear, and covers in-place migration of a database left in WAL by an older build
  • tests/pinky_federation/test_state.py pinned journal_mode == "wal"; converted, as 77f0662 did for the daemon stores
  • Targeted run: 71 passed (test_non_daemon_stores_no_wal, test_sqlite_journal, pinky_federation/test_state)
  • Full suite: 4620 passed, 2 skipped in 41:36 — run from an isolated worktree, never from the live checkout (these stores default to relative db_paths and would otherwise touch the production databases)

One caveat on that full-suite number, stated plainly: it was produced on the original base, before this branch was rebased onto #968's head to keep it clean of unrelated local commits. On the current base I have only re-run the 71 targeted tests, which pass. The change itself is byte-identical across both bases (git diff between them is empty), but the underlying base is not — it now includes #942 — so CI is the first full-suite run on this exact tree.

The on-disk assertion is != "wal" rather than == "truncate" on purpose: only WAL is sticky in the database header, so an independent opener reports the delete default. Both are rollback modes with no -wal to unlink, which is the property that matters.

Review note

pinky_identity, pinky_hub, pinky_federation and pinky_memory now import from pinky_daemon.sqlite_journal, so the MCP server packages take a dependency on the daemon package. pinky_daemon/__init__.py is a docstring and a version string, so this adds no import weight beyond sqlite3 — but it is a new coupling direction and worth an explicit opinion from a reviewer rather than being buried in a commit message.

🤖 Opened by Engineer

Engineer and others added 4 commits August 1, 2026 11:31
…nalling

Port of fix/wal-orphan-rollback-journal (651f2f8 + 00550a4) onto the
deployed tree, which still opens one shared connection per store instead
of the upstream thread-local one — the pragma call site differs, the bug
and the remedy do not.

WAL + POSIX locks owned per (process, inode) let an external opener
unlink the -wal: any connection close drops the whole process's locks,
the next opener believes it is alone, checkpoints and unlinks, and the
daemon keeps writing into an orphaned inode — writes visible only
in-process and lost at exit.

Observed in production 2026-08-01 on conversations, tasks, agent_comms
and research; conversations_tasks.db-wal went orphan again on the
restarted daemon (pid 998969) before this deploy.
…rnalling

Follow-up to ba1bded, which converted the four stores caught by the
2026-08-01 orphaned-WAL incident (conversations, tasks, agent_comms,
research). Every other daemon store had the same exposure: WAL plus
several connections to one file inside one process, where the first
close drops the whole process's POSIX locks and lets an outside opener
checkpoint and unlink the -wal out from under the survivors.

Converted via configure_rollback_journal(), which checkpoints any hot
WAL first and raises rather than silently staying on WAL:

  activity, apps, audit (hooks), mesh, message_context, outreach_config,
  presentations, sessions (both SessionStore and SessionEventStore),
  triggers, user_profiles, voice_calls

...plus the stores that open a fresh connection per call — kb, librarian
state, analytics and the plugin state/context DBs. Those are exposed too:
two overlapping short connections in one process still share locks, so
whichever closes first disarms the other.

Notes:
- message_context_store's explicit busy_timeout=5000 is dropped; the
  helper installs 30s, which is the right way round under rollback
  journalling (readers serialise against the writer).
- analytics_store set the pragma inside its schema executescript; the
  call now runs on the init connection, before the script. TRUNCATE is
  not persisted in the header, so its later per-call connections report
  "delete" — still rollback, which is what matters.
- Deliberately NOT ported: upstream's _reset_connection() healing. Closing
  a descriptor is precisely what drops the process's locks on that inode.
- Out of scope, still on WAL: pinky_identity, pinky_hub, pinky_federation,
  pinky_memory.

Tests: extends the existing rollback-mode parametrisation to the twelve
long-lived stores, adds cover for the four per-call ones, and adds a
source guard so a new daemon store cannot copy-paste its way back onto
WAL. test_message_context_store's assertion updated (it pinned "wal").

307 passed across the affected suites.

Co-Authored-By: Claude Opus 5 <[email protected]>
…y hammers

The thread-local hammer tests pinned `journal_mode == "wal"` on each
connection they opened. Now that every long-lived store configures
rollback journalling in its connection factory, they pin "truncate" —
the same property, against the mode the stores actually run in.
Completes the sweep started in this branch. The orphaned-WAL failure mode
documented in pinky_daemon.sqlite_journal is a property of how a process
holds SQLite open, not of which package the store lives in, and these four
modules have the same shape as the daemon stores: one long-lived
check_same_thread=False connection kept open for the life of the process.

Converted:
- pinky_federation.state.FederationStateStore
- pinky_hub.hub_store.HubStore
- pinky_identity.bearer_tokens.BearerTokenStore
- pinky_identity.signer_store.EncryptedSignerStore
- pinky_memory.store.ReflectionStore — both the constructor and reopen(),
  which built a second connection and would have silently reverted to WAL

They import the daemon's helper rather than growing copies of it; that is
what the module was extracted for. pinky_daemon/__init__.py is a docstring
and a version string, so this adds no import weight beyond sqlite3 itself.

ReflectionStore's two explicit busy_timeout=5000 pragmas are dropped in
favour of the helper's 30000, matching the standardisation in bradbrok#942.

signer_store's security-notes docstring advertised WAL; it now points at
sqlite_journal for why WAL is unsafe here.

Tests: tests/pinky_federation/test_state.py pinned journal_mode == "wal";
converted, as 4b9bd7f did for the daemon stores. New
tests/test_non_daemon_stores_no_wal.py pins all five stores plus reopen(),
asserts no -wal/-shm sidecars appear, and covers in-place migration of a
database left in WAL by an older build.

Note for reviewers: the on-disk assertion is != "wal" rather than
== "truncate". Only WAL is sticky in the database header; TRUNCATE is a
per-connection setting, so an independent opener reports the "delete"
default. Both are rollback modes with no -wal to unlink, which is the
property that matters.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant