Skip to content
Merged
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
58 changes: 26 additions & 32 deletions apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ 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 { calculateNextScheduleRunTimes, formatScheduleWindow } from "~/v3/scheduleWindow.server";
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
import { previousScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server";
import { resolveScheduleTimings } from "~/v3/scheduleTimings.server";
import { env } from "~/env.server";
import { BasePresenter } from "./basePresenter.server";

Expand All @@ -16,6 +16,12 @@ type ScheduleListOptions = {
environmentId: string;
userId?: string;
pageSize?: number;
/**
* Walking each cron backwards to approximate "last run" costs an order of
* magnitude more than everything else here, so it is opt-in: only the
* dashboard renders the column. Defaults off.
*/
includeLastRun?: boolean;
} & ScheduleListFilters;

const DEFAULT_PAGE_SIZE = 20;
Expand Down Expand Up @@ -54,6 +60,7 @@ export class ScheduleListPresenter extends BasePresenter {
page,
type,
pageSize = DEFAULT_PAGE_SIZE,
includeLastRun = false,
}: ScheduleListOptions) {
const hasFilters =
type !== undefined || tasks !== undefined || (search !== undefined && search !== "");
Expand Down Expand Up @@ -274,46 +281,33 @@ export class ScheduleListPresenter extends BasePresenter {
skip: (page - 1) * pageSize,
});

