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,