test: share unresolved cron next-run fixtures

This commit is contained in:
Peter Steinberger
2026-04-19 01:05:15 +01:00
parent c96a0b1112
commit e1fe71872c

View File

@@ -12,52 +12,89 @@ import { onTimer } from "./service/timer.js";
const issue66019Fixtures = setupCronRegressionFixtures({ prefix: "cron-66019-" });
function createIssue66019Job(params: { id: string; scheduledAt: number }) {
return createIsolatedRegressionJob({
id: params.id,
name: params.id,
scheduledAt: params.scheduledAt,
schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" },
payload: { kind: "agentTurn", message: "ping" },
state: { nextRunAtMs: params.scheduledAt - 1_000 },
});
}
function createIssue66019State(params: {
storePath: string;
nowMs: () => number;
runIsolatedAgentJob: Parameters<typeof createCronServiceState>[0]["runIsolatedAgentJob"];
}) {
return createCronServiceState({
cronEnabled: true,
storePath: params.storePath,
log: noopLogger,
nowMs: params.nowMs,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: params.runIsolatedAgentJob,
});
}
function clearCronTimer(state: ReturnType<typeof createCronServiceState>) {
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
}
async function expectJobDoesNotRefireWhenNextRunIsUnresolved(params: {
state: ReturnType<typeof createCronServiceState>;
runIsolatedAgentJob: unknown;
advanceNow: () => void;
}) {
await onTimer(params.state);
expect(params.runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(params.state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
params.advanceNow();
await onTimer(params.state);
expect(params.runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(params.state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
}
describe("#66019 unresolved next-run repro", () => {
it("does not refire a recurring cron job 2s later when next-run resolution returns undefined", async () => {
const store = issue66019Fixtures.makeStorePath();
const scheduledAt = Date.parse("2026-04-13T15:40:00.000Z");
let now = scheduledAt;
const cronJob = createIsolatedRegressionJob({
const cronJob = createIssue66019Job({
id: "cron-66019-minimal-success",
name: "cron-66019-minimal-success",
scheduledAt,
schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" },
payload: { kind: "agentTurn", message: "ping" },
state: { nextRunAtMs: scheduledAt - 1_000 },
});
await writeCronJobs(store.storePath, [cronJob]);
const runIsolatedAgentJob = createDefaultIsolatedRunner();
const nextRunSpy = vi.spyOn(schedule, "computeNextRunAtMs").mockReturnValue(undefined);
const state = createCronServiceState({
cronEnabled: true,
const state = createIssue66019State({
storePath: store.storePath,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob,
});
try {
await onTimer(state);
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
// Before the fix, applyJobResult would synthesize endedAt + 2_000 here,
// so a second tick a couple seconds later would refire the same job.
now = scheduledAt + 2_001;
await onTimer(state);
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
await expectJobDoesNotRefireWhenNextRunIsUnresolved({
state,
runIsolatedAgentJob,
advanceNow: () => {
now = scheduledAt + 2_001;
},
});
} finally {
nextRunSpy.mockRestore();
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
clearCronTimer(state);
}
});
@@ -66,13 +103,9 @@ describe("#66019 unresolved next-run repro", () => {
const scheduledAt = Date.parse("2026-04-13T15:45:00.000Z");
let now = scheduledAt;
const cronJob = createIsolatedRegressionJob({
const cronJob = createIssue66019Job({
id: "cron-66019-minimal-error",
name: "cron-66019-minimal-error",
scheduledAt,
schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" },
payload: { kind: "agentTurn", message: "ping" },
state: { nextRunAtMs: scheduledAt - 1_000 },
});
await writeCronJobs(store.storePath, [cronJob]);
@@ -81,34 +114,25 @@ describe("#66019 unresolved next-run repro", () => {
error: "synthetic failure",
});
const nextRunSpy = vi.spyOn(schedule, "computeNextRunAtMs").mockReturnValue(undefined);
const state = createCronServiceState({
cronEnabled: true,
const state = createIssue66019State({
storePath: store.storePath,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob,
});
try {
await onTimer(state);
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
// Before the fix, the error branch would synthesize the first backoff
// retry (30s), so the next tick after that window would rerun the job.
now = scheduledAt + 30_001;
await onTimer(state);
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined();
await expectJobDoesNotRefireWhenNextRunIsUnresolved({
state,
runIsolatedAgentJob,
advanceNow: () => {
now = scheduledAt + 30_001;
},
});
} finally {
nextRunSpy.mockRestore();
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
clearCronTimer(state);
}
});
@@ -117,13 +141,9 @@ describe("#66019 unresolved next-run repro", () => {
const scheduledAt = Date.parse("2026-04-13T15:50:00.000Z");
let now = scheduledAt;
const cronJob = createIsolatedRegressionJob({
const cronJob = createIssue66019Job({
id: "cron-66019-error-backoff-floor",
name: "cron-66019-error-backoff-floor",
scheduledAt,
schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" },
payload: { kind: "agentTurn", message: "ping" },
state: { nextRunAtMs: scheduledAt - 1_000 },
});
await writeCronJobs(store.storePath, [cronJob]);
@@ -138,13 +158,9 @@ describe("#66019 unresolved next-run repro", () => {
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(undefined)
.mockReturnValue(naturalNext);
const state = createCronServiceState({
cronEnabled: true,
const state = createIssue66019State({
storePath: store.storePath,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob,
});
@@ -162,10 +178,7 @@ describe("#66019 unresolved next-run repro", () => {
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(2);
} finally {
nextRunSpy.mockRestore();
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
clearCronTimer(state);
}
});
});