diff --git a/docs/1.docs/50.tasks.md b/docs/1.docs/50.tasks.md index 744cca90b8..940cf78417 100644 --- a/docs/1.docs/50.tasks.md +++ b/docs/1.docs/50.tasks.md @@ -158,7 +158,7 @@ Prefer `scheduledTasks` when your deployment target supports it (see [platform s ### Platform support -- **`dev`**, **`node_server`**, **`node_cluster`**, **`node_middleware`**, **`bun`** and **`deno_server`** presets are supported by the [croner](https://croner.56k.guru/) engine. +- **`dev`**, **`node_server`**, **`node_cluster`**, **`node_middleware`**, **`bun`** and **`deno_server`** presets are supported by the [croner](https://croner.56k.guru/) engine. Schedules stop when the Nitro `close` hook runs. With `node_middleware`, Nitro does not own the server, so call `useNitroHooks().callHook("close")` when your host server shuts down. - **`cloudflare_module`** and **`cloudflare_pages`** presets have native integration with [Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/). Nitro automatically generates the cron triggers in the wrangler config at build time - no manual wrangler setup required. - **`vercel`** preset has native integration with [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs). Nitro automatically generates the cron job configuration at build time - no manual `vercel.json` setup required. You can secure cron endpoints by setting the `CRON_SECRET` environment variable. - More presets (with native primitives support) are planned to be supported! diff --git a/src/runtime/internal/task.ts b/src/runtime/internal/task.ts index f119cefd3d..0b03edecfa 100644 --- a/src/runtime/internal/task.ts +++ b/src/runtime/internal/task.ts @@ -1,6 +1,7 @@ import { Cron } from "croner"; import { HTTPError } from "h3"; import type { Task, TaskContext, TaskEvent, TaskPayload, TaskResult } from "nitro/types"; +import { useNitroHooks } from "./app.ts"; import { scheduledTasks, tasks } from "#nitro/virtual/tasks"; /** @experimental */ @@ -64,19 +65,32 @@ export function startScheduleRunner({ scheduledTime: Date.now(), }; + const cronJobs: Cron[] = []; + useNitroHooks().hook("close", () => { + for (const job of cronJobs) { + try { + job.stop(); + } catch (error) { + console.error("Error while stopping scheduled task", error); + } + } + }); + for (const schedule of scheduledTasks) { - new Cron(schedule.cron, async () => { - await Promise.all( - schedule.tasks.map((name) => - runTask(name, { - payload, - context: { waitUntil }, - }).catch((error) => { - console.error(`Error while running scheduled task "${name}"`, error); - }) - ) - ); - }); + cronJobs.push( + new Cron(schedule.cron, async () => { + await Promise.all( + schedule.tasks.map((name) => + runTask(name, { + payload, + context: { waitUntil }, + }).catch((error) => { + console.error(`Error while running scheduled task "${name}"`, error); + }) + ) + ); + }) + ); } } diff --git a/test/unit/task.test.ts b/test/unit/task.test.ts new file mode 100644 index 0000000000..37eba95a76 --- /dev/null +++ b/test/unit/task.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { closeHookMock, cronMock, cronStopMock } = vi.hoisted(() => { + const cronStopMock = vi.fn(); + return { + closeHookMock: vi.fn(), + cronMock: vi.fn(function () { + return { stop: cronStopMock }; + }), + cronStopMock, + }; +}); + +vi.mock("croner", () => ({ Cron: cronMock })); +vi.mock("../../src/runtime/internal/app.ts", () => ({ + useNitroHooks: () => ({ hook: closeHookMock }), +})); +vi.mock("#nitro/virtual/tasks", () => ({ + scheduledTasks: [ + { cron: "*/5 * * * *", tasks: ["test"] }, + { cron: "*/10 * * * *", tasks: ["test"] }, + ], + tasks: {}, +})); + +import { startScheduleRunner } from "../../src/runtime/internal/task.ts"; + +describe("startScheduleRunner", () => { + beforeEach(() => { + vi.stubEnv("TEST", ""); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("creates a Cron job for each schedule", () => { + startScheduleRunner(); + + expect(cronMock).toHaveBeenCalledTimes(2); + expect(cronMock).toHaveBeenCalledWith("*/5 * * * *", expect.any(Function)); + expect(cronMock).toHaveBeenCalledWith("*/10 * * * *", expect.any(Function)); + }); + + it("stops the schedule runner when the Nitro app closes", () => { + startScheduleRunner(); + + expect(closeHookMock).toHaveBeenCalledWith("close", expect.any(Function)); + + const closeScheduleRunner = closeHookMock.mock.calls[0]![1]; + closeScheduleRunner(); + + expect(cronStopMock).toHaveBeenCalledTimes(2); + }); + + it("continues stopping schedules when one Cron job fails to stop", () => { + const error = new Error("stop failed"); + const firstStopMock = vi.fn(() => { + throw error; + }); + const secondStopMock = vi.fn(); + const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => {}); + cronMock + .mockImplementationOnce(function () { + return { stop: firstStopMock }; + }) + .mockImplementationOnce(function () { + return { stop: secondStopMock }; + }); + + startScheduleRunner(); + const closeScheduleRunner = closeHookMock.mock.calls[0]![1]; + + expect(() => closeScheduleRunner()).not.toThrow(); + expect(firstStopMock).toHaveBeenCalledOnce(); + expect(secondStopMock).toHaveBeenCalledOnce(); + expect(consoleErrorMock).toHaveBeenCalledWith("Error while stopping scheduled task", error); + }); +});