Skip to content

Commit 8005f93

Browse files
committed
add schedule timing logic
1 parent b6f7254 commit 8005f93

5 files changed

Lines changed: 533 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { calculateNextNominalTimestamp } from "./scheduleCalculation.js";
2+
3+
describe("calculateNextNominalTimestamp", () => {
4+
it("advances from the previous nominal tick instead of wall-clock time", () => {
5+
const next = calculateNextNominalTimestamp(
6+
"* * * * *",
7+
"UTC",
8+
new Date("2024-01-01T09:00:00.000Z")
9+
);
10+
11+
expect(next).toEqual(new Date("2024-01-01T09:01:00.000Z"));
12+
});
13+
14+
it("uses the 23-hour elapsed interval across spring DST", () => {
15+
const nominalAt = new Date("2026-03-08T05:00:00.000Z");
16+
const next = calculateNextNominalTimestamp("0 0 * * *", "America/New_York", nominalAt);
17+
18+
expect(next).toEqual(new Date("2026-03-09T04:00:00.000Z"));
19+
expect(next.getTime() - nominalAt.getTime()).toBe(23 * 60 * 60 * 1_000);
20+
});
21+
22+
it("uses the 25-hour elapsed interval across autumn DST", () => {
23+
const nominalAt = new Date("2026-11-01T04:00:00.000Z");
24+
const next = calculateNextNominalTimestamp("0 0 * * *", "America/New_York", nominalAt);
25+
26+
expect(next).toEqual(new Date("2026-11-02T05:00:00.000Z"));
27+
expect(next.getTime() - nominalAt.getTime()).toBe(25 * 60 * 60 * 1_000);
28+
});
29+
30+
it("preserves cron-parser calendar semantics across month boundaries", () => {
31+
const next = calculateNextNominalTimestamp(
32+
"0 23 L * *",
33+
"UTC",
34+
new Date("2027-01-31T23:00:00.000Z")
35+
);
36+
37+
expect(next).toEqual(new Date("2027-02-28T23:00:00.000Z"));
38+
});
39+
});

internal-packages/schedule-engine/src/engine/scheduleCalculation.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ export function calculateNextScheduledTimestampFromNow(schedule: string, timezon
44
return calculateNextScheduledTimestamp(schedule, timezone, new Date());
55
}
66

