duckdb-file-lock-contention-diagnosis
Root cause chain: DuckDB enforces a single-process writer via a whole-file lock. The production :3000 fleet daemon opens the default namespace's DuckDB file at startup and holds that lock (lsof shows 25uW = fd 25, read/write mode, whole-file Write lock) for the process's lifetime. Per-session stdio MCP servers that try to open the same file — even READ_ONLY — collide with that lock and surface as DUCKDB_CONNECTION_LOST. The stdio servers were innocent: the lock holder was the fleet daemon all along. A separate bug (BUG-034) lets stale tsx test daemons linger and hold locks on test-namespace files.
Fix rules (in priority order):
lsof first; only kill a PID that lsof proves holds the lock and that matches a disposable pattern (test daemon). A blanket pkill -f duckdb takes down production.:3000 fallback from per-session stdio servers — they must never open the default-ns file directly.tsx test daemons (BUG-034) surgically, pattern-matched and lsof-re-verified.// scripts/who-holds-lock.ts (Node/tsx, run as: tsx who-holds-lock.ts <dbPath>)
import { execSync } from "node:child_process";
const dbPath = process.argv[2] ?? "data/ns/default.db";
// FD column: "<fd><mode><lock>", e.g. "25uW" = fd 25, read/write, whole-file write lock
const lsof = execSync(`lsof "${dbPath}"`, { encoding: "utf8", stdio: ["ignore","pipe","pipe"] });
const holders = lsof.split("\n").slice(1).filter(Boolean).map(line => {
const [cmd, pid, user, fd, ...rest] = line.split(/\s+/);
return { cmd, pid, user, fd, lock: fd.includes("W") ? "whole-file-WRITE" : fd.includes("w") ? "partial-write" : "other" };
});
console.table(holders);
const w = holders.filter(h => h.lock === "whole-file-WRITE");
if (w.length) {
console.error(`LOCK HELD BY: ${w[0].cmd} pid=${w[0].pid} fd=${w[0].fd} — do NOT kill if this is the :3000 fleet daemon`);
process.exit(1); // caller must route via HTTP instead of killing
}
console.log("No write-lock holder — safe to open.");
// src/store.ts
import path from "node:path";
const DATA_DIR = process.env.DUCKBRAIN_DATA ?? "data";
const FLEET_DAEMON_URL = process.env.DUCKBRAIN_HTTP ?? "http://<ip-address>:3000";
const isFleetDaemon = () => process.env.DUCKBRAIN_FLEET === "1";
/** One DuckDB file per namespace — explicit namespaces never collide. */
export function nsPath(ns: string): string {
return path.join(DATA_DIR, "ns", ns === "default" ? "default.db" : `${ns}.db`);
}
export async function openNamespace(ns: string, opts: { readOnly?: boolean } = {}) {
// Rule 2: the fleet daemon owns default-ns from startup. Stdio servers must
// NOT open that file (even read-only) — route through the HTTP fallback.
if (ns === "default" && !isFleetDaemon()) {
return DuckHttpClient.open(FLEET_DAEMON_URL, ns, opts); // :3000 proxies DuckDB
}
// Rule 3: non-default namespaces are separate files → no lock contention.
return DuckDBConnection.open(nsPath(ns), { readOnly: opts.readOnly ?? false });
}
// scripts/kill-stale-test-daemons.ts
import { execSync } from "node:child_process";
const dbPath = "data/ns/test-e2e.db";
const lsof = execSync(`lsof "${dbPath}"`, { encoding: "utf8" }).trim();
if (!lsof) { console.log("no lock holder — nothing to do"); process.exit(0); }
for (const line of lsof.split("\n").slice(1)) {
const [cmd, pid, , fd] = line.split(/\s+/);
const cmdline = execSync(`ps -p ${pid} -o args=`, { encoding: "utf8" });
const isTestDaemon = /tsx .*--test|test-runner|vitest/.test(cmdline);
if (fd.includes("W") && isTestDaemon) {
process.kill(Number(pid), "SIGTERM"); // kill ONLY the stale test daemon
console.log(`killed stale test daemon pid=${pid} (fd=${fd})`);
}
}
// Fleet daemon never matches the pattern → never touched.
// When opening default-ns directly fails, fall back to HTTP instead of retrying the file.
async function connectDefaultNs() {
try { return await DuckDBConnection.open(nsPath("default"), { readOnly: true }); }
catch (e) { if (/Conflicting lock|Could not set lock/.test(String(e))) {
return DuckHttpClient.open(FLEET_DAEMON_URL, "default"); } // :3000 fallback
throw e;
}
}
All verified live on this machine with `duckdb 1.5.5` + `lsof 4.99.4` (scripts in `/tmp/repro/`): **Scenario 1 — default-ns contention (the reported incident):** | Check | Result | |---|---| | Fleet daemon (simulating `:3000`) starts and opens `ns/default.db` | ✅ `FLEET_DAEMON_READY pid=366` | | `lsof ns/default.db` identifies the holder | ✅ `python 366 kara 3uW REG … /tmp/repro/ns/default.db` — matches the production `25uW` signature; holder is the daemon, **not** the stdio servers | | Per-session stdio server, read-write open of default ns | ❌ `IO Error: Could not set lock on file … Conflicting lock is held in … (PID 366)` → the `DUCKDB_CONNECTION_LOST` equivalent | | Per-session stdio server, **read-only** open of default ns | ❌ **Identically blocked** — confirms "read-only open also blocks" (verified, not assumed) | | Explicit namespace (`analytics.db`) while daemon still holds default ns | ✅ `OK elapsed=0.01s` — no contention, separate file (Fix rule 3) | | Default ns after the lock holder exits | ✅ `STDIO_READ_WRITE: OPEN_OK elapsed=0.01s` — proves the only blocker was the daemon's lock, and that killing it would be the *only* "fix" that restores direct access — which is why rule 1 says never do that; the correct path is HTTP routing | **Scenario 2 — BUG-034 stale test daemon:** | Check | Result | |---|---| | Stale test daemon opens `ns/test-e2e.db` and lingers | ✅ `STALE_TEST_DAEMON_READY` (pid 584) | | Fresh test run opens the same file | ❌ `Conflicting lock is held in … (PID 584)` | | `lsof` identifies holder = PID 584 (test daemon) | ✅ `python 584 kara 3uW … test-e2e.db` | | Surgical kill of **only** PID 584 | ✅ Fleet daemon still alive (`fleet_still_alive: true`); fresh test run now `rc=0 FRESH_TEST_OPEN_OK` | **Edge cases tested:** read-only open blocked exactly like read-write (so "just use READ_ONLY" is not a workaround); both per-session stdio servers dead while the lock persisted (lock is process-lifetime, survives session death — the fleet daemon holds it from startup); explicit namespaces never contend; recovery after holder exit confirmed the diagnosis was lock-only; kill targeting never matched the fleet daemon pattern. Note: in DuckDB ≥1.5 the second opener fails fast with `Could not set lock` rather than hanging; older builds block indefinitely — the diagnosis and fix are identical either way.
{"model": "deepseek-v4-flash", "problem_class": "duckdb-file-lock-contention-diagnosis", "result": "passed", "tests": 9}