Repository navigation
Description
SQLite DB corruption + SQLITE_BUSY lock storm when many agents run concurrently with $HOME on a shared/parallel filesystem
Summary
Two failure modes that appear to share a root cause: multiple opencode processes coordinating over one SQLite DB (WAL mode) on a network/parallel filesystem, where cross-process/cross-host lock coordination is not guaranteed by the FS.
- Lock storms → server disconnects. Running 10+ top-level
opencodeagents in tmux panes on a Slurm cluster node (each spawning N sub-agents) causes cascadingSQLiteError: database is locked(SQLITE_BUSY →LockTimeoutError) inside the server, which surface as HTTP 500s across/api/*endpoints and disconnect the clients in the tmux sessions. - Structural DB corruption. After the lock storms, the DB becomes structurally corrupted at the page level ("2nd reference to page" = two b-trees claiming the same page → page-allocator desync between writers). This has now happened on two successive DB instances.
Environment
- opencode v2.0.32 (Bun / sqlite + drizzle)
$HOMEis a parallel shared network filesystem (NFS/WekaFS-class); multiple cluster nodes may run agents that all resolve the same~/.local/share/opencode/opencode.db- DB is in WAL journal mode (
opencode.db-wal/-shmpresent) - Workload: 10+ concurrent sessions in tmux, each spawning several sub-agents; work scheduled via Slurm on a GPU/CPU cluster
Reproduction case 1 — concurrent agents (lock storm / disconnects)
- On a shared
$HOME, start the opencode server. - Open 10+ sessions (tmux panes) and run agents in parallel, each spawning sub-agents that call back into the server (
/api/shell/.../outputpolling, permission/form prompts, etc.). - Observe the server log.
Observed in our log within a ~60s window (18 occurrences):
cause="EffectDrizzleQueryError: Failed query: insert into "project" ...
(cause: effect/sql/SqlError/LockTimeoutError: Failed to execute statement
(cause: SQLiteError: database is locked))" http.status=500
The 500s then hit a range of endpoints (shell output polling, session permission, session form, /api/location, /api/project) and the clients disconnect from the server.
Expected: begin: SQLITE_BUSY on a busy writer should be retried with busy_timeout + backoff/jitter, and ideally all DB writes should be owned by exactly one server process per DB.
Reproduction case 2 — page-level corruption (repeated)
PRAGMA integrity_check on the corrupted DB (private strings redacted):
*** in database main ***
Tree 31 page 197857: btreeInitPage() returns error code 11
Tree 50 page 197341: btreeInitPage() returns error code 11
Tree 49 page 189371 cell 3: 2nd reference to page 197803
Tree 49 page 189371 cell 2: 2nd reference to page 197739
Tree 49 page 189371 cell 1: 2nd reference to page 197714
Tree 47 page 2159 cell 3: 2nd reference to page 197807
Tree 35 page 2029 cell 79: Rowid 1894 out of order
Tree 9 page 1263: btreeInitPage() returns error code 11
wrong # of entries in index session_message_session_seq_idx
wrong # of entries in index sqlite_autoindex_session_v2_1
wrong # of entries in index sqlite_autoindex_event_sequence_1
...
2nd reference to page means two independent b-trees believe they own the same page — i.e., concurrent writers desynced their page allocators. The previous DB (day-old backup from the same environment) shows the same class of corruption ("invalid page number", "Rowid out of order", "Child page depth differs"), so this recurs rather than being a single incident.
Notes we collected while diagnosing:
- POSIX advisory-lock soundness on the shared FS within a single node tests as sound (a second process's
LOCK_EX|LOCK_NBcorrectly fails withEWOULDBLOCK), so single-node contention reproduces the BUSY storm, while the page-allocator desync is most consistent with lock coherence breaking down when writers run on different cluster nodes sharing the same$HOMEfile. - SQLite documentation warns explicitly: WAL mode depends on shared-memory lock coordination that network filesystems do not reliably provide — https://www.sqlite.org/whentouse.html#networkfilesystems and https://www.sqlite.org/wal.html
- WAL replay/checkpoint into the corrupted main DB does not clear the corruption;
sqlite3 <corrupt.db> .recover | sqlite3 <new.db>repairs it (0/1 lost rows in practice; integrity check then passes), which is the workaround we use.
Suggested fix / mitigation
- Guarantee a single writer: all persistence through one server process; reject (or transparently redirect) direct multi-process access to the same DB.
- Set
PRAGMA busy_timeout = ...(and retry with exponential backoff + jitter) around drizzle statements instead of failing the API request on SQLITE_BUSY. - On startup, detect a DB on a network/parallel FS and either (a) refuse to start with a clear message, or (b) fall back from WAL to
DELETEjournal mode / non-shared-memory setup; at minimum expose a documented override. - Optional:
opencode db check/opencode db repair(integrity check +.recoverpath) and/or automatic pre-repair backups.
Activity
opencode-agent commentedon Oct 17, 2026
Thanks for the detailed report and the
integrity_checkoutput.I couldn't reproduce this here because it depends on your storage setup. On a single host with a local disk, I ran up to 16
opencode serve(2.0.32) processes sharing oneopencode.dbunder the same$HOME. I sent hundreds of parallel requests to them: project/location lookups, session creation, and shell commands with large output. Every request succeeded and there were nodatabase is lockederrors. The page-level corruption needs writers on different hosts sharing one database file over NFS/WekaFS, which I couldn't set up. So this needs someone who has a shared-filesystem or cluster environment.On the latest
dev, the database is still always opened in WAL mode with a 5sbusy_timeout. There is no network-filesystem detection and no option to turn WAL off. Until that changes, giving each node or job its own data directory (for example by settingXDG_DATA_HOMEto a node-local path) should avoid sharing one SQLite file across hosts.Related issues, most relevant first:
opencode.dbopencode.dbfails with "database is locked"It would help to know whether the lock storms also happen when every opencode process runs on one node, or only when agents on several nodes share the same
$HOME.mvillmow commentedon Oct 17, 2026
Follow-up: third failure mode found — cross-node
serve --serviceregistration teardown on shared$HOMEAfter posting, we diagnosed a further interaction in the same environment (same setup: opencode v2.0.32, DB in WAL on a parallel network FS, 10+ agents plus sub-agents in tmux/Slurm). Adding it here because it is likely the same root cause (a single per-user coordination file on shared
$HOMEused by many processes across hosts) and because its symptoms (servers vanishing / tmux client disconnects) overlap with the lock storm.What happens
opencode serve --servicemaintains~/.local/state/opencode/service.json. With$HOMEshared across cluster nodes, any node'sserve --servicesees the previous registration (different PID, possibly a server running on a different host), treats it as conflicting, shuts down the previous server, and re-registers itself ("last writer wins"):Observed loop, within minutes, repeatedly: server A (node X) running fine with agents → someone on node Y (re)starts
serve --service→ A shuts down → B registers on node Y → later, a (re)start on node X does the same → B shuts down, unrelated to load. Meanwhile at the time that A's registration was torn down, there was no OOM kill and no crash trace instderr; the only death mechanism is this registration swap. Clients (tmux sessions on any node that trusts the sharedservice.json) then attempt to connect to the stale registration URL (which on the other nodes points at that node's local127.0.0.1... i.e., unreachable) and appear as silent disconnects.We also confirmed this is completely independent of the DB integrity: in a window where agents ran a full parallel swarm (thousands of spawned child commands registering tool calls and DB writes; DB grew ~12 MB and the WAL was ~10 MB), a post-swarm
PRAGMA integrity_check(full, notquick_check) on the repaired DB readokand.db-walcheckpointing was clean. The DB has remained healthy since the.recoverrepair — the standby branch of the lock/ordering problems is still the one tearing tmux sessions down. Worse, the advertisedurlishttp://127.0.0.1:<port>— a URL that is only meaningful on the node that wrote it. Clients on other nodes reading the sharedservice.jsontherefore point at their own loopback port, where either nothing listens (silent connection refusal) or an unrelated service listens.Minimal reproduction (per the Suggested-fix section above)
$HOMEin between.opencode serve --service &→ registers.opencode serve --service &→ logs "managed service registration replaced; shutting down", and A's server goes away.service.jsonnow contains node B's PID (a number that does not exist on node A) and a127.0.0.1URL.Suggested fix directions
hostname/machine-id, or write it under a per-host state directory instead of one shared file).service.jsonmerely advertises a URL, so the design intent of "one service per user, known by the last writer" only holds where each user maps to exactly one host — which shared$HOMEdefeats.serve --servicestarts and observes an unrelated registration whose URL points at a different host (or a PID that is not scan-able viakill(pid, 0)or/proc/<pid>owning the expected process name), it should NOT kill that server but should fall back to starting a fresh server bound to a node-scoped state entry (or to per-node port).GET /api/infoon the observed URL would returnUnauthorizedErrorwithout auth but proves whether the observed URL is unsatisfied.Context to the concerns from the main report: the SQLite page-corruption story is still the DB root cause observed on this shared filesystem (recovered cleanly again more than once now), but the servers vanishing mid-swarm appears to be primarily this registration liveness issue rather than DB/memory crashes. Both are worth investigating independently.
bull-rainer-thierfelder commentedon Nov 13, 2026
v2.3.0 data point: single-node corruption during service handoff, and proof the WAL pragma is set unconditionally on every open
Environment: opencode v2.3.0 (release, channel
latest), single-user desktop-grade workload (no agent swarms),$HOMEon an NFSv4 automount; pooled/random Linux hosts, so the database must stay on NFS.1. Corruption happened on a single node — no multi-host writers, no agent swarm. During a version-upgrade handoff, two
opencode serve --servicegenerations overlapped (log:managed service registration replaced; shutting downtwice, plusEBUSY: resource busy or lockederrors removing files under~/.local/state/opencode/locks/). The next generation's very first models.dev access failed:PRAGMA integrity_check(on a copy) showed exactly one damaged tree —Tree 15 page 15: btreeInitPage() returns error code 11, tree 15 being thekvtable (rootpage 15); all other tables passed. So the single-node handoff window alone sufficed to corrupt the WAL database; cross-node access is not required. To your question in the first comment: our case is single-node only, corruption but no lock storm (light write load).Recovery: service stop →
sqlite3 <db> ".recover"→ integrity-check → atomic swap → restart. Every readable row survived (sessions, events, migrations); only the KV cache rows were lost, and the plugin refetched its catalog. Kept the corrupt file for forensics.2. Empirical proof for the suggested "documented override" — a file-level journal-mode switch does not stick. With the service stopped we flipped the database to rollback-journal mode (
PRAGMA journal_mode=DELETE, file header write/read version bytes verified01 01). The first server start reverted it within seconds (header02 02,-wal/-shmrecreated). The reason is visible in current main —packages/core/src/database/database.tsruns unconditionally for file databases:So in the current release, journal mode cannot be opted out of by any user-visible mechanism; the override has to exist in OpenCode itself. Given
bun:sqliteleaves WAL opt-in, an env/config knob (e.g.OPENCODE_DB_JOURNAL=delete) plus a warning when the DB path is on a network filesystem would cover environments like ours (pooled hosts, NFS-only homes,XDG_DATA_HOMEon local disk is not an option because nothing host-local persists).Meanwhile our mitigations:
opencode service stopbefore version upgrades (the handoff window is the danger), never running sessions from two hosts simultaneously, periodic DB+WAL snapshots, and a scripted.recover-swap repair for when it recurs.