## 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.
@internal/schedule-engine
The @internal/schedule-engine package encapsulates all scheduling logic for Trigger.dev, providing a clean API boundary for managing scheduled tasks and their execution.
Architecture
The ScheduleEngine follows the same pattern as the RunEngine, providing:
- Centralized Schedule Management: All schedule-related operations go through the ScheduleEngine
- Redis Worker Integration: Built-in Redis-based distributed task scheduling
- Distributed Execution: Prevents thundering herd issues by distributing executions across time windows
- Comprehensive Testing: Built-in utilities for testing schedule behavior
Key Components
ScheduleEngine Class
The main interface for all schedule operations:
import { ScheduleEngine } from "@internal/schedule-engine";
const engine = new ScheduleEngine({
prisma,
redis: {
/* Redis configuration */
},
worker: {
/* Worker configuration */
},
distributionWindow: { seconds: 30 }, // Optional: default 30s
});
// Register next schedule instance
await engine.registerNextTaskScheduleInstance({ instanceId });
// Upsert a schedule
await engine.upsertTaskSchedule({
projectId,
schedule: {
taskIdentifier: "my-task",
cron: "0 */5 * * *",
timezone: "UTC",
environments: ["env-1", "env-2"],
},
});
Distributed Scheduling
The engine includes built-in distributed scheduling to prevent all scheduled tasks from executing at exactly the same moment:
import { calculateDistributedExecutionTime } from "@internal/schedule-engine";
const exactTime = new Date("2024-01-01T12:00:00Z");
const distributedTime = calculateDistributedExecutionTime(exactTime, 30); // 30-second window
Schedule Calculation
High-performance CRON schedule calculation with optimization for old timestamps:
import {
calculateNextScheduledTimestampFromNow,
nextScheduledTimestamps,
} from "@internal/schedule-engine";
const nextRun = calculateNextScheduledTimestampFromNow("0 */5 * * *", "UTC");
const upcoming = nextScheduledTimestamps("0 */5 * * *", "UTC", nextRun, 5);
Integration with Webapp
The ScheduleEngine should be the API boundary between the webapp and schedule logic. Services in the webapp should call into the ScheduleEngine rather than implementing schedule logic directly.
Migration Path
Currently, the webapp uses individual services like:
RegisterNextTaskScheduleInstanceServiceTriggerScheduledTaskService- Schedule calculation utilities
These should be replaced with ScheduleEngine method calls:
// Old approach
const service = new RegisterNextTaskScheduleInstanceService(tx);
await service.call(instanceId);
// New approach
await scheduleEngine.registerNextTaskScheduleInstance({ instanceId });
Configuration
The ScheduleEngine expects these configuration options:
prisma: PrismaClient instanceredis: Redis connection configurationworker: Worker configuration (concurrency, polling intervals)distributionWindow: Optional time window for distributed executiontracer: Optional OpenTelemetry tracermeter: Optional OpenTelemetry meter
Testing
The package includes comprehensive test utilities and examples. See the test directory for usage examples.