test/scripts/stale-claim/losing.test.ts
Rama main · réplica de lectura del repositorio original.
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import { FakeTime } from "@std/testing/time";
import {
ageClaimToStale,
claimIsFresh,
keepClaimFresh,
withClaimGuard,
} from "#scripts/stale-claim.ts";
import {
countClaimReads,
eventually,
FRESH_FOR_MS,
holdOnFakeClock,
holdThenLoseClaim,
holdWhileTouchesFail,
LONG_AGO_MS,
settle,
tickBy,
withClaimDir,
writeClaim,
} from "#test/scripts/stale-claim/helpers.ts";
describe("a holder whose touches stop landing or land elsewhere", () => {
test("carries on when every touch fails but the claim stays its own", async () => {
await withClaimDir((path) =>
holdWhileTouchesFail(path, async ({ held, time }) => {
// The file ages unwritten for the whole window — but nobody took the
// claim, so no duplicate work can have started and the hold may end
// quietly.
await tickBy(time, 10, 10, 10, 10);
held.release();
await held.holding;
await expect(Deno.stat(path)).rejects.toThrow();
}),
);
});
test("carries on past a failed touch, touching again so the blip heals", async () => {
await withClaimDir((path) =>
holdWhileTouchesFail(path, async ({ held, recover, time }) => {
await tickBy(time, 10);
recover();
// Step the clock until a healed touch lands: on a slow machine the
// failed touch's own settling can push the next touch a step later,
// so no fixed tick script can say exactly when it fires. Fresh
// against a window this small can only mean a touch after the blip
// — the claim's first write is many steps old by now.
await eventually(async () => {
await tickBy(time, 10);
return claimIsFresh(path, 12);
});
held.release();
await held.holding;
}),
);
});
});
describe("a holder whose claim is taken while it works", () => {
test("stops touching and reports a claim another taker now holds", async () => {
await withClaimDir(async (path) => {
// A taker judged the aged claim abandoned and took it.
const { held, takenRecord, time } = await holdThenLoseClaim(path);
using _clock = time;
// The next touch finds somebody else's name and must not write.
await tickBy(time, 10, 10);
expect(await Deno.readTextFile(path)).toBe(takenRecord);
held.release();
await expect(held.holding).rejects.toThrow("was lost while the work ran");
// The new owner's record is left exactly as it was.
expect(await Deno.readTextFile(path)).toBe(takenRecord);
});
});
test("reports a claim that vanished mid-run without re-creating it", async () => {
await withClaimDir(async (path) => {
const { held, time } = await holdOnFakeClock(path);
using _clock = time;
// A taker took the aged claim, finished, and released it.
await Deno.remove(path);
await tickBy(time, 10, 10);
held.release();
await expect(held.holding).rejects.toThrow("was lost while the work ran");
await expect(Deno.stat(path)).rejects.toThrow();
});
});
test("stops looking at a claim it has learned is lost", async () => {
await withClaimDir(async (path) => {
const { held, time } = await holdThenLoseClaim(path);
using _clock = time;
const counter = { reads: 0 };
using _read = countClaimReads(path, counter);
// The touch that discovers the loss is the last look it takes.
await tickBy(time, 10);
await eventually(() => Promise.resolve(counter.reads > 0));
const readsAtDiscovery = counter.reads;
await tickBy(time, 10, 10, 10);
expect(counter.reads).toBe(readsAtDiscovery);
held.release();
await expect(held.holding).rejects.toThrow("was lost while the work ran");
});
});
test("reports at release a taking no touch was awake to see", async () => {
await withClaimDir(async (path) => {
// Touches far rarer than the hold is long: the only ownership check
// left is the release's own.
const { held, takenRecord, time } = await holdThenLoseClaim(path, 60_000);
using _clock = time;
await tickBy(time, 40);
held.release();
await expect(held.holding).rejects.toThrow("was lost while the work ran");
expect(await Deno.readTextFile(path)).toBe(takenRecord);
});
});
});
describe("keeping somebody else's claim fresh for them", () => {
test("touches it from the very first moment, keeping their name on it", async () => {
await withClaimDir(async (path) => {
await writeClaim(path, `their-supervisor\n${LONG_AGO_MS}`);
const claim = await keepClaimFresh(path, {
staleMs: FRESH_FOR_MS,
touchMs: FRESH_FOR_MS,
});
expect(claim.ownedBy).toBe("their-supervisor");
expect(await claimIsFresh(path, FRESH_FOR_MS)).toBe(true);
await claim.stopTouching();
// Stopping never removes the claim: it stays theirs to release.
expect(
(await Deno.readTextFile(path)).startsWith("their-supervisor"),
).toBe(true);
});
});
test("reports a claim taken while it was being kept fresh", async () => {
await withClaimDir(async (path) => {
await writeClaim(path, `their-supervisor\n${Date.now()}`);
using time = new FakeTime();
const claim = await keepClaimFresh(path, { staleMs: 30, touchMs: 10 });
await writeClaim(path, `new-owner\n${Date.now()}`);
await tickBy(time, 10);
await expect(claim.stopTouching()).rejects.toThrow(
"was lost while the work ran",
);
// Not the keeper's to touch or remove: the new owner's record stays.
expect((await Deno.readTextFile(path)).startsWith("new-owner")).toBe(
true,
);
});
});
test("waits at the takers' door before reading who to keep fresh for", async () => {
await withClaimDir(async (path) => {
await writeClaim(path, `their-supervisor\n${Date.now()}`);
// The owner's own touch is mid-write: it holds the guard. Reading the
// claim now could catch a truncated record with no owner in it.
const inside = Promise.withResolvers<void>();
const letGo = Promise.withResolvers<void>();
const guarding = withClaimGuard(path, async () => {
inside.resolve();
await letGo.promise;
});
await inside.promise;
const counter = { reads: 0 };
using _read = countClaimReads(path, counter);
const keeping = keepClaimFresh(path, {
staleMs: FRESH_FOR_MS,
touchMs: FRESH_FOR_MS,
});
await settle();
expect(counter.reads).toBe(0);
letGo.resolve();
await guarding;
const claim = await keeping;
expect(claim.ownedBy).toBe("their-supervisor");
await claim.stopTouching();
});
});
test("refuses a claim that names nobody to keep it fresh for", async () => {
await withClaimDir(async (path) => {
// A record from before owners were named carries a time alone.
await writeClaim(path, String(Date.now()));
await expect(
keepClaimFresh(path, { staleMs: FRESH_FOR_MS, touchMs: FRESH_FOR_MS }),
).rejects.toThrow("names no owner");
});
});
test("refuses when there is no claim there at all", async () => {
await withClaimDir(async (path) => {
// Missing means missing — not the "names no owner" of a real record.
await expect(
keepClaimFresh(path, { staleMs: FRESH_FOR_MS, touchMs: FRESH_FOR_MS }),
).rejects.toThrow(Deno.errors.NotFound);
});
});
});
describe("aging a claim for an owner who cannot release it", () => {
test("refuses to age a claim that is no longer that owner's", async () => {
await withClaimDir(async (path) => {
await writeClaim(path, `new-owner\n${Date.now()}`);
await expect(ageClaimToStale(path, "their-supervisor")).rejects.toThrow(
"was lost while the work ran",
);
// The taker's record is not the ager's to touch.
expect((await Deno.readTextFile(path)).startsWith("new-owner")).toBe(
true,
);
});
});
test("refuses to age a claim that has already gone", async () => {
await withClaimDir(async (path) => {
await expect(ageClaimToStale(path, "their-supervisor")).rejects.toThrow(
"was lost while the work ran",
);
});
});
});