Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/smooth-schedule-windows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@trigger.dev/core": patch
"@trigger.dev/sdk": patch
"trigger.dev": patch
---

Define stable execution windows on declarative scheduled tasks. Schedule API responses now expose both the nominal CRON time and its assigned time, while deploy output and the dashboard show configured windows and upcoming assignments.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document imperative schedule windows.

Line 7 limits execution windows to declarative schedules. This PR also adds imperative schedule window support through the API. Include both paths in the package release note.

Proposed text
- Define stable execution windows on declarative scheduled tasks.
+ Define stable execution windows on declarative scheduled tasks and imperative schedules.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Define stable execution windows on declarative scheduled tasks. Schedule API responses now expose both the nominal CRON time and its assigned time, while deploy output and the dashboard show configured windows and upcoming assignments.
Define stable execution windows on declarative scheduled tasks and imperative schedules. Schedule API responses now expose both the nominal CRON time and its assigned time, while deploy output and the dashboard show configured windows and upcoming assignments.

29 changes: 19 additions & 10 deletions apps/webapp/app/components/schedules/ScheduleInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,14 @@ export type ScheduleInspectorData = {
cron: string;
cronDescription: string;
timezone: string;
window?: string;
externalId: string | null;
deduplicationKey: string | null;
userProvidedDeduplicationKey: boolean;
active: boolean;
environments: EnvironmentRow[];
runs: RunRow[];
nextRuns: Date[];
nextRuns: Array<{ nominalAt: Date; effectiveAt: Date }>;
};

