Skip to content

Commit f471bcb

Browse files
committed
Merge pull request #575 from ndycode/claude/audit-56-write-queue-property
test: property-check write-queue serialization and clamp invariants
2 parents f0a20e0 + 3e85766 commit f471bcb

1 file changed

Lines changed: 158 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, expect, it } from "vitest";
2+
import * as fc from "fast-check";
3+
import { withQueuedRetry } from "../../lib/codex-manager/settings-write-queue.js";
4+
5+
type TaskBehavior = "ok" | "flaky" | "fatal" | "exhausted";
6+
7+
const RETRYABLE_CODES = [
8+
"EBUSY",
9+
"EPERM",
10+
"EACCES",
11+
"EAGAIN",
12+
"ENOTEMPTY",
13+
] as const;
14+
15+
const arbSchedule = fc.array(
16+
fc.record({
17+
key: fc.integer({ min: 0, max: 2 }),
18+
behavior: fc.constantFrom<TaskBehavior>("ok", "flaky", "fatal", "exhausted"),
19+
// The transient code a flaky/exhausted task throws: the whole
20+
// retryable set matters on Windows, not just EBUSY.
21+
retryableCode: fc.constantFrom(...RETRYABLE_CODES),
22+
}),
23+
{ minLength: 1, maxLength: 12 },
24+
);
25+
26+
function errnoError(code: string): NodeJS.ErrnoException {
27+
return Object.assign(new Error(code), { code });
28+
}
29+
30+
const immediateSleep = { sleep: async () => {} };
31+
32+
// Unique key namespace per run so the module-level queue map never couples
33+
// property iterations.
34+
let runCounter = 0;
35+
36+
describe("withQueuedRetry serialization properties", () => {
37+
it("keeps every key's tasks contiguous and in submission order, for any schedule", async () => {
38+
await fc.assert(
39+
fc.asyncProperty(arbSchedule, async (schedule) => {
40+
const run = runCounter++;
41+
// Invocation log per key: the task index for every task() call,
42+
// including retries.
43+
const invocationsByKey = new Map<number, number[]>();
44+
const flakyFailed = new Set<number>();
45+
46+
const pending = schedule.map((spec, taskIndex) =>
47+
withQueuedRetry(
48+
`/settings/prop-${run}-key-${spec.key}.json`,
49+
async () => {
50+
const log = invocationsByKey.get(spec.key) ?? [];
51+
log.push(taskIndex);
52+
invocationsByKey.set(spec.key, log);
53+
if (spec.behavior === "fatal") {
54+
throw errnoError("ENOSPC");
55+
}
56+
if (spec.behavior === "exhausted") {
57+
// Retryable on every attempt: burns the whole budget.
58+
throw errnoError(spec.retryableCode);
59+
}
60+
if (spec.behavior === "flaky" && !flakyFailed.has(taskIndex)) {
61+
flakyFailed.add(taskIndex);
62+
throw errnoError(spec.retryableCode);
63+
}
64+
return `result-${taskIndex}`;
65+
},
66+
immediateSleep,
67+
).then(
68+
(value) => ({ taskIndex, outcome: "ok" as const, value }),
69+
() => ({ taskIndex, outcome: "error" as const }),
70+
),
71+
);
72+
const settled = await Promise.all(pending);
73+
74+
// Outcomes match behaviors: fatal rejects, ok/flaky resolve with
75+
// their own result; a failed predecessor never blocks successors.
76+
for (const result of settled) {
77+
const spec = schedule[result.taskIndex];
78+
if (spec.behavior === "fatal" || spec.behavior === "exhausted") {
79+
expect(result.outcome).toBe("error");
80+
} else {
81+
expect(result).toMatchObject({
82+
outcome: "ok",
83+
value: `result-${result.taskIndex}`,
84+
});
85+
}
86+
}
87+
88+
// An exhausted task burns exactly the full retry budget.
89+
const allInvocations = [...invocationsByKey.values()].flat();
90+
for (let taskIndex = 0; taskIndex < schedule.length; taskIndex += 1) {
91+
if (schedule[taskIndex].behavior === "exhausted") {
92+
expect(
93+
allInvocations.filter((id) => id === taskIndex),
94+
).toHaveLength(4);
95+
}
96+
}
97+
98+
// Per key: invocations form contiguous groups (retries never
99+
// interleave with another task) and groups run in submission order.
100+
for (const [, invocations] of invocationsByKey) {
101+
const groups: number[] = [];
102+
for (const taskIndex of invocations) {
103+
if (groups[groups.length - 1] !== taskIndex) {
104+
groups.push(taskIndex);
105+
}
106+
}
107+
expect(new Set(groups).size).toBe(groups.length);
108+
expect([...groups].sort((a, b) => a - b)).toEqual(groups);
109+
}
110+
111+
// Every task ran at least once, on its own key.
112+
const totalInvocations = [...invocationsByKey.values()].flat();
113+
for (let taskIndex = 0; taskIndex < schedule.length; taskIndex += 1) {
114+
expect(totalInvocations).toContain(taskIndex);
115+
expect(
116+
invocationsByKey.get(schedule[taskIndex].key) ?? [],
117+
).toContain(taskIndex);
118+
}
119+
}),
120+
);
121+
});
122+
123+
it("clamps any 429 retry-after hint into the 10ms..30s range", async () => {
124+
await fc.assert(
125+
fc.asyncProperty(
126+
fc.integer({ min: 1, max: 2_000_000_000 }),
127+
async (retryAfterMs) => {
128+
const run = runCounter++;
129+
const delays: number[] = [];
130+
let failed = false;
131+
132+
await withQueuedRetry(
133+
`/settings/prop-${run}-clamp.json`,
134+
async () => {
135+
if (!failed) {
136+
failed = true;
137+
throw Object.assign(new Error("throttled"), {
138+
status: 429,
139+
retryAfterMs,
140+
});
141+
}
142+
return "written";
143+
},
144+
{
145+
sleep: async (ms: number) => {
146+
delays.push(ms);
147+
},
148+
},
149+
);
150+
151+
expect(delays).toEqual([
152+
Math.max(10, Math.min(30_000, Math.round(retryAfterMs))),
153+
]);
154+
},
155+
),
156+
);
157+
});
158+
});

0 commit comments

Comments
 (0)