From 0d60ef6fef195b8495d24382e3378ca3da114f1c Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 7 Feb 2026 16:47:00 -0800 Subject: [PATCH] Memory: queue forced QMD sync and handle sqlite busy reads --- src/memory/qmd-manager.test.ts | 78 ++++++++++++++++++++++++++++++++++ src/memory/qmd-manager.ts | 43 +++++++++++++++++-- 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 2905fe7e5e..4eee92874c 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -253,6 +253,61 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("queues a forced sync behind an in-flight update", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { + interval: "0s", + debounceMs: 0, + onBoot: false, + updateTimeoutMs: 1_000, + }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + let updateCalls = 0; + let releaseFirstUpdate: (() => void) | null = null; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "update") { + updateCalls += 1; + if (updateCalls === 1) { + const first = createMockChild({ autoClose: false }); + releaseFirstUpdate = () => first.closeWith(0); + return first; + } + return createMockChild(); + } + return createMockChild(); + }); + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + const inFlight = manager.sync({ reason: "interval" }); + const forced = manager.sync({ reason: "manual", force: true }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(updateCalls).toBe(1); + if (!releaseFirstUpdate) { + throw new Error("first update release missing"); + } + releaseFirstUpdate(); + + await Promise.all([inFlight, forced]); + expect(updateCalls).toBe(2); + await manager.close(); + }); + it("logs and continues when qmd embed times out", async () => { cfg = { ...cfg, @@ -342,4 +397,27 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + + it("skips doc lookup when sqlite index is busy", async () => { + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + const inner = manager as unknown as { + db: { prepare: () => { get: () => never }; close: () => void } | null; + resolveDocLocation: (docid?: string) => Promise; + }; + inner.db = { + prepare: () => ({ + get: () => { + throw new Error("SQLITE_BUSY: database is locked"); + }, + }), + close: () => {}, + }; + await expect(inner.resolveDocLocation("abc123")).resolves.toBeNull(); + await manager.close(); + }); }); diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 700d8fc615..1aca2223a2 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -84,6 +84,7 @@ export class QmdMemoryManager implements MemorySearchManager { private readonly sessionExporter: SessionExporterConfig | null; private updateTimer: NodeJS.Timeout | null = null; private pendingUpdate: Promise | null = null; + private queuedForcedUpdate: Promise | null = null; private closed = false; private db: SqliteDatabase | null = null; private lastUpdateAt: number | null = null; @@ -393,7 +394,26 @@ export class QmdMemoryManager implements MemorySearchManager { } private async runUpdate(reason: string, force?: boolean): Promise { + if (this.closed) { + return; + } if (this.pendingUpdate) { + if (force) { + if (!this.queuedForcedUpdate) { + this.queuedForcedUpdate = this.pendingUpdate + .catch(() => undefined) + .then(async () => { + if (this.closed) { + return; + } + await this.runUpdate(`${reason}:queued`, true); + }) + .finally(() => { + this.queuedForcedUpdate = null; + }); + } + return this.queuedForcedUpdate; + } return this.pendingUpdate; } if (this.shouldSkipUpdate(force)) { @@ -474,6 +494,8 @@ 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"); return this.db; } @@ -547,9 +569,18 @@ export class QmdMemoryManager implements MemorySearchManager { return cached; } const db = this.ensureDb(); - const row = db - .prepare("SELECT collection, path FROM documents WHERE hash LIKE ? AND active = 1 LIMIT 1") - .get(`${normalized}%`) as { collection: string; path: string } | undefined; + let row: { collection: string; path: string } | undefined; + try { + row = db + .prepare("SELECT collection, path FROM documents WHERE hash LIKE ? AND active = 1 LIMIT 1") + .get(`${normalized}%`) as { collection: string; path: string } | undefined; + } catch (err) { + if (this.isSqliteBusyError(err)) { + log.debug(`qmd index is busy while resolving doc path: ${String(err)}`); + return null; + } + throw err; + } if (!row) { return null; } @@ -825,6 +856,12 @@ export class QmdMemoryManager implements MemorySearchManager { return Date.now() - this.lastUpdateAt < debounceMs; } + private isSqliteBusyError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + const normalized = message.toLowerCase(); + return normalized.includes("sqlite_busy") || normalized.includes("database is locked"); + } + private async waitForPendingUpdateBeforeSearch(): Promise { const pending = this.pendingUpdate; if (!pending) {