Files
openclaw/src/commitments/store-writer.ts
clawsweeper[bot] d3c293d9c8 fix(commitments): serialize load-modify-save with in-process queue + cross-process file lock (#86326)
Summary:
- The PR adds a commitments-store writer helper, wraps load-modify-save mutators and expiry cleanup with a per-path queue plus `withFileLock`, adds three concurrency regressions, and updates the changelog.
- PR surface: Source +153, Tests +61, Docs +1. Total +215 across 4 files.
- Reproducibility: yes. Source inspection on current main shows the unqueued load-modify-save mutation path, a ... inked proof log shows the Promise.all repro changing from 20/20 lost writes before the patch to 0/20 after.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(commitments): serialize load-modify-save with in-process queue + …

Validation:
- ClawSweeper review passed for head a349f41ccf.
- Required merge gates passed before the squash merge.

Prepared head SHA: a349f41ccf
Review: https://github.com/openclaw/openclaw/pull/86326#issuecomment-4531553610

Co-authored-by: ai-hpc <mail.speedy.hpc@hotmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-25 05:18:19 +00:00

136 lines
3.7 KiB
TypeScript

// Per-store-path mutation gate for the commitments store. Mirrors the
// in-process queue + cross-process file-lock pattern in
// src/plugin-sdk/persistent-dedupe.ts (issue #81145).
import fs from "node:fs/promises";
import path from "node:path";
import { type FileLockOptions, withFileLock } from "../plugin-sdk/file-lock.js";
type CommitmentsStoreWriterTask = {
fn: () => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
};
type CommitmentsStoreWriterQueue = {
running: boolean;
pending: CommitmentsStoreWriterTask[];
drainPromise: Promise<void> | null;
};
const WRITER_QUEUES = new Map<string, CommitmentsStoreWriterQueue>();
// Matches src/plugin-sdk/persistent-dedupe.ts so both lock-protected stores share tuning.
const DEFAULT_COMMITMENTS_LOCK_OPTIONS: FileLockOptions = {
retries: {
retries: 6,
factor: 1.35,
minTimeout: 8,
maxTimeout: 180,
randomize: true,
},
stale: 60_000,
};
function getOrCreateWriterQueue(storePath: string): CommitmentsStoreWriterQueue {
const existing = WRITER_QUEUES.get(storePath);
if (existing) {
return existing;
}
const created: CommitmentsStoreWriterQueue = {
running: false,
pending: [],
drainPromise: null,
};
WRITER_QUEUES.set(storePath, created);
return created;
}
async function drainCommitmentsStoreWriterQueue(storePath: string): Promise<void> {
const queue = WRITER_QUEUES.get(storePath);
if (!queue) {
return;
}
if (queue.drainPromise) {
await queue.drainPromise;
return;
}
queue.running = true;
queue.drainPromise = (async () => {
try {
while (queue.pending.length > 0) {
const task = queue.pending.shift();
if (!task) {
continue;
}
let result: unknown;
let failed: unknown;
let hasFailure = false;
try {
result = await task.fn();
} catch (err) {
hasFailure = true;
failed = err;
}
if (hasFailure) {
task.reject(failed);
continue;
}
task.resolve(result);
}
} finally {
queue.running = false;
queue.drainPromise = null;
if (queue.pending.length === 0) {
WRITER_QUEUES.delete(storePath);
} else {
queueMicrotask(() => {
void drainCommitmentsStoreWriterQueue(storePath);
});
}
}
})();
await queue.drainPromise;
}
// The advisory lockfile lives next to the data file; create the parent dir up
// front so acquireFileLock does not ENOENT before the user fn ever runs.
async function ensureCommitmentsStoreDir(storePath: string): Promise<void> {
await fs.mkdir(path.dirname(storePath), { recursive: true });
}
export async function runExclusiveCommitmentsStoreWrite<T>(
storePath: string,
fn: () => Promise<T>,
): Promise<T> {
if (!storePath || typeof storePath !== "string") {
throw new Error(
`runExclusiveCommitmentsStoreWrite: storePath must be a non-empty string, got ${JSON.stringify(
storePath,
)}`,
);
}
const queue = getOrCreateWriterQueue(storePath);
return await new Promise<T>((resolve, reject) => {
const task: CommitmentsStoreWriterTask = {
fn: async () => {
await ensureCommitmentsStoreDir(storePath);
return await withFileLock(storePath, DEFAULT_COMMITMENTS_LOCK_OPTIONS, fn);
},
resolve: (value) => resolve(value as T),
reject,
};
queue.pending.push(task);
void drainCommitmentsStoreWriterQueue(storePath);
});
}
export function clearCommitmentsStoreWriterQueuesForTest(): void {
for (const queue of WRITER_QUEUES.values()) {
for (const task of queue.pending) {
task.reject(new Error("commitments store writer queue cleared for test"));
}
}
WRITER_QUEUES.clear();
}