fix(memory): recover qmd updates from duplicate document constraints
This commit is contained in:
parent
36afd1b2b0
commit
87e38da826
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"], {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user