Skip to content

Commit 0044bbf

Browse files
committed
prevent multiple schedules after downtime
1 parent beb6074 commit 0044bbf

5 files changed

Lines changed: 215 additions & 38 deletions

File tree

internal-packages/schedule-engine/src/engine/index.ts

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { PrismaClient } from "@trigger.dev/database";
55
import { Worker, type JobHandlerParams } from "@trigger.dev/redis-worker";
66
import { calculateDistributedExecutionTime } from "./distributedScheduling.js";
77
import {
8-
calculateNextNominalTimestamp,
8+
calculateNextSchedulableOccurrence,
99
nextScheduledTimestamps,
1010
previousScheduledTimestamp,
1111
} from "./scheduleCalculation.js";
@@ -15,11 +15,7 @@ import type {
1515
TriggerScheduledTaskCallback,
1616
TriggerScheduleParams,
1717
} from "./types.js";
18-
import {
19-
calculateEffectiveScheduleTime,
20-
calculateSchedulePhase,
21-
type NormalizedScheduleWindow,
22-
} from "./scheduleTiming.js";
18+
import { calculateSchedulePhase, type NormalizedScheduleWindow } from "./scheduleTiming.js";
2319
import { scheduleWorkerCatalog } from "./workerCatalog.js";
2420
import { tryCatch } from "@trigger.dev/core/utils";
2521

@@ -217,33 +213,29 @@ export class ScheduleEngine {
217213
);
218214
span.setAttribute("schedule_phase", schedulePhase);
219215

220-
const fromTimestamp = params.fromTimestamp ?? new Date();
216+
const registrationTime = new Date();
217+
const fromTimestamp = params.fromTimestamp ?? registrationTime;
221218
span.setAttribute("from_timestamp", fromTimestamp.toISOString());
222219