7+
export function calculateNextNominalTimestamp(
8+
schedule: string,
9+
timezone: string | null,
10+
nominalTimestamp: Date
11+
) {
12+
return calculateNextStep(schedule, timezone, nominalTimestamp);
13+
}
14+
715
export function calculateNextScheduledTimestamp(
816
schedule: string,
917
timezone: string | null,
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import {
2+
MAX_SCHEDULE_PHASE,
3+
MINIMUM_SCHEDULE_RANGE_MS,
4+
SCHEDULE_PHASE_DENOMINATOR,
5+
calculateEffectiveScheduleTime,
6+
calculateSchedulePhase,
7+
parseScheduleWindow,
8+
resolveScheduleWindowMs,
9+
validateScheduleWindow,
10+
validateScheduleWindowForInterval,
11+
} from "./scheduleTiming.js";
12+
13+
describe("parseScheduleWindow", () => {
14+
it.each([
15+
["30m", { type: "duration", durationSeconds: 1_800 }],
16+
["2h", { type: "duration", durationSeconds: 7_200 }],
17+
["1d", { type: "duration", durationSeconds: 86_400 }],
18+
["0%", { type: "percentage", percentage: 0 }],
19+
["12%", { type: "percentage", percentage: 12 }],
20+
["100%", { type: "percentage", percentage: 100 }],
21+
] as const)("normalizes %s", (input, expected) => {
22+
expect(parseScheduleWindow(input)).toEqual(expected);
23+
});
24+
25+
it.each([
26+
"",
27+
"0m",
28+
"01m",
29+
"1.5h",
30+
"30s",
31+
"0.01%",
32+
"1.0%",
33+
"12.3%",
34+
"100.01%",
35+
"101%",
36+
"1.234%",
37+
"1e2%",
38+
" 30m",
39+
"30m ",
40+
])("rejects %j", (input) => {
41+
expect(() => parseScheduleWindow(input)).toThrow();
42+
});
43+
44+
it("rejects durations that cannot be persisted as a Postgres Int", () => {
45+
expect(() => parseScheduleWindow("24856d")).toThrow("duration is too large");
46+
});
47+
});
48+
49+
describe("schedule window validation", () => {
50+
it.each([0, 100])("allows %s percent", (percentage) => {
51+
expect(() => validateScheduleWindow({ type: "percentage", percentage })).not.toThrow();
52+
});
53+
54+
it("allows an absolute window equal to the nominal interval", () => {
55+
expect(() =>
56+
validateScheduleWindowForInterval({ type: "duration", durationSeconds: 300 }, 5 * 60_000)
57+
).not.toThrow();
58+
});
59+
60+
it("rejects an absolute window larger than the nominal interval", () => {
61+
expect(() =>
62+
validateScheduleWindowForInterval({ type: "duration", durationSeconds: 1_800 }, 5 * 60_000)
63+
).toThrow("cannot exceed the interval");
64+
});
65+
66+
it.each([
67+
{ type: "duration", durationSeconds: 0 },
68+
{ type: "duration", durationSeconds: 1.5 },
69+
{ type: "percentage", percentage: -100 },
70+
{ type: "percentage", percentage: 101 },
71+
{ type: "percentage", percentage: 1.5 },
72+
] as const)("rejects an invalid normalized window: %o", (window) => {
73+
expect(() => validateScheduleWindow(window)).toThrow();
74+
});
75+
});
76+
77+
describe("resolveScheduleWindowMs", () => {
78+
it("returns zero when no window was configured", () => {
79+
expect(resolveScheduleWindowMs(undefined, 5 * 60_000)).toBe(0);
80+
});
81+
82+
it("resolves percentage windows using integer arithmetic", () => {
83+
expect(resolveScheduleWindowMs({ type: "percentage", percentage: 33 }, 5 * 60_000)).toBe(
84+
99_000
85+
);
86+
});
87+
});
88+
89+
describe("calculateEffectiveScheduleTime", () => {
90+
const nominalAt = new Date("2026-08-10T10:00:00.000Z");
91+
92+
it("uses the 60-second baseline when no window was configured", () => {
93+
const timing = calculateEffectiveScheduleTime({
94+
nominalAt,
95+
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
96+
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2,
97+
});
98+
99+
expect(timing).toEqual({
100+
nominalAt,
101+
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
102+
effectiveAt: new Date("2026-08-10T10:00:30.000Z"),
103+
intervalMs: 300_000,
104+
windowMs: 0,
105+
effectiveRangeMs: MINIMUM_SCHEDULE_RANGE_MS,
106+
offsetMs: 30_000,
107+
rangeWasClamped: false,
108+
});
109+
});
110+
111+
it.each([
112+
[0, 0],
113+
[10, 30_000],
114+
])("uses the 60-second baseline when %s percent resolves to %sms", (percentage, windowMs) => {
115+
const timing = calculateEffectiveScheduleTime({
116+
nominalAt,
117+
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
118+
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2,
119+
window: { type: "percentage", percentage },
120+
});
121+
122+
expect(timing.windowMs).toBe(windowMs);
123+
expect(timing.effectiveRangeMs).toBe(60_000);
124+
expect(timing.offsetMs).toBe(30_000);
125+
});
126+
127+
it("uses 30% of a five-minute interval", () => {
128+
const timing = calculateEffectiveScheduleTime({
129+
nominalAt,
130+
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
131+
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2,
132+
window: { type: "percentage", percentage: 30 },
133+
});
134+
135+
expect(timing.windowMs).toBe(90_000);
136+
expect(timing.effectiveRangeMs).toBe(90_000);
137+
expect(timing.offsetMs).toBe(45_000);
138+
expect(timing.effectiveAt).toEqual(new Date("2026-08-10T10:00:45.000Z"));
139+
});
140+
141+
it("keeps a 100% window half-open at the maximum phase", () => {
142+
const nextNominalAt = new Date("2026-08-10T10:05:00.000Z");
143+
const timing = calculateEffectiveScheduleTime({
144+
nominalAt,
145+
nextNominalAt,
146+
schedulePhase: MAX_SCHEDULE_PHASE,
147+
window: { type: "percentage", percentage: 100 },
148+
});
149+
150+
expect(timing.effectiveRangeMs).toBe(300_000);
151+
expect(timing.offsetMs).toBe(299_999);
152+
expect(timing.effectiveAt).toEqual(new Date(nextNominalAt.getTime() - 1));
153+
expect(timing.effectiveAt.getTime()).toBeLessThan(nextNominalAt.getTime());
154+
});
155+
156+
it("preserves cadence for consecutive occurrences with a stable 100% phase", () => {
157+
const phase = 1_610_612_735;
158+
const first = calculateEffectiveScheduleTime({
159+
nominalAt,
160+
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
161+
schedulePhase: phase,
162+
window: { type: "percentage", percentage: 100 },
163+
});
164+
const second = calculateEffectiveScheduleTime({
165+
nominalAt: new Date("2026-08-10T10:05:00.000Z"),
166+
nextNominalAt: new Date("2026-08-10T10:10:00.000Z"),
167+
schedulePhase: phase,
168+
window: { type: "percentage", percentage: 100 },
169+
});
170+
171+
expect(second.effectiveAt.getTime() - first.effectiveAt.getTime()).toBe(5 * 60_000);
172+
});
173+
174+
it("allows an effective time to cross a calendar boundary", () => {
175+
const timing = calculateEffectiveScheduleTime({
176+
nominalAt: new Date("2026-12-31T23:00:00.000Z"),
177+
nextNominalAt: new Date("2027-01-01T23:00:00.000Z"),
178+
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2,
179+
window: { type: "duration", durationSeconds: 3 * 60 * 60 },
180+
});
181+
182+
expect(timing.effectiveAt).toEqual(new Date("2027-01-01T00:30:00.000Z"));
183+
});
184+
185+
it("defensively clamps an invalid range to the next nominal tick", () => {
186+
const timing = calculateEffectiveScheduleTime({
187+
nominalAt,
188+
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
189+
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2,
190+
window: { type: "duration", durationSeconds: 30 * 60 },
191+
});
192+
193+
expect(timing.windowMs).toBe(1_800_000);
194+
expect(timing.effectiveRangeMs).toBe(300_000);
195+
expect(timing.rangeWasClamped).toBe(true);
196+
expect(timing.effectiveAt).toEqual(new Date("2026-08-10T10:02:30.000Z"));
197+
});
198+
199+
it.each([-1, 1.5, SCHEDULE_PHASE_DENOMINATOR])(
200+
"rejects invalid schedule phase %s",
201+
(schedulePhase) => {
202+
expect(() =>
203+
calculateEffectiveScheduleTime({
204+
nominalAt,
205+
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
206+
schedulePhase,
207+
})
208+
).toThrow("Schedule phase must be an integer");
209+
}
210+
);
211+
212+
it("rejects a non-positive nominal interval", () => {
213+
expect(() =>
214+
calculateEffectiveScheduleTime({
215+
nominalAt,
216+
nextNominalAt: nominalAt,
217+
schedulePhase: 0,
218+
})
219+
).toThrow("Nominal schedule interval must be a positive integer");
220+
});
221+
});
222+
223+
describe("calculateSchedulePhase", () => {
224+
const input = {
225+
secret: "test-secret",
226+
environmentId: "env_789",
227+
deduplicationKey: "daily-report",
228+
};
229+
230+
it("uses the agreed domain-separated HMAC input", () => {
231+
expect(calculateSchedulePhase(input)).toBe(43_063_717);
232+
});
233+
234+
it("is stable for the same logical schedule instance", () => {
235+
expect(calculateSchedulePhase(input)).toBe(calculateSchedulePhase(input));
236+
});
237+
238+
it.each(["environmentId", "deduplicationKey"] as const)("changes when %s changes", (field) => {
239+
expect(calculateSchedulePhase({ ...input, [field]: `${input[field]}_other` })).not.toBe(
240+
calculateSchedulePhase(input)
241+
);
242+
});
243+
244+
it("changes when the secret changes", () => {
245+
expect(calculateSchedulePhase({ ...input, secret: "other-secret" })).not.toBe(
246+
calculateSchedulePhase(input)
247+
);
248+
});
249+
250+
it("always returns a non-negative signed 31-bit integer", () => {
251+
for (let index = 0; index < 1_000; index++) {
252+
const phase = calculateSchedulePhase({ ...input, deduplicationKey: `schedule-${index}` });
253+
expect(phase).toBeGreaterThanOrEqual(0);
254+
expect(phase).toBeLessThan(SCHEDULE_PHASE_DENOMINATOR);
255+
}
256+
});
257+
258+
it("rejects an empty secret", () => {
259+
expect(() => calculateSchedulePhase({ ...input, secret: "" })).toThrow(
260+
"secret must not be empty"
261+
);
262+
});
263+
});

0 commit comments

Comments
 (0)