Skip to content

Commit ac7177d

Browse files
authored
feat(schedule-engine): stop persisting per-tick schedule state (#3476)
## Summary Each scheduled-task tick previously issued **3 Prisma `UPDATE`s** against `TaskSchedule.lastRunTriggeredAt`, `TaskScheduleInstance.lastScheduledTimestamp`, and `TaskScheduleInstance.nextScheduledTimestamp`. All three were pure denormalization — every value can be derived without persisting. After this PR `TaskSchedule` and `TaskScheduleInstance` become **near read-only**: writes happen only on schedule create / update / delete (rare admin actions), so the per-tick autovacuum churn on these hot tables disappears. ## Design The previous fire time travels forward through the **schedule worker payload**, not through the database. Concretely: - The `schedule.triggerScheduledTask` worker payload gains an optional `lastScheduleTime: z.coerce.date().optional()` field. - When the engine fires a schedule, it re-enqueues the next tick with `lastScheduleTime = scheduleTimestamp` (the just-fired time). - When the next tick dequeues, `payload.lastTimestamp` is sourced from `params.lastScheduleTime` directly. No DB round-trip, no cron-derivation drift across DST boundaries, no caveats around recently-edited cron expressions. `payload.lastTimestamp` keeps its `Date | undefined` SDK shape. First-ever fires still report `undefined`, so customer `if (!payload.lastTimestamp)` first-run patterns keep working. For Redis jobs that were enqueued **before** this change (which lack `lastScheduleTime` in their payload), the engine falls back to `instance.lastScheduledTimestamp` once. Once those drain, the column is never read again. Revert is code-only; the columns stay in place and can be dropped in a follow-up once the rollout is stable. ## Files - `internal-packages/schedule-engine/*` — engine refactor, `workerCatalog` schema field, `TriggerScheduleParams` extension, tests updated to assert on the worker-payload flow rather than DB readbacks. - `internal-packages/database/prisma/schema.prisma` — `/// @deprecated` triple-slash docstrings on the three columns. No migration. - `apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts` — drops the `lastRunTriggeredAt` Prisma select; "Last run" cell is approximated from the cron expression's previous slot, gated on `schedule.createdAt` so brand-new schedules show "–". UI is best-effort; the runs page is the source of truth. - `apps/webapp/app/v3/utils/calculateNextSchedule.server.ts` — adds a `previousScheduledTimestamp` helper for the UI cell above. Public API responses (`api.v1.schedules.*`) already compute `nextRun` from cron and don't expose `lastTimestamp` — no public API change. - `references/scheduled-tasks/` — new reference project with declarative schedules at multiple cadences and three throw-on-fail validators (`first-fire-detector`, `interval-validator`, `upcoming-validator`) for E2E-verifying the worker-payload flow. Refs TRI-8891 ## Test plan - [x] `pnpm run typecheck --filter @internal/schedule-engine --filter webapp` - [x] `pnpm run build --filter @trigger.dev/core` - [x] `pnpm run test --filter @internal/schedule-engine` — integration test asserts first-fire `lastTimestamp === undefined`, second fire carries the previous fire's timestamp exactly. - [x] E2E against local webapp via `references/scheduled-tasks`: - Fresh schedules attached → all three deprecated columns stay `NULL` after multiple fires. - Redis payload at second fire contains `"lastScheduleTime":"<previous fire timestamp>"`. - `TaskRun.payload` and the every-minute task's returned output both confirm `lastTimestamp = null` on first fire and `lastTimestamp = <prev fire>` on second fire, exactly 60s apart. - All three throw-on-FAIL validators completed successfully on every non-first fire. - [x] Schedules REST API end-to-end (`POST` / `GET` / `PUT` / `activate` / `deactivate` / `DELETE`) — `nextRun` recomputed live from cron + tz on every response, no reads of deprecated columns.
1 parent 19c1675 commit ac7177d

16 files changed

Lines changed: 856 additions & 145 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: 29 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,8 +196,8 @@ export class ScheduleListPresenter extends BasePresenter {
193196
},
194197
},
195198
active: true,
196-
lastRunTriggeredAt: true,
197199
createdAt: true,
200+
updatedAt: true,
198201
},
199202
where: {
200203
projectId: project.id,
@@ -244,6 +247,29 @@ export class ScheduleListPresenter extends BasePresenter {
244247
});
245248

246249
const schedules: ScheduleListItem[] = rawSchedules.map((schedule) => {
250+
// Approximate "last run" from the cron's previous slot. Skip inactive
251+
// schedules — the cron's previous slot reflects what *would* have
252+
// fired, but a deactivated schedule didn't actually fire there. Skip
253+
// when the cron's previous slot predates `updatedAt`: any config
254+
// change (cron edited, timezone changed, deactivate/reactivate)
255+
// bumps updatedAt, and a slot from before the most recent change
256+
// didn't fire under the current configuration. cron-parser throws
257+
// on malformed expressions, so degrade to undefined per-row rather
258+
// than failing the whole list. UI is best-effort; the runs page is
259+
// the source of truth.
260+
let lastRun: Date | undefined;
261+
if (schedule.active) {
262+
try {
263+
const cronPrev = previousScheduledTimestamp(
264+
schedule.generatorExpression,
265+
schedule.timezone
266+
);
267+
lastRun = cronPrev.getTime() > schedule.updatedAt.getTime() ? cronPrev : undefined;
268+
} catch {
269+
lastRun = undefined;
270+
}
271+
}
272+
247273
return {
248274
id: schedule.id,
249275
type: schedule.type,
@@ -256,7 +282,7 @@ export class ScheduleListPresenter extends BasePresenter {
256282
timezone: schedule.timezone,
257283
active: schedule.active,
258284
externalId: schedule.externalId,
259-
lastRun: schedule.lastRunTriggeredAt ?? undefined,
285+
lastRun,
260286
nextRun: calculateNextScheduledTimestampFromNow(
261287
schedule.generatorExpression,
262288
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

0 commit comments

Comments
 (0)