fix(crons): recover overdue triggers instead of dropping them forever

processCrons only selected triggers with nextRun >= now, so any trigger
whose scheduled time passed while the server was down (or during tick
jitter) never matched the query again and silently stopped running.
Overdue triggers are now included, fire immediately based on their
stored nextRun, and reschedule onto the next future boundary.

Adds regression tests for overdue firing and null-nextRun init.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Space-Banane
2026-07-18 14:29:25 +02:00
parent 37e0171bdf
commit d6e86c97f4
2 changed files with 84 additions and 6 deletions
+6 -5
View File
@@ -106,8 +106,9 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
where: {
OR: [
{
// Includes overdue triggers (nextRun in the past): a missed
// tick or server downtime must not permanently kill a cron.
nextRun: {
gte: now,
lte: fiveMinutesFromNow,
},
},
@@ -152,9 +153,9 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
continue;
}
const next = interval.next();
if (next.getTime() <= now.getTime() + 1000) {
// Fire based on the stored schedule so overdue triggers run instead
// of waiting for (or missing) the next parsed boundary.
if (cron.nextRun.getTime() <= now.getTime() + 1000) {
const followingRun = interval.next().toDate();
await prisma.functionTrigger.update({
@@ -225,7 +226,7 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
})();
} else {
const secondsUntilNextRun = Math.round(
(next.getTime() - now.getTime()) / 1000,
(cron.nextRun.getTime() - now.getTime()) / 1000,
);
if (secondsUntilNextRun <= 5) {
+78 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { validateCronExpression } from "../lib/Cron";
import { processGitPulls } from "../lib/SystemCrons";
import { processCrons, processGitPulls } from "../lib/SystemCrons";
describe("validateCronExpression", () => {
it("returns true for a valid cron expression", async () => {
@@ -14,6 +14,83 @@ describe("validateCronExpression", () => {
});
});
describe("processCrons", () => {
const buildDependencies = (trigger: Record<string, unknown>) => {
const update = vi.fn().mockResolvedValue({});
const executeFunction = vi
.fn()
.mockResolvedValue({ exit_code: 0, logs: "", result: null, tooks: [] });
const prisma = {
functionTrigger: {
findMany: vi.fn().mockResolvedValue([trigger]),
update,
},
functionFile: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const dependencies: Parameters<typeof processCrons>[0] = {
prisma: prisma as unknown as Parameters<typeof processCrons>[0]["prisma"],
executeFunction:
executeFunction as unknown as Parameters<typeof processCrons>[0]["executeFunction"],
performGitPull: vi.fn() as unknown as Parameters<
typeof processCrons
>[0]["performGitPull"],
};
return { dependencies, update, executeFunction };
};
it("fires overdue triggers instead of dropping them", async () => {
const overdue = {
id: 42,
functionId: 7,
name: "overdue-cron",
cron: "*/5 * * * *",
enabled: true,
nextRun: new Date(Date.now() - 60 * 60 * 1000), // missed an hour ago
data: null,
function: { id: 7 },
};
const { dependencies, update, executeFunction } = buildDependencies(overdue);
await processCrons(dependencies);
// the execution itself runs detached; wait for the microtask queue
await new Promise((resolve) => setTimeout(resolve, 0));
expect(executeFunction).toHaveBeenCalledTimes(1);
const rescheduleCall = update.mock.calls.find(
(call) => call[0]?.data?.nextRun instanceof Date,
);
expect(rescheduleCall).toBeDefined();
expect(rescheduleCall![0].data.nextRun.getTime()).toBeGreaterThan(Date.now());
});
it("initializes nextRun without executing when it is null", async () => {
const fresh = {
id: 43,
functionId: 7,
name: "fresh-cron",
cron: "*/5 * * * *",
enabled: true,
nextRun: null,
data: null,
function: { id: 7 },
};
const { dependencies, update, executeFunction } = buildDependencies(fresh);
await processCrons(dependencies);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(executeFunction).not.toHaveBeenCalled();
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 43 },
data: { nextRun: expect.any(Date) },
}),
);
});
});
describe("processGitPulls", () => {
it("passes configured git source directories to scheduled pulls", async () => {
const performGitPull = vi.fn().mockResolvedValue({ success: true, logs: "" });