Skip to content

[Bug] Memory SQLite database never sets a busy timeout, so concurrent writes fail instantly with "database is locked" #618

Description

@lijia838-source

Summary

The memory store opens its SQLite database with no busy timeout — the runtime never
executes PRAGMA busy_timeout (or passes an equivalent driver option), so the default
timeout of 0 ms applies. Any write that collides with another holder fails immediately
with database is locked instead of waiting for the lock to clear.

Environment

Item Value
Build v2026.923.0, commit 5a0efbc5106e7c80c3df8e20184c439ed3d0603b
Runtime packaged ESM JS at resources/runtime/dist
Host Windows 11, Node 22.x
Database <pilotdeck home>/memory/workspaces/<project>/control.sqlite

Evidence

1. No busy timeout is configured anywhere in the runtime

A search of the entire shipped runtime returns zero matches:

$ grep -rn "busy_timeout" resources/runtime/dist
$ echo $?
1

The only PRAGMA statements executed against the memory database are
journal_mode and a schema introspection query, in
dist/src/context/memory/edgeclaw-memory-core/src/core/storage/sqlite.js:

// sqlite.js:386
PRAGMA journal_mode = WAL;
// sqlite.js:407
const columns = this.db.prepare("PRAGMA table_info(pipeline_state)").all();

WAL mode is therefore enabled, but the connection still has a 0 ms lock-acquisition
timeout, so it takes no advantage of WAL's concurrency benefits.

2. The database handle is created without any lock-related option

The connection factory loads the driver and constructs the handle with no options:

// dist/src/context/memory/edgeclaw-memory-core/src/core/storage/sqlite.js (~:259)
async function loadSqlDatabaseFactory() {
    if (typeof globalThis.Bun !== "undefined") {
        const bunSqliteModuleName = "bun:sqlite";
        const bunSqlite = await import(bunSqliteModuleName);
        return (dbPath) => {
            const db = new bunSqlite.Database(dbPath, { create: true });
            ...

on the Node path it uses the built-in node:sqlite DatabaseSync (declared for
engines: ">=22.13.0 <23"), likewise without a timeout option.

The database path is a single shared file per workspace:

// sqlite.js:430
this.dbPath = resolve(options.dbPath ?? join(this.dataDir, "control.sqlite"));

3. The lock error is real and observed in the wild

runtime.log, note the [server] process prefix — the memory scheduler is not
running in the process the user's session is writing from:

2026-09-25T13:26:05.399Z [server] [memory-scheduler] scheduled maintenance failed for
  C:\Users\Administrator\.pilotdeck\memory\workspaces\6278025c52: Error: database is locked
2026-09-25T13:26:05.399Z [server]   errstr: 'database is locked'

Two occurrences were observed in a single day of logs. The string SQLITE_BUSY does not
appear at all in the logs, because the driver surfaces this condition as the message
database is locked.

4. Concurrent access is expected, not exceptional

  • The memory subsystem is enabled by default and is opened by more than one entry point in
    the same installation; multiple processes (the gateway handling the user's session and the
    desktop/WebUI server process that runs the scheduled maintenance task) can target the same
    control.sqlite for a given workspace.
  • The observed failure is exactly a cross-process symptom: the process that logs the
    error is the [server] one, while the workspace in question is simultaneously being
    written by an active agent session.
  • No retry/backoff wrapper around database writes could be located in the runtime, so a
    failed write is not retried.

To be explicit about the limit of this analysis: I confirmed that no busy timeout exists and
that the failure occurs in a process other than the active session. I did not instrument the
two processes to prove which one held the competing lock at that instant, so the precise
lock pairing is inferred rather than captured.

Impact

The scheduled memory-maintenance task aborts for that workspace. It appears to be
recoverable on the next cycle, so the practical impact is degraded/ delayed memory
maintenance and noisy logs rather than data loss — but the same 0 ms timeout applies to any
write the memory subsystem performs, so any future concurrent writer inherits the same
failure mode. Severity: moderate.

Suggested fix

Set a lock timeout once, right after the connection is opened, in whatever place the runtime
already applies its PRAGMAs (sqlite.js, next to PRAGMA journal_mode = WAL):

// Wait up to 5s for a competing writer instead of failing immediately.
db.exec("PRAGMA busy_timeout = 5000");

Notes for whoever picks this up:

  • PRAGMA busy_timeout is driver-agnostic, so it also covers the better-sqlite3 handle
    used for auth.db (resources/runtime/package.json:15, better-sqlite3 ^12.6.2), which
    has its own separate connection configuration and should be audited independently.
  • A constructor-level equivalent also exists on some drivers (e.g. Bun's Database accepts
    a timeout option), but the PRAGMA is the portable choice.
  • On top of the timeout, the maintenance path could treat SQLITE_BUSY / "database is locked"
    as a retryable condition and re-schedule the task rather than logging a failure.

How to reproduce

  1. Have an active agent session writing to a workspace's memory database.
  2. Trigger the scheduled memory maintenance for the same workspace (or otherwise have two
    processes write to the same control.sqlite concurrently).
  3. The losing writer fails immediately with database is locked; there is no retry window.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions