Skip to content

Commit ed1bb72

Browse files
authored
feat: implement cron window spread backend (#4566)
- New DB fields on Schedule and ScheduleInstance - Use `queueTimestamp` for the "effectiveAt" delayed start time, propagate it to Clickhouse TaskRun table - Disable fastpath for delayed jobs - Add schedule timing logic, API endpoints with windows, persistence - Calculate phase for every schedule, only persist when window is non-null - Additional o11y for phased rollout
1 parent 3c5bbc1 commit ed1bb72

43 files changed

Lines changed: 2137 additions & 89 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Add backend support for delaying cron schedules within a specified window with a minimum of 60 seconds.

apps/webapp/app/env.server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1684,6 +1684,11 @@ const EnvironmentSchema = z
16841684
SCHEDULE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50),
16851685
SCHEDULE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(30_000),
16861686
SCHEDULE_WORKER_DISTRIBUTION_WINDOW_SECONDS: z.coerce.number().int().default(30),
1687+
SCHEDULE_WORKER_CRON_SPREAD_FRACTION: z.coerce
1688+
.number()
1689+
.catch(0)
1690+
.default(0)
1691+
.transform((value) => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0)),
16871692

16881693
SCHEDULE_WORKER_REDIS_HOST: z
16891694
.string()

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: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ 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;
1213
projectId: string;
1314
friendlyId: string;
1415
environmentId: string;
16+
includeRunHistory?: boolean;
1517
};
1618

1719
export class ViewSchedulePresenter {
@@ -21,7 +23,13 @@ export class ViewSchedulePresenter {
2123
this.#prismaClient = prismaClient;
2224
}
2325

24-
public async call({ userId, projectId, friendlyId, environmentId }: ViewScheduleOptions) {
26+
public async call({
27+
userId,
28+
projectId,
29+
friendlyId,
30+
environmentId,
31+
includeRunHistory = true,
32+
}: ViewScheduleOptions) {
2533
const schedule = await this.#prismaClient.taskSchedule.findFirst({
2634
select: {
2735
id: true,
@@ -30,6 +38,8 @@ export class ViewSchedulePresenter {
3038
generatorExpression: true,
3139
generatorDescription: true,
3240
timezone: true,
41+
windowDurationSeconds: true,
42+
windowPercentage: true,
3343
externalId: true,
3444
deduplicationKey: true,
3545
userProvidedDeduplicationKey: true,
@@ -76,17 +86,14 @@ export class ViewSchedulePresenter {
7686
? nextScheduledTimestamps(schedule.generatorExpression, schedule.timezone, new Date(), 5)
7787
: [];
7888

79-
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
80-
schedule.project.organizationId,
81-
"standard"
82-
);
83-
const runPresenter = new NextRunListPresenter(this.#prismaClient, clickhouse);
84-
const { runs } = await runPresenter.call(schedule.project.organizationId, environmentId, {
85-
projectId: schedule.project.id,
86-
scheduleId: schedule.id,
87-
pageSize: 5,
88-
period: "31d",
89-
});
89+
const runs = includeRunHistory
90+
? await this.#getRunHistory({
91+
organizationId: schedule.project.organizationId,
92+
environmentId,
93+
projectId: schedule.project.id,
94+
scheduleId: schedule.id,
95+
})
96+
: [];
9097

9198
return {
9299
schedule: {
@@ -107,6 +114,32 @@ export class ViewSchedulePresenter {
107114
};
108115
}
109116

117+
async #getRunHistory({
118+
organizationId,
119+
environmentId,
120+
projectId,
121+
scheduleId,
122+
}: {
123+
organizationId: string;
124+
environmentId: string;
125+
projectId: string;
126+
scheduleId: string;
127+
}) {
128+
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
129+
organizationId,
130+
"standard"
131+
);
132+
const runPresenter = new NextRunListPresenter(this.#prismaClient, clickhouse);
133+
const { runs } = await runPresenter.call(organizationId, environmentId, {
134+
projectId,
135+
scheduleId,
136+
pageSize: 5,
137+
period: "31d",
138+
});
139+
140+
return runs;
141+
}
142+
110143
public toJSONResponse(result: NonNullable<Awaited<ReturnType<ViewSchedulePresenter["call"]>>>) {
111144
const response: ScheduleObject = {
112145
id: result.schedule.friendlyId,
@@ -120,6 +153,7 @@ export class ViewSchedulePresenter {
120153
description: result.schedule.cronDescription,
121154
},
122155
timezone: result.schedule.timezone,
156+
window: formatScheduleWindow(result.schedule),
123157
externalId: result.schedule.externalId ?? undefined,
124158
deduplicationKey: result.schedule.userProvidedDeduplicationKey
125159
? (result.schedule.deduplicationKey ?? undefined)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
6464
projectId: authenticationResult.environment.projectId,
6565
friendlyId: parsedParams.data.scheduleId,
6666
environmentId: authenticationResult.environment.id,
67+
includeRunHistory: false,
6768
});
6869

6970
if (!result) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
6464
projectId: authenticationResult.environment.projectId,
6565
friendlyId: parsedParams.data.scheduleId,
6666
environmentId: authenticationResult.environment.id,
67+
includeRunHistory: false,
6768
});
6869

6970
if (!result) {

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

Lines changed: 3 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,
@@ -176,6 +178,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
176178
projectId: authenticationResult.environment.projectId,
177179
friendlyId: parsedParams.data.scheduleId,
178180
environmentId: authenticationResult.environment.id,
181+
includeRunHistory: false,
179182
});
180183

181184
if (!result) {

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,

apps/webapp/app/services/runsReplicationService.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,7 @@ export class RunsReplicationService {
13071307
run.id, // run_id
13081308
run.updatedAt.getTime(), // updated_at
13091309
run.createdAt.getTime(), // created_at
1310+
run.queueTimestamp?.getTime() ?? null, // queue_timestamp
13101311
run.status, // status
13111312
environmentType, // environment_type
13121313
run.friendlyId, // friendly_id

apps/webapp/app/v3/scheduleEngine.server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ function createScheduleEngine() {
7272
distributionWindow: {
7373
seconds: env.SCHEDULE_WORKER_DISTRIBUTION_WINDOW_SECONDS,
7474
},
75+
schedulePhaseSecret: env.ENCRYPTION_KEY,
76+
cronSpreadFraction: env.SCHEDULE_WORKER_CRON_SPREAD_FRACTION,
7577
tracer,
7678
meter,
7779
onTriggerScheduledTask: async ({
@@ -81,6 +83,7 @@ function createScheduleEngine() {
8183
scheduleInstanceId,
8284
scheduleId,
8385
exactScheduleTime,
86+
effectiveScheduleTime,
8487
}) => {
8588
try {
8689
// v3 (engine V1) is retired: skip firing V1 schedules instead of triggering into a guaranteed rejection every tick.
@@ -104,6 +107,7 @@ function createScheduleEngine() {
104107
scheduleInstanceId,
105108
scheduleId,
106109
exactScheduleTime,
110+
effectiveScheduleTime,
107111
});
108112

109113
const result = await triggerService.call(
@@ -114,7 +118,7 @@ function createScheduleEngine() {
114118
customIcon: "scheduled",
115119
scheduleId,
116120
scheduleInstanceId,
117-
queueTimestamp: exactScheduleTime,
121+
queueTimestamp: effectiveScheduleTime,
118122
overrideCreatedAt: exactScheduleTime,
119123
triggerSource: "schedule",
120124
triggerAction: "trigger",

0 commit comments

Comments
 (0)