Skip to content

Commit 8d01722

Browse files
authored
Merge pull request #1969 from Automattic/feat/1968-coordinator-state-adoption
Adopt exact Cloudflare coordinator state
2 parents 0b8525d + 9c87ec6 commit 8d01722

7 files changed

Lines changed: 150 additions & 2 deletions

File tree

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export class D1RevisionCoordinator implements RevisionCoordinator {
4242
return readCommittedPointer(this.database, version)
4343
}
4444

45+
adopt(pointer: MarkdownPointer, version: number): Promise<{ pointer: MarkdownPointer; version: number }> {
46+
return adoptWordPressState(this.database, pointer, version)
47+
}
48+
4549
async reset(): Promise<void> {
4650
await resetWordPressState(this.database)
4751
}
@@ -116,6 +120,36 @@ async function readCommittedPointer(database: D1Database, version: number): Prom
116120
return pointer
117121
}
118122

123+
async function adoptWordPressState(database: D1Database, pointer: MarkdownPointer, version: number): Promise<{ pointer: MarkdownPointer; version: number }> {
124+
validatePointer(pointer)
125+
if (!Number.isSafeInteger(version) || version < 1) throw new RevisionConflict("A positive canonical version is required for D1 adoption.")
126+
await ensureSchema(database)
127+
const now = Date.now()
128+
await database.batch([
129+
database.prepare(`UPDATE wp_codebox_state
130+
SET revision = ?, manifest_key = ?, persisted_at = ?, version = ?,
131+
lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL
132+
WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?)
133+
AND ((revision IS NULL AND manifest_key IS NULL AND persisted_at IS NULL)
134+
OR (version = ? AND revision = ? AND manifest_key = ? AND persisted_at = ?))
135+
AND (NOT EXISTS (SELECT 1 FROM wp_codebox_commits WHERE site_id = ? AND version = ?)
136+
OR EXISTS (SELECT 1 FROM wp_codebox_commits WHERE site_id = ? AND version = ?
137+
AND revision = ? AND manifest_key = ? AND persisted_at = ?))`)
138+
.bind(pointer.revision, pointer.manifestKey, pointer.persistedAt, version, SITE_ID, now,
139+
version, pointer.revision, pointer.manifestKey, pointer.persistedAt,
140+
SITE_ID, version, SITE_ID, version, pointer.revision, pointer.manifestKey, pointer.persistedAt),
141+
database.prepare(`INSERT OR IGNORE INTO wp_codebox_commits (site_id, version, revision, manifest_key, persisted_at)
142+
SELECT site_id, version, revision, manifest_key, persisted_at FROM wp_codebox_state
143+
WHERE site_id = ? AND version = ? AND revision = ? AND manifest_key = ? AND persisted_at = ? AND lease_token IS NULL`)
144+
.bind(SITE_ID, version, pointer.revision, pointer.manifestKey, pointer.persistedAt),
145+
])
146+
const [state, committed] = await Promise.all([readRow(database), readCommittedPointer(database, version)])
147+
if (state.lease_token !== null || state.version !== version || !samePointer(pointerFromRow(state), pointer) || !samePointer(committed, pointer)) {
148+
throw new RevisionConflict("D1 coordinator adoption requires empty or exactly matching state without an active lease.", state.lease_expires_at ?? undefined)
149+
}
150+
return { pointer, version }
151+
}
152+
119153
async function resetWordPressState(database: D1Database): Promise<{ reset: true }> {
120154
await ensureSchema(database)
121155
await database.batch([
@@ -160,6 +194,10 @@ function validatePointer(pointer: unknown): asserts pointer is MarkdownPointer {
160194
}
161195
}
162196

197+
function samePointer(left: MarkdownPointer | null, right: MarkdownPointer): boolean {
198+
return !!left && left.revision === right.revision && left.manifestKey === right.manifestKey && left.persistedAt === right.persistedAt
199+
}
200+
163201
function ensureSchema(database: D1Database): Promise<void> {
164202
const key = database as object
165203
const existing = schemaReady.get(key)

packages/runtime-cloudflare/src/request-routing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type WorkerRequestRoute =
55
| { kind: "r2-mutate" }
66
| { kind: "operator-reset" }
77
| { kind: "operator-restore" }
8+
| { kind: "operator-adopt" }
89
| { kind: "operator-publish" }
910
| { kind: "probe"; phase: string }
1011

@@ -16,6 +17,7 @@ export function routeWorkerRequest(request: Request): WorkerRequestRoute {
1617
if (phase === "r2-mutate") return { kind: "r2-mutate" }
1718
if (phase === "operator-reset") return { kind: "operator-reset" }
1819
if (phase === "operator-restore") return { kind: "operator-restore" }
20+
if (phase === "operator-adopt") return { kind: "operator-adopt" }
1921
if (phase === "operator-publish") return { kind: "operator-publish" }
2022
return { kind: "probe", phase }
2123
}

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

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

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ export class DurableObjectRevisionCoordinator implements RevisionCoordinator {
5050
return this.call("committed", { version })
5151
}
5252

53+
adopt(pointer: MarkdownPointer, version: number): Promise<{ pointer: MarkdownPointer; version: number }> {
54+
return this.call("adopt", { pointer, version })
55+
}
56+
5357
async reset(): Promise<void> {
5458
await this.call("reset", {})
5559
}
@@ -103,6 +107,7 @@ export class WordPressStateCoordinator implements DurableObject {
103107
if (action === "abort") return Response.json(await this.abort(body))
104108
if (action === "commit") return Response.json(await this.commit(body))
105109
if (action === "committed") return Response.json(await this.committed(body))
110+
if (action === "adopt") return Response.json(await this.adopt(body))
106111
if (action === "reset") return Response.json(await this.reset())
107112
return new Response("Unknown coordinator action.", { status: 404 })
108113
}
@@ -164,6 +169,26 @@ export class WordPressStateCoordinator implements DurableObject {
164169
return await this.state.storage.get<MarkdownPointer>(`wordpress-state-commit/${body.version}`) ?? null
165170
}
166171

172+
private async adopt(body: Record<string, unknown>): Promise<{ pointer: MarkdownPointer; version: number }> {
173+
const record = await this.record()
174+
const pointer = body.pointer as MarkdownPointer
175+
const version = body.version
176+
if (!pointer || typeof pointer.revision !== "string" || typeof pointer.manifestKey !== "string" || typeof pointer.persistedAt !== "string"
177+
|| !Number.isSafeInteger(version) || (version as number) < 1) throw new RevisionConflict("A complete canonical pointer and positive version are required for adoption.")
178+
if (record.lease && record.lease.expiresAt > Date.now()) throw new RevisionConflict("Coordinator adoption requires no active lease.", record.lease.expiresAt)
179+
if (record.lease) delete record.lease
180+
const empty = record.version === 0 && record.pointer === null
181+
const exact = record.version === version && samePointer(record.pointer, pointer)
182+
if (!empty && !exact) throw new RevisionConflict("Coordinator adoption requires empty or exactly matching state.")
183+
record.pointer = pointer
184+
record.version = version as number
185+
await this.state.storage.transaction(async (transaction) => {
186+
await transaction.put(`wordpress-state-commit/${record.version}`, pointer)
187+
await transaction.put(STORAGE_KEY, record)
188+
})
189+
return { pointer, version: record.version }
190+
}
191+
167192
private requireLease(record: CoordinatorRecord, body: Record<string, unknown>): StoredLease {
168193
const lease = record.lease
169194
if (!lease || lease.expiresAt <= Date.now()) throw new RevisionConflict("The canonical WordPress lease has expired.")
@@ -190,3 +215,7 @@ export class WordPressStateCoordinator implements DurableObject {
190215
return this.state.storage.put(STORAGE_KEY, record)
191216
}
192217
}
218+
219+
function samePointer(left: MarkdownPointer | null, right: MarkdownPointer): boolean {
220+
return !!left && left.revision === right.revision && left.manifestKey === right.manifestKey && left.persistedAt === right.persistedAt
221+
}

packages/runtime-cloudflare/src/worker.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export function createCloudflareRuntime<Env extends RuntimeEnv>(resolveCoordinat
153153
if (uploadResponse) return uploadResponse
154154
if (route.kind === "operator-reset") return resetCanonicalWordPress(request, env, coordinator)
155155
if (route.kind === "operator-restore") return restoreCanonicalWordPress(request, env, coordinator)
156+
if (route.kind === "operator-adopt") return adoptCanonicalWordPress(request, env, coordinator)
156157
if (route.kind === "operator-publish") return publishCanonicalWordPressPages(request, env, coordinator)
157158
if (route.kind === "probe") {
158159
return runBootProbe(route.phase, env.WORDPRESS_STATE_BUCKET)
@@ -322,6 +323,39 @@ async function restoreCanonicalWordPress(request: Request, env: RuntimeEnv, coor
322323
}
323324
}
324325

326+
async function adoptCanonicalWordPress(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator): Promise<Response> {
327+
if (request.method !== "POST") return new Response("Canonical adoption requires POST.", { status: 405 })
328+
const authorization = request.headers.get("authorization")
329+
if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) {
330+
return new Response("Canonical adoption authorization failed.", { status: 401 })
331+
}
332+
let pointer: MarkdownPointer
333+
let version: number
334+
try {
335+
const body = await request.json<{ pointer?: unknown; version?: unknown }>()
336+
pointer = body.pointer as MarkdownPointer
337+
version = body.version as number
338+
} catch {
339+
return new Response("Canonical adoption requires JSON state.", { status: 400 })
340+
}
341+
if (!isCanonicalRestorePointer(pointer) || !Number.isSafeInteger(version) || version < 1) return new Response("Canonical adoption state is invalid.", { status: 400 })
342+
const manifest = await readMarkdownManifest(env.WORDPRESS_STATE_BUCKET, pointer)
343+
if (!manifest || manifest.revision !== pointer.revision || manifest.manifestKey !== pointer.manifestKey || manifest.persistedAt !== pointer.persistedAt || !Array.isArray(manifest.files)) {
344+
return new Response("Canonical adoption manifest is unavailable or inconsistent.", { status: 409 })
345+
}
346+
let adopted: { pointer: MarkdownPointer; version: number }
347+
try {
348+
adopted = await coordinator.adopt(pointer, version)
349+
} catch (error) {
350+
if (!(error instanceof RevisionConflict)) throw error
351+
const headers = new Headers()
352+
if (error.retryAt) headers.set("retry-after", String(Math.max(1, Math.ceil((error.retryAt - Date.now()) / 1000))))
353+
return Response.json({ schema: "wp-codebox/cloudflare-coordinator-conflict/v1", message: error.message, retryAt: error.retryAt }, { status: 409, headers })
354+
}
355+
await discardCachedRuntime()
356+
return Response.json({ adopted: true, ...adopted })
357+
}
358+
325359
function isCanonicalRestorePointer(pointer: unknown): pointer is MarkdownPointer {
326360
if (!pointer || typeof pointer !== "object") return false
327361
const candidate = pointer as Partial<MarkdownPointer>

scripts/cloudflare-local-gate.mjs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ try {
2626
await assertWordPressCronDisabled()
2727
await assertConcurrentMutations()
2828
await assertCoordinatorBackend()
29+
console.log(`Coordinator adoption probe starting for ${coordinator}.`)
30+
await assertCoordinatorAdoption()
31+
console.log(`Coordinator adoption probe passed for ${coordinator}.`)
2932
const coldHome = await timedWordPressPage(origin, "cold explanatory homepage")
3033
const warmHome = await timedWordPressPage(origin, "warm explanatory homepage")
3134
await assertExplanatoryHomepage(warmHome.body)
@@ -102,8 +105,14 @@ async function startWorker() {
102105
env: { ...process.env, NO_PROXY: "wordpress.org,github.com,codeload.github.com", no_proxy: "wordpress.org,github.com,codeload.github.com" },
103106
stdio: ["ignore", "pipe", "pipe"],
104107
})
105-
child.stdout.on("data", (chunk) => { output += chunk })
106-
child.stderr.on("data", (chunk) => { output += chunk })
108+
child.stdout.on("data", (chunk) => {
109+
output += chunk
110+
if (process.env.CLOUDFLARE_GATE_DEBUG) process.stdout.write(chunk)
111+
})
112+
child.stderr.on("data", (chunk) => {
113+
output += chunk
114+
if (process.env.CLOUDFLARE_GATE_DEBUG) process.stderr.write(chunk)
115+
})
107116
await waitForServer()
108117
}
109118

@@ -451,6 +460,30 @@ async function assertCoordinatorBackend() {
451460
}
452461
}
453462

463+
async function assertCoordinatorAdoption() {
464+
const before = await (await fetch(`${origin}/?phase=r2-state`)).json()
465+
const reset = await fetch(`${origin}/?phase=operator-reset`, { method: "POST", headers: { authorization: `Bearer ${operatorToken}` } })
466+
if (!reset.ok) throw new Error(`Coordinator reset before adoption failed: ${reset.status} ${await reset.text()}.`)
467+
const adoption = await fetch(`${origin}/?phase=operator-adopt`, {
468+
method: "POST",
469+
headers: { authorization: `Bearer ${operatorToken}`, "content-type": "application/json" },
470+
body: JSON.stringify({ pointer: before.pointer, version: before.version }),
471+
})
472+
const adoptionBody = await adoption.text()
473+
if (!adoption.ok) throw new Error(`Exact coordinator adoption failed: status=${adoption.status} body=${adoptionBody}.\nWorker output:\n${output}`)
474+
const adopted = JSON.parse(adoptionBody)
475+
if (!adoption.ok || !adopted.adopted || adopted.version !== before.version || adopted.pointer?.revision !== before.pointer?.revision) {
476+
throw new Error(`Exact coordinator adoption failed: status=${adoption.status} payload=${JSON.stringify(adopted)}.`)
477+
}
478+
const divergent = await fetch(`${origin}/?phase=operator-adopt`, {
479+
method: "POST",
480+
headers: { authorization: `Bearer ${operatorToken}`, "content-type": "application/json" },
481+
body: JSON.stringify({ pointer: before.pointer, version: before.version + 1 }),
482+
})
483+
if (divergent.status !== 409) throw new Error(`Divergent coordinator adoption was not rejected: ${divergent.status} ${await divergent.text()}.`)
484+
await assertCoordinatorBackend()
485+
}
486+
454487
async function assertWordPressPage(target, label) {
455488
const response = await request(target)
456489
const body = await response.text()

tests/cloudflare-runtime.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ test("Cloudflare routing reserves phases while the phase-less route serves WordP
7878
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=r2-mutate")), { kind: "r2-mutate" })
7979
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=operator-reset")), { kind: "operator-reset" })
8080
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=operator-restore")), { kind: "operator-restore" })
81+
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=operator-adopt")), { kind: "operator-adopt" })
8182
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=operator-publish")), { kind: "operator-publish" })
8283
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=seeded-wordpress")), { kind: "probe", phase: "seeded-wordpress" })
8384
})
@@ -605,6 +606,16 @@ test("Cloudflare coordinator serializes leases, promotes with CAS, and recovers
605606
const coordinator = new WordPressStateCoordinator(state as never, { WORDPRESS_STATE_BUCKET: bucket as never, COORDINATOR_LEASE_MS: 50 })
606607
const call = async (action: string, body: Record<string, unknown> = {}) => coordinator.fetch(new Request(`https://worker.example/?__wp_codebox_coordinator=${action}`, { method: action === "state" ? "GET" : "POST", headers: { "content-type": "application/json" }, body: action === "state" ? undefined : JSON.stringify(body) }))
607608

609+
const adoptedPointer = { revision: "adopted", manifestKey: "sites/default/markdown/revisions/adopted.json", persistedAt: "2026-01-01T00:00:00.000Z" }
610+
assert.equal((await call("adopt", { pointer: adoptedPointer, version: 31 })).status, 200)
611+
assert.deepEqual(await (await call("committed", { version: 31 })).json(), adoptedPointer)
612+
assert.equal((await call("adopt", { pointer: adoptedPointer, version: 31 })).status, 200)
613+
assert.equal((await call("adopt", { pointer: { ...adoptedPointer, revision: "divergent" }, version: 31 })).status, 409)
614+
const adoptedLease = await (await call("begin")).json() as { token: string }
615+
assert.equal((await call("adopt", { pointer: adoptedPointer, version: 31 })).status, 409)
616+
assert.equal((await call("abort", { token: adoptedLease.token })).status, 200)
617+
assert.equal((await call("reset")).status, 200)
618+
608619
const first = await (await call("begin")).json() as { token: string; pointer: null; version: number }
609620
assert.equal(first.pointer, null)
610621
assert.equal((await call("begin")).status, 409)

0 commit comments

Comments
 (0)