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:
@@ -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) {
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
Reference in New Issue
Block a user