223-
const nominalAt = calculateNextNominalTimestamp(
224-
instance.taskSchedule.generatorExpression,
225-
instance.taskSchedule.timezone,
226-
fromTimestamp
227-
);
228-
const nextNominalAt = calculateNextNominalTimestamp(
229-
instance.taskSchedule.generatorExpression,
230-
instance.taskSchedule.timezone,
231-
nominalAt
232-
);
233220
const {
234-
effectiveAt: candidateEffectiveAt,
221+
nominalAt,
222+
candidateEffectiveAt,
223+
effectiveAt,
235224
effectiveRangeMs,
236225
windowMs,
237226
offsetMs: candidateDelayMs,
238227
intervalMs,
239228
windowWasCappedToInterval,
240-
} = calculateEffectiveScheduleTime({
241-
nominalAt,
242-
nextNominalAt,
229+
skippedExpiredOccurrences,
230+
} = calculateNextSchedulableOccurrence({
231+
schedule: instance.taskSchedule.generatorExpression,
232+
timezone: instance.taskSchedule.timezone,
233+
afterNominal: fromTimestamp,
234+
now: registrationTime,
243235
schedulePhase,
244236
window: scheduleWindow,
237+
cronSpreadEnabled: this.options.cronSpreadEnabled,
245238
});
246-
const effectiveAt = this.options.cronSpreadEnabled ? candidateEffectiveAt : nominalAt;
247239
const appliedDelayMs = effectiveAt.getTime() - nominalAt.getTime();
248240

249241
span.setAttribute("cron_spread_enabled", this.options.cronSpreadEnabled);
@@ -256,6 +248,14 @@ export class ScheduleEngine {
256248
span.setAttribute("schedule_window_ms", windowMs);
257249
span.setAttribute("effective_range_ms", effectiveRangeMs);
258250
span.setAttribute("schedule_window_was_capped_to_interval", windowWasCappedToInterval);
251+
span.setAttribute("schedule_expired_occurrences_skipped", skippedExpiredOccurrences);
252+
253+
if (skippedExpiredOccurrences) {
254+
span.addEvent("schedule_expired_occurrences_skipped", {
255+
from_nominal_time: fromTimestamp.toISOString(),
256+
selected_nominal_time: nominalAt.toISOString(),
257+
});
258+
}
259259

260260
if (windowWasCappedToInterval) {
261261
span.addEvent("schedule_window_capped_to_interval", {
@@ -268,7 +268,7 @@ export class ScheduleEngine {
268268
});
269269
}
270270

271-
const schedulingDelayMs = effectiveAt.getTime() - Date.now();
271+
const schedulingDelayMs = effectiveAt.getTime() - registrationTime.getTime();
272272
span.setAttribute("scheduling_delay_ms", schedulingDelayMs);
273273

274274
this.logger.debug("Calculated next schedule timestamps", {
@@ -283,6 +283,7 @@ export class ScheduleEngine {
283283
appliedDelayMs,
284284
effectiveRangeMs,
285285
windowWasCappedToInterval,
286+
skippedExpiredOccurrences,
286287
schedulingDelayMs,
287288
generatorExpression: instance.taskSchedule.generatorExpression,
288289
timezone: instance.taskSchedule.timezone,
@@ -674,8 +675,9 @@ export class ScheduleEngine {
674675
});
675676
}
676677

677-
// Register the next run. `fromTimestamp` advances on every tick so
678-
// the next cron slot keeps marching forward even through skips.
678+
// Register the next run. `fromTimestamp` anchors nominal chaining;
679+
// registration preserves an upcoming effective occurrence and skips
680+
// expired intermediate ticks after downtime.
679681
// `lastScheduleTime` is the actual previous fire time the next job
680682
// will report as `payload.lastTimestamp` — only advance it when we
681683
// actually triggered, otherwise carry forward the existing value so

internal-packages/schedule-engine/src/engine/scheduleCalculation.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { calculateNextNominalTimestamp, nextScheduledTimestamps } from "./scheduleCalculation.js";
1+
import {
2+
calculateNextNominalTimestamp,
3+
calculateNextSchedulableOccurrence,
4+
nextScheduledTimestamps,
5+
} from "./scheduleCalculation.js";
6+
import { SCHEDULE_PHASE_DENOMINATOR } from "./scheduleTiming.js";
27

38
describe("calculateNextNominalTimestamp", () => {
49
it("advances from the previous nominal tick instead of wall-clock time", () => {
@@ -38,6 +43,90 @@ describe("calculateNextNominalTimestamp", () => {
3843
});
3944
});
4045

46+
describe("calculateNextSchedulableOccurrence", () => {
47+
const hourlySchedule = "0 * * * *";
48+
const window = { type: "percentage", percentage: 100 } as const;
49+
50+
it("restores wall-clock catch-up behavior when spreading is disabled", () => {
51+
const occurrence = calculateNextSchedulableOccurrence({
52+
schedule: hourlySchedule,
53+
timezone: "UTC",
54+
afterNominal: new Date("2026-08-11T09:00:00.000Z"),
55+
now: new Date("2026-08-11T12:30:00.000Z"),
56+
schedulePhase: (SCHEDULE_PHASE_DENOMINATOR * 3) / 4,
57+
window,
58+
cronSpreadEnabled: false,
59+
});
60+
61+
expect(occurrence.nominalAt).toEqual(new Date("2026-08-11T13:00:00.000Z"));
62+
expect(occurrence.effectiveAt).toEqual(occurrence.nominalAt);
63+
expect(occurrence.skippedExpiredOccurrences).toBe(true);
64+
});
65+
66+
it("keeps strict nominal chaining when the next effective time is upcoming", () => {
67+
const occurrence = calculateNextSchedulableOccurrence({
68+
schedule: hourlySchedule,
69+
timezone: "UTC",
70+
afterNominal: new Date("2026-08-11T09:00:00.000Z"),
71+
now: new Date("2026-08-11T10:00:01.000Z"),
72+
schedulePhase: (SCHEDULE_PHASE_DENOMINATOR * 3) / 4,
73+
window,
74+
cronSpreadEnabled: true,
75+
});
76+
77+
expect(occurrence.nominalAt).toEqual(new Date("2026-08-11T10:00:00.000Z"));
78+
expect(occurrence.effectiveAt).toEqual(new Date("2026-08-11T10:45:00.000Z"));
79+
expect(occurrence.skippedExpiredOccurrences).toBe(false);
80+
});
81+
82+
it("keeps the latest nominal occurrence when its effective time is upcoming", () => {
83+
const occurrence = calculateNextSchedulableOccurrence({
84+
schedule: hourlySchedule,
85+
timezone: "UTC",
86+
afterNominal: new Date("2026-08-11T09:00:00.000Z"),
87+
now: new Date("2026-08-11T12:30:00.000Z"),
88+
schedulePhase: (SCHEDULE_PHASE_DENOMINATOR * 3) / 4,
89+
window,
90+
cronSpreadEnabled: true,
91+
});
92+
93+
expect(occurrence.nominalAt).toEqual(new Date("2026-08-11T12:00:00.000Z"));
94+
expect(occurrence.effectiveAt).toEqual(new Date("2026-08-11T12:45:00.000Z"));
95+
expect(occurrence.skippedExpiredOccurrences).toBe(true);
96+
});
97+
98+
it("skips to the next future nominal occurrence when the latest effective time expired", () => {
99+
const occurrence = calculateNextSchedulableOccurrence({
100+
schedule: hourlySchedule,
101+
timezone: "UTC",
102+
afterNominal: new Date("2026-08-11T09:00:00.000Z"),
103+
now: new Date("2026-08-11T12:30:00.000Z"),
104+
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 4,
105+
window,
106+
cronSpreadEnabled: true,
107+
});
108+
109+
expect(occurrence.nominalAt).toEqual(new Date("2026-08-11T13:00:00.000Z"));
110+
expect(occurrence.effectiveAt).toEqual(new Date("2026-08-11T13:15:00.000Z"));
111+
expect(occurrence.skippedExpiredOccurrences).toBe(true);
112+
});
113+
114+
it("includes a nominal occurrence exactly at now when it is still eligible", () => {
115+
const occurrence = calculateNextSchedulableOccurrence({
116+
schedule: hourlySchedule,
117+
timezone: "UTC",
118+
afterNominal: new Date("2026-08-11T09:00:00.000Z"),
119+
now: new Date("2026-08-11T12:00:00.000Z"),
120+
schedulePhase: 0,
121+
window,
122+
cronSpreadEnabled: true,
123+
});
124+
125+
expect(occurrence.nominalAt).toEqual(new Date("2026-08-11T12:00:00.000Z"));
126+
expect(occurrence.effectiveAt).toEqual(new Date("2026-08-11T12:00:00.000Z"));
127+
});
128+
});
129+
41130
describe("nextScheduledTimestamps", () => {
42131
it("advances every timestamp from the preceding nominal tick", () => {
43132
const upcoming = nextScheduledTimestamps(

internal-packages/schedule-engine/src/engine/scheduleCalculation.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import { parseExpression } from "cron-parser";
2+
import {
3+
calculateEffectiveScheduleTime,
4+
type EffectiveScheduleTime,
5+
type NormalizedScheduleWindow,
6+
} from "./scheduleTiming.js";
27

38
export function calculateNextScheduledTimestampFromNow(schedule: string, timezone: string | null) {
49
return calculateNextScheduledTimestamp(schedule, timezone, new Date());
@@ -37,6 +42,82 @@ function calculateNextStep(schedule: string, timezone: string | null, currentDat
3742
.toDate();
3843
}
3944

45+
type SchedulableOccurrence = Omit<EffectiveScheduleTime, "effectiveAt"> & {
46+
candidateEffectiveAt: Date;
47+
effectiveAt: Date;
48+
skippedExpiredOccurrences: boolean;
49+
};
50+
51+
/**
52+
* Selects the next occurrence that has not passed its actual eligibility time.
53+
*
54+
* The usual path advances strictly from the preceding nominal tick. If that occurrence expired
55+
* during downtime, selection jumps directly to the latest nominal tick that could still be
56+
* eligible, or to the first future nominal tick. This preserves one late catch-up without
57+
* replaying every missed occurrence.
58+
*/
59+
export function calculateNextSchedulableOccurrence({
60+
schedule,
61+
timezone,
62+
afterNominal,
63+
now,
64+
schedulePhase,
65+
window,
66+
cronSpreadEnabled,
67+
}: {
68+
schedule: string;
69+
timezone: string | null;
70+
afterNominal: Date;
71+
now: Date;
72+
schedulePhase: number;
73+
window?: NormalizedScheduleWindow;
74+
cronSpreadEnabled: boolean;
75+
}): SchedulableOccurrence {
76+
const occurrenceAt = (
77+
nominalAt: Date
78+
): Omit<SchedulableOccurrence, "skippedExpiredOccurrences"> => {
79+
const nextNominalAt = calculateNextNominalTimestamp(schedule, timezone, nominalAt);
80+
const { effectiveAt: candidateEffectiveAt, ...timing } = calculateEffectiveScheduleTime({
81+
nominalAt,
82+
nextNominalAt,
83+
schedulePhase,
84+
window,
85+
});
86+
87+
return {
88+
...timing,
89+
candidateEffectiveAt,
90+
effectiveAt: cronSpreadEnabled ? candidateEffectiveAt : nominalAt,
91+
};
92+
};
93+
94+
const firstNominalAt = calculateNextNominalTimestamp(schedule, timezone, afterNominal);
95+
const firstOccurrence = occurrenceAt(firstNominalAt);
96+
97+
if (firstOccurrence.effectiveAt.getTime() >= now.getTime()) {
98+
return { ...firstOccurrence, skippedExpiredOccurrences: false };
99+
}
100+
101+
// `prev()` is strictly before its current date. Advancing by one millisecond includes a cron
102+
// tick exactly at `now`, whose effective time may still be upcoming.
103+
const latestNominalAt = previousScheduledTimestamp(
104+
schedule,
105+
timezone,
106+
new Date(now.getTime() + 1)
107+
);
108+
109+
if (latestNominalAt.getTime() > afterNominal.getTime()) {
110+
const latestOccurrence = occurrenceAt(latestNominalAt);
111+
112+
if (latestOccurrence.effectiveAt.getTime() >= now.getTime()) {
113+
return { ...latestOccurrence, skippedExpiredOccurrences: true };
114+
}
115+
}
116+
117+
const nextOccurrence = occurrenceAt(calculateNextNominalTimestamp(schedule, timezone, now));
118+
return { ...nextOccurrence, skippedExpiredOccurrences: true };
119+
}
120+
40121
/**
41122
* Cron's previous slot relative to `fromTimestamp`. For a continuously-
42123
* running schedule this equals the actual last fire time; for paused or

internal-packages/schedule-engine/src/engine/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ export interface TriggerScheduleParams {
8484
export interface RegisterScheduleInstanceParams {
8585
instanceId: string;
8686
/**
87-
* Anchor for computing the next cron slot. Defaults to now() when omitted.
88-
* This advances on every tick (fired or skipped) so the next slot keeps
89-
* marching forward regardless of skip reasons.
87+
* Nominal anchor for selecting the next non-expired cron occurrence. Defaults
88+
* to now() when omitted. The engine advances from this timestamp when the
89+
* next occurrence is still eligible and skips expired intermediate ticks.
9090
*/
9191
fromTimestamp?: Date;
9292
/**

internal-packages/schedule-engine/test/scheduleEngine2.test.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ describe("ScheduleEngine Integration (part 2)", () => {
100100
// Call triggerScheduledTask directly without lastScheduleTime or an
101101
// effective time, simulating an in-flight Redis job from the old engine.
102102
const exactScheduleTime = new Date("2026-04-30T10:05:00.000Z");
103+
const beforeTrigger = new Date();
103104
await engine.triggerScheduledTask({
104105
instanceId: scheduleInstance.id,
105106
finalAttempt: false,
@@ -120,16 +121,19 @@ describe("ScheduleEngine Integration (part 2)", () => {
120121
exactScheduleTime: string;
121122
effectiveScheduleTime: string;
122123
};
123-
const nextNominalAt = new Date("2026-04-30T10:10:00.000Z");
124+
const nextNominalAt = new Date(nextJobPayload.exactScheduleTime);
124125

125-
// The next job advances from the legacy job's nominal T, not from the
126-
// current wall clock. With cron spread disabled, actual eligibility
127-
// remains nominal even though registration still calculates candidate E.
128-
expect(new Date(nextJobPayload.exactScheduleTime)).toEqual(nextNominalAt);
126+
// The legacy occurrence fires once, then expired intermediate ticks are
127+
// skipped instead of being replayed. With spread disabled, eligibility
128+
// remains nominal and the next job is in the future.
129+
expect(nextNominalAt.getTime()).toBeGreaterThan(beforeTrigger.getTime());
129130
expect(new Date(nextJobPayload.effectiveScheduleTime)).toEqual(nextNominalAt);
130131
expect(nextJob!.timestamp).toEqual(
131132
calculateDistributedExecutionTime(nextNominalAt, 10, scheduleInstance.id)
132133
);
134+
expect(new Date((nextJob!.item as { lastScheduleTime: string }).lastScheduleTime)).toEqual(
135+
exactScheduleTime
136+
);
133137

134138
const updatedInstance = await prisma.taskScheduleInstance.findUniqueOrThrow({
135139
where: { id: scheduleInstance.id },
@@ -287,8 +291,9 @@ describe("ScheduleEngine Integration (part 2)", () => {
287291
});
288292
expect(preservedInstance.schedulePhase).toBe(pinnedPhase);
289293

290-
const exactScheduleTime = new Date("2026-04-30T10:00:00.000Z");
291-
const effectiveScheduleTime = new Date("2026-04-30T10:00:45.000Z");
294+
const intervalMs = 5 * 60_000;
295+
const exactScheduleTime = new Date(Math.floor(Date.now() / intervalMs) * intervalMs);
296+
const effectiveScheduleTime = new Date(exactScheduleTime.getTime() + 45_000);
292297
await engine.triggerScheduledTask({
293298
instanceId: scheduleInstance.id,
294299
finalAttempt: false,
@@ -306,8 +311,8 @@ describe("ScheduleEngine Integration (part 2)", () => {
306311
exactScheduleTime: string;
307312
effectiveScheduleTime: string;
308313
};
309-
const nextNominalAt = new Date("2026-04-30T10:05:00.000Z");
310-
const followingNominalAt = new Date("2026-04-30T10:10:00.000Z");
314+
const nextNominalAt = new Date(exactScheduleTime.getTime() + intervalMs);
315+
const followingNominalAt = new Date(nextNominalAt.getTime() + intervalMs);
311316
const { effectiveAt: nextEffectiveAt } = calculateEffectiveScheduleTime({
312317
nominalAt: nextNominalAt,
313318
nextNominalAt: followingNominalAt,

0 commit comments

Comments
 (0)