Skip to content

Commit b5ddfc5

Browse files
authored
Merge pull request #1951 from Automattic/feat/cloudflare-durable-cron
Run durable WordPress cron on Cloudflare
2 parents 40b6a33 + 400bde3 commit b5ddfc5

9 files changed

Lines changed: 222 additions & 14 deletions

packages/runtime-cloudflare/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ The same revision transaction persists bounded user-managed files under `wp-cont
1616

1717
The Worker forwards browser cookies directly to Playground and disables Playground's internal cookie store, preventing an empty per-isolate store from replacing a valid browser session after cold restart.
1818

19-
The bundled MDI source is pinned to immutable commit `2a8ee7f6a46e1d64b4606f1ee3c97e14032dc96c` from [MDI PR #139](https://github.com/Automattic/markdown-database-integration/pull/139). The bundle generator, worker provenance, and source-contract test use the same revision.
19+
A minute Cloudflare Cron Trigger drains at most five due WordPress events or 25 seconds of work per invocation. Each event receives its own Durable Object lease and canonical R2 revision transaction. Direct `/wp-cron.php` requests are disabled so callbacks cannot bypass that transaction boundary.
20+
21+
The bundled MDI source is pinned to immutable commit `bf6d434d1673fdd86d777501f7eaec292d32ad1f`, including [MDI PR #141](https://github.com/Automattic/markdown-database-integration/pull/141). The bundle generator, worker provenance, and source-contract test use the same revision.
2022

2123
WordPress server files, browser assets, and pinned runtime dependencies are separate deployment artifacts, never Worker-module imports. `generate:cloudflare-wordpress-runtime-corpus` emits a deterministic server ZIP containing only `isWordPressRuntimeFile()` entries, one concatenated browser-asset blob containing the complete supported `wp-admin`, `wp-includes`, and bundled-theme surface, and the pinned SQLite integration ZIP. Checked-in manifests record content-addressed R2 keys, hashes, budgets, provenance, and each browser asset's exact byte range. Cold boot validates and streams the server ZIP into PHP MEMFS while loading SQLite integration from R2. Browser requests use one bounded R2 range read, validate the selected bytes, and populate an immutable artifact-versioned Worker cache. Runtime requests never fetch WordPress or SQLite integration release archives from third-party origins.
2224

packages/runtime-cloudflare/assets/markdown-database-integration-canonical-seed.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema": "wp-codebox/cloudflare-canonical-mdi-seed/v1",
3-
"markdownDatabaseIntegrationRevision": "2a8ee7f6a46e1d64b4606f1ee3c97e14032dc96c",
3+
"markdownDatabaseIntegrationRevision": "bf6d434d1673fdd86d777501f7eaec292d32ad1f",
44
"wordpressInstallSeedSha256": "7212b89f1c01f98a23b89e9a2dd2b214306a85c10adb0355b9c7a6005b4fe822",
55
"archiveSha256": "4f05439e26a63ef749de250a9b56ee3467697b1b1a166aeb90c5660ecfc9e845",
66
"files": [
Binary file not shown.

packages/runtime-cloudflare/src/worker.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const wordpressWpContentRuntimeBaselinePaths = new Set((wordpressRuntimeArtifact
4141
.filter((file) => file.path.startsWith("wordpress/wp-content/"))
4242
.map((file) => file.path.slice("wordpress/wp-content/".length))
4343
.filter(isCanonicalWpContentPath))
44-
const MARKDOWN_DATABASE_INTEGRATION_REVISION = "2a8ee7f6a46e1d64b4606f1ee3c97e14032dc96c"
44+
const MARKDOWN_DATABASE_INTEGRATION_REVISION = "bf6d434d1673fdd86d777501f7eaec292d32ad1f"
4545
const SITE_URL = "https://wp-codebox-runtime.invalid"
4646
const DATABASE_PATH = "/wordpress/wp-content/database/.ht.sqlite"
4747
const MARKDOWN_ROOT = "/wordpress/wp-content/markdown"
@@ -54,6 +54,8 @@ const R2_MARKDOWN_OBJECT_PREFIX = "sites/default/markdown/objects"
5454
const R2_WORDPRESS_PAGE_PREFIX = "sites/default/pages"
5555
const WORDPRESS_PAGE_CACHE_SCHEMA = "v2"
5656
const PUBLIC_WP_CONTENT_EXTENSION = /\.(?:css|js|mjs|json|txt|xml|woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|avif|ico)$/i
57+
const MAX_CRON_EVENTS_PER_INVOCATION = 5
58+
const MAX_CRON_INVOCATION_MS = 25_000
5759
const SERIALIZED_MARKDOWN_MUTATION_CODE = `<?php
5860
define('SHORTINIT', true);
5961
require '/wordpress/wp-load.php';
@@ -91,6 +93,35 @@ if (empty($option_rows)) {
9193
}
9294
$changes = $runtime->flush();
9395
echo json_encode(['revisionValue' => $value, 'previousPostFound' => !empty($previous_rows), 'postId' => $post_id, 'wordpressVersion' => $wp_version, 'canonicalChanges' => $changes]);`
96+
const NEXT_CRON_EVENT_CODE = `<?php
97+
require '/wordpress/wp-load.php';
98+
$ready = wp_get_ready_cron_jobs();
99+
if (empty($ready)) {
100+
echo json_encode(['executed' => false], JSON_THROW_ON_ERROR);
101+
return;
102+
}
103+
ksort($ready, SORT_NUMERIC);
104+
foreach ($ready as $timestamp => $hooks) {
105+
ksort($hooks, SORT_STRING);
106+
foreach ($hooks as $hook => $events) {
107+
ksort($events, SORT_STRING);
108+
foreach ($events as $event) {
109+
$schedule = $event['schedule'] ?? false;
110+
$args = $event['args'] ?? array();
111+
if ($schedule) {
112+
$rescheduled = wp_reschedule_event((int) $timestamp, $schedule, $hook, $args, true);
113+
if (is_wp_error($rescheduled)) throw new RuntimeException($rescheduled->get_error_message());
114+
}
115+
$unscheduled = wp_unschedule_event((int) $timestamp, $hook, $args, true);
116+
if (is_wp_error($unscheduled)) throw new RuntimeException($unscheduled->get_error_message());
117+
do_action_ref_array($hook, $args);
118+
$GLOBALS['wpdb']->flush_canonical_writes();
119+
echo json_encode(['executed' => true, 'hook' => $hook, 'timestamp' => (int) $timestamp], JSON_THROW_ON_ERROR);
120+
return;
121+
}
122+
}
123+
}
124+
echo json_encode(['executed' => false], JSON_THROW_ON_ERROR);`
94125
interface Env {
95126
WORDPRESS_STATE: DurableObjectNamespace
96127
WORDPRESS_STATE_BUCKET: R2Bucket
@@ -101,6 +132,7 @@ interface Env {
101132

102133
export default {
103134
async fetch(request: Request, env: Env): Promise<Response> {
135+
if (new URL(request.url).pathname === "/wp-cron.php") return new Response("WordPress cron is managed by the Cloudflare scheduled handler.", { status: 404 })
104136
const coordinator = env.WORDPRESS_STATE.getByName("default")
105137
const wpContentResponse = await serveWordPressWpContent(request, env.WORDPRESS_STATE_BUCKET, coordinator)
106138
if (wpContentResponse) return wpContentResponse
@@ -120,6 +152,10 @@ export default {
120152
}
121153
return runCoordinatedWordPressRequest(request, env, coordinator, route.kind)
122154
},
155+
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
156+
const evidence = await runScheduledWordPressCron(env, controller.scheduledTime)
157+
console.log(JSON.stringify(evidence))
158+
},
123159
}
124160

125161
interface MarkdownManifestFile {
@@ -174,6 +210,15 @@ interface Runtime {
174210
pointer: MarkdownPointer
175211
}
176212

213+
interface CronInvocationEvidence {
214+
schema: "wp-codebox/cloudflare-cron/v1"
215+
scheduledTime: number
216+
startedAt: string
217+
completedAt: string
218+
events: Array<{ hook: string; timestamp: number; revision: string }>
219+
status: "completed" | "bounded" | "no-canonical-state"
220+
}
221+
177222
let cachedRuntime: { baseRevision: string; promise: Promise<Runtime> } | undefined
178223
const LEASE_ACQUISITION_TIMEOUT_MS = 100_000
179224

@@ -539,6 +584,61 @@ async function runSyntheticMutation(runtime: Runtime): Promise<{ response: Respo
539584
return { response: Response.json({ schema: "wp-codebox/cloudflare-wordpress-mutation/v1", source: "entry-worker-primary-runtime", ...mutation, canonicalFiles: collectRuntimeFiles(runtime.php, MARKDOWN_ROOT).length, markdownDatabaseIntegrationRevision: MARKDOWN_DATABASE_INTEGRATION_REVISION, sqlitePersisted: false }), canonicalChanges: mutation.canonicalChanges }
540585
}
541586

587+
async function runScheduledWordPressCron(env: Env, scheduledTime: number): Promise<CronInvocationEvidence> {
588+
const started = Date.now()
589+
const evidence: CronInvocationEvidence = {
590+
schema: "wp-codebox/cloudflare-cron/v1",
591+
scheduledTime,
592+
startedAt: new Date(started).toISOString(),
593+
completedAt: "",
594+
events: [],
595+
status: "completed",
596+
}
597+
const coordinator = env.WORDPRESS_STATE.getByName("default")
598+
const requestUrl = `${SITE_URL}/wp-cron.php?doing_wp_cron=${scheduledTime}`
599+
while (evidence.events.length < MAX_CRON_EVENTS_PER_INVOCATION && Date.now() - started < MAX_CRON_INVOCATION_MS) {
600+
const lease = await acquireLease(coordinator, requestUrl)
601+
if (!lease.pointer) {
602+
await releaseLease(coordinator, requestUrl, lease)
603+
evidence.status = "no-canonical-state"
604+
break
605+
}
606+
let runtime: Runtime | undefined
607+
let finalized = false
608+
try {
609+
runtime = await getRuntime(env, lease.pointer, SITE_URL)
610+
const event = await runNextCronEvent(runtime)
611+
if (!event.executed) {
612+
await releaseLease(coordinator, requestUrl, lease)
613+
finalized = true
614+
await discardRuntime(runtime)
615+
break
616+
}
617+
const next = await persistRuntime(env.WORDPRESS_STATE_BUCKET, runtime, event.canonicalChanges)
618+
await commitLease(coordinator, requestUrl, lease, next)
619+
finalized = true
620+
evidence.events.push({ hook: event.hook, timestamp: event.timestamp, revision: next.revision })
621+
await discardRuntime(runtime)
622+
} catch (error) {
623+
if (!finalized) await abortLease(coordinator, requestUrl, lease)
624+
if (runtime) await discardRuntime(runtime)
625+
throw error
626+
}
627+
}
628+
if (evidence.events.length === MAX_CRON_EVENTS_PER_INVOCATION || Date.now() - started >= MAX_CRON_INVOCATION_MS) evidence.status = "bounded"
629+
evidence.completedAt = new Date().toISOString()
630+
return evidence
631+
}
632+
633+
async function runNextCronEvent(runtime: Runtime): Promise<{ executed: false } | { executed: true; hook: string; timestamp: number; canonicalChanges: MarkdownChanges }> {
634+
runtime.php.writeFile(MARKDOWN_CHANGES_PATH, new TextEncoder().encode(JSON.stringify({ created: [], changed: [], deleted: [] })))
635+
const output = (await runtime.php.run({ code: NEXT_CRON_EVENT_CODE })).text.trim()
636+
const event = JSON.parse(output) as { executed: boolean; hook?: string; timestamp?: number }
637+
if (!event.executed) return { executed: false }
638+
if (!event.hook || !Number.isSafeInteger(event.timestamp)) throw new Error("WordPress cron returned invalid event evidence.")
639+
return { executed: true, hook: event.hook, timestamp: event.timestamp!, canonicalChanges: readCanonicalChanges(runtime.php) }
640+
}
641+
542642
async function health(runtime: Runtime): Promise<Response> {
543643
const phpVersion = (await runtime.php.run({ code: "<?php echo PHP_VERSION;" })).text.trim()
544644
return cloudflareRuntimeHealthResponse({ schema: CLOUDFLARE_RUNTIME_HEALTH_SCHEMA, marker: CLOUDFLARE_RUNTIME_HEALTH_MARKER, wordpressVersion: runtime.wordpressVersion, phpVersion, runtime: { backend: "wordpress-playground", environment: "wordpress" }, evidence: { initialization: "completed", execution: "completed", initializationScope: "isolate" } })
@@ -1065,6 +1165,10 @@ function patchCanonicalRuntimePoliciesAtInit(php: PHP): void {
10651165
remove_action( 'init', 'wp_schedule_delete_old_privacy_export_files' );
10661166
remove_action( 'init', 'wp_schedule_update_checks' );
10671167
}
1168+
// This runtime has an explicit scheduled handler, so cron events are canonical.
1169+
add_filter( 'markdown_database_integration_ephemeral_option_names', static function ( $names ) {
1170+
return array_values( array_diff( $names, array( 'cron' ) ) );
1171+
} );
10681172
// Rewrite rules are derived for each runtime; keep them out of canonical MDI state.
10691173
add_filter( 'pre_update_option_rewrite_rules', static function ( $value, $old_value ) {
10701174
return $old_value;

packages/runtime-cloudflare/wrangler.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"compatibility_date": "2026-07-18",
66
"compatibility_flags": ["nodejs_compat"],
77
"limits": { "cpu_ms": 300000 },
8+
"triggers": { "crons": ["* * * * *"] },
89
"r2_buckets": [
910
{ "binding": "WORDPRESS_STATE_BUCKET", "bucket_name": "wp-codebox-runtime-chubes" }
1011
],

scripts/build-cloudflare-canonical-mdi-seed.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?php
22
declare( strict_types=1 );
33

4-
const MDI_REVISION = '2a8ee7f6a46e1d64b4606f1ee3c97e14032dc96c';
4+
const MDI_REVISION = 'bf6d434d1673fdd86d777501f7eaec292d32ad1f';
55
const REQUIRED_PATHS = array(
66
'_options/siteurl.json', '_options/home.json', '_tables/users.json', '_tables/usermeta.json',
77
'_tables/terms.json', '_tables/term_taxonomy.json', '_tables/termmeta.json', '_tables/postmeta.json',

scripts/build-cloudflare-mdi-runtime-bundle.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readFile, writeFile } from "node:fs/promises"
22
import { decodeZip, encodeZip } from "@php-wasm/stream-compression"
33

4-
const revision = "2a8ee7f6a46e1d64b4606f1ee3c97e14032dc96c"
4+
const revision = "bf6d434d1673fdd86d777501f7eaec292d32ad1f"
55
const jsonMachineRevision = "8bf0b0ff6ff60ab480778eaa5ad7d505b442c2d4"
66
const archiveUrl = `https://codeload.github.com/Automattic/markdown-database-integration/zip/${revision}`
77
const jsonMachineArchiveUrl = `https://codeload.github.com/halaxa/json-machine/zip/${jsonMachineRevision}`

0 commit comments

Comments
 (0)