-
Notifications
You must be signed in to change notification settings - Fork 970
fix(scheduling): drive scheduled publishing from a real heartbeat #1312
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ascorbic
wants to merge
3
commits into
main
Choose a base branch
from
feat/scheduled-publishing-driver
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| --- | ||
| "emdash": minor | ||
| "@emdash-cms/cloudflare": minor | ||
| --- | ||
|
|
||
| Drive scheduled publishing from a real heartbeat instead of request side effects (#1303). | ||
|
|
||
| Content scheduled via the admin now actually transitions to `published` when its time arrives. Previously nothing promoted the row — `status` stayed `scheduled` and `published_at` stayed null forever. | ||
|
|
||
| A new sweep (`publishDueContent`) promotes due content and runs alongside the existing cron tick and system cleanup: | ||
|
|
||
| - **Node / single-process:** the timer-based scheduler already drives it — no action needed. | ||
| - **Cloudflare Workers:** a `scheduled()` handler driven by a Cron Trigger now runs the sweep. The request-driven `PiggybackScheduler` is gone, so there are no maintenance side effects on visitor requests. | ||
|
|
||
| `@emdash-cms/cloudflare` ships a Worker entry that wraps Astro's handler with the `scheduled()` handler (`@emdash-cms/cloudflare/worker`, plus `createScheduledHandler()` for hand-assembled Workers). When a cache provider is configured, the handler also purges edge-cache tags for whatever it published, so stale snapshots produced before the scheduled time are evicted. | ||
|
|
||
| **Migration for existing Cloudflare sites.** New sites get this from the templates. Existing deployments must update two files: | ||
|
|
||
| ```ts | ||
| // src/worker.ts | ||
| export { default, PluginBridge } from "@emdash-cms/cloudflare/worker"; | ||
| ``` | ||
|
|
||
| ```jsonc | ||
| // wrangler.jsonc | ||
| "triggers": { "crons": ["* * * * *"] } | ||
| ``` | ||
|
|
||
| Without the Cron Trigger, scheduled publishing and plugin cron do not run on Workers. | ||
|
|
||
| Scheduled publishing matches manual publishing exactly: it fires `content:afterPublish` hooks (search indexing, webhooks, syndication), and records the _scheduled_ time as `published_at` on first publication rather than the (later) sweep time. The sweep claims each row atomically before promoting it, so an entry unscheduled or rescheduled just before its time is never published, and overlapping sweeps can't double-publish. Local `astro dev` keeps running the timer-driven sweep even under the Cloudflare adapter (where production relies on the Cron Trigger). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| /** | ||
| * Cloudflare Worker entry for EmDash sites. | ||
| * | ||
| * Wraps the Astro Cloudflare server handler with a `scheduled()` handler so a | ||
| * Cron Trigger drives scheduled publishing, plugin cron, and system cleanup | ||
| * without any request side effects. Re-exports the `PluginBridge` Durable | ||
| * Object so the sandbox binding resolves against the entry module. | ||
| * | ||
| * Templates use this as their entire `src/worker.ts`: | ||
| * | ||
| * export { default, PluginBridge } from "@emdash-cms/cloudflare/worker"; | ||
| * | ||
| * and add a Cron Trigger to wrangler.jsonc: | ||
| * | ||
| * "triggers": { "crons": ["* * * * *"] } | ||
| * | ||
| * The `@astrojs/cloudflare/entrypoints/server` import is resolved by the | ||
| * consuming app's Astro build (it pulls the build-time `virtual:astro:app` | ||
| * module), so this package keeps the adapter external. | ||
| */ | ||
|
|
||
| // @ts-ignore - resolved against the consuming app's Astro build | ||
| import astroHandler from "@astrojs/cloudflare/entrypoints/server"; | ||
| import { createApp } from "astro/app/entrypoint"; | ||
| import { runScheduledTasks } from "emdash/middleware"; | ||
|
|
||
| export { PluginBridge } from "./sandbox/index.js"; | ||
|
|
||
| // The Astro App wraps the build manifest; reuse one per isolate so each tick | ||
| // doesn't re-resolve the cache provider. | ||
| let app: ReturnType<typeof createApp> | null = null; | ||
|
|
||
| /** | ||
| * Purge edge-cache tags for content the sweep just published. Without a | ||
| * request there's no `locals.cache`, so we reach the configured cache provider | ||
| * through the Astro App pipeline — the same provider routes invalidate against. | ||
| * A no-op when no cache provider is configured. | ||
| */ | ||
| async function invalidatePublishedTags( | ||
| published: ReadonlyArray<{ collection: string; id: string }>, | ||
| ): Promise<void> { | ||
| if (published.length === 0) return; | ||
| app ??= createApp(); | ||
| const provider = await app.pipeline.getCacheProvider(); | ||
| if (!provider) return; | ||
| const tags = [...new Set(published.flatMap((ref) => [ref.collection, ref.id]))]; | ||
| await provider.invalidate({ tags }); | ||
| } | ||
|
|
||
| /** | ||
| * Build a Worker `scheduled()` handler that runs EmDash's scheduled | ||
| * maintenance batch and purges edge-cache tags for anything it published. | ||
| * Exported for sites that assemble their own Worker object; most sites get it | ||
| * via this module's default export. | ||
| */ | ||
| export function createScheduledHandler(): ExportedHandlerScheduledHandler { | ||
| return (_controller, _env, ctx) => { | ||
| ctx.waitUntil( | ||
| runScheduledTasks() | ||
| .then(async ({ published }) => { | ||
| await invalidatePublishedTags(published); | ||
| if (published.length > 0) { | ||
| console.log(`[scheduled] Published ${published.length} scheduled item(s)`); | ||
| } | ||
| return undefined; | ||
| }) | ||
| .catch((error: unknown) => { | ||
| console.error("[scheduled] runScheduledTasks failed:", error); | ||
| }), | ||
|
Comment on lines
+58
to
+69
|
||
| ); | ||
| }; | ||
| } | ||
|
|
||
| // eslint-disable-next-line typescript/no-unsafe-type-assertion -- astroHandler is the adapter's { fetch } worker object; resolved at app-build time | ||
| const handler = astroHandler as ExportedHandler; | ||
|
|
||
| export default { | ||
| ...handler, | ||
| scheduled: createScheduledHandler(), | ||
| } satisfies ExportedHandler; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[suggestion]
createApp()is called with no arguments here. Astro'screateApptypically requires a manifest (createApp(manifest)). Ifastro/app/entrypointdoes not provide a zero-arity overload, this will throw at runtime inside thescheduled()handler. The error is caught by the outer.catch(), so the worker won't crash, but edge-cache tag invalidation for published content will silently never run.Please verify that the Cloudflare adapter build pipeline injects the manifest automatically for
astro/app/entrypointconsumers. If not, pass the manifest explicitly (or construct the cache provider directly) soinvalidatePublishedTagsactually purges stale cache entries.