From f873990e7218d1a5df05cfbd3984006e15aa15bb Mon Sep 17 00:00:00 2001 From: opencode-agent Date: Thu, 8 Oct 2026 17:20:21 +0800 Subject: [PATCH] sqlite: document lifecycle and concurrency behaviour User asked what happens if the connection is never closed and whether concurrent calls are safe. Adds a Lifecycle and concurrency section to README.md covering: - not closing only leaks fds; data is durable on commit - 3-tier concurrency model: same service / multi-CLI same service / multi-process, with the actual busy_timeout default (5s) and what to do if a longer wait is needed (run PRAGMA busy_timeout via sqlite_exec) - :memory: semantics (gone on close, even within one process) - NFS/SMB caveat No code changes; current defaults (busy_timeout 5s, synchronous FULL, locking normal) are sensible and the existing test sweep already verified end-to-end. --- plugins/sqlite/README.md | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/plugins/sqlite/README.md b/plugins/sqlite/README.md index a32f032..3306c75 100644 --- a/plugins/sqlite/README.md +++ b/plugins/sqlite/README.md @@ -267,3 +267,60 @@ client logs; plugin loading shows up in the service log at - The plugin keeps connections in memory. They are closed on OpenCode shutdown. The cleanup hook also runs on plugin reload, so editing the file in place does not leak file descriptors. + +## Lifecycle and concurrency + +**What happens if I never call `sqlite_close`?** + +Data is safe. `better-sqlite3` auto-commits every transaction and the +default `synchronous=FULL` setting fsyncs on commit, so a successful +`sqlite_exec` return means the bytes are on disk. The only cost is a +leaked file descriptor: each open connection holds one fd on the +database file (plus, in WAL mode, one each on the `-wal` and `-shm` +sidecar files). Connections are released when: + +- the model calls `sqlite_close`, +- the plugin is reloaded (edit `index.ts`, save), +- the OpenCode service is restarted. + +For a one-off task it is fine to skip `sqlite_close` and let the next +service restart clean up. For a long-lived service that opens many +aliases, call `sqlite_close` to keep the fd count predictable. + +**`":memory:"` databases** are gone the moment the last handle closes, +even within the same process. The two `sqlite_open(":memory:")` calls +above are *not* the same database. + +**Can I call the tools concurrently?** + +There are three levels to think about: + +1. *Within one OpenCode service process* — Node is single-threaded and + `better-sqlite3` is a synchronous API, so even if the model fires + several tool calls in parallel, the service processes them one at a + time. The database itself never sees a real concurrent call from + here, so you do not need locks. + +2. *Multiple OpenCode CLI / TUI sessions against the same service* — + the service is a single process, so the same single-threaded rule + applies. + +3. *Multiple independent OpenCode services (or any other process) + opening the same database file* — this is real concurrency. + `better-sqlite3` opens the file with `locking_mode=normal` and the + default `busy_timeout=5000ms`, so the second writer politely waits + up to 5 seconds for the first writer's transaction to commit. If + the wait expires, the call returns `SQLITE_BUSY` and the model + should retry. There is no built-in knob on this plugin to raise + the timeout; if you need a longer wait, run + `PRAGMA busy_timeout = 30000;` once after `sqlite_open` (use + `sqlite_exec`). + +4. *Network filesystems (NFS, SMB, sshfs)* — SQLite's locking is + unreliable on them and `SQLITE_BUSY` is the best case. Copy the + database to local disk first, or run a real database server. + +**Read-only mode (`mode: "ro"`)** opens the file normally and rejects +writes inside the plugin. The journal mode is left at the default +(`delete` for files, `memory` for `:memory:`), so concurrent reads +work as expected.