fix(state): close ownership WAL transition race

Retry an immutable ownership probe through SQLite normal read-only access when a WAL appears during the open and produces a false corruption result. Treat a WAL without SHM as live state, and cover that rebuildable-index boundary with a real database-family regression.
This commit is contained in:
Peter Steinberger
2026-08-10 00:32:34 -07:00
parent 7c8192bc03
commit 020ba72f8d
2 changed files with 66 additions and 11 deletions
@@ -88,6 +88,38 @@ describe("external shared-state ownership", () => {
expect(inspectOpenClawStateOwnershipAtPath(database.path)).toBeNull();
});
it("reads ownership from a WAL when the SHM index is absent", () => {
const env = createEnv(true);
const databasePath = openOpenClawStateDatabase({ env }).path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const writer = new DatabaseSync(databasePath);
try {
writer.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;");
const ownership = {
version: 1,
mode: "external",
managerId: "wal-only-manager",
claimedAt: 1,
} as const;
writer
.prepare(
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)",
)
.run(STATE_SUPERVISION_KEY, JSON.stringify(ownership), ownership.claimedAt);
const copyDir = tempDirs.make("openclaw-state-ownership-wal-only-");
const copyPath = path.join(copyDir, "openclaw.sqlite");
fs.copyFileSync(databasePath, copyPath);
fs.copyFileSync(`${databasePath}-wal`, `${copyPath}-wal`);
expect(fs.existsSync(`${copyPath}-shm`)).toBe(false);
expect(inspectOpenClawStateOwnershipAtPath(copyPath)).toEqual(ownership);
} finally {
writer.close();
}
});
it("requires the external marker and makes claims idempotent only for one manager", () => {
const env = createEnv();
expect(() => claimOpenClawStateOwnership("gateway-supervisor", { env })).toThrow(
+34 -11
View File
@@ -4,6 +4,7 @@ import type { DatabaseSync } from "node:sqlite";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { isGatewayExternallySupervised } from "../infra/gateway-supervision.js";
import { openNodeSqliteDatabase, resolveImmutableSqliteFileUri } from "../infra/node-sqlite.js";
import { isSqliteCorruptionError } from "../infra/sqlite-transaction.js";
import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db-contract.js";
import { tableExists } from "./openclaw-state-db-schema-helpers.js";
@@ -114,7 +115,26 @@ export function inspectOpenClawStateOwnershipFromDatabase(
return parseExternalOwnership(row.value_json, databasePath);
}
/** Inspect one resolved state database path without mutating a quiescent SQLite family. */
function hasLiveWal(databasePath: string): boolean {
return existsSync(`${databasePath}-wal`);
}
function inspectOpenClawStateOwnershipAtLocation(
databasePath: string,
location = databasePath,
): OpenClawExternalStateOwnership | null {
const database = openNodeSqliteDatabase(location, { readOnly: true });
try {
database.exec(
`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS}; PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;`,
);
return inspectOpenClawStateOwnershipFromDatabase(database, databasePath);
} finally {
database.close();
}
}
/** Inspect one resolved state database path without joining the writable lifecycle. */
export function inspectOpenClawStateOwnershipAtPath(
databasePath: string,
): OpenClawExternalStateOwnership | null {
@@ -122,18 +142,21 @@ export function inspectOpenClawStateOwnershipAtPath(
if (!existsSync(resolvedPath)) {
return null;
}
// WAL readers need the live sidecars. Rollback-journal recovery stays with the
// writable lifecycle after this ownership fence, so it remains immutable here.
const hasLiveWal = ["-shm", "-wal"].some((suffix) => existsSync(`${resolvedPath}${suffix}`));
const location = hasLiveWal ? resolvedPath : resolveImmutableSqliteFileUri(resolvedPath);
const database = openNodeSqliteDatabase(location, { readOnly: true });
if (hasLiveWal(resolvedPath)) {
return inspectOpenClawStateOwnershipAtLocation(resolvedPath);
}
try {
database.exec(
`${hasLiveWal ? `PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS}; ` : ""}PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;`,
return inspectOpenClawStateOwnershipAtLocation(
resolvedPath,
resolveImmutableSqliteFileUri(resolvedPath),
);
return inspectOpenClawStateOwnershipFromDatabase(database, resolvedPath);
} finally {
database.close();
} catch (error) {
// External claims checkpoint before returning, so the main file is authoritative.
// If a WAL appeared during this open, retry with SQLite's normal WAL-aware reader.
if (!isSqliteCorruptionError(error) || !hasLiveWal(resolvedPath)) {
throw error;
}
return inspectOpenClawStateOwnershipAtLocation(resolvedPath);
}
}