Skip to content

Commit ae6b325

Browse files
committed
feat(schedule-engine): stop persisting per-tick schedule state
Each scheduled-task tick previously issued 3 Prisma UPDATEs against TaskSchedule.lastRunTriggeredAt, TaskScheduleInstance.lastScheduledTimestamp, and TaskScheduleInstance.nextScheduledTimestamp. All three were pure denormalization — every value can be derived without persisting. Engine - Drop the three per-tick prisma.update calls. - Refactor registerNextTaskScheduleInstance to take a fromTimestamp arg instead of reading instance.lastScheduledTimestamp from the DB. - Add optional lastScheduleTime to the schedule worker payload so the previous fire time travels forward via Redis. payload.lastTimestamp is now sourced from the worker payload, not a DB column. First-ever fires still report undefined so customer "first-run" sentinel patterns keep working. - For in-flight Redis jobs enqueued before this change (which lack lastScheduleTime), fall back to instance.lastScheduledTimestamp once. After those drain, the column is never read again. Schema - Mark the three columns @deprecated via triple-slash Prisma docstrings. No migration — columns remain in place so revert is code-only. They can be dropped in a follow-up once the rollout is stable. Webapp - ScheduleListPresenter derives the dashboard "Last run" cell from the cron expression's previous slot, gated on schedule.createdAt so brand-new schedules show "–". UI is best-effort; runs page is the source of truth. - API responses (api.v1.schedules.*) already compute nextRun from cron; no public API change. lastTimestamp on the SDK payload retains Date | undefined semantics — no SDK change either. Tests - scheduleEngine integration test asserts first-fire lastTimestamp is undefined and the second fire carries the previous fire's timestamp exactly. - scheduleRecovery tests no longer assert against the deprecated nextScheduledTimestamp column; presence of the worker job is the source of truth. References - New references/scheduled-tasks project with declarative schedules at multiple cadences plus three validators (first-fire-detector, interval-validator, upcoming-validator) that throw on FAIL — used for E2E-verifying the worker-payload flow. Refs TRI-8891
1 parent c69e939 commit ae6b325

15 files changed

Lines changed: 453 additions & 128 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Stop writing per-tick state (`lastScheduledTimestamp`, `nextScheduledTimestamp`, `lastRunTriggeredAt`) on `TaskSchedule` and `TaskScheduleInstance`. The schedule engine now carries the previous fire time forward via the worker queue payload, eliminating ~270K dead-tuple-driven autovacuums per year on these hot tables and the associated `IO:XactSync` mini-spikes on the writer. Customer-facing `payload.lastTimestamp` semantics are unchanged.

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { getLimit } from "~/services/platform.v3.server";
66
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
77
import { ServiceValidationError } from "~/v3/services/baseService.server";
88
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
9-
import { calculateNextScheduledTimestampFromNow } from "~/v3/utils/calculateNextSchedule.server";
9+
import {
10+
calculateNextScheduledTimestampFromNow,
11+
previousScheduledTimestamp,
12+
} from "~/v3/utils/calculateNextSchedule.server";
1013
import { BasePresenter } from "./basePresenter.server";
1114

1215
type ScheduleListOptions = {
@@ -193,7 +196,6 @@ export class ScheduleListPresenter extends BasePresenter {
193196
},
194197
},
195198
active: true,
196-
lastRunTriggeredAt: true,
197199
createdAt: true,
198200
},
199201
where: {
@@ -244,6 +246,16 @@ export class ScheduleListPresenter extends BasePresenter {
244246
});
245247

