Skip to content

Commit 5967430

Browse files
committed
fix(state): cover sqlite wal nfs helpers
1 parent 5b8b4dd commit 5967430

5 files changed

Lines changed: 120 additions & 19 deletions

File tree

extensions/memory-core/src/memory/manager-db.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,7 @@ export function openMemoryDatabaseAtPath(
2828
probe.close();
2929
} catch (err) {
3030
const msg = (err as Error).message ?? "";
31-
if (
32-
msg.includes("unable to open database file") ||
33-
msg.includes("SQLITE_CANTOPEN")
34-
) {
31+
if (msg.includes("unable to open database file") || msg.includes("SQLITE_CANTOPEN")) {
3532
throw new Error(
3633
`Memory database not found at ${dbPath}; refusing to auto-create an empty database during an index swap window.`,
3734
{ cause: err },

src/infra/sqlite-wal.test.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import {
1414
function createMockDb(): DatabaseSync {
1515
return {
1616
exec: vi.fn(),
17+
prepare: vi.fn(() => ({
18+
get: vi.fn(() => ({ journal_mode: "delete" })),
19+
})),
1720
} as unknown as DatabaseSync;
1821
}
1922

@@ -59,11 +62,32 @@ describe("sqlite WAL maintenance", () => {
5962
});
6063

6164
expect(statfs).toHaveBeenCalledWith(tempDir);
62-
expect(db["exec"]).toHaveBeenCalledTimes(1);
63-
expect(db["exec"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
65+
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
66+
expect(db["exec"]).not.toHaveBeenCalled();
6467
expect(maintenance.checkpoint()).toBe(true);
6568
expect(maintenance.close()).toBe(true);
66-
expect(db["exec"]).toHaveBeenCalledTimes(1);
69+
expect(db["exec"]).not.toHaveBeenCalled();
70+
} finally {
71+
fs.rmSync(tempDir, { recursive: true, force: true });
72+
}
73+
});
74+
75+
it("refuses NFS-backed databases when SQLite keeps WAL active", () => {
76+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
77+
try {
78+
const db = createMockDb();
79+
vi.mocked(db["prepare"]).mockReturnValue({
80+
get: vi.fn(() => ({ journal_mode: "wal" })),
81+
} as unknown as ReturnType<DatabaseSync["prepare"]>);
82+
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0x6969));
83+
84+
expect(() =>
85+
configureSqliteWalMaintenance(db, {
86+
checkpointIntervalMs: 0,
87+
databaseLabel: "test-db",
88+
databasePath: path.join(tempDir, "openclaw.sqlite"),
89+
}),
90+
).toThrow(/test-db .*journal_mode=wal/);
6791
} finally {
6892
fs.rmSync(tempDir, { recursive: true, force: true });
6993
}
@@ -83,7 +107,7 @@ describe("sqlite WAL maintenance", () => {
83107
databasePath: path.join(tempDir, "openclaw.sqlite"),
84108
});
85109

86-
expect(db["exec"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
110+
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
87111
} finally {
88112
fs.rmSync(tempDir, { recursive: true, force: true });
89113
}
@@ -106,7 +130,30 @@ describe("sqlite WAL maintenance", () => {
106130
databasePath: path.join(tempDir, "openclaw.sqlite"),
107131
});
108132

109-
expect(db["exec"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
133+
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
134+
} finally {
135+
fs.rmSync(tempDir, { recursive: true, force: true });
136+
}
137+
});
138+
139+
it("parses Linux mount command filesystem names when proc mountinfo is unavailable", () => {
140+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
141+
try {
142+
const db = createMockDb();
143+
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
144+
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
145+
throw new Error("no proc mountinfo");
146+
});
147+
vi.spyOn(childProcess, "execFileSync").mockReturnValue(
148+
Buffer.from(`server:/share on ${tempDir} type nfs4 (rw,relatime)\n`),
149+
);
150+
151+
configureSqliteWalMaintenance(db, {
152+
checkpointIntervalMs: 0,
153+
databasePath: path.join(tempDir, "openclaw.sqlite"),
154+
});
155+
156+
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
110157
} finally {
111158
fs.rmSync(tempDir, { recursive: true, force: true });
112159
}

src/infra/sqlite-wal.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,14 @@ function parseProcMountInfoEntries(
8585
function parseMountCommandEntries(contents: string): Array<{ mountPoint: string; fsType: string }> {
8686
const entries: Array<{ mountPoint: string; fsType: string }> = [];
8787
for (const line of contents.split("\n")) {
88-
const match = /^.* on (.+) \(([^,\s)]+)/.exec(line);
89-
if (match) {
90-
entries.push({ mountPoint: match[1], fsType: match[2] });
88+
const linuxMatch = /^.* on (.+) type ([^,\s)]+) \(/.exec(line);
89+
if (linuxMatch) {
90+
entries.push({ mountPoint: linuxMatch[1], fsType: linuxMatch[2] });
91+
continue;
92+
}
93+
const bsdMatch = /^.* on (.+) \(([^,\s)]+)/.exec(line);
94+
if (bsdMatch) {
95+
entries.push({ mountPoint: bsdMatch[1], fsType: bsdMatch[2] });
9196
}
9297
}
9398
return entries;
@@ -146,6 +151,28 @@ function isNfsBackedPath(targetPath: string): boolean {
146151
return isNfsMountEntryPath(checkedPath);
147152
}
148153

154+
function readJournalModeResult(row: unknown): string | null {
155+
if (!row || typeof row !== "object") {
156+
return null;
157+
}
158+
const record = row as Record<string, unknown>;
159+
const value = record.journal_mode ?? Object.values(record)[0];
160+
return typeof value === "string" ? value.toLowerCase() : null;
161+
}
162+
163+
function requireRollbackJournalMode(db: DatabaseSync, options: SqliteWalMaintenanceOptions): void {
164+
const row = db.prepare("PRAGMA journal_mode = DELETE;").get();
165+
const journalMode = readJournalModeResult(row);
166+
if (journalMode !== "delete") {
167+
const label = options.databaseLabel ?? "sqlite database";
168+
const location = options.databasePath ? ` at ${options.databasePath}` : "";
169+
const actual = journalMode ?? "unknown";
170+
throw new Error(
171+
`${label}${location} is on an NFS-backed volume but SQLite kept journal_mode=${actual}; refusing to continue with WAL on NFS.`,
172+
);
173+
}
174+
}
175+
149176
/** Configure WAL pragmas and return a handle for checkpoint/close maintenance. */
150177
export function configureSqliteWalMaintenance(
151178
db: DatabaseSync,
@@ -162,7 +189,7 @@ export function configureSqliteWalMaintenance(
162189
const timerIntervalMs = Math.min(checkpointIntervalMs, MAX_TIMER_TIMEOUT_MS);
163190
const checkpointMode = options.checkpointMode ?? "TRUNCATE";
164191
if (options.databasePath && isNfsBackedPath(options.databasePath)) {
165-
db.exec("PRAGMA journal_mode = DELETE;");
192+
requireRollbackJournalMode(db, options);
166193
return {
167194
checkpoint: () => true,
168195
close: () => true,

src/proxy-capture/store.sqlite.test.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Proxy capture SQLite store tests cover persisted capture reads and writes.
2-
import { mkdtempSync, rmSync } from "node:fs";
2+
import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
5-
import { afterEach, describe, expect, it } from "vitest";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
66
import {
77
acquireDebugProxyCaptureStore,
88
closeDebugProxyCaptureStore,
@@ -15,23 +15,24 @@ const cleanupDirs: string[] = [];
1515

1616
afterEach(() => {
1717
closeDebugProxyCaptureStore();
18+
vi.restoreAllMocks();
1819
while (cleanupDirs.length > 0) {
1920
const dir = cleanupDirs.pop();
2021
if (dir) {
21-
rmSync(dir, { recursive: true, force: true });
22+
fs.rmSync(dir, { recursive: true, force: true });
2223
}
2324
}
2425
});
2526

2627
function makeStore() {
27-
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-capture-"));
28+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-capture-"));
2829
cleanupDirs.push(root);
2930
return new DebugProxyCaptureStore(path.join(root, "capture.sqlite"), path.join(root, "blobs"));
3031
}
3132

3233
describe("DebugProxyCaptureStore", () => {
3334
it("keeps the cached store open until the last lease releases", () => {
34-
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-capture-lease-"));
35+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-capture-lease-"));
3536
cleanupDirs.push(root);
3637
const dbPath = path.join(root, "capture.sqlite");
3738
const blobDir = path.join(root, "blobs");
@@ -51,6 +52,32 @@ describe("DebugProxyCaptureStore", () => {
5152
expect(reopened.isClosed).toBe(false);
5253
});
5354

55+
it("uses rollback journaling for captures on NFS-backed volumes", () => {
56+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-capture-nfs-"));
57+
cleanupDirs.push(root);
58+
vi.spyOn(fs, "statfsSync").mockReturnValue({
59+
type: 0x6969,
60+
bsize: 1024,
61+
blocks: 1,
62+
bfree: 1,
63+
bavail: 1,
64+
files: 0,
65+
ffree: 0,
66+
});
67+
68+
const store = new DebugProxyCaptureStore(
69+
path.join(root, "capture.sqlite"),
70+
path.join(root, "blobs"),
71+
);
72+
try {
73+
expect(store.db.prepare("PRAGMA journal_mode").get()).toMatchObject({
74+
journal_mode: "delete",
75+
});
76+
} finally {
77+
store.close();
78+
}
79+
});
80+
5481
it("ignores duplicate close calls", () => {
5582
const store = makeStore();
5683

src/proxy-capture/store.sqlite.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ function openDatabase(dbPath: string): OpenedDatabase {
3333
ensureParentDir(dbPath);
3434
const { DatabaseSync } = requireNodeSqlite();
3535
const db = new DatabaseSync(dbPath);
36-
const walMaintenance = configureSqliteWalMaintenance(db);
36+
const walMaintenance = configureSqliteWalMaintenance(db, {
37+
databaseLabel: "debug-proxy-capture",
38+
databasePath: dbPath,
39+
});
3740
db.exec("PRAGMA busy_timeout = 5000");
3841
db.exec(`
3942
CREATE TABLE IF NOT EXISTS capture_sessions (

0 commit comments

Comments
 (0)