fix(sqlite): disable WAL on network filesystems

This commit is contained in:
Vincent Koc
2026-06-16 09:00:11 +08:00
parent 8694fe7e81
commit ac8a3f367c
6 changed files with 220 additions and 28 deletions

View File

@@ -250,7 +250,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types plus centralized connection pragma and WAL maintenance setup for plugin-owned databases |
| `plugin-sdk/routing` | Route/session-key/account binding helpers such as `resolveAgentRoute`, `buildAgentSessionKey`, and `resolveDefaultAgentBoundAccountId` |
| `plugin-sdk/status-helpers` | Shared channel/account status summary helpers, runtime-state defaults, and issue metadata helpers |
| `plugin-sdk/target-resolver-runtime` | Shared target resolver helpers |

View File

@@ -2,6 +2,7 @@
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
import { configureSqliteConnectionPragmas } from "openclaw/plugin-sdk/plugin-state-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import type {
PersistedWorkboardAttachment,
@@ -361,15 +362,6 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
).run(`schema-${SCHEMA_VERSION}`, Date.now());
}
function configureWorkboardDatabase(db: DatabaseSync): void {
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = ${WORKBOARD_SQLITE_BUSY_TIMEOUT_MS};
PRAGMA foreign_keys = ON;
`);
}
function chmodIfExists(targetPath: string, mode: number): void {
try {
fs.chmodSync(targetPath, mode);
@@ -385,19 +377,30 @@ function hardenWorkboardDatabaseFiles(dbPath: string): void {
chmodIfExists(dbPath, WORKBOARD_SQLITE_FILE_MODE);
chmodIfExists(`${dbPath}-wal`, WORKBOARD_SQLITE_FILE_MODE);
chmodIfExists(`${dbPath}-shm`, WORKBOARD_SQLITE_FILE_MODE);
chmodIfExists(`${dbPath}-journal`, WORKBOARD_SQLITE_FILE_MODE);
}
function createDatabase(dbPath: string): DatabaseSync {
function createDatabase(dbPath: string): {
db: DatabaseSync;
maintenance: ReturnType<typeof configureSqliteConnectionPragmas>;
} {
fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: WORKBOARD_SQLITE_DIR_MODE });
chmodIfExists(path.dirname(dbPath), WORKBOARD_SQLITE_DIR_MODE);
if (!fs.existsSync(dbPath)) {
fs.closeSync(fs.openSync(dbPath, "a", WORKBOARD_SQLITE_FILE_MODE));
}
const db = new DatabaseSync(dbPath);
configureWorkboardDatabase(db);
const maintenance = configureSqliteConnectionPragmas(db, {
busyTimeoutMs: WORKBOARD_SQLITE_BUSY_TIMEOUT_MS,
checkpointIntervalMs: 0,
databaseLabel: "workboard database",
databasePath: dbPath,
foreignKeys: true,
synchronous: "NORMAL",
});
ensureWorkboardSchema(db);
hardenWorkboardDatabaseFiles(dbPath);
return db;
return { db, maintenance };
}
function childRows(db: DatabaseSync, table: string, cardId: string): Row[] {
@@ -1401,12 +1404,17 @@ export function createWorkboardSqliteStores(
env?: NodeJS.ProcessEnv;
} = {},
): WorkboardSqliteStores {
const db = createDatabase(options.dbPath ?? resolveWorkboardSqlitePath(options.env));
const { db, maintenance } = createDatabase(
options.dbPath ?? resolveWorkboardSqlitePath(options.env),
);
return {
cards: new WorkboardSqliteCardStore(db),
boards: new WorkboardSqliteBoardStore(db),
subscriptions: new WorkboardSqliteSubscriptionStore(db),
attachments: new WorkboardSqliteAttachmentStore(db),
close: () => db.close(),
close: () => {
maintenance.close();
db.close();
},
};
}

View File

@@ -36,6 +36,18 @@ function createMemoryStore<T = PersistedWorkboardCard>(options?: {
};
}
function statfsFixture(type: number): ReturnType<typeof fs.statfsSync> {
return {
type,
bsize: 1024,
blocks: 1,
bfree: 1,
bavail: 1,
files: 0,
ffree: 0,
};
}
describe("WorkboardStore", () => {
it("persists boards, cards, subscriptions, and attachment blobs in sqlite", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-"));
@@ -90,7 +102,7 @@ describe("WorkboardStore", () => {
if (process.platform !== "win32") {
expect(fs.statSync(dir).mode & 0o777).toBe(0o700);
expect(fs.statSync(dbPath).mode & 0o777).toBe(0o600);
for (const sidecarPath of [`${dbPath}-wal`, `${dbPath}-shm`]) {
for (const sidecarPath of [`${dbPath}-wal`, `${dbPath}-shm`, `${dbPath}-journal`]) {
if (fs.existsSync(sidecarPath)) {
expect(fs.statSync(sidecarPath).mode & 0o777).toBe(0o600);
}
@@ -144,6 +156,27 @@ describe("WorkboardStore", () => {
}
});
it("uses rollback journaling on network-backed volumes", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-network-"));
const dbPath = path.join(dir, "workboard.sqlite");
const statfs = vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0xff534d42));
try {
const stores = createWorkboardSqliteStores({ dbPath });
stores.close();
const rawDb = new DatabaseSync(dbPath);
expect(rawDb.prepare("PRAGMA journal_mode").get()).toMatchObject({
journal_mode: "delete",
});
rawDb.close();
expect(fs.existsSync(`${dbPath}-wal`)).toBe(false);
expect(fs.existsSync(`${dbPath}-shm`)).toBe(false);
} finally {
statfs.mockRestore();
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("creates and lists cards by status order and position", async () => {
const store = new WorkboardStore(createMemoryStore());

View File

@@ -73,7 +73,97 @@ describe("sqlite WAL maintenance", () => {
}
});
it("refuses NFS-backed databases when SQLite keeps WAL active", () => {
it.each([
["SMB", 0x517b],
["CIFS", 0xff534d42],
["SMB2", 0xfe534d42],
])("uses rollback journaling for databases on Linux %s volumes", (_label, fsType) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-network-"));
try {
const db = createMockDb();
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(fsType));
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath: path.join(tempDir, "openclaw.sqlite"),
});
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it.each([
String.raw`\\server\share\openclaw.sqlite`,
String.raw`\\?\UNC\server\share\openclaw.sqlite`,
"//server/share/openclaw.sqlite",
"//?/UNC/server/share/openclaw.sqlite",
])("uses rollback journaling for databases on Windows UNC paths: %s", (databasePath) => {
const db = createMockDb();
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath,
});
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
expect(db["exec"]).not.toHaveBeenCalled();
});
it("uses rollback journaling for mapped Windows network drives", () => {
const db = createMockDb();
const databasePath = String.raw`Z:\state\openclaw.sqlite`;
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const realpath = vi
.spyOn(fs.realpathSync, "native")
.mockReturnValue(String.raw`\\server\share\state\openclaw.sqlite`);
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath,
});
expect(realpath).toHaveBeenCalledWith(databasePath);
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
expect(db["exec"]).not.toHaveBeenCalled();
});
it("does not treat namespaced Windows local drives as UNC paths", () => {
const db = createMockDb();
const databasePath = String.raw`\\?\C:\state\openclaw.sqlite`;
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const realpath = vi.spyOn(fs.realpathSync, "native").mockReturnValue(databasePath);
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath,
});
expect(realpath).toHaveBeenCalledWith(databasePath);
expect(db["prepare"]).not.toHaveBeenCalled();
expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA journal_mode = WAL;");
});
it("uses rollback journaling when Windows cannot classify an opened drive path", () => {
const db = createMockDb();
const databasePath = String.raw`Z:\restricted\openclaw.sqlite`;
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
vi.spyOn(fs.realpathSync, "native").mockImplementation(() => {
throw new Error("access denied");
});
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath,
});
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
expect(db["exec"]).not.toHaveBeenCalled();
});
it("refuses network-backed databases when SQLite keeps WAL active", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
try {
const db = createMockDb();
@@ -137,6 +227,29 @@ describe("sqlite WAL maintenance", () => {
}
});
it("uses macOS SMB mount filesystem names", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-smb-"));
try {
const db = createMockDb();
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw new Error("no proc mountinfo");
});
vi.spyOn(childProcess, "execFileSync").mockReturnValue(
Buffer.from(`//server/share on ${tempDir} (smbfs, nodev, nosuid)\n`),
);
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databasePath: path.join(tempDir, "openclaw.sqlite"),
});
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode = DELETE;");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("parses Linux mount command filesystem names when proc mountinfo is unavailable", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
try {

View File

@@ -15,7 +15,11 @@ export const DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS = 30 * 60 * 1000;
*/
export const DEFAULT_SQLITE_WAL_TRUNCATE_INTERVAL_MS = DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS;
const LINUX_NFS_SUPER_MAGIC = 0x6969;
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";
const NETWORK_FILESYSTEM_TYPES = new Set(["cifs", "smbfs", "smb2", "smb3"]);
type IntervalHandle = ReturnType<typeof setInterval> & {
unref?: () => void;
@@ -133,33 +137,66 @@ function isPathWithinMount(targetPath: string, mountPoint: string): boolean {
);
}
function isNfsMountType(fsType: string): boolean {
return fsType.toLowerCase().startsWith("nfs");
function isNetworkMountType(fsType: string): boolean {
const normalized = fsType.toLowerCase();
return normalized.startsWith("nfs") || NETWORK_FILESYSTEM_TYPES.has(normalized);
}
function isNfsMountEntryPath(targetPath: string): boolean {
function isNetworkMountEntryPath(targetPath: string): boolean {
const mountEntry = readMountEntries()
.filter((entry) => isPathWithinMount(targetPath, entry.mountPoint))
.toSorted((a, b) => b.mountPoint.length - a.mountPoint.length)[0];
return mountEntry ? isNfsMountType(mountEntry.fsType) : false;
return mountEntry ? isNetworkMountType(mountEntry.fsType) : false;
}
function isNfsBackedPath(targetPath: string): boolean {
function isWindowsUncPath(targetPath: string): boolean {
return (
/^\\\\\?\\UNC\\[^\\]+\\[^\\]+/i.test(targetPath) ||
/^\\\\(?![?.]\\)[^\\]+\\[^\\]+/.test(targetPath)
);
}
function isWindowsDrivePath(targetPath: string): boolean {
return /^[A-Za-z]:[\\/]/.test(targetPath) || /^\\\\\?\\[A-Za-z]:[\\/]/i.test(targetPath);
}
function isNetworkBackedPath(targetPath: string): boolean {
if (process.platform === "win32") {
const normalizedTargetPath = path.win32.normalize(targetPath);
if (isWindowsUncPath(normalizedTargetPath)) {
return true;
}
if (isWindowsDrivePath(normalizedTargetPath)) {
try {
return isWindowsUncPath(path.win32.normalize(fs.realpathSync.native(targetPath)));
} catch {
// Windows can deny SMB path normalization when parent components are
// unreadable. Treat an unclassifiable opened database as network-backed.
return true;
}
}
}
if (typeof fs.statfsSync !== "function") {
return isNfsMountEntryPath(targetPath);
return isNetworkMountEntryPath(targetPath);
}
const checkedPath = findExistingVolumePath(targetPath);
if (!checkedPath) {
return false;
}
try {
if (fs.statfsSync(checkedPath).type === LINUX_NFS_SUPER_MAGIC) {
const filesystemType = fs.statfsSync(checkedPath).type;
if (
filesystemType === LINUX_NFS_SUPER_MAGIC ||
filesystemType === LINUX_SMB_SUPER_MAGIC ||
filesystemType === LINUX_CIFS_SUPER_MAGIC ||
filesystemType === LINUX_SMB2_SUPER_MAGIC
) {
return true;
}
} catch {
return isNfsMountEntryPath(checkedPath);
return isNetworkMountEntryPath(checkedPath);
}
return isNfsMountEntryPath(checkedPath);
return isNetworkMountEntryPath(checkedPath);
}
function readJournalModeResult(row: unknown): string | null {
@@ -179,7 +216,7 @@ function requireRollbackJournalMode(db: DatabaseSync, options: SqliteWalMaintena
const location = options.databasePath ? ` at ${options.databasePath}` : "";
const actual = journalMode ?? "unknown";
throw new Error(
`${label}${location} is on an NFS-backed volume but SQLite kept journal_mode=${actual}; refusing to continue with WAL on NFS.`,
`${label}${location} is on a network-backed volume but SQLite kept journal_mode=${actual}; refusing to continue with WAL on network storage.`,
);
}
}
@@ -200,7 +237,7 @@ export function configureSqliteWalMaintenance(
const timerIntervalMs = Math.min(checkpointIntervalMs, MAX_TIMER_TIMEOUT_MS);
const checkpointMode = options.checkpointMode ?? "TRUNCATE";
const periodicCheckpointMode = options.checkpointMode ?? "PASSIVE";
if (options.databasePath && isNfsBackedPath(options.databasePath)) {
if (options.databasePath && isNetworkBackedPath(options.databasePath)) {
requireRollbackJournalMode(db, options);
return {
checkpoint: () => true,

View File

@@ -1,6 +1,7 @@
/**
* Runtime SDK type surface for plugin-scoped keyed state stores.
*/
export { configureSqliteConnectionPragmas } from "../infra/sqlite-wal.js";
export type {
OpenKeyedStoreOptions,
PluginStateEntry,