fix: track realtime physical work from invocation to settlement
RT-RR-01. An effect or recovery task was registered as retained only after its public wait expired, so a close() that arrived first saw an empty set and reported quiescence while the raw task was still running against the authority. Tasks are now registered when they are created and removed when they settle; DRAINING keeps its narrower meaning through a separate timed-out set. RT-RR-02. Admission happened when an event was queued; execution is a second decision. A queue entry admitted before the stream entered DRAINING no longer starts running inside it. And an abandoned task may have applied part of its effect, so the resume token it was based on is discarded and recovery is required explicitly — the next ordinary event can no longer skip authoritative recovery on the strength of state a timed-out effect may have invalidated. RT-RR-03. close() cached the first timeout forever, so a writer that later settled could never be proved quiescent and the retained registry could never be pruned. Only an in-flight close is shared now, every writer a close fences is retained until its tail actually settles, and the tail prunes itself. A second close therefore converges to success once the writer finishes. RT-RR-04. Checkpoint work joins writer tails in the physical-task registry from invocation to settlement and is drained on the same terms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a7390e3b3a
commit
c0f53d1855
@@ -527,12 +527,87 @@ describe("live/poll authoritative writer handoff", () => {
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
await expect(harness.coordinator.close()).resolves.toMatchObject({
|
||||
// RT-RR-03. A second close re-runs rather than replaying a cached verdict.
|
||||
// The writer is still hung, so it still reports a timeout.
|
||||
const second = harness.coordinator.close();
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(second).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RT-RR-03. Caching the first timeout forever meant a writer that later
|
||||
* settled could never be proved quiescent: every subsequent close replayed
|
||||
* the stale failure and the retained registry could never be pruned.
|
||||
*/
|
||||
it("converges to success once a retired writer finally settles", async () => {
|
||||
let release: ((value: RealtimeResult<void>) => void) | undefined;
|
||||
const harness = createHarness({
|
||||
apply: vi.fn(
|
||||
async () =>
|
||||
await new Promise<RealtimeResult<void>>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
),
|
||||
});
|
||||
const live = harness.coordinator.currentWriter()!;
|
||||
void live.write("late-settle", 8);
|
||||
await flush();
|
||||
|
||||
const first = harness.coordinator.close();
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(first).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
|
||||
// The writer finishes after the first close gave up.
|
||||
release?.(realtimeSuccess(undefined));
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const second = harness.coordinator.close();
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(second).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* RT-RR-04. Checkpoint work is an external authority call like a writer
|
||||
* tail. Racing it against a timeout bounded the public wait but left it out
|
||||
* of the retained registry, so `close()` could report quiescence while the
|
||||
* checkpoint was still running.
|
||||
*/
|
||||
it("does not report quiescence while a checkpoint is still running", async () => {
|
||||
let checkpointSignal: AbortSignal | undefined;
|
||||
const harness = createHarness({
|
||||
recover: vi.fn(async ({ signal }) => {
|
||||
checkpointSignal = signal;
|
||||
return await new Promise<RealtimeResult<void>>(() => {});
|
||||
}),
|
||||
});
|
||||
|
||||
const transition = harness.coordinator.switchToPoll();
|
||||
await flush();
|
||||
expect(checkpointSignal).toBeDefined();
|
||||
|
||||
const closing = harness.coordinator.close();
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(closing).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
|
||||
harness.clock.advance(100);
|
||||
await flush();
|
||||
await transition;
|
||||
});
|
||||
|
||||
it("rejects invalid initial authority and resource ceilings", () => {
|
||||
expect(() =>
|
||||
createLivePollHandoffCoordinator({
|
||||
|
||||
Reference in New Issue
Block a user