Files
openclaw/src/infra/sqlite-wal.ts
Josh Lehman 0a8e3604ba refactor: flip sessions and transcripts to sqlite storage (#98236)
* refactor(sessions): migrate runtime storage to sqlite

* test(sessions): fix sqlite CI regressions

* test(sessions): align remaining sqlite fixtures

* fix(codex): require sqlite trajectory recorder

* test(sessions): align orphan recovery sqlite fixture

* test(sessions): align sqlite rebase fixtures

* fix(sessions): finish current-main integration of the sqlite flip

Resolve the whole-store SDK removal across its owner boundary: drop the
loadSessionStore re-export and the registry whole-store wrappers, wire
hasTrackedActiveSessionRun into gateway chat, complete the
preserveLockedHarnessIds cleanup contract, flip the codex thread-history
import to storePath targets, and port remaining main-side tests from
file-store helpers to session accessor reads.

* chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs

Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore
the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain
bump gets its own review, and regenerate docs_map, the plugin SDK API baseline,
and the export-surface ratchet for the merged tree.

* feat(sessions): keep archived transcripts by default with zstd cold storage

Codex-style retention: deleting or resetting a session archives its
transcript as a zstd-compressed JSONL artifact (plain when the runtime
lacks node:zlib zstd) and keeps it until the disk budget evicts oldest
first. resetArchiveRetention now governs both deleted and reset archives
and defaults to keep; maxDiskBytes defaults to 2gb so retention stays
bounded, with archives evicted before live sessions. The cron reaper
follows the same knob instead of deleting archives on its own timer.

* fix(state): converge agent DB migration lineages and bound database growth

Merge coherence: run both structure-gated legacy memory-schema repairs
(flip-lineage drop, main-lineage identity rebuild) before the flip
migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all
converge, and hoist foreign_keys=OFF outside the schema transaction
where the pragma was silently ignored and the v1 sessions rebuild
cascade-deleted session_entries.

Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL
maintenance releases freed pages in bounded passes (never a blocking
full VACUUM), and doctor reports state/agent DB bloat from freelist
stats.

* fix(codex): resolve the store path for thread-history import via the SDK

The supervision catalog passed the legacy sessionFile locator to the
storePath-targeted transcript mirror; resolve the agent store path with
the session-store SDK helper instead of a runtime-object seam so test
fakes and headless callers need no extra surface. Drop the obsolete
missing-session-id preprocessing case: sessions rows are NOT NULL on
session_id and upsert repairs id-less patches at write time.

* fix(sessions): fail safe on malformed disk-budget config and doctor stat errors

A malformed explicit maxDiskBytes disables the budget instead of
falling back to the destructive 2gb default the user never chose, and
the doctor bloat check skips databases whose paths stat-fail instead of
aborting doctor.

* fix(sessions): complete sqlite conflict translations

* test(sqlite): align hardening checks with maintenance

* test(sessions): inspect compressed transcript archives

* fix(tests): await session seeds and drop unused helpers flagged by CI lint

The five unawaited writeSessionStoreSeed calls raced their SQLite seeds
against the assertions, failing compact shards; the bloat probe drops a
useless initializer and the merged tests drop now-unused helpers.

* test(sessions): type legacy proof events directly

* test(sessions): align hardening contracts

* perf(sessions): read usage transcript sizes from SQL aggregates

Usage/cost scans walked every session and materialized every transcript
event just to re-stringify it for a byte estimate — the #86718 stall
class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes
in SQLite without loading a single row.

* fix(sessions): re-root foreign-root transcript paths onto the current sessions dir

Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry
absolute sessionFile paths from the old root; the containment fallback
kept those foreign paths, so migration read (and would archive) files in
the original root and reported local copies missing. Re-root the
canonical agents/<id>/sessions suffix onto the current dir when the file
exists there; genuine cross-root layouts still fall through unchanged.

* test(agents): seed harness admission through sqlite

* fix(sqlite): close agent db on pragma setup failure

* fix(doctor): compact and retrofit incremental auto-vacuum after session import

The migration is the sanctioned offline window: post-import compact
reclaims import churn and applies auto_vacuum=INCREMENTAL to databases
created before the fresh-DB pragma existed, so runtime maintenance can
release pages in bounded passes on every install.

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-11 14:50:37 -07:00

417 lines
14 KiB
TypeScript

// 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 { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js";
// WAL maintenance configures SQLite write-ahead logging and schedules bounded
// checkpoints so state databases do not accumulate unbounded WAL files.
export const DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000;
export const DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS = 30 * 60 * 1000;
/**
* @deprecated Use DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS.
* Periodic checkpoints default to PASSIVE.
*/
export const DEFAULT_SQLITE_WAL_TRUNCATE_INTERVAL_MS = DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS;
// 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;
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;
};
type SqliteWalCheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE";
type SqliteFilesystemJournalPolicy = "rollback" | "unsupported" | "wal";
type MountEntry = { mountPoint: string; fsType: string; source?: string };
export type SqliteWalMaintenance = {
checkpoint: () => boolean;
close: () => boolean;
};
/** Options controlling WAL autocheckpoint and periodic checkpoint behavior. */
export type SqliteWalMaintenanceOptions = {
autoCheckpointPages?: number;
checkpointIntervalMs?: number;
checkpointMode?: SqliteWalCheckpointMode;
databaseLabel?: string;
databasePath?: string;
onCheckpointError?: (error: unknown) => void;
};
export type SqliteConnectionPragmaOptions = SqliteWalMaintenanceOptions & {
busyTimeoutMs?: number;
foreignKeys?: boolean;
synchronous?: "NORMAL";
};
function normalizeNonNegativeInteger(value: number, label: string): number {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${label} must be a non-negative integer`);
}
return value;
}
function findExistingVolumePaths(
targetPath: string,
): { canonicalPath: string; originalPath: string } | null {
let current = path.resolve(targetPath);
while (true) {
let stats: ReturnType<typeof fs.statSync>;
try {
stats = fs.statSync(current);
} catch {
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
continue;
}
const existingPath = fs.realpathSync(current);
return {
canonicalPath: stats.isDirectory() ? existingPath : path.dirname(existingPath),
originalPath: stats.isDirectory() ? current : path.dirname(current),
};
}
}
function decodeMountPath(value: string): string {
return value.replace(/\\([0-7]{3})/g, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
function parseProcMountInfoEntries(contents: string): MountEntry[] {
const entries: MountEntry[] = [];
for (const line of contents.split("\n")) {
const separator = line.indexOf(" - ");
if (separator === -1) {
continue;
}
const fields = line.slice(0, separator).split(" ");
const suffixFields = line.slice(separator + 3).split(" ");
const mountPoint = fields[4];
const fsType = suffixFields[0];
if (mountPoint && fsType) {
entries.push({
mountPoint: decodeMountPath(mountPoint),
fsType,
...(suffixFields[1] ? { source: decodeMountPath(suffixFields[1]) } : {}),
});
}
}
return entries;
}
function parseMountCommandEntries(contents: string): MountEntry[] {
const entries: MountEntry[] = [];
for (const line of contents.split("\n")) {
const linuxMatch = /^(.+) on (.+) type ([^,\s)]+) \(/.exec(line);
if (linuxMatch) {
entries.push({ source: linuxMatch[1], mountPoint: linuxMatch[2], fsType: linuxMatch[3] });
continue;
}
const bsdMatch = /^(.+) on (.+) \(([^,\s)]+)/.exec(line);
if (bsdMatch) {
entries.push({ source: bsdMatch[1], mountPoint: bsdMatch[2], fsType: bsdMatch[3] });
}
}
return entries;
}
function readMountEntries(): MountEntry[] {
try {
return 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 [];
}
}
function isPathWithinMount(targetPath: string, mountPoint: string): boolean {
const resolvedTarget = path.resolve(targetPath);
const resolvedMountPoint = path.resolve(mountPoint);
return (
resolvedTarget === resolvedMountPoint ||
resolvedMountPoint === path.parse(resolvedMountPoint).root ||
resolvedTarget.startsWith(`${resolvedMountPoint}${path.sep}`)
);
}
function isSshfsMountSource(source: string | undefined): boolean {
if (!source) {
return false;
}
const normalized = source.toLowerCase();
return (
normalized === "sshfs" ||
normalized.startsWith("sshfs#") ||
normalized.startsWith("sshfs@") ||
/^(?:[^/\s:]+@)?[^/\s:]+:.*/u.test(source)
);
}
function resolveMountTypeJournalPolicy(entry: MountEntry): SqliteFilesystemJournalPolicy {
const normalized = entry.fsType.toLowerCase();
if (normalized.startsWith("nfs") || NETWORK_FILESYSTEM_TYPES.has(normalized)) {
return "rollback";
}
if (normalized === "fuse.sshfs") {
return "unsupported";
}
if ((normalized === "macfuse" || normalized === "osxfuse") && isSshfsMountSource(entry.source)) {
return "unsupported";
}
return "wal";
}
function resolveMountEntryJournalPolicy(
targetPath: string,
mountEntries: MountEntry[],
): SqliteFilesystemJournalPolicy {
const mountEntry = mountEntries
.filter((entry) => isPathWithinMount(targetPath, entry.mountPoint))
.toSorted((a, b) => b.mountPoint.length - a.mountPoint.length)[0];
return mountEntry ? resolveMountTypeJournalPolicy(mountEntry) : "wal";
}
function combineMountEntryJournalPolicies(
targetPaths: readonly string[],
): SqliteFilesystemJournalPolicy {
const mountEntries = readMountEntries();
const policies = new Set(
targetPaths.map((targetPath) => resolveMountEntryJournalPolicy(targetPath, mountEntries)),
);
if (policies.has("unsupported")) {
return "unsupported";
}
return policies.has("rollback") ? "rollback" : "wal";
}
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 resolvePathJournalPolicy(targetPath: string): SqliteFilesystemJournalPolicy {
if (process.platform === "win32") {
const normalizedTargetPath = path.win32.normalize(targetPath);
if (isWindowsUncPath(normalizedTargetPath)) {
return "rollback";
}
if (isWindowsDrivePath(normalizedTargetPath)) {
try {
return isWindowsUncPath(path.win32.normalize(fs.realpathSync.native(targetPath)))
? "rollback"
: "wal";
} catch {
// Windows can deny SMB path normalization when parent components are
// unreadable. Treat an unclassifiable opened database as network-backed.
return "rollback";
}
}
}
const checkedPaths = findExistingVolumePaths(targetPath);
if (!checkedPaths) {
return "wal";
}
const mountLookupPaths = [checkedPaths.originalPath, checkedPaths.canonicalPath];
if (typeof fs.statfsSync !== "function") {
return combineMountEntryJournalPolicies(mountLookupPaths);
}
try {
const filesystemType = fs.statfsSync(checkedPaths.canonicalPath).type;
if (
filesystemType === LINUX_NFS_SUPER_MAGIC ||
filesystemType === LINUX_SMB_SUPER_MAGIC ||
filesystemType === LINUX_CIFS_SUPER_MAGIC ||
filesystemType === LINUX_SMB2_SUPER_MAGIC
) {
return "rollback";
}
} catch {
return combineMountEntryJournalPolicies(mountLookupPaths);
}
return combineMountEntryJournalPolicies(mountLookupPaths);
}
function readJournalModeResult(row: unknown): string | null {
if (!row || typeof row !== "object") {
return null;
}
const record = row as Record<string, unknown>;
const value = record.journal_mode ?? Object.values(record)[0];
return typeof value === "string" ? value.toLowerCase() : null;
}
function requireRollbackJournalMode(db: DatabaseSync, options: SqliteWalMaintenanceOptions): void {
const row = db.prepare("PRAGMA journal_mode = DELETE;").get();
const journalMode = readJournalModeResult(row);
if (journalMode !== "delete") {
const label = options.databaseLabel ?? "sqlite database";
const location = options.databasePath ? ` at ${options.databasePath}` : "";
const actual = journalMode ?? "unknown";
throw new Error(
`${label}${location} is on a network-backed volume but SQLite kept journal_mode=${actual}; refusing to continue with WAL on network storage.`,
);
}
}
function enableMacosCheckpointFullfsync(db: DatabaseSync): void {
if (process.platform !== "darwin") {
return;
}
try {
db.exec("PRAGMA checkpoint_fullfsync = 1;");
} catch {
// Older SQLite builds may ignore or reject platform-specific pragmas. WAL
// setup should still proceed because this is a durability upgrade, not a
// prerequisite for opening the store.
}
}
function refuseUnsupportedFilesystem(options: SqliteWalMaintenanceOptions): never {
const label = options.databaseLabel ?? "sqlite database";
const location = options.databasePath ? ` at ${options.databasePath}` : "";
throw new Error(
`${label}${location} is on SSHFS, which cannot safely coordinate SQLite writes across mounts; refusing to open the database.`,
);
}
/** Configure safe journaling pragmas and return a handle for checkpoint/close maintenance. */
export function configureSqliteWalMaintenance(
db: DatabaseSync,
options: SqliteWalMaintenanceOptions = {},
): SqliteWalMaintenance {
const autoCheckpointPages = normalizeNonNegativeInteger(
options.autoCheckpointPages ?? DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES,
"autoCheckpointPages",
);
const checkpointIntervalMs = normalizeNonNegativeInteger(
options.checkpointIntervalMs ?? DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS,
"checkpointIntervalMs",
);
const timerIntervalMs = Math.min(checkpointIntervalMs, MAX_TIMER_TIMEOUT_MS);
const checkpointMode = options.checkpointMode ?? "TRUNCATE";
const periodicCheckpointMode = options.checkpointMode ?? "PASSIVE";
const journalPolicy = options.databasePath
? resolvePathJournalPolicy(options.databasePath)
: "wal";
if (journalPolicy === "unsupported") {
refuseUnsupportedFilesystem(options);
}
if (journalPolicy === "rollback") {
requireRollbackJournalMode(db, options);
return {
checkpoint: () => true,
close: () => true,
};
}
db.exec("PRAGMA journal_mode = WAL;");
enableMacosCheckpointFullfsync(db);
db.exec(`PRAGMA wal_autocheckpoint = ${autoCheckpointPages};`);
const runCheckpoint = (mode: SqliteWalCheckpointMode): boolean => {
try {
db.exec(`PRAGMA wal_checkpoint(${mode});`);
return true;
} catch (error) {
options.onCheckpointError?.(error);
return false;
}
};
// Bounded page release for databases opened with auto_vacuum=INCREMENTAL.
// A no-op elsewhere, and never a blocking full VACUUM: unbounded vacuums on
// the event loop have starved channel sockets in production (#83712).
const runIncrementalVacuum = (): void => {
try {
db.exec(`PRAGMA incremental_vacuum(${INCREMENTAL_VACUUM_MAX_PAGES_PER_PASS});`);
} catch (error) {
options.onCheckpointError?.(error);
}
};
const checkpoint = (): boolean => runCheckpoint(checkpointMode);
let timer: IntervalHandle | null = null;
if (timerIntervalMs > 0) {
timer = setInterval(() => {
runCheckpoint(periodicCheckpointMode);
runIncrementalVacuum();
}, timerIntervalMs) as IntervalHandle;
timer.unref?.();
}
return {
checkpoint,
close: () => {
if (timer) {
clearInterval(timer);
timer = null;
}
return checkpoint();
},
};
}
/**
* Register a best-effort exit-time close for a SQLite handle cache. Returns an
* unregister callback the cache's orderly close path must invoke, so tests and
* runtime shutdowns do not accumulate listeners on shared worker processes.
*/
export function registerSqliteCacheExitClose(closeAll: () => void): () => void {
const closeOnExit = () => {
try {
closeAll();
} catch {
// Exit-time close is best-effort; unclean exits rely on WAL recovery.
}
};
process.once("exit", closeOnExit);
return () => {
process.removeListener("exit", closeOnExit);
};
}
/** Configure per-connection SQLite pragmas in the safe lock-retry/WAL order. */
export function configureSqliteConnectionPragmas(
db: DatabaseSync,
options: SqliteConnectionPragmaOptions = {},
): SqliteWalMaintenance {
const { busyTimeoutMs, foreignKeys, synchronous, ...walOptions } = options;
if (busyTimeoutMs !== undefined) {
db.exec(
`PRAGMA busy_timeout = ${normalizeNonNegativeInteger(busyTimeoutMs, "busyTimeoutMs")};`,
);
}
const maintenance = configureSqliteWalMaintenance(db, walOptions);
if (synchronous) {
db.exec(`PRAGMA synchronous = ${synchronous};`);
}
if (foreignKeys) {
db.exec("PRAGMA foreign_keys = ON;");
}
return maintenance;
}