Skip to content

Commit c38c473

Browse files
committed
Simulate "next steps" when calculating the next timestamp to prevent millions of iterations on dev schedues
1 parent 7c791dd commit c38c473

File tree

2 files changed

+196
-2
lines changed

2 files changed

+196
-2
lines changed

apps/webapp/app/v3/utils/calculateNextSchedule.server.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,33 @@ export function calculateNextScheduledTimestamp(
55
timezone: string | null,
66
lastScheduledTimestamp: Date = new Date()
77
) {
8+
const now = Date.now();
9+
810
let nextStep = calculateNextStep(schedule, timezone, lastScheduledTimestamp);
911

10-
while (nextStep.getTime() < Date.now()) {
11-
nextStep = calculateNextStep(schedule, timezone, nextStep);
12+
// If the next step is still in the past, we might need to skip ahead
13+
if (nextStep.getTime() <= now) {
14+
// Calculate a second step to determine the interval
15+
const secondStep = calculateNextStep(schedule, timezone, nextStep);
16+
const interval = secondStep.getTime() - nextStep.getTime();
17+
18+
// If we have a consistent interval and it would take many iterations,
19+
// skip ahead mathematically instead of iterating
20+
if (interval > 0) {
21+
const stepsNeeded = Math.floor((now - nextStep.getTime()) / interval);
22+
23+
// Only skip ahead if it would save us more than a few iterations
24+
if (stepsNeeded > 10) {
25+
// Skip ahead by calculating how many intervals to add
26+
const skipAheadTime = nextStep.getTime() + stepsNeeded * interval;
27+
nextStep = calculateNextStep(schedule, timezone, new Date(skipAheadTime));
28+
}
29+
}
30+
31+
// Use the normal iteration for the remaining steps (should be <= 10 now)
32+
while (nextStep.getTime() <= now) {
33+
nextStep = calculateNextStep(schedule, timezone, nextStep);
34+
}
1235
}
1336

1437
return nextStep;
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2+
import { calculateNextScheduledTimestamp } from "../app/v3/utils/calculateNextSchedule.server";
3+
4+
describe("calculateNextScheduledTimestamp", () => {
5+
beforeEach(() => {
6+
// Mock the current time to make tests deterministic
7+
vi.useFakeTimers();
8+
vi.setSystemTime(new Date("2024-01-01T12:30:00.000Z"));
9+
});
10+
11+
afterEach(() => {
12+
vi.useRealTimers();
13+
});
14+
15+
test("should calculate next run time for a recent timestamp", () => {
16+
const schedule = "0 * * * *"; // Every hour
17+
const lastRun = new Date("2024-01-01T11:00:00.000Z"); // 1.5 hours ago
18+
19+
const nextRun = calculateNextScheduledTimestamp(schedule, null, lastRun);
20+
21+
// Should be 13:00 (next hour after current time 12:30)
22+
expect(nextRun).toEqual(new Date("2024-01-01T13:00:00.000Z"));
23+
});
24+
25+
test("should handle timezone correctly", () => {
26+
const schedule = "0 * * * *"; // Every hour
27+
const lastRun = new Date("2024-01-01T11:00:00.000Z");
28+
29+
const nextRun = calculateNextScheduledTimestamp(schedule, "America/New_York", lastRun);
30+
31+
// The exact time will depend on timezone calculation, but should be in the future
32+
expect(nextRun).toBeInstanceOf(Date);
33+
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
34+
});
35+
36+
test("should efficiently handle very old timestamps (performance fix)", () => {
37+
const schedule = "*/1 * * * *"; // Every minute
38+
const veryOldTimestamp = new Date("2020-01-01T00:00:00.000Z"); // 4 years ago
39+
40+
const startTime = performance.now();
41+
const nextRun = calculateNextScheduledTimestamp(schedule, null, veryOldTimestamp);
42+
const duration = performance.now() - startTime;
43+
44+
// Should complete quickly (under 10ms) instead of iterating millions of times
45+
expect(duration).toBeLessThan(10);
46+
47+
// Should still return a valid future timestamp
48+
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
49+
50+
// Should be the next minute after current time (12:31)
51+
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
52+
});
53+
54+
test("should still work correctly when timestamp is within threshold", () => {
55+
const schedule = "0 */2 * * *"; // Every 2 hours
56+
const recentTimestamp = new Date("2024-01-01T10:00:00.000Z"); // 2.5 hours ago
57+
58+
const nextRun = calculateNextScheduledTimestamp(schedule, null, recentTimestamp);
59+
60+
// Should properly iterate: 10:00 -> 12:00 -> 14:00 (since current time is 12:30)
61+
expect(nextRun).toEqual(new Date("2024-01-01T14:00:00.000Z"));
62+
});
63+
64+
test("should handle frequent schedules with old timestamps efficiently", () => {
65+
const schedule = "*/5 * * * *"; // Every 5 minutes
66+
const oldTimestamp = new Date("2023-12-01T00:00:00.000Z"); // Over a month ago
67+
68+
const startTime = performance.now();
69+
const nextRun = calculateNextScheduledTimestamp(schedule, null, oldTimestamp);
70+
const duration = performance.now() - startTime;
71+
72+
// Should be fast due to dynamic skip-ahead optimization
73+
expect(duration).toBeLessThan(10);
74+
75+
// Should return next 5-minute interval after current time
76+
expect(nextRun).toEqual(new Date("2024-01-01T12:35:00.000Z"));
77+
});
78+
79+
test("should work with complex cron expressions", () => {
80+
const schedule = "0 9 * * MON"; // Every Monday at 9 AM
81+
const oldTimestamp = new Date("2022-01-01T00:00:00.000Z"); // Very old (beyond 1hr threshold)
82+
83+
const nextRun = calculateNextScheduledTimestamp(schedule, null, oldTimestamp);
84+
85+
// Should return a valid future Monday at 9 AM
86+
expect(nextRun.getHours()).toBe(9);
87+
expect(nextRun.getMinutes()).toBe(0);
88+
expect(nextRun.getDay()).toBe(1); // Monday
89+
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
90+
});
91+
92+
test("performance: dynamic optimization for extreme scenarios", () => {
93+
// This test simulates the exact scenario that was causing event loop lag
94+
const schedule = "* * * * *"; // Every minute (very frequent)
95+
const extremelyOldTimestamp = new Date("2000-01-01T00:00:00.000Z"); // 24 years ago
96+
97+
const startTime = performance.now();
98+
const nextRun = calculateNextScheduledTimestamp(schedule, null, extremelyOldTimestamp);
99+
const duration = performance.now() - startTime;
100+
101+
// Should complete extremely quickly due to dynamic skip-ahead
102+
expect(duration).toBeLessThan(5);
103+
104+
// Should still return the correct next minute
105+
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
106+
});
107+
108+
test("dynamic optimization: 23h59m old now handled efficiently", () => {
109+
// This should now be handled efficiently regardless of being "just under" a threshold
110+
const schedule = "* * * * *"; // Every minute
111+
const oldTimestamp = new Date("2023-12-31T12:31:00.000Z"); // 23h59m ago
112+
113+
const startTime = performance.now();
114+
const nextRun = calculateNextScheduledTimestamp(schedule, null, oldTimestamp);
115+
const duration = performance.now() - startTime;
116+
117+
// Should be fast due to dynamic skip-ahead (1439 steps > 10 threshold)
118+
expect(duration).toBeLessThan(10);
119+
120+
// Should return correct result
121+
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
122+
});
123+
124+
test("small intervals still use normal iteration", () => {
125+
// This should use normal iteration since it's only a few steps
126+
const schedule = "*/5 * * * *"; // Every 5 minutes
127+
const recentTimestamp = new Date("2024-01-01T12:00:00.000Z"); // 30 minutes ago (6 steps)
128+
129+
const startTime = performance.now();
130+
const nextRun = calculateNextScheduledTimestamp(schedule, null, recentTimestamp);
131+
const duration = performance.now() - startTime;
132+
133+
// Should still be reasonably fast with normal iteration
134+
expect(duration).toBeLessThan(50);
135+
136+
// Should return next 5-minute interval
137+
expect(nextRun).toEqual(new Date("2024-01-01T12:35:00.000Z"));
138+
});
139+
140+
test("should work with weekly schedules and old timestamps", () => {
141+
const schedule = "0 9 * * MON"; // Every Monday at 9 AM
142+
const oldTimestamp = new Date("2023-12-25T09:00:00.000Z"); // Old Monday
143+
144+
const startTime = performance.now();
145+
const nextRun = calculateNextScheduledTimestamp(schedule, null, oldTimestamp);
146+
const duration = performance.now() - startTime;
147+
148+
// Should be fast and still calculate correctly from the old timestamp
149+
expect(duration).toBeLessThan(50);
150+
151+
// Should return a valid future Monday at 9 AM
152+
expect(nextRun.getHours()).toBe(9);
153+
expect(nextRun.getMinutes()).toBe(0);
154+
expect(nextRun.getDay()).toBe(1); // Monday
155+
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
156+
});
157+
158+
test("weekly schedule with 2-hour old timestamp should calculate properly", () => {
159+
// This tests your specific concern about weekly schedules
160+
const schedule = "0 14 * * SUN"; // Every Sunday at 2 PM
161+
const twoHoursAgo = new Date("2024-01-01T10:30:00.000Z"); // 2 hours before current time (12:30)
162+
163+
const nextRun = calculateNextScheduledTimestamp(schedule, null, twoHoursAgo);
164+
165+
// Should properly calculate the next Sunday at 2 PM, not skip to "now"
166+
expect(nextRun.getHours()).toBe(14);
167+
expect(nextRun.getMinutes()).toBe(0);
168+
expect(nextRun.getDay()).toBe(0); // Sunday
169+
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
170+
});
171+
});

0 commit comments

Comments
 (0)