const schedules: ScheduleListItem[] = rawSchedules.map((schedule) => {
// Approximate "last run" from the cron's previous slot. Skip inactive
// schedules — the cron's previous slot reflects what *would* have
// fired, but a deactivated schedule didn't actually fire there. Skip
// when the cron's previous slot predates `updatedAt`: any config
// change (cron edited, timezone changed, deactivate/reactivate)
// bumps updatedAt, and a slot from before the most recent change
// didn't fire under the current configuration. cron-parser throws
// on malformed expressions, so degrade to undefined per-row rather
// than failing the whole list. UI is best-effort; the runs page is
// the source of truth.
let lastRun: Date | undefined;
if (schedule.active) {
try {
const cronPrev = previousScheduledTimestamp(
schedule.generatorExpression,
schedule.timezone
);
lastRun = cronPrev.getTime() > schedule.updatedAt.getTime() ? cronPrev : undefined;
} catch {
lastRun = undefined;
}
}

const instances = rawSchedules.map((schedule) => {
const instance = schedule.instances.find(
(instance) => instance.environmentId === environmentId
);
if (!instance) {
throw new Error(`Schedule instance not found for environment: ${environmentId}`);
}
const [nextRun] = calculateNextScheduleRunTimes({
return instance;
});

const timings = resolveScheduleTimings(
rawSchedules.map((schedule, index) => ({
cron: schedule.generatorExpression,
timezone: schedule.timezone,
deduplicationKey: schedule.deduplicationKey,
environmentId,
schedulePhase: instance.schedulePhase,
phaseSecret: env.ENCRYPTION_KEY,
schedulePhase: instances[index].schedulePhase,
windowDurationSeconds: schedule.windowDurationSeconds,
windowPercentage: schedule.windowPercentage,
});
active: schedule.active,
updatedAt: schedule.updatedAt,
})),
{ phaseSecret: env.ENCRYPTION_KEY, includeLastRun }
);

const schedules: ScheduleListItem[] = rawSchedules.map((schedule, index) => {
const { nextRun, nextRunEffectiveAt, lastRun } = timings[index];

return {
id: schedule.id,
Expand All @@ -329,8 +323,8 @@ export class ScheduleListPresenter extends BasePresenter {
active: schedule.active,
externalId: schedule.externalId,
lastRun,
nextRun: nextRun.nominalAt,
nextRunEffectiveAt: nextRun.effectiveAt,
nextRun,
nextRunEffectiveAt,
environments: schedule.instances.map((instance) => {
const environment = project.environments.find((env) => env.id === instance.environmentId);
if (!environment) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
tasks: [task.slug],
page: schedulesPage,
pageSize: 25,
includeLastRun: true,
})
.catch(() => null);

Expand Down
181 changes: 181 additions & 0 deletions apps/webapp/app/v3/scheduleTimings.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {
MINIMUM_SCHEDULE_RANGE_MS,
calculateEffectiveScheduleTime,
calculateSchedulePhase,
} from "@internal/schedule-engine";
import { type NormalizedScheduleWindow } from "@trigger.dev/core/v3";
import {
nextScheduledTimestamps,
previousScheduledTimestamp,
} from "./utils/calculateNextSchedule.server";

/**
* Everything a single row needs to have its run times resolved. Deliberately
* free of Prisma types so this stays testable and benchmarkable on its own.
*/
export type ScheduleTimingInput = {
cron: string;
timezone: string | null;
deduplicationKey: string;
environmentId: string;
schedulePhase: number | null;
windowDurationSeconds: number | null;
windowPercentage: number | null;
active: boolean;
updatedAt: Date;
};

export type ScheduleTiming = {
nextRun: Date;
nextRunEffectiveAt: Date;
/** Only ever set when the caller asked for it AND the schedule is active. */
lastRun: Date | undefined;
};

export type ResolveScheduleTimingsOptions = {
phaseSecret: string;
/**
* Walking the cron backwards to approximate "last run" is by far the most
* expensive thing here, and only the dashboard renders it. Callers that
* don't show the column (the public API) leave this off and skip the walk.
*/
includeLastRun: boolean;
/**
* Fixed reference point for the whole batch. Pinning it once is what makes
* the cron walks cacheable across rows, and it stops rows in one response
* disagreeing about "now".
*/
now?: Date;
};

/**
* Resolves run times for a page of schedules.
*
* The cron walk (`cron-parser`) dominates this path: one step costs tens of
* microseconds for a plain UTC expression and milliseconds for a sparse one in
* a named timezone, because the library walks the calendar unit by unit
* through luxon. At 100 rows that is enough to block the event loop for
* seconds.
*
* Two properties keep it cheap:
*
* 1. Nominal run times depend only on (cron, timezone, now). With `now` pinned
* for the batch, rows sharing an expression share an answer, so cost is
* O(distinct crons) rather than O(rows) — projects tend to run the same
* handful of expressions across many schedules.
* 2. Everything that genuinely varies per row (phase, window, effectiveAt) is
* arithmetic over the cached nominal times, not another walk.
* 3. Windowless schedules take one step instead of two. The second step exists
* only to measure the interval to the following occurrence, and the
* interval reaches `calculateEffectiveScheduleTime`'s result solely through
* `min(intervalMs, max(MINIMUM_SCHEDULE_RANGE_MS, windowMs))`. With no
* window `windowMs` is 0, and `CronPattern` rejects expressions with a
* seconds field, so consecutive occurrences are always at least
* `MINIMUM_SCHEDULE_RANGE_MS` apart and that `min` can never bind. Stepping
* a second time would change nothing, and it is the more expensive of the
* two steps because it walks a whole period rather than the remainder of
* the current one.
*
* Caches live for one call only: every entry is valid solely against this
* batch's `now`.
*/
export function resolveScheduleTimings(
inputs: ScheduleTimingInput[],
{ phaseSecret, includeLastRun, now = new Date() }: ResolveScheduleTimingsOptions
): ScheduleTiming[] {
const nominalCache = new Map<string, Date[]>();
const previousCache = new Map<string, Date | undefined>();

return inputs.map((input) => {
const window: NormalizedScheduleWindow | undefined =
input.windowPercentage !== null
? { type: "percentage", percentage: input.windowPercentage }
: input.windowDurationSeconds !== null
? { type: "duration", durationSeconds: input.windowDurationSeconds }
: undefined;

const steps = window ? 2 : 1;
const key = `${cacheKey(input.cron, input.timezone)}\n${steps}`;

let nominalTimes = nominalCache.get(key);
if (!nominalTimes) {
nominalTimes = nextScheduledTimestamps(input.cron, input.timezone, now, steps);
nominalCache.set(key, nominalTimes);
}

const nominalAt = nominalTimes[0];
const nextNominalAt =
nominalTimes[1] ?? new Date(nominalAt.getTime() + MINIMUM_SCHEDULE_RANGE_MS);

const phase =
input.schedulePhase ??
calculateSchedulePhase({
secret: phaseSecret,
environmentId: input.environmentId,
deduplicationKey: input.deduplicationKey,
});

const { effectiveAt } = calculateEffectiveScheduleTime({
nominalAt,
nextNominalAt,
schedulePhase: phase,
window,
});

return {
nextRun: nominalAt,
nextRunEffectiveAt: effectiveAt,
lastRun: includeLastRun ? resolveLastRun(input, now, previousCache) : undefined,
};
});
}

/**
* Approximates "last run" from the cron's previous slot.
*
* Skips inactive schedules — the previous slot reflects what *would* have
* fired. Skips slots that predate `updatedAt`: any config change (cron edited,
* timezone changed, deactivate/reactivate) bumps `updatedAt`, and a slot from
* before the most recent change didn't fire under the current configuration.
*
* `cron-parser` throws on malformed expressions, so this degrades to undefined
* per row rather than failing the whole list. Best-effort by design; the runs
* page is the source of truth.
*/
function resolveLastRun(
input: ScheduleTimingInput,
now: Date,
cache: Map<string, Date | undefined>
): Date | undefined {
if (!input.active) {
return undefined;
}

const key = cacheKey(input.cron, input.timezone);

let previous: Date | undefined;
if (cache.has(key)) {
previous = cache.get(key);
} else {
try {
previous = previousScheduledTimestamp(input.cron, input.timezone, now);
} catch {
previous = undefined;
}
cache.set(key, previous);
}

if (!previous) {
return undefined;
}

return previous.getTime() > input.updatedAt.getTime() ? previous : undefined;
}

/**
* Newline separator: an IANA timezone name cannot contain one, so no
* (cron, timezone) pair can collide with another by straddling the boundary.
*/
function cacheKey(cron: string, timezone: string | null): string {
return `${timezone ?? ""}\n${cron}`;
}
19 changes: 11 additions & 8 deletions apps/webapp/app/v3/utils/calculateNextSchedule.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,26 @@ export function previousScheduledTimestamp(
.toDate();
}

/**
* Steps one parsed expression `count` times, rather than re-parsing and
* re-walking the calendar from scratch for every step.
*/
export function nextScheduledTimestamps(
cron: string,
timezone: string | null,
lastScheduledTimestamp: Date,
count: number = 1
) {
const interval = parseExpression(cron, {
currentDate: lastScheduledTimestamp,
utc: timezone === null,
tz: timezone ?? undefined,
});

const result: Array<Date> = [];
let nextScheduledTimestamp = lastScheduledTimestamp;

for (let i = 0; i < count; i++) {
nextScheduledTimestamp = calculateNextScheduledTimestamp(
cron,
timezone,
nextScheduledTimestamp
);

result.push(nextScheduledTimestamp);
result.push(interval.next().toDate());
}

return result;
Expand Down
1 change: 1 addition & 0 deletions apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"db:seed:webhooks": "tsx seed-webhook-deliveries.ts",
"upload:sourcemaps": "bash ./upload-sourcemaps.sh",
"test": "vitest --no-file-parallelism",
"test:perf": "vitest --config ./vitest.perf.config.ts --run",
"eval:dev": "evalite watch"
},
"dependencies": {
Expand Down
Loading
Loading