Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/fix-piggyback-cron-tick.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
186 changes: 186 additions & 0 deletions packages/core/tests/unit/astro/middleware-cron-tick.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
redirect: vi.fn(),
isPrerendered: false,
session: { get: vi.fn(async () => null) },
} as Record<string, unknown>;
}

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<typeof onRequest>[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<typeof onRequest>[0], async () => new Response("ok"));

expect(mockTickCron).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const {
syncMarketplacePlugins: async () => undefined,
syncRegistryPlugins: async () => undefined,
setPluginStatus: async () => undefined,
tickCron: () => undefined,
},
PUBLIC_PLUGIN_RESULT: publicPluginResult,
mockGetPluginRouteMeta: getPluginRouteMeta,
Expand Down
Loading