Skip to content

Commit f3fd0ee

Browse files
kunpeng-ai-lab鲲鹏AI探索局
andauthored
fix(memory): retry transient index swaps on Windows
Fixes #64187. Adds bounded retry handling for transient Windows rename failures (`EBUSY`, `EPERM`, `EACCES`) during memory-core SQLite atomic reindex swaps. Keeps missing optional SQLite sidecars ignored and non-transient rename failures fail-fast. Verification: - PR CI green, including `check`, `check-additional`, `checks-node-core`, `build-smoke`, and security fast checks - Contributor local proof: `pnpm exec vitest run extensions/memory-core/src/memory/manager.atomic-reindex.test.ts` - Contributor local proof: `pnpm lint:extensions -- extensions/memory-core/src/memory/manager-atomic-reindex.ts extensions/memory-core/src/memory/manager.atomic-reindex.test.ts` - Contributor local proof: `pnpm check:changed` Co-authored-by: 鲲鹏AI探索局 <kunpeng-ai@outlook.com>
1 parent b1cfba2 commit f3fd0ee

3 files changed

Lines changed: 137 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai
6868
- Music generation: raise too-small tool timeouts to the provider-safe 10-second floor and collapse cascading abort fallback errors into a clearer root-cause summary. Thanks @shakkernerd.
6969
- Memory-core/dreaming: include the primary runtime workspace in multi-agent dreaming sweeps without mixing main-agent session transcripts into configured subagent workspaces. Fixes #70014. Thanks @ttomiczek.
7070
- Control UI: add tab/RPC timing attribution and decouple slow Overview/Cron secondary refreshes so Sessions navigation gets immediate visible feedback. Refs #64004. Thanks @WaMaSeDu.
71+
- Memory: retry transient SQLite index file swaps during atomic reindex on Windows, so brief `EBUSY`, `EPERM`, or `EACCES` locks do not fail memory rebuilds. Fixes #64187. Thanks @kunpeng-ai-lab.
7172
- Telegram/startup: use the existing `getMe` request guard for the gateway bot probe instead of a fixed 2.5-second budget, and honor higher `timeoutSeconds` configs for slow Telegram API paths. Fixes #75783. Thanks @tankotan.
7273
- Telegram/models: make model picker confirmations say selections are session-scoped and do not change the agent's persistent default. Fixes #75965. Thanks @sd1114820.
7374
- Control UI/slash commands: keep fallback command metadata on a browser-safe registry path, so provider thinking runtime imports cannot blank the Web UI with `process is not defined`. Fixes #75987. Thanks @novkien.

extensions/memory-core/src/memory/manager-atomic-reindex.ts

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,79 @@
11
import { randomUUID } from "node:crypto";
22
import fs from "node:fs/promises";
3+
import { setTimeout as sleep } from "node:timers/promises";
34

4-
async function moveMemoryIndexFiles(sourceBase: string, targetBase: string): Promise<void> {
5-
const suffixes = ["", "-wal", "-shm"];
6-
for (const suffix of suffixes) {
7-
const source = `${sourceBase}${suffix}`;
8-
const target = `${targetBase}${suffix}`;
5+
type MemoryIndexFileOps = {
6+
rename: typeof fs.rename;
7+
rm: typeof fs.rm;
8+
wait: (ms: number) => Promise<void>;
9+
};
10+
11+
type MoveMemoryIndexFilesOptions = {
12+
fileOps?: MemoryIndexFileOps;
13+
maxRenameAttempts?: number;
14+
renameRetryDelayMs?: number;
15+
};
16+
17+
const defaultFileOps: MemoryIndexFileOps = {
18+
rename: fs.rename,
19+
rm: fs.rm,
20+
wait: sleep,
21+
};
22+
23+
const transientRenameErrorCodes = new Set(["EBUSY", "EPERM", "EACCES"]);
24+
const defaultMaxRenameAttempts = 6;
25+
const defaultRenameRetryDelayMs = 25;
26+
27+
function isTransientRenameError(err: unknown): boolean {
28+
return transientRenameErrorCodes.has((err as NodeJS.ErrnoException).code ?? "");
29+
}
30+
31+
async function renameWithRetry(
32+
source: string,
33+
target: string,
34+
options: Required<MoveMemoryIndexFilesOptions>,
35+
): Promise<void> {
36+
for (let attempt = 1; attempt <= options.maxRenameAttempts; attempt++) {
937
try {
10-
await fs.rename(source, target);
38+
await options.fileOps.rename(source, target);
39+
return;
1140
} catch (err) {
12-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
41+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
42+
return;
43+
}
44+
if (!isTransientRenameError(err) || attempt === options.maxRenameAttempts) {
1345
throw err;
1446
}
47+
await options.fileOps.wait(options.renameRetryDelayMs * attempt);
1548
}
1649
}
50+
throw new Error("rename retry loop exited unexpectedly");
51+
}
52+
53+
export async function moveMemoryIndexFiles(
54+
sourceBase: string,
55+
targetBase: string,
56+
options: MoveMemoryIndexFilesOptions = {},
57+
): Promise<void> {
58+
const resolvedOptions: Required<MoveMemoryIndexFilesOptions> = {
59+
fileOps: options.fileOps ?? defaultFileOps,
60+
maxRenameAttempts: Math.max(1, options.maxRenameAttempts ?? defaultMaxRenameAttempts),
61+
renameRetryDelayMs: options.renameRetryDelayMs ?? defaultRenameRetryDelayMs,
62+
};
63+
const suffixes = ["", "-wal", "-shm"];
64+
for (const suffix of suffixes) {
65+
const source = `${sourceBase}${suffix}`;
66+
const target = `${targetBase}${suffix}`;
67+
await renameWithRetry(source, target, resolvedOptions);
68+
}
1769
}
1870

19-
async function removeMemoryIndexFiles(basePath: string): Promise<void> {
71+
async function removeMemoryIndexFiles(
72+
basePath: string,
73+
fileOps: MemoryIndexFileOps = defaultFileOps,
74+
): Promise<void> {
2075
const suffixes = ["", "-wal", "-shm"];
21-
await Promise.all(suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })));
76+
await Promise.all(suffixes.map((suffix) => fileOps.rm(`${basePath}${suffix}`, { force: true })));
2277
}
2378

