fix(sqlite): stop mount probes blocking database startup (#109432)

* fix(sqlite): bound fallback mount classification

* test(sqlite): cover mount probe failure policy

* test(sqlite): use tracked mount probe fixtures

* perf(sqlite): keep mount result import type-only

* style(sqlite): format mount probe result

* perf(sqlite): defer mount process module loading

* perf(sqlite): avoid eager mount parser dependency

* refactor(sqlite): isolate lock error detection

* revert: keep sqlite lock errors colocated

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
mushuiyu886
2026-07-17 18:41:25 +08:00
committed by GitHub
parent c45f299cea
commit 3a268c08df
2 changed files with 94 additions and 22 deletions
+50 -3
View File
@@ -331,21 +331,68 @@ describe("sqlite WAL maintenance", () => {
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw new Error("no proc mountinfo");
});
vi.spyOn(childProcess, "execFileSync").mockReturnValue(
Buffer.from(`server:/share on ${tempDir} (nfs, nodev, nosuid)\n`),
);
const mount = vi
.spyOn(childProcess, "execFileSync")
.mockReturnValue(Buffer.from(`server:/share on ${tempDir} (nfs, nodev, nosuid)\n`));
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath: path.join(tempDir, "openclaw.sqlite"),
});
expect(mount).toHaveBeenCalledWith("mount", [], {
killSignal: "SIGKILL",
timeout: 1_000,
});
expect(mount).toHaveBeenCalledTimes(1);
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("uses rollback journaling when mount classification times out", () => {
const tempDir = tempDirs.make("openclaw-sqlite-mount-timeout-");
const db = createMockDb();
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw new Error("no proc mountinfo");
});
vi.spyOn(childProcess, "execFileSync").mockImplementation(() => {
throw Object.assign(new Error("spawnSync mount ETIMEDOUT"), { code: "ETIMEDOUT" });
});
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath: path.join(tempDir, "openclaw.sqlite"),
});
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
expect(db["exec"]).not.toHaveBeenCalled();
});
it("preserves WAL policy when mount classification fails without timing out", () => {
const tempDir = tempDirs.make("openclaw-sqlite-mount-error-");
const db = createMockDb();
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw new Error("no proc mountinfo");
});
vi.spyOn(childProcess, "execFileSync").mockImplementation(() => {
throw Object.assign(new Error("spawnSync mount ENOENT"), { code: "ENOENT" });
});
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath: path.join(tempDir, "openclaw.sqlite"),
});
expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA journal_mode = WAL;");
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode;");
});
it("uses macOS SMB mount filesystem names", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-smb-"));
try {
+44 -19
View File
@@ -1,9 +1,8 @@
// Configures SQLite WAL and related pragmas for local stores.
import childProcess from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { expectDefined } from "@openclaw/normalization-core";
import type { Result } from "@openclaw/normalization-core/result";
import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js";
import { isSqliteLockError } from "./sqlite-transaction.js";
@@ -19,6 +18,8 @@ const LINUX_SMB_SUPER_MAGIC = 0x517b;
const LINUX_CIFS_SUPER_MAGIC = 0xff534d42;
const LINUX_SMB2_SUPER_MAGIC = 0xfe534d42;
const PROC_MOUNTINFO_PATH = "/proc/self/mountinfo";
// Filesystem classification runs during database open, so never let the fallback probe stall it.
const MOUNT_COMMAND_TIMEOUT_MS = 1_000;
const NETWORK_FILESYSTEM_TYPES = new Set(["cifs", "smbfs", "smb2", "smb3"]);
const JOURNAL_MODE_RETRY_INTERVAL_MS = 10;
const JOURNAL_MODE_RETRY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
@@ -145,36 +146,57 @@ function parseMountCommandEntries(contents: string): MountEntry[] {
for (const line of contents.split("\n")) {
const linuxMatch = /^(.+) on (.+) type ([^,\s)]+) \(/.exec(line);
if (linuxMatch) {
entries.push({
source: linuxMatch[1],
mountPoint: expectDefined(linuxMatch[2], "linux match capture group 2"),
fsType: expectDefined(linuxMatch[3], "linux match capture group 3"),
});
const source = linuxMatch[1];
const mountPoint = linuxMatch[2];
const fsType = linuxMatch[3];
if (source && mountPoint && fsType) {
entries.push({ source, mountPoint, fsType });
}
continue;
}
const bsdMatch = /^(.+) on (.+) \(([^,\s)]+)/.exec(line);
if (bsdMatch) {
entries.push({
source: bsdMatch[1],
mountPoint: expectDefined(bsdMatch[2], "bsd match capture group 2"),
fsType: expectDefined(bsdMatch[3], "bsd match capture group 3"),
});
const source = bsdMatch[1];
const mountPoint = bsdMatch[2];
const fsType = bsdMatch[3];
if (source && mountPoint && fsType) {
entries.push({ source, mountPoint, fsType });
}
}
}
return entries;
}
function readMountEntries(): MountEntry[] {
function isMountCommandTimeout(error: unknown): boolean {
return (
error !== null && typeof error === "object" && "code" in error && error.code === "ETIMEDOUT"
);
}
function readMountEntries(): Result<MountEntry[], "timeout"> {
try {
return parseProcMountInfoEntries(fs.readFileSync(PROC_MOUNTINFO_PATH, "utf8"));
return {
ok: true,
value: parseProcMountInfoEntries(fs.readFileSync(PROC_MOUNTINFO_PATH, "utf8")),
};
} catch {
// macOS/BSD expose filesystem type names in `mount` output instead of
// Linux superblock magic, so keep this fallback for named filesystem types.
}
try {
return parseMountCommandEntries(String(childProcess.execFileSync("mount", [])));
} catch {
return [];
return {
ok: true,
value: parseMountCommandEntries(
String(
process.getBuiltinModule("node:child_process").execFileSync("mount", [], {
killSignal: "SIGKILL",
timeout: MOUNT_COMMAND_TIMEOUT_MS,
}),
),
),
};
} catch (error) {
return isMountCommandTimeout(error) ? { ok: false, error: "timeout" } : { ok: true, value: [] };
}
}
@@ -228,9 +250,12 @@ function resolveMountEntryJournalPolicy(
function combineMountEntryJournalPolicies(
targetPaths: readonly string[],
): SqliteFilesystemJournalPolicy {
const mountEntries = readMountEntries();
const mountResult = readMountEntries();
if (!mountResult.ok) {
return "rollback";
}
const policies = new Set(
targetPaths.map((targetPath) => resolveMountEntryJournalPolicy(targetPath, mountEntries)),
targetPaths.map((targetPath) => resolveMountEntryJournalPolicy(targetPath, mountResult.value)),
);
if (policies.has("unsupported")) {
return "unsupported";