Skip to content

Commit be6e27f

Browse files
committed
fix(schedule-engine): split fromTimestamp from carried lastScheduleTime
Address CodeRabbit review on PR #3476: 1. Engine — split fromTimestamp from lastScheduleTime `RegisterScheduleInstanceParams.fromTimestamp` was doing double duty as both the next-cron-slot anchor and the "previous fire time" embedded in the next worker job's payload. On skipped ticks (inactive schedule, dev env disconnected, etc.) the engine still re-registered with `fromTimestamp = scheduleTimestamp`, so a long pause/disconnect would quietly overwrite the real last-fire time with a stream of skipped slots — defeating the workerCatalog accuracy benefit for the very case it was meant to handle. `RegisterScheduleInstanceParams` now has a separate optional `lastScheduleTime` field. `fromTimestamp` advances on every tick; `lastScheduleTime` only advances on real fires. After a skip the re-registration carries forward the existing `params.lastScheduleTime` (or the legacy `instance.lastScheduledTimestamp` fallback). 2. ScheduleListPresenter — guard cron back-calculation `previousScheduledTimestamp(...)` calls cron-parser, which throws on malformed expressions. A single bad row would have failed the whole schedules-list response. Wrapped per-row in try/catch so a degraded row falls back to `lastRun: undefined` instead of taking down the page. 3. scheduleEngine integration test — anchor on observed values The test compared against a precomputed "next minute boundary" Date, which is flaky if the test setup straddles a minute boundary. Switched to deriving expectations from the first observed `exactScheduleTime` and asserting relative invariants (60s gap, second lastTimestamp equals first timestamp).
1 parent ae6b325 commit be6e27f

4 files changed

Lines changed: 71 additions & 43 deletions

File tree

apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -249,12 +249,19 @@ export class ScheduleListPresenter extends BasePresenter {
249249
// Approximate "last run" from the cron's previous slot. If that slot
250250
// predates the schedule itself, the schedule hasn't fired yet — show
251251
// undefined rather than a misleading timestamp. UI is best-effort;
252-
// accurate run history is on the schedule's runs page.
253-
const cronPrev = previousScheduledTimestamp(
254-
schedule.generatorExpression,
255-
schedule.timezone
256-
);
257-
const lastRun = cronPrev.getTime() > schedule.createdAt.getTime() ? cronPrev : undefined;
252+
// accurate run history is on the schedule's runs page. cron-parser
253+
// throws on malformed expressions, so degrade to undefined per-row
254+
// rather than failing the whole list.
255+
let lastRun: Date | undefined;
256+
try {
257+
const cronPrev = previousScheduledTimestamp(
258+
schedule.generatorExpression,
259+
schedule.timezone
260+
);
261+
lastRun = cronPrev.getTime() > schedule.createdAt.getTime() ? cronPrev : undefined;
262+
} catch {
263+
lastRun = undefined;
264+
}
258265

259266
return {
260267
id: schedule.id,

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

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -194,15 +194,16 @@ export class ScheduleEngine {
194194
timezone: instance.taskSchedule.timezone,
195195
});
196196

197-
// Enqueue the scheduled task. Pass fromTimestamp through as the next
198-
// job's lastScheduleTime so the dequeueing engine can populate
199-
// payload.lastTimestamp without re-reading state from the DB. When
200-
// fromTimestamp is undefined (first-ever registration / recovery),
201-
// lastScheduleTime is also undefined → consumer reports undefined.
197+
// Enqueue the scheduled task. The next job's `lastScheduleTime`
198+
// payload is the *actual* previous fire time (passed in by the
199+
// caller), not `fromTimestamp` — `fromTimestamp` advances on every
200+
// tick (including skips) so it can't be used as the previous-fire
201+
// anchor without leaking skipped slots into customer-visible
202+
// payload.lastTimestamp.
202203
await this.enqueueScheduledTask(
203204
params.instanceId,
204205
nextScheduledTimestamp,
205-
params.fromTimestamp
206+
params.lastScheduleTime
206207
);
207208

208209
// Record metrics
@@ -535,13 +536,22 @@ export class ScheduleEngine {
535536
});
536537
}
537538