246248
const schedules: ScheduleListItem[] = rawSchedules.map((schedule) => {
249+
// Approximate "last run" from the cron's previous slot. If that slot
250+
// predates the schedule itself, the schedule hasn't fired yet — show
251+
// 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;
258+
247259
return {
248260
id: schedule.id,
249261
type: schedule.type,
@@ -256,7 +268,7 @@ export class ScheduleListPresenter extends BasePresenter {
256268
timezone: schedule.timezone,
257269
active: schedule.active,
258270
externalId: schedule.externalId,
259-
lastRun: schedule.lastRunTriggeredAt ?? undefined,
271+
lastRun,
260272
nextRun: calculateNextScheduledTimestampFromNow(
261273
schedule.generatorExpression,
262274
schedule.timezone

apps/webapp/app/v3/utils/calculateNextSchedule.server.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ function calculateNextStep(schedule: string, timezone: string | null, currentDat
2222
.toDate();
2323
}
2424

25+
export function previousScheduledTimestamp(
26+
schedule: string,
27+
timezone: string | null,
28+
fromTimestamp: Date = new Date()
29+
) {
30+
return parseExpression(schedule, {
31+
currentDate: fromTimestamp,
32+
utc: timezone === null,
33+
tz: timezone ?? undefined,
34+
})
35+
.prev()
36+
.toDate();
37+
}
38+
2539
export function nextScheduledTimestamps(
2640
cron: string,
2741
timezone: string | null,

internal-packages/database/prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2128,6 +2128,7 @@ model TaskSchedule {
21282128
///Instances of the schedule that are active
21292129
instances TaskScheduleInstance[]
21302130
2131+
/// @deprecated stop writing 2026-04-30; reads moved out of code (UI now derives from cron's previous slot). Drop in follow-up.
21312132
lastRunTriggeredAt DateTime?
21322133
21332134
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@ -2173,7 +2174,9 @@ model TaskScheduleInstance {
21732174
21742175
active Boolean @default(true)
21752176
2177+
/// @deprecated stop writing 2026-04-30; engine derives from cron + exactScheduleTime. Drop in follow-up.
21762178
lastScheduledTimestamp DateTime?
2179+
/// @deprecated stop writing 2026-04-30; engine derives from cron + now(). Drop in follow-up.
21772180
nextScheduledTimestamp DateTime?
21782181
21792182
//you can only have a schedule attached to each environment once

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

Lines changed: 44 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,13 @@ export class ScheduleEngine {
171171
instance.taskSchedule.generatorExpression
172172
);
173173

174-
const lastScheduledTimestamp = instance.lastScheduledTimestamp ?? new Date();
175-
span.setAttribute("last_scheduled_timestamp", lastScheduledTimestamp.toISOString());
174+
const fromTimestamp = params.fromTimestamp ?? new Date();
175+
span.setAttribute("from_timestamp", fromTimestamp.toISOString());
176176

177177
const nextScheduledTimestamp = calculateNextScheduledTimestamp(
178178
instance.taskSchedule.generatorExpression,
179179
instance.taskSchedule.timezone,
180-
lastScheduledTimestamp
180+
fromTimestamp
181181
);
182182

183183
span.setAttribute("next_scheduled_timestamp", nextScheduledTimestamp.toISOString());
@@ -194,17 +194,16 @@ export class ScheduleEngine {
194194
timezone: instance.taskSchedule.timezone,
195195
});
196196

197-
await this.prisma.taskScheduleInstance.update({
198-
where: {
199-
id: params.instanceId,
200-
},
201-
data: {
202-
nextScheduledTimestamp,
203-
},
204-
});
205-
206-
// Enqueue the scheduled task
207-
await this.enqueueScheduledTask(params.instanceId, nextScheduledTimestamp);
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.
202+
await this.enqueueScheduledTask(
203+
params.instanceId,
204+
nextScheduledTimestamp,
205+
params.fromTimestamp
206+
);
208207

209208
// Record metrics
210209
this.scheduleRegistrationCounter.add(1, {
@@ -244,6 +243,7 @@ export class ScheduleEngine {
244243
instanceId: payload.instanceId,
245244
finalAttempt: false, // TODO: implement retry logic
246245
exactScheduleTime: payload.exactScheduleTime,
246+
lastScheduleTime: payload.lastScheduleTime,
247247
});
248248
}
249249

@@ -350,14 +350,6 @@ export class ScheduleEngine {
350350
skipReason = "schedule_inactive";
351351
}
352352

353-
if (!instance.nextScheduledTimestamp) {
354-
this.logger.debug("No next scheduled timestamp", {
355-
instanceId: params.instanceId,
356-
});
357-
shouldTrigger = false;
358-
skipReason = "no_next_timestamp";
359-
}
360-
361353
// For development environments, check if there's an active session
362354
if (instance.environment.type === "DEVELOPMENT") {
363355
this.devEnvironmentCheckCounter.add(1, {
@@ -396,15 +388,26 @@ export class ScheduleEngine {
396388
}
397389

398390
// Calculate the schedule timestamp that will be used (regardless of whether we trigger or not)
399-
const scheduleTimestamp =
400-
params.exactScheduleTime ?? instance.nextScheduledTimestamp ?? new Date();
391+
const scheduleTimestamp = params.exactScheduleTime ?? new Date();
401392

402393
if (shouldTrigger) {
394+
// payload.lastTimestamp is the actual previous fire time. Sources, in
395+
// order:
396+
// 1. params.lastScheduleTime — populated by the engine when this
397+
// job was enqueued. Always present for jobs enqueued post-deploy.
398+
// 2. instance.lastScheduledTimestamp — backward-compat fallback for
399+
// in-flight Redis jobs enqueued by older engines that didn't
400+
// include lastScheduleTime in the payload. Once those drain
401+
// this fallback never triggers and we can drop the column.
402+
// 3. undefined — first-ever fire (no previous fire to point at).
403+
const lastTimestamp =
404+
params.lastScheduleTime ?? instance.lastScheduledTimestamp ?? undefined;
405+
403406
const payload = {
404407
scheduleId: instance.taskSchedule.friendlyId,
405408
type: instance.taskSchedule.type as "DECLARATIVE" | "IMPERATIVE",
406409
timestamp: scheduleTimestamp,
407-
lastTimestamp: instance.lastScheduledTimestamp ?? undefined,
410+
lastTimestamp,
408411
externalId: instance.taskSchedule.externalId ?? undefined,
409412
timezone: instance.taskSchedule.timezone,
410413
upcoming: nextScheduledTimestamps(
@@ -428,7 +431,7 @@ export class ScheduleEngine {
428431
scheduleTimestamp: scheduleTimestamp.toISOString(),
429432
actualExecutionTime: actualExecutionTime.toISOString(),
430433
schedulingAccuracyMs,
431-
lastTimestamp: instance.lastScheduledTimestamp?.toISOString(),
434+
lastTimestamp: lastTimestamp?.toISOString(),
432435
});
433436

434437
const triggerStartTime = Date.now();
@@ -473,16 +476,6 @@ export class ScheduleEngine {
473476
);
474477
} else if (result) {
475478
if (result.success) {
476-
// Update the last run triggered timestamp
477-
await this.prisma.taskSchedule.update({
478-
where: {
479-
id: instance.taskSchedule.id,
480-
},
481-
data: {
482-
lastRunTriggeredAt: new Date(),
483-
},
484-
});
485-
486479
this.logger.info("Successfully triggered scheduled task", {
487480
instanceId: params.instanceId,
488481
taskIdentifier: instance.taskSchedule.taskIdentifier,
@@ -542,20 +535,14 @@ export class ScheduleEngine {
542535
});
543536
}
544537

545-
// Always update the last scheduled timestamp and register next run
546-
await this.prisma.taskScheduleInstance.update({
547-
where: {
548-
id: params.instanceId,
549-
},
550-
data: {
551-
lastScheduledTimestamp: scheduleTimestamp,
552-
},
553-
});
554-
555-
// Register the next run
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.
556540
// Rewritten try/catch to use tryCatch utility
557541
const [nextRunError] = await tryCatch(
558-
this.registerNextTaskScheduleInstance({ instanceId: params.instanceId })
542+
this.registerNextTaskScheduleInstance({
543+
instanceId: params.instanceId,
544+
fromTimestamp: scheduleTimestamp,
545+
})
559546
);
560547
if (nextRunError) {
561548
this.logger.error("Failed to schedule next run after execution", {
@@ -610,10 +597,17 @@ export class ScheduleEngine {
610597
/**
611598
* Enqueues a scheduled task with distributed execution timing
612599
*/
613-
private async enqueueScheduledTask(instanceId: string, exactScheduleTime: Date) {
600+
private async enqueueScheduledTask(
601+
instanceId: string,
602+
exactScheduleTime: Date,
603+
lastScheduleTime?: Date
604+
) {
614605
return startSpan(this.tracer, "enqueueScheduledTask", async (span) => {
615606
span.setAttribute("instanceId", instanceId);
616607
span.setAttribute("exactScheduleTime", exactScheduleTime.toISOString());
608+
if (lastScheduleTime) {
609+
span.setAttribute("lastScheduleTime", lastScheduleTime.toISOString());
610+
}
617611

618612
const distributedExecutionTime = calculateDistributedExecutionTime(
619613
exactScheduleTime,
@@ -646,6 +640,7 @@ export class ScheduleEngine {
646640
payload: {
647641
instanceId,
648642
exactScheduleTime,
643+
lastScheduleTime,
649644
},
650645
availableAt: distributedExecutionTime,
651646
});
@@ -698,8 +693,6 @@ export class ScheduleEngine {
698693
select: {
699694
id: true,
700695
environmentId: true,
701-
lastScheduledTimestamp: true,
702-
nextScheduledTimestamp: true,
703696
},
704697
},
705698
},
@@ -774,8 +767,6 @@ export class ScheduleEngine {
774767
instance: {
775768
id: string;
776769
environmentId: string;
777-
lastScheduledTimestamp: Date | null;
778-
nextScheduledTimestamp: Date | null;
779770
};
780771
schedule: { id: string; generatorExpression: string };
781772
}) {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ export interface TriggerScheduleParams {
7474
instanceId: string;
7575
finalAttempt: boolean;
7676
exactScheduleTime?: Date;
77+
lastScheduleTime?: Date;
7778
}
7879

7980
export interface RegisterScheduleInstanceParams {
8081
instanceId: string;
82+
fromTimestamp?: Date;
8183
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ export const scheduleWorkerCatalog = {
55
schema: z.object({
66
instanceId: z.string(),
77
exactScheduleTime: z.coerce.date(),
8+
// Optional for backward compat with in-flight jobs enqueued by older
9+
// engines. After deploy, every newly-enqueued job populates this with
10+
// the just-fired schedule time so the next dequeue can report
11+
// payload.lastTimestamp accurately without a DB round-trip.
12+
lastScheduleTime: z.coerce.date().optional(),
813
}),
914
visibilityTimeoutMs: 60_000,
1015
retry: {

0 commit comments

Comments
 (0)