fix(memory): recover qmd updates from duplicate document constraints

This commit is contained in:
Vignesh Natarajan 2026-03-05 20:20:25 -08:00
parent 36afd1b2b0
commit 87e38da826
3 changed files with 141 additions and 11 deletions

View File

@ -153,6 +153,7 @@ Docs: https://docs.openclaw.ai
- Gateway/status self version reporting: make Gateway self version in `openclaw status` prefer runtime `VERSION` (while preserving explicit `OPENCLAW_VERSION` override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai.
- Memory/QMD index isolation: set `QMD_CONFIG_DIR` alongside `XDG_CONFIG_HOME` so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind.
- Memory/QMD collection safety: stop destructive collection rebinds when QMD `collection list` only reports names without path metadata, preventing `memory search` from dropping existing collections if re-add fails. (#36870) Thanks @Adnannnnnnna.
- Memory/QMD duplicate-document recovery: detect `UNIQUE constraint failed: documents.collection, documents.path` update failures, rebuild managed collections once, and retry update so periodic QMD syncs recover instead of failing every run; includes regression coverage to avoid over-matching unrelated unique constraints. (#27649) Thanks @MiscMich.
- Memory/local embedding initialization hardening: add regression coverage for transient initialization retry and mixed `embedQuery` + `embedBatch` concurrent startup to lock single-flight initialization behavior. (#15639) thanks @SubtleSpark.
- CLI/Coding-agent reliability: switch default `claude-cli` non-interactive args to `--permission-mode bypassPermissions`, auto-normalize legacy `--dangerously-skip-permissions` backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. Related to #28261. Landed from contributor PRs #28610 and #31149. Thanks @niceysam, @cryptomaltese and @vincentkoc.
- ACP/ACPX session bootstrap: retry with `sessions new` when `sessions ensure` returns no session identifiers so ACP spawns avoid `NO_SESSION`/`ACP_TURN_FAILED` failures on affected agents. Related to #28786. Landed from contributor PR #31338. Thanks @Sid-Qin and @vincentkoc.

View File

@ -691,6 +691,98 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("rebuilds managed collections once when qmd update hits duplicate document constraint", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
update: { interval: "0s", debounceMs: 0, onBoot: false },
paths: [],
},
},
} as OpenClawConfig;
let updateCalls = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "update") {
updateCalls += 1;
const child = createMockChild({ autoClose: false });
if (updateCalls === 1) {
emitAndClose(
child,
"stderr",
"SQLiteError: UNIQUE constraint failed: documents.collection, documents.path",
1,
);
return child;
}
queueMicrotask(() => {
child.closeWith(0);
});
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "status" });
await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined();
const removeCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "collection" && args[1] === "remove")
.map((args) => args[2]);
const addCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "collection" && args[1] === "add")
.map((args) => args[args.indexOf("--name") + 1]);
expect(updateCalls).toBe(2);
expect(removeCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]);
expect(addCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]);
expect(logWarnMock).toHaveBeenCalledWith(
expect.stringContaining("duplicate document constraint"),
);
await manager.close();
});
it("does not rebuild collections for unrelated unique constraint failures", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
update: { interval: "0s", debounceMs: 0, onBoot: false },
paths: [],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "update") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stderr", "SQLiteError: UNIQUE constraint failed: documents.docid", 1);
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "status" });
await expect(manager.sync({ reason: "manual" })).rejects.toThrow(
"SQLiteError: UNIQUE constraint failed: documents.docid",
);
const removeCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "collection" && args[1] === "remove");
expect(removeCalls).toHaveLength(0);
await manager.close();
});
it("does not rebuild collections for generic qmd update failures", async () => {
cfg = {
...cfg,

View File

@ -215,6 +215,7 @@ export class QmdMemoryManager implements MemorySearchManager {
private embedBackoffUntil: number | null = null;
private embedFailureCount = 0;
private attemptedNullByteCollectionRepair = false;
private attemptedDuplicateDocumentRepair = false;
private constructor(params: {
cfg: OpenClawConfig;
@ -601,17 +602,17 @@ export class QmdMemoryManager implements MemorySearchManager {
);
}
private async tryRepairNullByteCollections(err: unknown, reason: string): Promise<boolean> {
if (this.attemptedNullByteCollectionRepair) {
return false;
}
if (!this.shouldRepairNullByteCollectionError(err)) {
return false;
}
this.attemptedNullByteCollectionRepair = true;
log.warn(
`qmd update failed with suspected null-byte collection metadata (${reason}); rebuilding managed collections and retrying once`,
private shouldRepairDuplicateDocumentConstraint(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
const lower = message.toLowerCase();
return (
lower.includes("unique constraint failed") &&
lower.includes("documents.collection") &&
lower.includes("documents.path")
);
}
private async rebuildManagedCollectionsForRepair(reason: string): Promise<void> {
for (const collection of this.qmd.collections) {
try {
await this.removeCollection(collection.name);
@ -630,6 +631,39 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
}
log.warn(`qmd managed collections rebuilt for update repair (${reason})`);
}
private async tryRepairNullByteCollections(err: unknown, reason: string): Promise<boolean> {
if (this.attemptedNullByteCollectionRepair) {
return false;
}
if (!this.shouldRepairNullByteCollectionError(err)) {
return false;
}
this.attemptedNullByteCollectionRepair = true;
log.warn(
`qmd update failed with suspected null-byte collection metadata (${reason}); rebuilding managed collections and retrying once`,
);
await this.rebuildManagedCollectionsForRepair(`null-byte metadata (${reason})`);
return true;
}
private async tryRepairDuplicateDocumentConstraint(
err: unknown,
reason: string,
): Promise<boolean> {
if (this.attemptedDuplicateDocumentRepair) {
return false;
}
if (!this.shouldRepairDuplicateDocumentConstraint(err)) {
return false;
}
this.attemptedDuplicateDocumentRepair = true;
log.warn(
`qmd update failed with duplicate document constraint (${reason}); rebuilding managed collections and retrying once`,
);
await this.rebuildManagedCollectionsForRepair(`duplicate-document constraint (${reason})`);
return true;
}
@ -962,7 +996,10 @@ export class QmdMemoryManager implements MemorySearchManager {
discardOutput: true,
});
} catch (err) {
if (!(await this.tryRepairNullByteCollections(err, reason))) {
if (
!(await this.tryRepairNullByteCollections(err, reason)) &&
!(await this.tryRepairDuplicateDocumentConstraint(err, reason))
) {
throw err;
}
await this.runQmd(["update"], {