|
| 1 | +import { |
| 2 | + MINIMUM_SCHEDULE_RANGE_MS, |
| 3 | + calculateEffectiveScheduleTime, |
| 4 | + calculateSchedulePhase, |
| 5 | +} from "@internal/schedule-engine"; |
| 6 | +import { type NormalizedScheduleWindow } from "@trigger.dev/core/v3"; |
| 7 | +import { |
| 8 | + nextScheduledTimestamps, |
| 9 | + previousScheduledTimestamp, |
| 10 | +} from "./utils/calculateNextSchedule.server"; |
| 11 | + |
| 12 | +/** |
| 13 | + * Everything a single row needs to have its run times resolved. Deliberately |
| 14 | + * free of Prisma types so this stays testable and benchmarkable on its own. |
| 15 | + */ |
| 16 | +export type ScheduleTimingInput = { |
| 17 | + cron: string; |
| 18 | + timezone: string | null; |
| 19 | + deduplicationKey: string; |
| 20 | + environmentId: string; |
| 21 | + schedulePhase: number | null; |
| 22 | + windowDurationSeconds: number | null; |
| 23 | + windowPercentage: number | null; |
| 24 | + active: boolean; |
| 25 | + updatedAt: Date; |
| 26 | +}; |
| 27 | + |
| 28 | +export type ScheduleTiming = { |
| 29 | + nextRun: Date; |
| 30 | + nextRunEffectiveAt: Date; |
| 31 | + /** Only ever set when the caller asked for it AND the schedule is active. */ |
| 32 | + lastRun: Date | undefined; |
| 33 | +}; |
| 34 | + |
| 35 | +export type ResolveScheduleTimingsOptions = { |
| 36 | + phaseSecret: string; |
| 37 | + /** |
| 38 | + * Walking the cron backwards to approximate "last run" is by far the most |
| 39 | + * expensive thing here, and only the dashboard renders it. Callers that |
| 40 | + * don't show the column (the public API) leave this off and skip the walk. |
| 41 | + */ |
| 42 | + includeLastRun: boolean; |
| 43 | + /** |
| 44 | + * Fixed reference point for the whole batch. Pinning it once is what makes |
| 45 | + * the cron walks cacheable across rows, and it stops rows in one response |
| 46 | + * disagreeing about "now". |
| 47 | + */ |
| 48 | + now?: Date; |
| 49 | +}; |
| 50 | + |
| 51 | +/** |
| 52 | + * Resolves run times for a page of schedules. |
| 53 | + * |
| 54 | + * The cron walk (`cron-parser`) dominates this path: one step costs tens of |
| 55 | + * microseconds for a plain UTC expression and milliseconds for a sparse one in |
| 56 | + * a named timezone, because the library walks the calendar unit by unit |
| 57 | + * through luxon. At 100 rows that is enough to block the event loop for |
| 58 | + * seconds. |
| 59 | + * |
| 60 | + * Two properties keep it cheap: |
| 61 | + * |
| 62 | + * 1. Nominal run times depend only on (cron, timezone, now). With `now` pinned |
| 63 | + * for the batch, rows sharing an expression share an answer, so cost is |
| 64 | + * O(distinct crons) rather than O(rows) — projects tend to run the same |
| 65 | + * handful of expressions across many schedules. |
| 66 | + * 2. Everything that genuinely varies per row (phase, window, effectiveAt) is |
| 67 | + * arithmetic over the cached nominal times, not another walk. |
| 68 | + * 3. Windowless schedules take one step instead of two. The second step exists |
| 69 | + * only to measure the interval to the following occurrence, and the |
| 70 | + * interval reaches `calculateEffectiveScheduleTime`'s result solely through |
| 71 | + * `min(intervalMs, max(MINIMUM_SCHEDULE_RANGE_MS, windowMs))`. With no |
| 72 | + * window `windowMs` is 0, and `CronPattern` rejects expressions with a |
| 73 | + * seconds field, so consecutive occurrences are always at least |
| 74 | + * `MINIMUM_SCHEDULE_RANGE_MS` apart and that `min` can never bind. Stepping |
| 75 | + * a second time would change nothing, and it is the more expensive of the |
| 76 | + * two steps because it walks a whole period rather than the remainder of |
| 77 | + * the current one. |
| 78 | + * |
| 79 | + * Caches live for one call only: every entry is valid solely against this |
| 80 | + * batch's `now`. |
| 81 | + */ |
| 82 | +export function resolveScheduleTimings( |
| 83 | + inputs: ScheduleTimingInput[], |
| 84 | + { phaseSecret, includeLastRun, now = new Date() }: ResolveScheduleTimingsOptions |
| 85 | +): ScheduleTiming[] { |
| 86 | + const nominalCache = new Map<string, Date[]>(); |
| 87 | + const previousCache = new Map<string, Date | undefined>(); |
| 88 | + |
| 89 | + return inputs.map((input) => { |
| 90 | + const window: NormalizedScheduleWindow | undefined = |
| 91 | + input.windowPercentage !== null |
| 92 | + ? { type: "percentage", percentage: input.windowPercentage } |
| 93 | + : input.windowDurationSeconds !== null |
| 94 | + ? { type: "duration", durationSeconds: input.windowDurationSeconds } |
| 95 | + : undefined; |
| 96 | + |
| 97 | + const steps = window ? 2 : 1; |
| 98 | + const key = `${cacheKey(input.cron, input.timezone)}\n${steps}`; |
| 99 | + |
| 100 | + let nominalTimes = nominalCache.get(key); |
| 101 | + if (!nominalTimes) { |
| 102 | + nominalTimes = nextScheduledTimestamps(input.cron, input.timezone, now, steps); |
| 103 | + nominalCache.set(key, nominalTimes); |
| 104 | + } |
| 105 | + |
| 106 | + const nominalAt = nominalTimes[0]; |
| 107 | + const nextNominalAt = |
| 108 | + nominalTimes[1] ?? new Date(nominalAt.getTime() + MINIMUM_SCHEDULE_RANGE_MS); |
| 109 | + |
| 110 | + const phase = |
| 111 | + input.schedulePhase ?? |
| 112 | + calculateSchedulePhase({ |
| 113 | + secret: phaseSecret, |
| 114 | + environmentId: input.environmentId, |
| 115 | + deduplicationKey: input.deduplicationKey, |
| 116 | + }); |
| 117 | + |
| 118 | + const { effectiveAt } = calculateEffectiveScheduleTime({ |
| 119 | + nominalAt, |
| 120 | + nextNominalAt, |
| 121 | + schedulePhase: phase, |
| 122 | + window, |
| 123 | + }); |
| 124 | + |
| 125 | + return { |
| 126 | + nextRun: nominalAt, |
| 127 | + nextRunEffectiveAt: effectiveAt, |
| 128 | + lastRun: includeLastRun ? resolveLastRun(input, now, previousCache) : undefined, |
| 129 | + }; |
| 130 | + }); |
| 131 | +} |
| 132 | + |
| 133 | +/** |
| 134 | + * Approximates "last run" from the cron's previous slot. |
| 135 | + * |
| 136 | + * Skips inactive schedules — the previous slot reflects what *would* have |
| 137 | + * fired. Skips slots that predate `updatedAt`: any config change (cron edited, |
| 138 | + * timezone changed, deactivate/reactivate) bumps `updatedAt`, and a slot from |
| 139 | + * before the most recent change didn't fire under the current configuration. |
| 140 | + * |
| 141 | + * `cron-parser` throws on malformed expressions, so this degrades to undefined |
| 142 | + * per row rather than failing the whole list. Best-effort by design; the runs |
| 143 | + * page is the source of truth. |
| 144 | + */ |
| 145 | +function resolveLastRun( |
| 146 | + input: ScheduleTimingInput, |
| 147 | + now: Date, |
| 148 | + cache: Map<string, Date | undefined> |
| 149 | +): Date | undefined { |
| 150 | + if (!input.active) { |
| 151 | + return undefined; |
| 152 | + } |
| 153 | + |
| 154 | + const key = cacheKey(input.cron, input.timezone); |
| 155 | + |
| 156 | + let previous: Date | undefined; |
| 157 | + if (cache.has(key)) { |
| 158 | + previous = cache.get(key); |
| 159 | + } else { |
| 160 | + try { |
| 161 | + previous = previousScheduledTimestamp(input.cron, input.timezone, now); |
| 162 | + } catch { |
| 163 | + previous = undefined; |
| 164 | + } |
| 165 | + cache.set(key, previous); |
| 166 | + } |
| 167 | + |
| 168 | + if (!previous) { |
| 169 | + return undefined; |
| 170 | + } |
| 171 | + |
| 172 | + return previous.getTime() > input.updatedAt.getTime() ? previous : undefined; |
| 173 | +} |
| 174 | + |
| 175 | +/** |
| 176 | + * Newline separator: an IANA timezone name cannot contain one, so no |
| 177 | + * (cron, timezone) pair can collide with another by straddling the boundary. |
| 178 | + */ |
| 179 | +function cacheKey(cron: string, timezone: string | null): string { |
| 180 | + return `${timezone ?? ""}\n${cron}`; |
| 181 | +} |
0 commit comments