feat: introduced native file lock bindings for cross-process advisory locking
- Added native `FileLock` bindings supporting cross-process advisory locking on Linux, Unix, and Windows. - Replaced directory-based file locking and custom stale-lock reclamation with OS-backed native locks. - Updated TypeScript declarations, native bindings, and package documentation for the new API. - Added comprehensive unit tests and fixtures validating single-owner constraints and process death handoff.
This commit is contained in:
@@ -51,24 +51,20 @@ import type {
|
||||
import { computeUsageWindowStats, fetchUsageSnapshots } from "./usage-windows";
|
||||
|
||||
const STATS_SYNC_LOCK_RETRY_MS = 25;
|
||||
const STATS_SYNC_LOCK_ACQUIRE_STALE_MS = 10 * 1000;
|
||||
const STATS_SYNC_LOCK_STALE_MS = 60 * 60 * 1000;
|
||||
const STATS_SYNC_LOCK_WAIT_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Serialize stats ingestion and archive reconciliation across processes.
|
||||
* The lock covers file discovery, parsing, and the final SQLite write so a
|
||||
* parse result for a session moved by GC can never commit after cleanup.
|
||||
* The lock lives at `${dbPath}.sync.lock`; a dead owner is reclaimed
|
||||
* immediately, a live-but-wedged one after an hour, and a lock abandoned
|
||||
* mid-acquisition after ten seconds.
|
||||
* The native lock is owned by an operating-system primitive, so an interrupted
|
||||
* owner is released automatically and a live owner is never displaced.
|
||||
*/
|
||||
export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T> {
|
||||
await fs.promises.mkdir(path.dirname(dbPath), { recursive: true });
|
||||
return await withFileLock(`${dbPath}.sync`, fn, {
|
||||
staleMs: STATS_SYNC_LOCK_STALE_MS,
|
||||
acquireStaleMs: STATS_SYNC_LOCK_ACQUIRE_STALE_MS,
|
||||
retryDelayMs: STATS_SYNC_LOCK_RETRY_MS,
|
||||
retries: Math.ceil(STATS_SYNC_LOCK_STALE_MS / STATS_SYNC_LOCK_RETRY_MS),
|
||||
retries: Math.ceil(STATS_SYNC_LOCK_WAIT_MS / STATS_SYNC_LOCK_RETRY_MS),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { syncAllSessions, withStatsSyncLock } from "@oh-my-pi/omp-stats/aggregator";
|
||||
import { syncAllSessions } from "@oh-my-pi/omp-stats/aggregator";
|
||||
import { getOverallStats } from "@oh-my-pi/omp-stats/db";
|
||||
import { getSessionsDir } from "@oh-my-pi/pi-utils";
|
||||
import { installStatsTestIsolation } from "./helpers/temp-agent";
|
||||
@@ -93,71 +93,4 @@ describe("stats sync serial mode", () => {
|
||||
await expect(syncAllSessions({ workers: 2 })).rejects.toBe(workerProbe);
|
||||
expect(workerSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reclaims a dead owner's abandoned lock", async () => {
|
||||
const dbPath = path.join(getSessionsDir(), "stats-lock.db");
|
||||
const lockPath = `${dbPath}.sync.lock`;
|
||||
const deadPid = 424_242;
|
||||
await fs.mkdir(lockPath, { recursive: true });
|
||||
await Bun.write(
|
||||
path.join(lockPath, "info"),
|
||||
JSON.stringify({ pid: deadPid, timestamp: Date.now() - 60_000, token: "dead-owner" }),
|
||||
);
|
||||
vi.spyOn(process, "kill").mockImplementation(pid => {
|
||||
if (pid === deadPid) throw Object.assign(new Error("dead owner"), { code: "ESRCH" });
|
||||
return true;
|
||||
});
|
||||
|
||||
vi.spyOn(Bun, "sleep").mockResolvedValue(undefined);
|
||||
const result = await withStatsSyncLock(dbPath, async () => "acquired");
|
||||
|
||||
expect(result).toBe("acquired");
|
||||
expect(await fs.stat(lockPath).catch(() => null)).toBeNull();
|
||||
});
|
||||
|
||||
it("reclaims an unstamped lock after the acquisition grace period", async () => {
|
||||
const dbPath = path.join(getSessionsDir(), "stats-unstamped-lock.db");
|
||||
const lockPath = `${dbPath}.sync.lock`;
|
||||
await fs.mkdir(lockPath, { recursive: true });
|
||||
const staleTime = new Date(Date.now() - 11_000);
|
||||
await fs.utimes(lockPath, staleTime, staleTime);
|
||||
vi.spyOn(Bun, "sleep").mockResolvedValue(undefined);
|
||||
|
||||
const result = await withStatsSyncLock(dbPath, async () => "acquired");
|
||||
|
||||
expect(result).toBe("acquired");
|
||||
expect(await fs.stat(lockPath).catch(() => null)).toBeNull();
|
||||
});
|
||||
|
||||
it("waits for a live recently-stamped owner instead of reclaiming it", async () => {
|
||||
const dbPath = path.join(getSessionsDir(), "stats-live-lock.db");
|
||||
const lockPath = `${dbPath}.sync.lock`;
|
||||
const infoPath = path.join(lockPath, "info");
|
||||
await fs.mkdir(lockPath, { recursive: true });
|
||||
await Bun.write(infoPath, JSON.stringify({ pid: process.pid, timestamp: Date.now(), token: "live-owner" }));
|
||||
const retryScheduled = Promise.withResolvers<void>();
|
||||
const resumeRetry = Promise.withResolvers<void>();
|
||||
let lockReleased = false;
|
||||
vi.spyOn(Bun, "sleep").mockImplementation(async () => {
|
||||
if (lockReleased) return;
|
||||
retryScheduled.resolve();
|
||||
await resumeRetry.promise;
|
||||
});
|
||||
|
||||
let acquired = false;
|
||||
const pending = withStatsSyncLock(dbPath, async () => {
|
||||
acquired = true;
|
||||
});
|
||||
try {
|
||||
await retryScheduled.promise;
|
||||
expect(acquired).toBe(false);
|
||||
expect(JSON.parse(await Bun.file(infoPath).text())).toMatchObject({ token: "live-owner" });
|
||||
} finally {
|
||||
lockReleased = true;
|
||||
await fs.rm(lockPath, { recursive: true, force: true });
|
||||
resumeRetry.resolve();
|
||||
await pending;
|
||||
}
|
||||
expect(acquired).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user