538-
// Register the next run, calculating from the timestamp we just fired (or
539-
// skipped) so we don't need to round-trip through DB state.
540-
// Rewritten try/catch to use tryCatch utility
539+
// Register the next run. `fromTimestamp` advances on every tick so
540+
// the next cron slot keeps marching forward even through skips.
541+
// `lastScheduleTime` is the actual previous fire time the next job
542+
// will report as `payload.lastTimestamp` — only advance it when we
543+
// actually triggered, otherwise carry forward the existing value so
544+
// a long pause/disconnect doesn't quietly overwrite the real
545+
// last-fire timestamp with a series of skipped slots.
546+
const carriedLastScheduleTime = shouldTrigger
547+
? scheduleTimestamp
548+
: params.lastScheduleTime ?? instance.lastScheduledTimestamp ?? undefined;
549+
541550
const [nextRunError] = await tryCatch(
542551
this.registerNextTaskScheduleInstance({
543552
instanceId: params.instanceId,
544553
fromTimestamp: scheduleTimestamp,
554+
lastScheduleTime: carriedLastScheduleTime,
545555
})
546556
);
547557
if (nextRunError) {

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,17 @@ export interface TriggerScheduleParams {
7979

8080
export interface RegisterScheduleInstanceParams {
8181
instanceId: string;
82+
/**
83+
* Anchor for computing the next cron slot. Defaults to now() when omitted.
84+
* This advances on every tick (fired or skipped) so the next slot keeps
85+
* marching forward regardless of skip reasons.
86+
*/
8287
fromTimestamp?: Date;
88+
/**
89+
* The actual previous fire time to embed in the next worker job's payload,
90+
* which becomes that job's `payload.lastTimestamp` on dequeue. Distinct
91+
* from `fromTimestamp` so that skipped ticks (inactive schedule, dev env
92+
* disconnected, etc.) do NOT advance this — only real fires do.
93+
*/
94+
lastScheduleTime?: Date;
8395
}

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

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -98,23 +98,11 @@ describe("ScheduleEngine Integration", () => {
9898
},
9999
});
100100

101-
// Calculate the expected next execution time (next minute boundary)
102-
const now = new Date();
103-
const expectedExecutionTime = new Date(now);
104-
expectedExecutionTime.setMinutes(now.getMinutes() + 1, 0, 0); // Next minute, 0 seconds, 0 milliseconds
105-
106-
// Calculate the expected upcoming execution times (next 10 minutes after the first execution)
107-
const expectedUpcoming = [];
108-
for (let i = 1; i <= 10; i++) {
109-
const upcoming = new Date(expectedExecutionTime);
110-
upcoming.setMinutes(expectedExecutionTime.getMinutes() + i);
111-
expectedUpcoming.push(upcoming);
112-
}
113-
114101
// Manually enqueue the first scheduled task to kick off the lifecycle.
115-
// Engine no longer persists nextScheduledTimestamp — the same time can be
116-
// reproduced from the cron expression alone, so we use expectedExecutionTime
117-
// directly downstream.
102+
// Anchor expectations to the first observed `exactScheduleTime` rather
103+
// than a precomputed wall-clock value — registration that happens to
104+
// straddle a minute boundary used to flake tests asserting against a
105+
// pre-baked "next minute".
118106
await engine.registerNextTaskScheduleInstance({ instanceId: scheduleInstance.id });
119107

120108
// Wait for the first execution
@@ -176,6 +164,17 @@ describe("ScheduleEngine Integration", () => {
176164
}
177165
}
178166

167+
// Anchor all expectations to what the engine actually fired with, so
168+
// the test stays deterministic regardless of when within a minute it
169+
// started.
170+
const firstScheduledTime = firstExecution.params.exactScheduleTime;
171+
const secondScheduledTime = secondExecution.params.exactScheduleTime;
172+
expect(firstScheduledTime).toBeDefined();
173+
expect(secondScheduledTime).toBeDefined();
174+
175+
// Each cron slot for "* * * * *" is exactly 60s apart.
176+
expect(secondScheduledTime!.getTime() - firstScheduledTime!.getTime()).toBe(60_000);
177+
179178
// Verify the first execution parameters
180179
expect(firstExecution.params).toEqual({
181180
taskIdentifier: "test-task",
@@ -196,24 +195,23 @@ describe("ScheduleEngine Integration", () => {
196195
payload: {
197196
scheduleId: "sched_abc123",
198197
type: "DECLARATIVE",
199-
timestamp: expectedExecutionTime,
200-
// First-ever fire: cron's previous slot predates instance.createdAt
201-
// (which was set ~now), so lastTimestamp is undefined. This preserves
202-
// the `if (!payload.lastTimestamp)` first-run sentinel customers rely on.
198+
timestamp: firstScheduledTime,
199+
// First-ever fire: no `lastScheduleTime` carried in the worker
200+
// payload and `instance.lastScheduledTimestamp` is null on a
201+
// fresh instance, so lastTimestamp is undefined. This preserves
202+
// the `if (!payload.lastTimestamp)` first-run sentinel customers
203+
// rely on.
203204
lastTimestamp: undefined,
204205
externalId: "ext-123",
205206
timezone: "UTC",
206207
upcoming: expect.arrayContaining([expect.any(Date)]),
207208
},
208209
scheduleInstanceId: scheduleInstance.id,
209210
scheduleId: taskSchedule.id,
210-
exactScheduleTime: expectedExecutionTime,
211+
exactScheduleTime: firstScheduledTime,
211212
});
212213

213214
// Verify the second execution parameters
214-
const expectedSecondExecution = new Date(expectedExecutionTime);
215-
expectedSecondExecution.setMinutes(expectedExecutionTime.getMinutes() + 1);
216-
217215
expect(secondExecution.params).toEqual({
218216
taskIdentifier: "test-task",
219217
environment: expect.objectContaining({
@@ -223,16 +221,17 @@ describe("ScheduleEngine Integration", () => {
223221
payload: {
224222
scheduleId: "sched_abc123",
225223
type: "DECLARATIVE",
226-
timestamp: expectedSecondExecution,
227-
// Previous slot before second execution = first execution time.
228-
lastTimestamp: expectedExecutionTime,
224+
timestamp: secondScheduledTime,
225+
// The previous fire's exactScheduleTime is carried through the
226+
// worker payload as `lastScheduleTime` and surfaced here.
227+
lastTimestamp: firstScheduledTime,
229228
externalId: "ext-123",
230229
timezone: "UTC",
231230
upcoming: expect.arrayContaining([expect.any(Date)]),
232231
},
233232
scheduleInstanceId: scheduleInstance.id,
234233
scheduleId: taskSchedule.id,
235-
exactScheduleTime: expectedSecondExecution,
234+
exactScheduleTime: secondScheduledTime,
236235
});
237236
} finally {
238237
// Clean up: stop the worker

0 commit comments

Comments
 (0)