Skip to content

Commit 7171362

Browse files
committed
cron window persistence
1 parent 8005f93 commit 7171362

12 files changed

Lines changed: 227 additions & 1 deletion

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getTaskIdentifiers } from "~/models/task.server";
55
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
66
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
77
import { ServiceValidationError } from "~/v3/services/baseService.server";
8+
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";
89
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
910
import {
1011
calculateNextScheduledTimestampFromNow,
@@ -31,6 +32,7 @@ export type ScheduleListItem = {
3132
cron: string;
3233
cronDescription: string;
3334
timezone: string;
35+
window?: string;
3436
externalId: string | null;
3537
nextRun: Date;
3638
lastRun: Date | undefined;
@@ -215,6 +217,8 @@ export class ScheduleListPresenter extends BasePresenter {
215217
generatorExpression: true,
216218
generatorDescription: true,
217219
timezone: true,
220+
windowDurationSeconds: true,
221+
windowPercentage: true,
218222
externalId: true,
219223
instances: {
220224
select: {
@@ -306,6 +310,7 @@ export class ScheduleListPresenter extends BasePresenter {
306310
cron: schedule.generatorExpression,
307311
cronDescription: schedule.generatorDescription,
308312
timezone: schedule.timezone,
313+
window: formatScheduleWindow(schedule),
309314
active: schedule.active,
310315
externalId: schedule.externalId,
311316
lastRun,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstan
66
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
77
import { NextRunListPresenter } from "./NextRunListPresenter.server";
88
import { scheduleWhereClause } from "~/models/schedules.server";
9+
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";
910

1011
type ViewScheduleOptions = {
1112
userId?: string;
@@ -30,6 +31,8 @@ export class ViewSchedulePresenter {
3031
generatorExpression: true,
3132
generatorDescription: true,
3233
timezone: true,
34+
windowDurationSeconds: true,
35+
windowPercentage: true,
3336
externalId: true,
3437
deduplicationKey: true,
3538
userProvidedDeduplicationKey: true,
@@ -120,6 +123,7 @@ export class ViewSchedulePresenter {
120123
description: result.schedule.cronDescription,
121124
},
122125
timezone: result.schedule.timezone,
126+
window: formatScheduleWindow(result.schedule),
123127
externalId: result.schedule.externalId ?? undefined,
124128
deduplicationKey: result.schedule.userProvidedDeduplicationKey
125129
? (result.schedule.deduplicationKey ?? undefined)

apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
107107
taskIdentifier: body.data.task,
108108
cron: body.data.cron,
109109
timezone: body.data.timezone,
110+
window: body.data.window,
110111
environments: [authenticationResult.environment.id],
111112
externalId: body.data.externalId,
112113
};
@@ -124,6 +125,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
124125
description: schedule.cronDescription,
125126
},
126127
timezone: schedule.timezone,
128+
window: schedule.window,
127129
externalId: schedule.externalId ?? undefined,
128130
deduplicationKey: schedule.deduplicationKey,
129131
environments: schedule.environments,

apps/webapp/app/routes/api.v1.schedules.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export async function action({ request }: ActionFunctionArgs) {
5151
externalId: body.data.externalId,
5252
deduplicationKey: body.data.deduplicationKey,
5353
timezone: body.data.timezone,
54+
window: body.data.window,
5455
};
5556

5657
const schedule = await service.call(authenticationResult.environment.projectId, options);
@@ -66,6 +67,7 @@ export async function action({ request }: ActionFunctionArgs) {
6667
description: schedule.cronDescription,
6768
},
6869
timezone: schedule.timezone,
70+
window: schedule.window,
6971
externalId: schedule.externalId ?? undefined,
7072
deduplicationKey: schedule.deduplicationKey,
7173
environments: schedule.environments,
@@ -121,6 +123,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
121123
description: schedule.cronDescription,
122124
},
123125
timezone: schedule.timezone,
126+
window: schedule.window,
124127
deduplicationKey: schedule.userProvidedDeduplicationKey
125128
? schedule.deduplicationKey
126129
: undefined,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
calculateNextNominalTimestamp,
3+
parseScheduleWindow,
4+
validateScheduleWindowForInterval,
5+
} from "@internal/schedule-engine";
6+
import type { ScheduleWindow } from "@trigger.dev/core/v3";
7+
import { calculateNextScheduledTimestampFromNow } from "./utils/calculateNextSchedule.server";
8+
9+
const SECONDS_PER_UNIT = {
10+
m: 60,
11+
h: 3_600,
12+
d: 86_400,
13+
} as const;
14+
15+
export type ScheduleWindowDatabaseFields = {
16+
windowDurationSeconds: number | null;
17+
windowPercentage: number | null;
18+
};
19+
20+
export function normalizeScheduleWindow(
21+
window: ScheduleWindow | undefined
22+
): ScheduleWindowDatabaseFields {
23+
if (window === undefined) {
24+
return {
25+
windowDurationSeconds: null,
26+
windowPercentage: null,
27+
};
28+
}
29+
30+
const parsedWindow = parseScheduleWindow(window);
31+
32+
if (parsedWindow.type === "percentage") {
33+
return {
34+
windowDurationSeconds: null,
35+
windowPercentage: parsedWindow.percentage,
36+
};
37+
}
38+
39+
return {
40+
windowDurationSeconds: parsedWindow.durationSeconds,
41+
windowPercentage: null,
42+
};
43+
}
44+
45+
export function formatScheduleWindow({
46+
windowDurationSeconds,
47+
windowPercentage,
48+
}: ScheduleWindowDatabaseFields): ScheduleWindow | undefined {
49+
if (windowPercentage !== null) {
50+
return `${windowPercentage}%`;
51+
}
52+
53+
if (windowDurationSeconds === null) {
54+
return undefined;
55+
}
56+
57+
if (windowDurationSeconds % SECONDS_PER_UNIT.d === 0) {
58+
return `${windowDurationSeconds / SECONDS_PER_UNIT.d}d`;
59+
}
60+
61+
if (windowDurationSeconds % SECONDS_PER_UNIT.h === 0) {
62+
return `${windowDurationSeconds / SECONDS_PER_UNIT.h}h`;
63+
}
64+
65+
return `${windowDurationSeconds / SECONDS_PER_UNIT.m}m`;
66+
}
67+
68+
export function validateScheduleWindowAgainstCron({
69+
window,
70+
cron,
71+
timezone,
72+
}: {
73+
window: ScheduleWindow | undefined;
74+
cron: string;
75+
timezone: string | null;
76+
}): { valid: true } | { valid: false; message: string } {
77+
if (window === undefined) {
78+
return { valid: true };
79+
}
80+
81+
try {
82+
const normalizedWindow = parseScheduleWindow(window);
83+
const nominalAt = calculateNextScheduledTimestampFromNow(cron, timezone);
84+
const nextNominalAt = calculateNextNominalTimestamp(cron, timezone, nominalAt);
85+
86+
validateScheduleWindowForInterval(
87+
normalizedWindow,
88+
nextNominalAt.getTime() - nominalAt.getTime()
89+
);
90+
91+
return { valid: true };
92+
} catch (error) {
93+
return {
94+
valid: false,
95+
message: error instanceof Error ? error.message : String(error),
96+
};
97+
}
98+
}

apps/webapp/app/v3/schedules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ScheduleWindow } from "@trigger.dev/core/v3";
12
import { parseExpression } from "cron-parser";
23
import { z } from "zod";
34

@@ -56,6 +57,7 @@ export const UpsertSchedule = z.object({
5657
externalId: z.string().optional(),
5758
deduplicationKey: z.string().optional(),
5859
timezone: z.string().optional(),
60+
window: ScheduleWindow.optional(),
5961
});
6062

6163
export type UpsertSchedule = z.infer<typeof UpsertSchedule>;

apps/webapp/app/v3/services/checkSchedule.server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import { resolveProjectScopedEnvironments } from "./resolveProjectScopedEnvironm
55
import { getLimit } from "~/services/platform.v3.server";
66
import { getTimezones } from "~/utils/timezones.server";
77
import { env } from "~/env.server";
8+
import type { ScheduleWindow } from "@trigger.dev/core/v3";
89
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
10+
import { validateScheduleWindowAgainstCron } from "../scheduleWindow.server";
911

1012
type Schedule = {
1113
cron: string;
1214
timezone?: string;
1315
taskIdentifier: string;
1416
friendlyId?: string;
17+
window?: ScheduleWindow;
1518
};
1619

1720
export class CheckScheduleService extends BaseService {
@@ -39,6 +42,15 @@ export class CheckScheduleService extends BaseService {
3942
}
4043
}
4144

45+
const windowValidation = validateScheduleWindowAgainstCron({
46+
window: schedule.window,
47+
cron: schedule.cron,
48+
timezone: schedule.timezone ?? "UTC",
49+
});
50+
if (!windowValidation.valid) {
51+
throw new ServiceValidationError(windowValidation.message);
52+
}
53+
4254
//check the task exists
4355
const task = await this._prisma.backgroundWorkerTask.findFirst({
4456
where: {

apps/webapp/app/v3/services/createBackgroundWorker.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
updateQueueConcurrencyLimits,
3030
} from "../runQueue.server";
3131
import { scheduleEngine } from "../scheduleEngine.server";
32+
import { normalizeScheduleWindow } from "../scheduleWindow.server";
3233
import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion";
3334
import { clampMaxDuration } from "../utils/maxDuration";
3435
import { BaseService, ServiceValidationError } from "./baseService.server";
@@ -698,6 +699,7 @@ export async function syncDeclarativeSchedules(
698699
timezone: task.schedule.timezone,
699700
taskIdentifier: task.id,
700701
friendlyId: existingSchedule?.friendlyId,
702+
window: task.schedule.window,
701703
},
702704
[environment.id]
703705
);
@@ -711,6 +713,7 @@ export async function syncDeclarativeSchedules(
711713
generatorExpression: task.schedule.cron,
712714
generatorDescription: cronstrue.toString(task.schedule.cron),
713715
timezone: task.schedule.timezone,
716+
...normalizeScheduleWindow(task.schedule.window),
714717
},
715718
include: {
716719
instances: true,
@@ -736,6 +739,7 @@ export async function syncDeclarativeSchedules(
736739
generatorDescription: cronstrue.toString(task.schedule.cron),
737740
timezone: task.schedule.timezone,
738741
type: "DECLARATIVE",
742+
...normalizeScheduleWindow(task.schedule.window),
739743
instances: {
740744
create: [
741745
{

apps/webapp/app/v3/services/upsertTaskSchedule.server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { calculateNextScheduledTimestampFromNow } from "../utils/calculateNextSc
77
import { BaseService, ServiceValidationError } from "./baseService.server";
88
import { CheckScheduleService } from "./checkSchedule.server";
99
import { scheduleEngine } from "../scheduleEngine.server";
10+
import { formatScheduleWindow, normalizeScheduleWindow } from "../scheduleWindow.server";
1011
import { scheduleWhereClause } from "~/models/schedules.server";
1112

1213
export type UpsertTaskScheduleServiceOptions = UpsertSchedule;
@@ -100,6 +101,7 @@ export class UpsertTaskScheduleService extends BaseService {
100101
generatorDescription: cronstrue.toString(options.cron),
101102
timezone: options.timezone ?? "UTC",
102103
externalId: options.externalId ? options.externalId : undefined,
104+
...normalizeScheduleWindow(options.window),
103105
},
104106
});
105107

@@ -161,12 +163,15 @@ export class UpsertTaskScheduleService extends BaseService {
161163
generatorDescription: cronstrue.toString(options.cron),
162164
timezone: options.timezone ?? "UTC",
163165
externalId: options.externalId ? options.externalId : null,
166+
...normalizeScheduleWindow(options.window),
164167
},
165168
});
166169

167170
const scheduleHasChanged =
168171
scheduleRecord.generatorExpression !== existingSchedule.generatorExpression ||
169-
scheduleRecord.timezone !== existingSchedule.timezone;
172+
scheduleRecord.timezone !== existingSchedule.timezone ||
173+
scheduleRecord.windowDurationSeconds !== existingSchedule.windowDurationSeconds ||
174+
scheduleRecord.windowPercentage !== existingSchedule.windowPercentage;
170175

171176
// create the new instances
172177
const newInstances: InstanceWithEnvironment[] = [];
@@ -245,6 +250,7 @@ export class UpsertTaskScheduleService extends BaseService {
245250
cron: taskSchedule.generatorExpression,
246251
cronDescription: taskSchedule.generatorDescription,
247252
timezone: taskSchedule.timezone,
253+
window: formatScheduleWindow(taskSchedule),
248254
nextRun: calculateNextScheduledTimestampFromNow(
249255
taskSchedule.generatorExpression,
250256
taskSchedule.timezone
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
formatScheduleWindow,
4+
normalizeScheduleWindow,
5+
validateScheduleWindowAgainstCron,
6+
} from "~/v3/scheduleWindow.server";
7+
8+
describe("schedule window persistence", () => {
9+
it("normalizes duration and percentage windows", () => {
10+
expect(normalizeScheduleWindow("30m")).toEqual({
11+
windowDurationSeconds: 1_800,
12+
windowPercentage: null,
13+
});
14+
expect(normalizeScheduleWindow("30%")).toEqual({
15+
windowDurationSeconds: null,
16+
windowPercentage: 30,
17+
});
18+
expect(normalizeScheduleWindow(undefined)).toEqual({
19+
windowDurationSeconds: null,
20+
windowPercentage: null,
21+
});
22+
});
23+
24+
it("formats stored windows canonically", () => {
25+
expect(
26+
formatScheduleWindow({
27+
windowDurationSeconds: 86_400,
28+
windowPercentage: null,
29+
})
30+
).toBe("1d");
31+
expect(
32+
formatScheduleWindow({
33+
windowDurationSeconds: 7_200,
34+
windowPercentage: null,
35+
})
36+
).toBe("2h");
37+
expect(
38+
formatScheduleWindow({
39+
windowDurationSeconds: null,
40+
windowPercentage: 30,
41+
})
42+
).toBe("30%");
43+
});
44+
45+
it("rejects invalid syntax through the authoritative timing parser", () => {
46+
expect(
47+
validateScheduleWindowAgainstCron({
48+
window: "30.5%",
49+
cron: "0 * * * *",
50+
timezone: "UTC",
51+
})
52+
).toMatchObject({ valid: false });
53+
});
54+
55+
it("rejects an absolute window longer than the next nominal interval", () => {
56+
expect(
57+
validateScheduleWindowAgainstCron({
58+
window: "30m",
59+
cron: "*/5 * * * *",
60+
timezone: "UTC",
61+
})
62+
).toMatchObject({ valid: false });
63+
64+
expect(
65+
validateScheduleWindowAgainstCron({
66+
window: "5m",
67+
cron: "*/5 * * * *",
68+
timezone: "UTC",
69+
})
70+
).toEqual({ valid: true });
71+
});
72+
});

0 commit comments

Comments
 (0)