diff --git a/docs/automation/tasks.md b/docs/automation/tasks.md index 8bcd68ea5f6f..ca700fc6b7ba 100644 --- a/docs/automation/tasks.md +++ b/docs/automation/tasks.md @@ -319,7 +319,7 @@ Task records and delivery state persist in the shared OpenClaw SQLite state data Set `OPENCLAW_STATE_DIR` to move the whole state root (default `~/.openclaw`) elsewhere; the shared database path moves with it. -The registry loads into memory on first use and persists every write back to SQLite, so records survive gateway restarts. WAL growth stays bounded through SQLite's default autocheckpoint threshold plus periodic `PASSIVE` checkpoints; shutdown and explicit maintenance checkpoints use `TRUNCATE` so normal closes reclaim WAL space without making the background sweeper wait on active readers. +The registry loads into memory on first use and persists every write back to SQLite, so records survive gateway restarts. WAL growth stays bounded through SQLite's default autocheckpoint threshold plus periodic `PASSIVE` checkpoints. After a checkpoint completes, the next commit resets the WAL and applies a 64 MiB `journal_size_limit` ceiling, so a reader cannot leave the file parked at a pathological high-water mark until restart. Shutdown and explicit maintenance checkpoints use `TRUNCATE` so normal closes reclaim WAL space without making the background sweeper wait on active readers. Legacy sidecar stores from older installs (`tasks/runs.sqlite`, `flows/registry.sqlite`) are imported into the shared database by `openclaw doctor`. diff --git a/src/infra/sqlite-pragma.test-support.ts b/src/infra/sqlite-pragma.test-support.ts index 5ef6bff538e9..b50a741e8c6d 100644 --- a/src/infra/sqlite-pragma.test-support.ts +++ b/src/infra/sqlite-pragma.test-support.ts @@ -6,6 +6,7 @@ type SqliteNumberPragma = | "auto_vacuum" | "busy_timeout" | "foreign_keys" + | "journal_size_limit" | "schema_version" | "synchronous" | "user_version" diff --git a/src/infra/sqlite-wal.test.ts b/src/infra/sqlite-wal.test.ts index c13edf300d40..36c4737dec00 100644 --- a/src/infra/sqlite-wal.test.ts +++ b/src/infra/sqlite-wal.test.ts @@ -201,6 +201,51 @@ describe("sqlite WAL maintenance", () => { } }); + it("reclaims an inflated WAL on the first commit after a completed checkpoint", () => { + const sqlite = requireNodeSqlite(); + const dir = tempDirs.make("openclaw-sqlite-wal-size-"); + const dbPath = path.join(dir, "openclaw.sqlite"); + const walPath = `${dbPath}-wal`; + const db = new sqlite.DatabaseSync(dbPath); + let maintenance: ReturnType | undefined; + try { + maintenance = configureSqliteWalMaintenance(db, { + autoCheckpointPages: 0, + checkpointIntervalMs: 0, + databaseLabel: "wal-size-default", + databasePath: dbPath, + }); + db.exec("CREATE TABLE payload (id INTEGER PRIMARY KEY, value TEXT NOT NULL);"); + db.prepare("INSERT INTO payload (value) VALUES (?)").run("before-checkpoint"); + + const checkpoint = db.prepare("PRAGMA wal_checkpoint(PASSIVE);").get() as { + busy: number; + checkpointed: number; + log: number; + }; + expect(checkpoint.busy).toBe(0); + expect(checkpoint.checkpointed).toBe(checkpoint.log); + + const sizeLimit = Number( + ( + db.prepare("PRAGMA journal_size_limit;").get() as { + journal_size_limit: number | bigint; + } + ).journal_size_limit, + ); + expect(sizeLimit).toBe(64 * 1024 * 1024); + // A sparse extension models a retained high-water WAL without writing a 65 MiB fixture. + fs.truncateSync(walPath, sizeLimit + 1024 * 1024); + + db.prepare("INSERT INTO payload (value) VALUES (?)").run("after-checkpoint"); + + expect(fs.statSync(walPath).size).toBe(sizeLimit); + } finally { + maintenance?.close(); + db.close(); + } + }); + it("rejects a memory journal for a file-backed database", () => { const db = createMockDb(); vi.mocked(db["prepare"]).mockImplementation( @@ -503,19 +548,20 @@ describe("sqlite WAL maintenance", () => { vi.spyOn(process, "platform", "get").mockReturnValue("linux"); const maintenance = configureSqliteWalMaintenance(db, { checkpointIntervalMs: 100 }); - expect(db["exec"]).toHaveBeenCalledTimes(2); + // journal_mode=WAL, wal_autocheckpoint, journal_size_limit. + expect(db["exec"]).toHaveBeenCalledTimes(3); vi.advanceTimersByTime(100); expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA wal_checkpoint(PASSIVE);"); - expect(db["exec"]).toHaveBeenNthCalledWith(3, "PRAGMA incremental_vacuum(512);"); - expect(db["exec"]).toHaveBeenCalledTimes(3); + expect(db["exec"]).toHaveBeenNthCalledWith(4, "PRAGMA incremental_vacuum(512);"); + expect(db["exec"]).toHaveBeenCalledTimes(4); expect(maintenance.close()).toBe(true); expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA wal_checkpoint(TRUNCATE);"); - expect(db["exec"]).toHaveBeenCalledTimes(3); + expect(db["exec"]).toHaveBeenCalledTimes(4); vi.advanceTimersByTime(200); - expect(db["exec"]).toHaveBeenCalledTimes(3); + expect(db["exec"]).toHaveBeenCalledTimes(4); }); it("clamps oversized checkpoint intervals before arming timers", () => { @@ -544,7 +590,7 @@ describe("sqlite WAL maintenance", () => { vi.advanceTimersByTime(100); expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA wal_checkpoint(FULL);"); - expect(db["exec"]).toHaveBeenNthCalledWith(3, "PRAGMA incremental_vacuum(512);"); + expect(db["exec"]).toHaveBeenNthCalledWith(4, "PRAGMA incremental_vacuum(512);"); expect(maintenance.close()).toBe(true); expect(db["prepare"]).toHaveBeenLastCalledWith("PRAGMA wal_checkpoint(FULL);"); diff --git a/src/infra/sqlite-wal.ts b/src/infra/sqlite-wal.ts index 0eb72a0f315d..264a94d21dc6 100644 --- a/src/infra/sqlite-wal.ts +++ b/src/infra/sqlite-wal.ts @@ -10,6 +10,10 @@ import { isSqliteLockError } from "./sqlite-transaction.js"; // checkpoints so state databases do not accumulate unbounded WAL files. const DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000; const DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS = 30 * 60 * 1000; +// SQLite applies this ceiling when a fully checkpointed WAL resets on the next +// commit. Keep it well above the usual ~4 MiB autocheckpoint window so only +// pathological high-water marks pay the truncation cost. +const DEFAULT_SQLITE_WAL_JOURNAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024; // 512 pages (~2MB at 4KB pages) per periodic pass keeps page release strictly // bounded so maintenance can never behave like a blocking full VACUUM. const INCREMENTAL_VACUUM_MAX_PAGES_PER_PASS = 512; @@ -467,6 +471,7 @@ export function configureSqliteWalMaintenance( } enableMacosCheckpointFullfsync(db); db.exec(`PRAGMA wal_autocheckpoint = ${autoCheckpointPages};`); + db.exec(`PRAGMA journal_size_limit = ${DEFAULT_SQLITE_WAL_JOURNAL_SIZE_LIMIT_BYTES};`); const runCheckpoint = (mode: SqliteWalCheckpointMode): boolean => { try { diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index be178b5d5132..7cad16ffeaad 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -2605,6 +2605,7 @@ describe("openclaw agent database", () => { expect(readSqliteNumberPragma(database.db, "auto_vacuum")).toBe(2); expect(readSqliteNumberPragma(database.db, "user_version")).toBe(OPENCLAW_AGENT_SCHEMA_VERSION); expect(readSqliteNumberPragma(database.db, "wal_autocheckpoint")).toBe(1000); + expect(readSqliteNumberPragma(database.db, "journal_size_limit")).toBe(64 * 1024 * 1024); const journalMode = database.db.prepare("PRAGMA journal_mode").get() as | { journal_mode?: string } | undefined; diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index 13cfc3ad24ab..9f4f92288938 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -2530,7 +2530,10 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const rmSync = fs.rmSync.bind(fs); let failRemoval = true; vi.spyOn(fs, "rmSync").mockImplementation(((pathname, options) => { - if (pathname === privateDirectory && failRemoval) { + if ( + fs.realpathSync.native(String(pathname)) === fs.realpathSync.native(privateDirectory) && + failRemoval + ) { failRemoval = false; const error = new Error("busy"); (error as NodeJS.ErrnoException).code = "EBUSY"; @@ -3951,6 +3954,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're expect(readSqliteNumberPragma(database.db, "auto_vacuum")).toBe(2); expect(readSqliteNumberPragma(database.db, "user_version")).toBe(OPENCLAW_STATE_SCHEMA_VERSION); expect(readSqliteNumberPragma(database.db, "wal_autocheckpoint")).toBe(1000); + expect(readSqliteNumberPragma(database.db, "journal_size_limit")).toBe(64 * 1024 * 1024); const journalMode = database.db.prepare("PRAGMA journal_mode").get() as | { journal_mode?: string } | undefined;