type Props = {
Expand Down Expand Up @@ -142,6 +143,10 @@ export function ScheduleInspector({
<Property.Label>Timezone</Property.Label>
<Property.Value>{schedule.timezone}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Window</Property.Label>
<Property.Value>{schedule.window ?? "Default (60 seconds)"}</Property.Value>
</Property.Item>
<Property.Item className="gap-1">
<Property.Label>Environment</Property.Label>
<Property.Value>
Expand Down Expand Up @@ -195,12 +200,13 @@ export function ScheduleInspector({
/>
</div>
<div className="flex flex-col gap-1 pt-2">
<Header3 className="pb-1 pl-3">Next 5 runs</Header3>
<Header3 className="pb-1 pl-3">Next 5 scheduled runs</Header3>
<Table variant="bright">
<TableHeader>
<TableRow>
{!isUtc && <TableHeaderCell>{schedule.timezone}</TableHeaderCell>}
<TableHeaderCell>UTC</TableHeaderCell>
{!isUtc && <TableHeaderCell>CRON ({schedule.timezone})</TableHeaderCell>}
<TableHeaderCell>CRON (UTC)</TableHeaderCell>
<TableHeaderCell>Assigned (UTC)</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
Expand All @@ -210,21 +216,24 @@ export function ScheduleInspector({
<TableRow key={index}>
{!isUtc && (
<TableCell>
<DateTime date={run} timeZone={schedule.timezone} />
<DateTime date={run.nominalAt} timeZone={schedule.timezone} />
</TableCell>
)}
<TableCell>
<DateTime date={run} timeZone="UTC" />
<DateTime date={run.nominalAt} timeZone="UTC" />
</TableCell>
<TableCell>
<DateTime date={run.effectiveAt} timeZone="UTC" />
</TableCell>
</TableRow>
))
) : (
<TableBlankRow colSpan={isUtc ? 1 : 2}>
<TableBlankRow colSpan={isUtc ? 2 : 3}>
<PlaceholderText title="You found a bug" />
</TableBlankRow>
)
) : (
<TableBlankRow colSpan={isUtc ? 1 : 2}>
<TableBlankRow colSpan={isUtc ? 2 : 3}>
<PlaceholderText title="Schedule disabled" />
</TableBlankRow>
)}
Expand All @@ -249,8 +258,8 @@ export function ScheduleInspector({
}
panelClassName="max-w-full"
>
You can only edit a declarative schedule by updating your schedules.task and then
running the CLI dev and deploy commands.
You can only edit a declarative schedule, including its window, by updating your
schedules.task and then running the CLI dev and deploy commands.
</InfoPanel>
</div>
)}
Expand Down
4 changes: 4 additions & 0 deletions apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { filterOrphanedEnvironments } from "~/utils/environmentSort";
import { getTimezones } from "~/utils/timezones.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";

type EditScheduleOptions = {
userId: string;
Expand Down Expand Up @@ -124,6 +125,8 @@ export class EditSchedulePresenter {
deduplicationKey: true,
userProvidedDeduplicationKey: true,
timezone: true,
windowDurationSeconds: true,
windowPercentage: true,
taskIdentifier: true,
instances: {
select: {
Expand All @@ -144,6 +147,7 @@ export class EditSchedulePresenter {
return {
...schedule,
cron: schedule.generatorExpression,
window: formatScheduleWindow(schedule),
environments: schedule.instances.flatMap((instance) => {
const environment = possibleEnvironments.find((env) => env.id === instance.environmentId);
if (!environment) {
Expand Down
33 changes: 24 additions & 9 deletions apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@ import { getTaskIdentifiers } from "~/models/task.server";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";
import { calculateNextScheduleRunTimes, formatScheduleWindow } from "~/v3/scheduleWindow.server";
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
import {
calculateNextScheduledTimestampFromNow,
previousScheduledTimestamp,
} from "~/v3/utils/calculateNextSchedule.server";
import { previousScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server";
import { env } from "~/env.server";
import { BasePresenter } from "./basePresenter.server";

type ScheduleListOptions = {
Expand All @@ -35,6 +33,7 @@ export type ScheduleListItem = {
window?: string;
externalId: string | null;
nextRun: Date;
nextRunEffectiveAt: Date;
lastRun: Date | undefined;
active: boolean;
environments: {
Expand Down Expand Up @@ -223,6 +222,7 @@ export class ScheduleListPresenter extends BasePresenter {
instances: {
select: {
environmentId: true,
schedulePhase: true,
},
},
active: true,
Expand Down Expand Up @@ -300,6 +300,23 @@ export class ScheduleListPresenter extends BasePresenter {
}
}

const instance = schedule.instances.find(
(instance) => instance.environmentId === environmentId
);
if (!instance) {
throw new Error(`Schedule instance not found for environment: ${environmentId}`);
}
const [nextRun] = calculateNextScheduleRunTimes({
cron: schedule.generatorExpression,
timezone: schedule.timezone,
deduplicationKey: schedule.deduplicationKey,
environmentId,
schedulePhase: instance.schedulePhase,
phaseSecret: env.ENCRYPTION_KEY,
windowDurationSeconds: schedule.windowDurationSeconds,
windowPercentage: schedule.windowPercentage,
});

return {
id: schedule.id,
type: schedule.type,
Expand All @@ -314,10 +331,8 @@ export class ScheduleListPresenter extends BasePresenter {
active: schedule.active,
externalId: schedule.externalId,
lastRun,
nextRun: calculateNextScheduledTimestampFromNow(
schedule.generatorExpression,
schedule.timezone
),
nextRun: nextRun.nominalAt,
nextRunEffectiveAt: nextRun.effectiveAt,
environments: schedule.instances.map((instance) => {
const environment = project.environments.find((env) => env.id === instance.environmentId);
if (!environment) {
Expand Down
31 changes: 26 additions & 5 deletions apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
import { NextRunListPresenter } from "./NextRunListPresenter.server";
import { scheduleWhereClause } from "~/models/schedules.server";
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";
import { calculateNextScheduleRunTimes, formatScheduleWindow } from "~/v3/scheduleWindow.server";
import { env } from "~/env.server";

type ViewScheduleOptions = {
userId?: string;
Expand Down Expand Up @@ -52,6 +52,8 @@ export class ViewSchedulePresenter {
},
instances: {
select: {
environmentId: true,
schedulePhase: true,
environment: {
select: {
id: true,
Expand Down Expand Up @@ -82,8 +84,25 @@ export class ViewSchedulePresenter {
return;
}

const instance = schedule.instances.find(
(instance) => instance.environmentId === environmentId
);
if (!instance) {
return;
}

const nextRuns = schedule.active
? nextScheduledTimestamps(schedule.generatorExpression, schedule.timezone, new Date(), 5)
? calculateNextScheduleRunTimes({
cron: schedule.generatorExpression,
timezone: schedule.timezone,
deduplicationKey: schedule.deduplicationKey,
environmentId,
schedulePhase: instance.schedulePhase,
phaseSecret: env.ENCRYPTION_KEY,
windowDurationSeconds: schedule.windowDurationSeconds,
windowPercentage: schedule.windowPercentage,
count: 5,
})
: [];

const runs = includeRunHistory
Expand All @@ -101,6 +120,7 @@ export class ViewSchedulePresenter {
timezone: schedule.timezone,
cron: schedule.generatorExpression,
cronDescription: schedule.generatorDescription,
window: formatScheduleWindow(schedule),
nextRuns,
runs,
environments: schedule.instances.map((instance) => {
Expand Down Expand Up @@ -146,14 +166,15 @@ export class ViewSchedulePresenter {
type: result.schedule.type,
task: result.schedule.taskIdentifier,
active: result.schedule.active,
nextRun: result.schedule.nextRuns[0],
nextRun: result.schedule.nextRuns[0]?.nominalAt ?? null,
nextRunEffectiveAt: result.schedule.nextRuns[0]?.effectiveAt ?? null,
generator: {
type: "CRON",
expression: result.schedule.cron,
description: result.schedule.cronDescription,
},
timezone: result.schedule.timezone,
window: formatScheduleWindow(result.schedule),
window: result.schedule.window,
externalId: result.schedule.externalId ?? undefined,
deduplicationKey: result.schedule.userProvidedDeduplicationKey
? (result.schedule.deduplicationKey ?? undefined)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -966,8 +966,10 @@ type ScheduleRow = {
type: "DECLARATIVE" | "IMPERATIVE";
cron: string;
cronDescription: string;
window?: string;
externalId: string | null;
nextRun: Date;
nextRunEffectiveAt: Date;
lastRun: Date | undefined;
active: boolean;
};
Expand All @@ -987,7 +989,7 @@ function SchedulesMiniTable({
return (
<Table variant={variant} showTopBorder={showTopBorder}>
<TableBody>
<TableBlankRow colSpan={6}>
<TableBlankRow colSpan={9}>
<Paragraph variant="small" className="flex items-center justify-center">
No schedules attached to this task yet.
</Paragraph>
Expand All @@ -1003,9 +1005,11 @@ function SchedulesMiniTable({
<TableRow>
<TableHeaderCell>Schedule ID</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Cron</TableHeaderCell>
<TableHeaderCell>CRON</TableHeaderCell>
<TableHeaderCell>Window</TableHeaderCell>
<TableHeaderCell>External ID</TableHeaderCell>
<TableHeaderCell>Next run</TableHeaderCell>
<TableHeaderCell>Next CRON time</TableHeaderCell>
<TableHeaderCell>Next assigned time</TableHeaderCell>
<TableHeaderCell>Last run</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
</TableRow>
Expand All @@ -1030,6 +1034,9 @@ function SchedulesMiniTable({
<TableCell onClick={open}>
<span className="font-mono text-xs">{schedule.cron}</span>
</TableCell>
<TableCell onClick={open}>
<span className="text-xs">{schedule.window ?? "Default (60s)"}</span>
</TableCell>
<TableCell onClick={open}>
{schedule.externalId ? (
<span className="font-mono text-xs">{schedule.externalId}</span>
Expand All @@ -1040,6 +1047,9 @@ function SchedulesMiniTable({
<TableCell onClick={open}>
<RelativeDateTime date={schedule.nextRun} />
</TableCell>
<TableCell onClick={open}>
<RelativeDateTime date={schedule.nextRunEffectiveAt} />
</TableCell>
<TableCell onClick={open}>
{schedule.lastRun ? (
<RelativeDateTime date={schedule.lastRun} />
Expand Down
42 changes: 41 additions & 1 deletion apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3";
import { BackgroundWorkerMetadata, type GetDeploymentResponseBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { env } from "~/env.server";
import { calculateNextScheduleRunTimes, normalizeScheduleWindow } from "~/v3/scheduleWindow.server";

const ParamsSchema = z.object({
deploymentId: z.string(),
Expand Down Expand Up @@ -53,6 +55,43 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
return json({ error: "Deployment not found" }, { status: 404 });
}

const workerMetadata = deployment.worker
? BackgroundWorkerMetadata.safeParse(deployment.worker.metadata)
: undefined;
const declarativeSchedules = workerMetadata?.success
? workerMetadata.data.tasks.flatMap((task) => {
if (
!task.schedule ||
(task.schedule.environments &&
!task.schedule.environments.includes(authenticatedEnv.type))
) {
return [];
}

const windowFields = normalizeScheduleWindow(task.schedule.window);
const [nextRun] = calculateNextScheduleRunTimes({
cron: task.schedule.cron,
timezone: task.schedule.timezone,
deduplicationKey: task.id,
environmentId: authenticatedEnv.id,
schedulePhase: null,
phaseSecret: env.ENCRYPTION_KEY,
...windowFields,
});

return [
{
task: task.id,
cron: task.schedule.cron,
timezone: task.schedule.timezone,
window: task.schedule.window,
nextRun: nextRun.nominalAt,
nextRunEffectiveAt: nextRun.effectiveAt,
Comment on lines +71 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline apps/webapp/app/v3/scheduleWindow.server.ts --items all
ast-grep outline apps/webapp/app/v3/services/createBackgroundWorker.server.ts --items all
ast-grep outline apps/webapp/app/routes/api.v1.deployments.\$deploymentId.ts --items all

rg -n -C 5 \
  'deduplicationKey|schedulePhase|calculateNextScheduleRunTimes|registerNextTaskScheduleInstance' \
  apps/webapp/app/v3/scheduleWindow.server.ts \
  apps/webapp/app/v3/services/createBackgroundWorker.server.ts \
  apps/webapp/app/routes/api.v1.deployments.\$deploymentId.ts \
  internal-packages/database/prisma/schema.prisma

Repository: triggerdotdev/trigger.dev

Length of output: 16698


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- route ---'
cat -n 'apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts' | sed -n '1,125p'

printf '%s\n' '--- declarative schedule sync ---'
cat -n apps/webapp/app/v3/services/createBackgroundWorker.server.ts | sed -n '643,780p'

printf '%s\n' '--- schedule models and creation/update paths ---'
rg -n -C 8 \
  'model TaskSchedule|model TaskScheduleInstance|schedulePhase|deduplicationKey|TaskScheduleInstance' \
  internal-packages/database/prisma/schema.prisma \
  apps/webapp/app/v3 apps/webapp/app/services apps/webapp/app/models \
  -g '*.ts' -g '*.tsx' -g '*.prisma'

printf '%s\n' '--- registration implementation ---'
rg -n -C 12 \
  'registerNextTaskScheduleInstance|nextRunEffectiveAt|effectiveAt|nominalAt' \
  apps/webapp/app/v3 -g '*.ts'

Repository: triggerdotdev/trigger.dev

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- schedule engine registration ---'
rg -n -C 18 \
  'registerNextTaskScheduleInstance|schedulePhase|deduplicationKey|calculateNextScheduleRunTimes|calculateEffectiveScheduleTime' \
  apps/webapp/app/v3/scheduleEngine.server.ts \
  apps/webapp/app/v3 -g '*.ts' \
  | sed -n '1,260p'

printf '%s\n' '--- schedule-phase helpers and tests ---'
rg -n -C 12 \
  'function calculateSchedulePhase|calculateSchedulePhase|calculateEffectiveScheduleTime|effectiveAt' \
  apps packages internal-packages -g '*.ts' -g '*.tsx' -g '*.test.*' -g '*.spec.*' \
  | sed -n '1,300p'

printf '%s\n' '--- exact declarative schedule lookup and route callers ---'
rg -n -C 10 \
  'taskIdentifier.*task\.id|taskIdentifier.*taskIdentifier|environmentId.*authenticatedEnv|taskSchedule\.find|taskScheduleInstance\.find' \
  apps/webapp/app -g '*.ts' -g '*.tsx' \
  | sed -n '1,260p'

Repository: triggerdotdev/trigger.dev

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- phase algorithm ---'
cat -n internal-packages/schedule-engine/src/engine/scheduleTiming.ts | sed -n '168,225p'

printf '%s\n' '--- registration definitions ---'
rg -l 'registerNextTaskScheduleInstance' . -g '*.ts' -g '*.tsx' | while read -r file; do
  printf '%s\n' "--- $file"
  rg -n -C 20 'registerNextTaskScheduleInstance' "$file" | sed -n '1,100p'
done

printf '%s\n' '--- declarative schedule tests ---'
rg -n -C 10 \
  'syncDeclarativeSchedules|DECLARATIVE|nextRunEffectiveAt|schedulePhase' \
  apps/webapp -g '*.test.*' -g '*.spec.*' -g '*.ts' \
  | sed -n '1,260p'

printf '%s\n' '--- static invariant check ---'
python3 - <<'PY'
from pathlib import Path
route = Path("apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts").read_text()
sync = Path("apps/webapp/app/v3/services/createBackgroundWorker.server.ts").read_text()
upsert = Path("apps/webapp/app/v3/services/upsertTaskSchedule.server.ts").read_text()

checks = {
    "route uses task.id as deduplicationKey": "deduplicationKey: task.id" in route,
    "route passes null schedulePhase": "schedulePhase: null" in route,
    "declarative create omits deduplicationKey": "const newSchedule = await prisma.taskSchedule.create" in sync
        and "deduplicationKey" not in sync[sync.index("const newSchedule = await prisma.taskSchedule.create"):
            sync.index("const newSchedule = await prisma.taskSchedule.create") + 1800],
    "declarative instance is registered": "registerNextTaskScheduleInstance({ instanceId: instance.id })" in sync,
    "canonical API calculation uses persisted values": "deduplicationKey: taskSchedule.deduplicationKey" in upsert
        and "schedulePhase: instance.schedulePhase" in upsert,
}
for name, result in checks.items():
    print(f"{name}: {'PASS' if result else 'FAIL'}")
PY

Repository: triggerdotdev/trigger.dev

Length of output: 46376


Use the persisted schedule identity for nextRunEffectiveAt.

The declarative schedule uses a generated deduplicationKey, not task.id. Query the matching TaskSchedule and TaskScheduleInstance for authenticatedEnv.id, then pass their deduplicationKey and schedulePhase values to calculateNextScheduleRunTimes. Add coverage against the registered schedule instance.

},
];
})
: [];

return json({
id: deployment.friendlyId,
status: deployment.status,
Expand All @@ -75,6 +114,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
filePath: task.filePath,
exportName: task.exportName ?? "@deprecated",
})),
declarativeSchedules,
}
: undefined,
integrationDeployments:
Expand Down
1 change: 1 addition & 0 deletions apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
deduplicationKey: schedule.deduplicationKey,
environments: schedule.environments,
nextRun: schedule.nextRun,
nextRunEffectiveAt: schedule.nextRunEffectiveAt,
};

return json(responseObject, { status: 200 });
Expand Down
Loading
Loading