scripts/stale-claim.ts
Rama main · réplica de lectura del repositorio original.
/**
* A claim on a shared job, kept as a file whose owner can walk away.
*
* The plain file locks in #scripts/lock-file.ts end the moment their holder's
* process ends, and a waiter cannot tell a busy holder from a dead one. Some
* jobs need exactly that told apart: an install another runner should take
* over if its downloader died, a run folder that must not be cleared while
* its supervisor lives. So a claim is a file naming its owner and when that
* owner last checked in. The holder keeps touching the file while it works; a
* claim untouched for long enough counts as walked away from, and the next
* taker removes it and claims in its place.
*
* The database migration lock (src/shared/db/migrations/lock.ts) is this same
* shape written into a settings row — an owner token, a freshness cutoff, a
* steal of anything older, a release only of your own claim. This module is
* the file version, shared by the stripe-mock install and mutation runs.
*/
import {
type LockBody,
type PathLockHolder,
withFileLock,
} from "#scripts/lock-file.ts";
import { nullIfNotFound, rethrowUnlessNotFound } from "#scripts/not-found.ts";
import { errorMessage } from "#shared/error-message.ts";
import { delay } from "#shared/now.ts";
/** How a claim is kept fresh, and when it counts as walked away from. */
export type StaleClaimSettings = {
/** A claim untouched for this long may be removed and taken over. */
staleMs: number;
/** How often the holder rewrites its check-in time while it works. */
touchMs: number;
};
/** How long to keep asking for a busy claim, and what to call it on giving up. */
export type ClaimWait = {
/** Names the claim in the giving-up error, e.g. "stripe-mock install lock". */
name: string;
retryMs: number;
timeoutMs: number;
};
/** A claim this caller holds: release it once the work is done. */
type HeldClaim = {
owner: string;
release: () => Promise<void>;
};
type ClaimRecord = {
owner?: string;
writtenAt: number;
};
const CLAIM_GUARD_SUFFIX = ".guard";
/**
* Takers serialize through a plain file lock beside the claim, so two of them
* cannot both judge a claim stale and both believe they created its successor.
*/
const claimGuardPath = (path: string): string => `${path}${CLAIM_GUARD_SUFFIX}`;
/**
* Hold the takers' guard for the claim at `path` while `body` runs, so a
* decision made about the claim — is it fresh, may what it protects go —
* cannot interleave with somebody in the middle of taking it.
*/
export const withClaimGuard: PathLockHolder = (path, body) =>
withFileLock(claimGuardPath(path), body);
const formatClaimRecord = (owner: string): string => `${owner}\n${Date.now()}`;
const parseClaimRecord = (text: string): ClaimRecord => {
// Splitting always yields at least one part, so first is always a string.
const [first, second] = text.split("\n") as [string, string?];
const writtenAt = Number(second ?? first);
return second === undefined ? { writtenAt } : { owner: first, writtenAt };
};
const readClaimRecord = async (path: string): Promise<ClaimRecord> => {
const record = parseClaimRecord(await Deno.readTextFile(path));
if (record.writtenAt > 0) return record;
const stat = await Deno.stat(path);
if (stat.mtime === null) {
throw new Error(
`The claim at ${path} names no readable time, and the filesystem keeps no last-write time to judge it by.`,
);
}
return { ...record, writtenAt: stat.mtime.getTime() };
};
const writeClaimTime = (path: string, owner: string): Promise<void> =>
Deno.writeTextFile(path, formatClaimRecord(owner));
const claimAgeMs = async (path: string): Promise<number> =>
Date.now() - (await readClaimRecord(path)).writtenAt;
/**
* Is somebody's claim here, touched recently enough to still be theirs? A
* missing claim is nobody's, which is the expected way for one to be absent.
*/
export const claimIsFresh = async (
path: string,
staleMs: number,
): Promise<boolean> => {
const age = await nullIfNotFound(claimAgeMs(path));
// A time from the future means a clock was put back; counting that as
// fresh would leave a dead owner's claim unstealable until it catches up.
return age !== null && age >= 0 && age < staleMs;
};
/** Remove the claim, treating one already cleared by another taker as gone. */
const removeClaim = async (path: string): Promise<void> => {
try {
await Deno.remove(path);
} catch (error) {
rethrowUnlessNotFound(error);
}
};
const claimLostError = (path: string): Error =>
new Error(
`The claim at ${path} was lost while the work ran: another taker judged it walked away and took or removed it, so the same work may also have run elsewhere.`,
);
/**
* Run `act` under the takers' guard, but only while the claim is still the
* given owner's; answers whether it was. A paused holder may have been
* stolen from, and acting blindly then would trample the new owner's record.
*/
const whileStillOwned = (
path: string,
owner: string,
act: () => Promise<void>,
): Promise<boolean> =>
withClaimGuard(path, async () => {
const record = await nullIfNotFound(readClaimRecord(path));
if (record === null || record.owner !== owner) return false;
await act();
return true;
});
const touchIfStillOurs = (path: string, owner: string): Promise<boolean> =>
whileStillOwned(path, owner, () => writeClaimTime(path, owner));
/** Remove the claim at release. Anything but our own claim there means the
* work ran unprotected for a while, which cannot end quietly. */
const releaseOwnClaim = async (path: string, owner: string): Promise<void> => {
if (!(await whileStillOwned(path, owner, () => removeClaim(path)))) {
throw claimLostError(path);
}
};
/**
* Age the claim so the next look judges it walked away — for a helper whose
* owner is gone and can never release it. Somebody else's claim is left be.
*/
export const ageClaimToStale = async (
path: string,
owner: string,
): Promise<void> => {
const aged = await whileStillOwned(path, owner, () =>
Deno.writeTextFile(path, `${owner}\n1`),
);
// A claim that is no longer that owner's cannot end quietly: the work it
// protected may already have run again elsewhere.
if (!aged) throw claimLostError(path);
};
/** Stops touching a claim, and throws if the claim was seen lost meanwhile. */
export type StopTouching = () => Promise<void>;
const startClaimTouching = (
path: string,
owner: string,
{ touchMs }: StaleClaimSettings,
): StopTouching => {
let stopped = false;
let claimLost = false;
let timeout = setTimeout(touchClaim, touchMs);
let latestTouch: Promise<void> = Promise.resolve();
const scheduleNextTouch = () => {
if (stopped || claimLost) return;
timeout = setTimeout(touchClaim, touchMs);
};
const touchOutcome = (stillOurs: boolean) => {
if (!stillOurs) claimLost = true;
scheduleNextTouch();
};
function touchClaim() {
// A timer can fire in the very moment of the stop; a touch that went
// ahead then could re-create the claim file after release removed it.
if (stopped) return;
// A write that failed is left alone: the file just ages, and the
// ownership checks here and at release notice anything that let happen.
latestTouch = touchIfStillOurs(path, owner).then(
touchOutcome,
scheduleNextTouch,
);
}
return async (): Promise<void> => {
stopped = true;
clearTimeout(timeout);
// A touch already under way finishes without booking another, because
// both writing and scheduling check the flag above.
await latestTouch;
if (claimLost) throw claimLostError(path);
};
};
const createClaim = async (path: string, owner: string): Promise<void> => {
const file = await Deno.open(path, { createNew: true, write: true });
file.close();
await writeClaimTime(path, owner);
};
const heldClaim = (
path: string,
owner: string,
settings: StaleClaimSettings,
): HeldClaim => {
const stopTouching = startClaimTouching(path, owner, settings);
return {
owner,
release: async () => {
try {
await stopTouching();
} finally {
await releaseOwnClaim(path, owner);
}
},
};
};
/**
* Take the claim at `path` if it is free or walked away from, and start
* keeping it fresh. `null` means somebody else's claim is still fresh.
*/
const tryTakeClaim = (
path: string,
settings: StaleClaimSettings,
): Promise<HeldClaim | null> =>
withClaimGuard(path, async () => {
const owner = crypto.randomUUID();
try {
await createClaim(path, owner);
} catch (error) {
if (!(error instanceof Deno.errors.AlreadyExists)) throw error;
if (await claimIsFresh(path, settings.staleMs)) return null;
await removeClaim(path);
await createClaim(path, owner);
}
return heldClaim(path, owner, settings);
});
/**
* Keep somebody else's claim at `path` fresh on their behalf, starting with a
* touch right now, until the returned stop is called. For a helper working
* under a claim its parent took — the mutation child inside its snapshot —
* so the claim outlives a parent that was killed without cleaning up.
* Stopping never removes the claim: that stays the owner's to release.
*/
export const keepClaimFresh = async (
path: string,
settings: StaleClaimSettings,
): Promise<{ ownedBy: string; stopTouching: StopTouching }> => {
// The owner is read and first touched under one hold of the takers' guard:
// an unguarded read could catch the owner's own touch mid-write and see a
// half-written record with no owner in it.
const owner = await withClaimGuard(path, async () => {
const record = await readClaimRecord(path);
if (record.owner === undefined) {
throw new Error(
`The claim at ${path} names no owner to keep it fresh for.`,
);
}
await writeClaimTime(path, record.owner);
return record.owner;
});
return {
ownedBy: owner,
stopTouching: startClaimTouching(path, owner, settings),
};
};
/**
* Take the claim at `path`, waiting out a fresh holder until `timeoutMs` runs
* out. Whoever holds it keeps touching it, so a wait that ends without the
* claim means a live holder, not an abandoned one.
*/
const takeClaim = async (
path: string,
settings: StaleClaimSettings & ClaimWait,
): Promise<HeldClaim> => {
const startedAt = Date.now();
while (true) {
const claim = await tryTakeClaim(path, settings);
if (claim) return claim;
if (Date.now() - startedAt >= settings.timeoutMs) {
throw new Error(`Timed out waiting for ${settings.name}`);
}
await delay(settings.retryMs);
}
};
/**
* Take the claim, do the work, and let the claim go — even on failure. A
* release that finds the claim lost fails the work; when the work had
* already failed on its own, that failure stays the story, with the loss
* noted alongside it.
*/
export const withClaim = async <Result>(
path: string,
settings: StaleClaimSettings & ClaimWait,
body: LockBody<Result>,
): Promise<Result> => {
const claim = await takeClaim(path, settings);
let result: Result;
try {
result = await body();
} catch (failure) {
try {
await claim.release();
} catch (releaseFailure) {
throw new Error(
`${errorMessage(failure)}; and letting the claim go then found more wrong: ${errorMessage(releaseFailure)}`,
{ cause: failure },
);
}
throw failure;
}
await claim.release();
return result;
};