From 3c881f976a97106de99c5a7ef11b24162a211b90 Mon Sep 17 00:00:00 2001 From: Kevin Kyburz <30409887+swissky@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:33:04 +0200 Subject: [PATCH] fix(core): tick the piggyback cron scheduler from the middleware request path PiggybackScheduler was instantiated on Cloudflare Workers but runtime.tickCron() had no call sites, so plugin cron tasks never executed. Tick once per request on both the anonymous fast path and the full runtime path; the scheduler debounces internally. Closes #1422 --- .changeset/fix-piggyback-cron-tick.md | 12 ++ packages/core/src/astro/middleware.ts | 9 + .../unit/astro/middleware-cron-tick.test.ts | 186 ++++++++++++++++++ .../unit/astro/middleware-prerender.test.ts | 1 + 4 files changed, 208 insertions(+) create mode 100644 .changeset/fix-piggyback-cron-tick.md create mode 100644 packages/core/tests/unit/astro/middleware-cron-tick.test.ts diff --git a/.changeset/fix-piggyback-cron-tick.md b/.changeset/fix-piggyback-cron-tick.md new file mode 100644 index 0000000000..fecb763a4f --- /dev/null +++ b/.changeset/fix-piggyback-cron-tick.md @@ -0,0 +1,12 @@ +--- +"emdash": patch +--- + +Plugin cron tasks now actually run on Cloudflare Workers (#1422) + +The middleware selected the `PiggybackScheduler` on Workers, but nothing ever +called `runtime.tickCron()`, so scheduled plugin tasks sat overdue at +`status = idle` forever. The middleware now ticks the cron system once per +request (both the anonymous fast path and the full runtime path). The +scheduler debounces internally (60s) and runs fire-and-forget, so requests +gain no latency. diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index a0e21c46a9..75a4060c09 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -437,6 +437,11 @@ export const onRequest = defineMiddleware(async (context, next) => { try { const runtime = await getRuntime(config, initSubTimings); markSetupVerified(); + // Drive the piggyback cron scheduler. On platforms without a + // dedicated scheduler (Cloudflare Workers) this is the only + // thing that executes due plugin cron tasks. Debounced + // internally (60s) and fire-and-forget — adds no latency. + runtime.tickCron(); const handlePublicPluginApiRoute = createPublicPluginApiRouteHandler(runtime); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- partial object; getPageRuntime() only checks for the page-contribution methods locals.emdash = { @@ -521,6 +526,10 @@ export const onRequest = defineMiddleware(async (context, next) => { // Runtime init runs migrations, so the DB is guaranteed set up markSetupVerified(); + // Drive the piggyback cron scheduler (see the anonymous fast path + // above for rationale) — admin/API traffic must tick it too. + runtime.tickCron(); + // The manifest is no longer pre-loaded here. It's admin-only // content that public/anonymous requests never read, and // loading it on every request put logged-out hot paths on diff --git a/packages/core/tests/unit/astro/middleware-cron-tick.test.ts b/packages/core/tests/unit/astro/middleware-cron-tick.test.ts new file mode 100644 index 0000000000..dffea77b1b --- /dev/null +++ b/packages/core/tests/unit/astro/middleware-cron-tick.test.ts @@ -0,0 +1,186 @@ +/** + * Piggyback cron tick on public requests (issue #1422). + * + * On platforms without a dedicated scheduler (Cloudflare Workers), cron + * execution relies on the PiggybackScheduler being driven from the request + * path via `runtime.tickCron()`. The bug: `tickCron()` existed but had no + * call sites, so plugin cron tasks never ran on Workers — overdue tasks sat + * at `status = idle` forever. + * + * These tests pin the contract: a public page request must tick the cron + * system exactly once (the scheduler debounces internally). + */ + +import { beforeEach, describe, it, expect, vi } from "vitest"; + +vi.mock("astro:middleware", () => ({ + defineMiddleware: (handler: unknown) => handler, +})); + +// vi.mock factories are hoisted above normal `const` declarations; use +// vi.hoisted so the marker objects are available to both the factories and +// the assertions below. +const { DB_CONFIG_MARKER } = vi.hoisted(() => ({ + DB_CONFIG_MARKER: { binding: "DB", session: "auto" }, +})); + +const { MOCK_RUNTIME, mockTickCron } = vi.hoisted(() => { + const ok = async () => ({ success: true }); + const tickCron = vi.fn(); + + return { + MOCK_RUNTIME: { + storage: { getPublicUrl: (key: string) => `https://media.example.com/${key}` }, + db: {}, + hooks: {}, + email: null, + configuredPlugins: [], + handleContentList: ok, + handleContentGet: ok, + handleContentCreate: ok, + handleContentUpdate: ok, + handleContentDelete: ok, + handleContentListTrashed: ok, + handleContentRestore: ok, + handleContentPermanentDelete: ok, + handleContentCountTrashed: ok, + handleContentGetIncludingTrashed: ok, + handleContentDuplicate: ok, + handleContentPublish: ok, + handleContentUnpublish: ok, + handleContentSchedule: ok, + handleContentUnschedule: ok, + handleContentCountScheduled: ok, + handleContentDiscardDraft: ok, + handleContentCompare: ok, + handleContentTranslations: ok, + handleMediaList: ok, + handleMediaGet: ok, + handleMediaCreate: ok, + handleMediaUpdate: ok, + handleMediaDelete: ok, + handleRevisionList: ok, + handleRevisionGet: ok, + handleRevisionRestore: ok, + getPluginRouteMeta: () => null, + handlePluginApiRoute: ok, + getMediaProvider: () => undefined, + getMediaProviderList: () => [], + collectPageMetadata: async () => [], + collectPageFragments: async () => [], + ensureSearchHealthy: async () => undefined, + getManifest: async () => ({}), + getSandboxRunner: () => null, + isSandboxBypassed: () => false, + syncMarketplacePlugins: async () => undefined, + syncRegistryPlugins: async () => undefined, + setPluginStatus: async () => undefined, + tickCron, + }, + mockTickCron: tickCron, + }; +}); + +vi.mock( + "virtual:emdash/config", + () => ({ + default: { + database: { config: DB_CONFIG_MARKER }, + auth: { mode: "none" }, + }, + }), + { virtual: true }, +); + +vi.mock( + "virtual:emdash/dialect", + () => ({ + createDialect: vi.fn(), + createRequestScopedDb: vi.fn().mockReturnValue(null), + }), + { virtual: true }, +); + +vi.mock("virtual:emdash/media-providers", () => ({ mediaProviders: [] }), { virtual: true }); +vi.mock("virtual:emdash/plugins", () => ({ plugins: [] }), { virtual: true }); +vi.mock( + "virtual:emdash/sandbox-runner", + () => ({ + createSandboxRunner: null, + sandboxBypassed: false, + sandboxEnabled: false, + }), + { virtual: true }, +); +vi.mock("virtual:emdash/sandboxed-plugins", () => ({ sandboxedPlugins: [] }), { virtual: true }); +vi.mock("virtual:emdash/storage", () => ({ createStorage: null }), { virtual: true }); +vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true }); + +vi.mock("../../../src/emdash-runtime.js", () => ({ + DB_INIT_DEADLINE_MS: 30_000, + EmDashRuntime: { + create: async () => MOCK_RUNTIME, + }, +})); + +vi.mock("../../../src/loader.js", () => ({ + getDb: vi.fn(async () => ({ + selectFrom: () => ({ + selectAll: () => ({ + limit: () => ({ + execute: async () => [], + }), + }), + }), + })), +})); + +import onRequest from "../../../src/astro/middleware.js"; + +function anonymousPublicPageContext() { + const cookies = { + get: vi.fn(() => undefined), + set: vi.fn(), + }; + return { + request: new Request("https://example.com/blog/hello"), + url: new URL("https://example.com/blog/hello"), + cookies, + locals: {} as Record, + redirect: vi.fn(), + isPrerendered: false, + session: { get: vi.fn(async () => null) }, + } as Record; +} + +describe("astro middleware piggyback cron tick", () => { + beforeEach(() => { + mockTickCron.mockClear(); + }); + + it("ticks the cron system on anonymous public page requests", async () => { + const context = anonymousPublicPageContext(); + + const response = await onRequest( + context as Parameters[0], + async () => new Response("ok"), + ); + + expect(response.status).toBe(200); + // One tick per request — the scheduler debounces internally, so the + // middleware must not try to be clever about frequency. + expect(mockTickCron).toHaveBeenCalledTimes(1); + }); + + it("ticks once per request on the full runtime path too", async () => { + // Prerendered public runtime routes take the full-runtime branch of the + // middleware — the tick must happen there as well (it is a no-op unless + // the platform actually uses the PiggybackScheduler). + const context = anonymousPublicPageContext(); + (context as { isPrerendered: boolean }).isPrerendered = true; + + await onRequest(context as Parameters[0], async () => new Response("ok")); + + expect(mockTickCron).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/tests/unit/astro/middleware-prerender.test.ts b/packages/core/tests/unit/astro/middleware-prerender.test.ts index d55b58cbac..e204329e44 100644 --- a/packages/core/tests/unit/astro/middleware-prerender.test.ts +++ b/packages/core/tests/unit/astro/middleware-prerender.test.ts @@ -76,6 +76,7 @@ const { syncMarketplacePlugins: async () => undefined, syncRegistryPlugins: async () => undefined, setPluginStatus: async () => undefined, + tickCron: () => undefined, }, PUBLIC_PLUGIN_RESULT: publicPluginResult, mockGetPluginRouteMeta: getPluginRouteMeta,