Skip to content

Commit 025dd2a

Browse files
committed
Drain Cloudflare publication in bounded jobs
1 parent 00f37f7 commit 025dd2a

8 files changed

Lines changed: 310 additions & 86 deletions

File tree

packages/runtime-cloudflare/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The same revision transaction persists bounded user-managed files under `wp-cont
1818

1919
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.
2020

21-
A minute Cloudflare Cron Trigger drains at most five due WordPress events or 25 seconds of work per invocation. Each event receives its own coordinator lease and canonical R2 revision transaction. Scheduled publication callbacks use the same affected-route compiler and post-commit conditional promotion as browser mutations. Direct `/wp-cron.php` requests are disabled so callbacks cannot bypass that transaction boundary.
21+
A minute Cloudflare Cron Trigger first drains at most one publication route, or otherwise drains at most five due WordPress events or 25 seconds of work. Canonical mutations persist immutable state, enqueue a revision- and coordinator-version-bound publication job, and return `x-wp-codebox-publication: queued` when public routes need work; they never render routes in the mutation request. A publication job boots only its committed canonical revision, writes one immutable page snapshot per scheduled invocation, then conditionally promotes a complete descriptor. A stale job cannot overwrite a later canonical version, and failed work leaves the prior publication readable for retry. Direct `/wp-cron.php` requests are disabled so callbacks cannot bypass that transaction boundary.
2222

2323
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.
2424

@@ -31,6 +31,6 @@ Canonical Cloudflare boots patch only the assembled PHP MEMFS copy of `/wordpres
3131
2. Run `npm run provision:cloudflare-wordpress-runtime-corpus -- --local --persist-to <directory>` to verify and upload all exact content-addressed artifacts into isolated local R2 storage. For an authorized deployment, run the provisioner with `--remote` and require every upload to succeed before deploying the Worker that imports their manifests.
3232
3. Run `npm run test:cloudflare-runtime` for routing, coordinator composition, canonical-state, artifact validation, source contract, and TypeScript coverage.
3333
4. Run `npm run cloudflare:dry-run` and `npm run cloudflare:dry-run:d1` to compile the Durable Object and D1 profiles without creating Cloudflare resources. The placeholder D1 database ID is for local/dry-run verification; an implementation supplies its provisioned binding at deployment.
34-
5. Run `npm run cloudflare:local-gate` and `npm run cloudflare:local-gate:d1` for the same isolated workerd workflow through both coordinator implementations. Each gate generates and provisions all artifacts, verifies the selected backend through the state envelope, injects stable test-only admin-password, auth-secret, and operator-token values, uploads and activates a real plugin ZIP, establishes an anonymous homepage and canonical-permalink publication, updates a published post through authenticated REST, and proves that the affected permalink and homepage promote automatically. The cron gate also proves that a scheduled post becomes readable from the publication path without an operator call. Restart verification requires the immutable R2 publication, while the remaining coverage includes login, concurrent canonical writes, media, representative frontend/admin/editor assets, PHP diagnostics, session recovery, and a fresh login after restart.
34+
5. Run `npm run cloudflare:local-gate` and `npm run cloudflare:local-gate:d1` for the same isolated workerd workflow through both coordinator implementations. Each gate generates and provisions all artifacts, verifies the selected backend through the state envelope, injects stable test-only admin-password, auth-secret, and operator-token values, uploads and activates a real plugin ZIP, establishes an anonymous homepage and canonical-permalink publication, updates a published post through authenticated REST without immediate rendering, restarts with pending publication work, and proves bounded scheduled draining eventually exposes the updated permalink through coordinator-free R2. The cron gate also proves that a scheduled post becomes readable from the publication path without an operator call. Restart verification requires the immutable R2 publication, while the remaining coverage includes login, concurrent canonical writes, media, representative frontend/admin/editor assets, PHP diagnostics, session recovery, and a fresh login after restart.
3535

3636
This document describes local candidate verification only. It does not claim remote deployment.

packages/runtime-cloudflare/src/d1-revision-coordinator.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export class D1RevisionCoordinator implements RevisionCoordinator {
3838
return commitStateLease(this.database, lease, pointer)
3939
}
4040

