mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user