2479
async function swapMemoryIndexFiles(targetPath: string, tempPath: string): Promise<void> {

extensions/memory-core/src/memory/manager.atomic-reindex.test.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { DatabaseSync } from "node:sqlite";
5-
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
6-
import { runMemoryAtomicReindex } from "./manager-atomic-reindex.js";
5+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6+
import { moveMemoryIndexFiles, runMemoryAtomicReindex } from "./manager-atomic-reindex.js";
77

88
describe("memory manager atomic reindex", () => {
99
let fixtureRoot = "";
@@ -57,6 +57,76 @@ describe("memory manager atomic reindex", () => {
5757
expect(readChunkMarker(indexPath)).toBe("after");
5858
await expect(fs.access(tempIndexPath)).rejects.toThrow();
5959
});
60+
61+
it("retries transient rename failures during index swaps", async () => {
62+
const rename = vi
63+
.fn()
64+
.mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" }))
65+
.mockResolvedValue(undefined);
66+
const wait = vi.fn().mockResolvedValue(undefined);
67+
68+
await moveMemoryIndexFiles("index.sqlite.tmp", "index.sqlite", {
69+
fileOps: { rename, rm: fs.rm, wait },
70+
maxRenameAttempts: 3,
71+
renameRetryDelayMs: 10,
72+
});
73+
74+
expect(rename).toHaveBeenCalledTimes(4);
75+
expect(wait).toHaveBeenCalledTimes(1);
76+
expect(wait).toHaveBeenCalledWith(10);
77+
});
78+
79+
it("throws after retrying transient rename failures up to the attempt limit", async () => {
80+
const rename = vi.fn().mockRejectedValue(Object.assign(new Error("busy"), { code: "EBUSY" }));
81+
const wait = vi.fn().mockResolvedValue(undefined);
82+
83+
await expect(
84+
moveMemoryIndexFiles("index.sqlite.tmp", "index.sqlite", {
85+
fileOps: { rename, rm: fs.rm, wait },
86+
maxRenameAttempts: 3,
87+
renameRetryDelayMs: 10,
88+
}),
89+
).rejects.toMatchObject({ code: "EBUSY" });
90+
91+
expect(rename).toHaveBeenCalledTimes(3);
92+
expect(wait).toHaveBeenCalledTimes(2);
93+
expect(wait).toHaveBeenNthCalledWith(1, 10);
94+
expect(wait).toHaveBeenNthCalledWith(2, 20);
95+
});
96+
97+
it("does not retry missing optional sqlite sidecar files", async () => {
98+
const rename = vi
99+
.fn()
100+
.mockResolvedValueOnce(undefined)
101+
.mockRejectedValueOnce(Object.assign(new Error("missing wal"), { code: "ENOENT" }))
102+
.mockRejectedValueOnce(Object.assign(new Error("missing shm"), { code: "ENOENT" }));
103+
const wait = vi.fn().mockResolvedValue(undefined);
104+
105+
await moveMemoryIndexFiles("index.sqlite.tmp", "index.sqlite", {
106+
fileOps: { rename, rm: fs.rm, wait },
107+
maxRenameAttempts: 3,
108+
renameRetryDelayMs: 10,
109+
});
110+
111+
expect(rename).toHaveBeenCalledTimes(3);
112+
expect(wait).not.toHaveBeenCalled();
113+
});
114+
115+
it("does not retry non-transient rename failures", async () => {
116+
const rename = vi.fn().mockRejectedValue(Object.assign(new Error("invalid"), { code: "EINVAL" }));
117+
const wait = vi.fn().mockResolvedValue(undefined);
118+
119+
await expect(
120+
moveMemoryIndexFiles("index.sqlite.tmp", "index.sqlite", {
121+
fileOps: { rename, rm: fs.rm, wait },
122+
maxRenameAttempts: 3,
123+
renameRetryDelayMs: 10,
124+
}),
125+
).rejects.toMatchObject({ code: "EINVAL" });
126+
127+
expect(rename).toHaveBeenCalledTimes(1);
128+
expect(wait).not.toHaveBeenCalled();
129+
});
60130
});
61131

62132
function writeChunkMarker(dbPath: string, marker: string): void {

0 commit comments

Comments
 (0)