41+
committed(version: number): Promise<MarkdownPointer | null> {
42+
return readCommittedPointer(this.database, version)
43+
}
44+
4145
async reset(): Promise<void> {
4246
await resetWordPressState(this.database)
4347
}
@@ -86,24 +90,41 @@ async function commitStateLease(database: D1Database, lease: RevisionLease, poin
8690
validatePointer(pointer)
8791
await ensureSchema(database)
8892
const baseRevision = lease.pointer?.revision ?? null
89-
const result = await database.prepare(`UPDATE wp_codebox_state
93+
const update = database.prepare(`UPDATE wp_codebox_state
9094
SET revision = ?, manifest_key = ?, persisted_at = ?, version = version + 1,
9195
lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL
9296
WHERE site_id = ? AND lease_token = ? AND lease_expires_at > ? AND version = ? AND lease_version = ?
9397
AND ((revision IS NULL AND ? IS NULL) OR revision = ?)
9498
AND ((lease_base_revision IS NULL AND ? IS NULL) OR lease_base_revision = ?)`)
9599
.bind(pointer.revision, pointer.manifestKey, pointer.persistedAt, SITE_ID, lease.token, Date.now(), lease.version, lease.version,
96-
baseRevision, baseRevision, baseRevision, baseRevision).run()
97-
if (result.meta.changes !== 1) throw new RevisionConflict("The canonical pointer changed before D1 promotion.")
100+
baseRevision, baseRevision, baseRevision, baseRevision)
101+
const receipt = database.prepare(`INSERT OR IGNORE INTO wp_codebox_commits (site_id, version, revision, manifest_key, persisted_at)
102+
SELECT ?, ?, ?, ?, ? FROM wp_codebox_state WHERE site_id = ? AND version = ? AND revision = ?`)
103+
.bind(SITE_ID, lease.version + 1, pointer.revision, pointer.manifestKey, pointer.persistedAt, SITE_ID, lease.version + 1, pointer.revision)
104+
const [result, receiptResult] = await database.batch([update, receipt])
105+
if (result.meta.changes !== 1 || receiptResult.meta.changes !== 1) throw new RevisionConflict("The canonical pointer changed before D1 promotion.")
98106
return { pointer, version: lease.version + 1 }
99107
}
100108

109+
async function readCommittedPointer(database: D1Database, version: number): Promise<MarkdownPointer | null> {
110+
if (!Number.isSafeInteger(version) || version < 1) throw new RevisionConflict("A canonical commit version is required.")
111+
await ensureSchema(database)
112+
const row = await database.prepare(`SELECT revision, manifest_key, persisted_at FROM wp_codebox_commits WHERE site_id = ? AND version = ?`).bind(SITE_ID, version).first<{ revision: string; manifest_key: string; persisted_at: string }>()
113+
if (!row) return null
114+
const pointer = { revision: row.revision, manifestKey: row.manifest_key, persistedAt: row.persisted_at }
115+
validatePointer(pointer)
116+
return pointer
117+
}
118+
101119
async function resetWordPressState(database: D1Database): Promise<{ reset: true }> {
102120
await ensureSchema(database)
103-
await database.prepare(`UPDATE wp_codebox_state
104-
SET revision = NULL, manifest_key = NULL, persisted_at = NULL, version = version + 1,
105-
lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL
106-
WHERE site_id = ?`).bind(SITE_ID).run()
121+
await database.batch([
122+
database.prepare(`UPDATE wp_codebox_state
123+
SET revision = NULL, manifest_key = NULL, persisted_at = NULL, version = version + 1,
124+
lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL
125+
WHERE site_id = ?`).bind(SITE_ID),
126+
database.prepare(`DELETE FROM wp_codebox_commits WHERE site_id = ?`).bind(SITE_ID),
127+
])
107128
return { reset: true }
108129
}
109130

@@ -156,6 +177,7 @@ function ensureSchema(database: D1Database): Promise<void> {
156177
lease_expires_at INTEGER
157178
)`).run()
158179
await database.prepare(`INSERT OR IGNORE INTO wp_codebox_state (site_id, version) VALUES (?, 0)`).bind(SITE_ID).run()
180+
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_commits (site_id TEXT NOT NULL, version INTEGER NOT NULL, revision TEXT NOT NULL, manifest_key TEXT NOT NULL, persisted_at TEXT NOT NULL, PRIMARY KEY (site_id, version))`).run()
159181
})()
160182
schemaReady.set(key, pending)
161183
pending.catch(() => schemaReady.delete(key))

packages/runtime-cloudflare/src/published-reader.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
export const PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v2" as const
1+
export const PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v3" as const
2+
const PREVIOUS_PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v2"
23
const LEGACY_PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v1"
34
export const PUBLISHED_PAGE_SCHEMA = "wp-codebox/wordpress-page/v2" as const
45
export const R2_PUBLISHED_CURRENT_KEY = "sites/default/publications/current.json"
@@ -17,6 +18,7 @@ export interface PublishedRevision {
1718
schema: typeof PUBLISHED_REVISION_SCHEMA
1819
revision: string
1920
canonicalRevision: string
21+
canonicalVersion: number
2022
publishedAt: string
2123
routes: PublishedRoute[]
2224
}
@@ -43,8 +45,10 @@ export function validatePublishedRevision(value: unknown): PublishedRevision {
4345
if (!value || typeof value !== "object") throw new Error("Published revision is invalid.")
4446
const revision = value as Partial<PublishedRevision>
4547
const legacy = (value as { schema?: unknown }).schema === LEGACY_PUBLISHED_REVISION_SCHEMA
46-
if (![PUBLISHED_REVISION_SCHEMA, LEGACY_PUBLISHED_REVISION_SCHEMA].includes(revision.schema as string) || !isRevision(revision.revision) || !isRevision(revision.canonicalRevision)
48+
const current = revision.schema === PUBLISHED_REVISION_SCHEMA
49+
if (![PUBLISHED_REVISION_SCHEMA, PREVIOUS_PUBLISHED_REVISION_SCHEMA, LEGACY_PUBLISHED_REVISION_SCHEMA].includes(revision.schema as string) || !isRevision(revision.revision) || !isRevision(revision.canonicalRevision)
4750
|| typeof revision.publishedAt !== "string" || !Number.isFinite(Date.parse(revision.publishedAt)) || !Array.isArray(revision.routes)
51+
|| (current && (typeof revision.canonicalVersion !== "number" || !Number.isSafeInteger(revision.canonicalVersion) || revision.canonicalVersion < 0))
4852
|| revision.routes.length === 0 || revision.routes.length > MAX_PUBLISHED_ROUTES) throw new Error("Published revision is invalid.")
4953
let previous = ""
5054
const normalizedRoutes: PublishedRoute[] = []
@@ -57,7 +61,7 @@ export function validatePublishedRevision(value: unknown): PublishedRevision {
5761
previous = route.route
5862
normalizedRoutes.push({ route: route.route, objectKey: route.objectKey, canonicalRevision: routeRevision })
5963
}
60-
return { ...revision, schema: PUBLISHED_REVISION_SCHEMA, routes: normalizedRoutes } as PublishedRevision
64+
return { ...revision, schema: PUBLISHED_REVISION_SCHEMA, canonicalVersion: current ? revision.canonicalVersion! : 0, routes: normalizedRoutes } as PublishedRevision
6165
}
6266

6367
export function normalizePublishedRoutes(value: unknown): string[] {

packages/runtime-cloudflare/src/revision-coordinator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface RevisionCoordinator {
2424
release(lease: RevisionLease): Promise<void>
2525
abort(lease: RevisionLease): Promise<void>
2626
commit(lease: RevisionLease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }>
27+
committed(version: number): Promise<MarkdownPointer | null>
2728
reset(): Promise<void>
2829
}
2930

packages/runtime-cloudflare/src/state-coordinator.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export class DurableObjectRevisionCoordinator implements RevisionCoordinator {
4646
return this.call("commit", { token: lease.token, baseRevision: lease.pointer?.revision ?? null, version: lease.version, pointer })
4747
}
4848

49+
committed(version: number): Promise<MarkdownPointer | null> {
50+
return this.call("committed", { version })
51+
}
52+
4953
async reset(): Promise<void> {
5054
await this.call("reset", {})
5155
}
@@ -98,6 +102,7 @@ export class WordPressStateCoordinator implements DurableObject {
98102
if (action === "release") return Response.json(await this.release(body))
99103
if (action === "abort") return Response.json(await this.abort(body))
100104
if (action === "commit") return Response.json(await this.commit(body))
105+
if (action === "committed") return Response.json(await this.committed(body))
101106
if (action === "reset") return Response.json(await this.reset())
102107
return new Response("Unknown coordinator action.", { status: 404 })
103108
}
@@ -147,10 +152,18 @@ export class WordPressStateCoordinator implements DurableObject {
147152
record.pointer = pointer
148153
record.version++
149154
delete record.lease
150-
await this.save(record)
155+
await this.state.storage.transaction(async (transaction) => {
156+
await transaction.put(`wordpress-state-commit/${record.version}`, pointer)
157+
await transaction.put(STORAGE_KEY, record)
158+
})
151159
return { pointer, version: record.version }
152160
}
153161

162+
private async committed(body: Record<string, unknown>): Promise<MarkdownPointer | null> {
163+
if (!Number.isSafeInteger(body.version) || (body.version as number) < 1) throw new RevisionConflict("A canonical commit version is required.")
164+
return await this.state.storage.get<MarkdownPointer>(`wordpress-state-commit/${body.version}`) ?? null
165+
}
166+
154167
private requireLease(record: CoordinatorRecord, body: Record<string, unknown>): StoredLease {
155168
const lease = record.lease
156169
if (!lease || lease.expiresAt <= Date.now()) throw new RevisionConflict("The canonical WordPress lease has expired.")

0 commit comments

Comments
 (0)