diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5e094727f..6b3101a6369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -287,6 +287,7 @@ Docs: https://docs.openclaw.ai - Daemon/Windows schtasks status normalization: derive runtime state from locale-neutral numeric `Last Run Result` codes only (without language string matching) and surface unknown when numeric result data is unavailable, preventing locale-specific misclassification drift. (#39153) Thanks @scoootscooob. - Telegram/polling conflict recovery: reset the polling `webhookCleared` latch on `getUpdates` 409 conflicts so webhook cleanup re-runs on restart cycles and polling avoids infinite conflict loops. (#39205) Thanks @amittell. - Heartbeat/requests-in-flight scheduling: stop advancing `nextDueMs` and avoid immediate `scheduleNext()` timer overrides on requests-in-flight skips, so wake-layer retry cooldowns are honored and heartbeat cadence no longer drifts under sustained contention. (#39182) Thanks @MumuTW. +- Memory/SQLite contention resilience: re-apply `PRAGMA busy_timeout` on every sync-store and QMD connection open so process restarts/reopens no longer revert to immediate `SQLITE_BUSY` failures under lock contention. (#39183) Thanks @MumuTW. ## 2026.3.2 diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index bfc86afffe7..1fe91599b34 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -258,7 +258,12 @@ export abstract class MemoryManagerSyncOps { const dir = path.dirname(dbPath); ensureDir(dir); const { DatabaseSync } = requireNodeSqlite(); - return new DatabaseSync(dbPath, { allowExtension: this.settings.store.vector.enabled }); + const db = new DatabaseSync(dbPath, { allowExtension: this.settings.store.vector.enabled }); + // busy_timeout is per-connection and resets to 0 on restart. + // Set it on every open so concurrent processes retry instead of + // failing immediately with SQLITE_BUSY. + db.exec("PRAGMA busy_timeout = 5000"); + return db; } private seedEmbeddingCache(sourceDb: DatabaseSync): void { diff --git a/src/memory/manager.readonly-recovery.test.ts b/src/memory/manager.readonly-recovery.test.ts index c6a566468bb..75b0252143f 100644 --- a/src/memory/manager.readonly-recovery.test.ts +++ b/src/memory/manager.readonly-recovery.test.ts @@ -109,4 +109,14 @@ describe("memory manager readonly recovery", () => { expect(runSyncSpy).toHaveBeenCalledTimes(1); expect(openDatabaseSpy).toHaveBeenCalledTimes(0); }); + + it("sets busy_timeout on memory sqlite connections", async () => { + const currentManager = await createManager(); + const db = (currentManager as unknown as { db: DatabaseSync }).db; + const row = db.prepare("PRAGMA busy_timeout").get() as + | { busy_timeout?: number; timeout?: number } + | undefined; + const busyTimeout = row?.busy_timeout ?? row?.timeout; + expect(busyTimeout).toBe(5000); + }); }); diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index cbfee6db11c..48c8a4ec5d5 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { DatabaseSync } from "node:sqlite"; import type { Mock } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -88,6 +89,7 @@ import { spawn as mockedSpawn } from "node:child_process"; import type { OpenClawConfig } from "../config/config.js"; import { resolveMemoryBackendConfig } from "./backend-config.js"; import { QmdMemoryManager } from "./qmd-manager.js"; +import { requireNodeSqlite } from "./sqlite.js"; const spawnMock = mockedSpawn as unknown as Mock; @@ -2644,6 +2646,24 @@ describe("QmdMemoryManager", () => { ).rejects.toThrow(/qmd query returned invalid JSON/); await manager.close(); }); + + it("sets busy_timeout on qmd sqlite connections", async () => { + const { manager } = await createManager(); + const indexPath = (manager as unknown as { indexPath: string }).indexPath; + await fs.mkdir(path.dirname(indexPath), { recursive: true }); + const { DatabaseSync } = requireNodeSqlite(); + const seedDb = new DatabaseSync(indexPath); + seedDb.close(); + + const db = (manager as unknown as { ensureDb: () => DatabaseSync }).ensureDb(); + const row = db.prepare("PRAGMA busy_timeout").get() as + | { busy_timeout?: number; timeout?: number } + | undefined; + const busyTimeout = row?.busy_timeout ?? row?.timeout; + expect(busyTimeout).toBe(1000); + await manager.close(); + }); + describe("model cache symlink", () => { let defaultModelsDir: string; let customModelsDir: string; diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index b79a1fc57e0..0c7a2185f9f 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -1556,8 +1556,12 @@ export class QmdMemoryManager implements MemorySearchManager { } const { DatabaseSync } = requireNodeSqlite(); this.db = new DatabaseSync(this.indexPath, { readOnly: true }); - // Keep QMD recall responsive when the updater holds a write lock. - this.db.exec("PRAGMA busy_timeout = 1"); + // busy_timeout is per-connection; set it on every open so concurrent + // processes retry instead of failing immediately with SQLITE_BUSY. + // Use a lower value than the write path (5 s) because this read-only + // connection runs synchronous queries on the main thread via DatabaseSync. + // In WAL mode readers rarely block, so 1 s is a safe upper bound. + this.db.exec("PRAGMA busy_timeout = 1000"); return this.db; }