Skip to content

Commit 5ef860b

Browse files
authored
Merge pull request #1977 from Automattic/feat/1976-cloudflare-cutover-fence
Fence Cloudflare coordinator cutovers
2 parents 0a4ffd6 + f26ba6e commit 5ef860b

8 files changed

Lines changed: 484 additions & 57 deletions

File tree

packages/runtime-cloudflare/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ WordPress server files, browser assets, and pinned runtime dependencies are sepa
2626

2727
Canonical Cloudflare boots patch only the assembled PHP MEMFS copy of `/wordpress/wp-settings.php` after the R2 WordPress corpus is materialized and before WordPress executes. The disabled-cron scheduling policy requires exactly one canonical `do_action( 'init' );` needle and fails closed otherwise. When `DISABLE_WP_CRON` is true, it removes only core's `wp_cron`, `wp_schedule_delete_old_privacy_export_files`, and `wp_schedule_update_checks` callbacks through public `remove_action()` before executing the original `init` call once. This prevents browser requests from recreating cron work outside the explicit scheduled-Worker transaction.
2828

29+
## Coordinator Cutover
30+
31+
The authenticated mutation-fence endpoints provide a bounded cutover window without interrupting anonymous published reads. `POST ?phase=operator-fence-acquire` accepts `{"ttlSeconds":30}` through `{"ttlSeconds":600}` and returns an opaque token plus expiry. While active, both coordinators reject new canonical leases, reset, adoption, manual publication, scheduled publication, and cron work with a conflict. `POST ?phase=operator-fence-renew` accepts the token and a fresh bounded TTL; `POST ?phase=operator-fence-release` accepts the token. Expiry automatically reopens mutations, and status responses never expose the token.
32+
33+
Use authenticated `GET ?phase=operator-fence-status` after acquisition to export a coherent cutover envelope. It includes the coordinator store, pointer, version, matching commit receipt, validated R2 manifest identity, fence expiry, and a `coherent` verdict. Coherence also reads every referenced canonical object and verifies its declared size and SHA-256, so an incomplete target cannot be promoted. Adopt that exact pointer/version into the target coordinator, require the target status envelope to match, then promote the target Worker before the source fence expires. The first target mutation must commit version `N+1`. Rollback uses the same sequence in reverse after fencing the active target; relying on an unfenced status read is not a lossless cutover procedure.
34+
2935
## Static Artifact Import
3036

3137
An authenticated `POST ?phase=operator-static-artifact-import` materializes a verified website artifact into the existing canonical site. The request is limited to 16 KiB and references an immutable JSON object at `sites/default/import-artifacts/<sha256>.json`; the Worker requires the declared R2 key, size, and SHA-256 to agree before acquiring a coordinator lease. The canonical `blocks-engine/php-transformer/site-artifact/v1` payload is limited to 4 MiB serialized, 500 safe unique files, 8 MiB per decoded file, and 32 MiB decoded in aggregate.

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

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RevisionConflict, type MarkdownPointer, type RevisionCoordinator, type RevisionLease, type RevisionState } from "./revision-coordinator.js"
1+
import { mutationFenceExpiresAt, revisionLeaseExpiresAt, RevisionConflict, type MarkdownPointer, type MutationFence, type MutationFenceStatus, type RevisionCoordinator, type RevisionLease, type RevisionState } from "./revision-coordinator.js"
22

33
interface StateRow {
44
revision: string | null
@@ -11,6 +11,11 @@ interface StateRow {
1111
lease_expires_at: number | null
1212
}
1313

14+
interface FenceRow {
15+
token: string
16+
expires_at: number
17+
}
18+
1419
const SITE_ID = "default"
1520
const LEASE_MS = 90_000
1621
const schemaReady = new WeakMap<object, Promise<void>>()
@@ -22,8 +27,12 @@ export class D1RevisionCoordinator implements RevisionCoordinator {
2227
return readWordPressState(this.database)
2328
}
2429

25-
acquire(): Promise<RevisionLease> {
26-
return beginStateLease(this.database, this.leaseMs)
30+
acquire(ttlMs = this.leaseMs): Promise<RevisionLease> {
31+
return beginStateLease(this.database, ttlMs)
32+
}
33+
34+
renew(lease: RevisionLease, ttlMs = this.leaseMs): Promise<RevisionLease> {
35+
return renewStateLease(this.database, lease, ttlMs)
2736
}
2837

2938
async release(lease: RevisionLease): Promise<void> {
@@ -42,6 +51,22 @@ export class D1RevisionCoordinator implements RevisionCoordinator {
4251
return readCommittedPointer(this.database, version)
4352
}
4453

54+
fenceStatus(): Promise<MutationFenceStatus> {
55+
return readMutationFence(this.database)
56+
}
57+
58+
acquireFence(ttlMs: number): Promise<MutationFence> {
59+
return acquireMutationFence(this.database, ttlMs)
60+
}
61+
62+
renewFence(token: string, ttlMs: number): Promise<MutationFence> {
63+
return renewMutationFence(this.database, token, ttlMs)
64+
}
65+
66+
async releaseFence(token: string): Promise<void> {
67+
await releaseMutationFence(this.database, token)
68+
}
69+
4570
adopt(pointer: MarkdownPointer, version: number): Promise<{ pointer: MarkdownPointer; version: number }> {
4671
return adoptWordPressState(this.database, pointer, version)
4772
}
@@ -66,13 +91,16 @@ async function beginStateLease(database: D1Database, leaseMs = LEASE_MS): Promis
6691
await ensureSchema(database)
6792
const now = Date.now()
6893
const token = crypto.randomUUID()
69-
const expiresAt = now + leaseMs
94+
const expiresAt = revisionLeaseExpiresAt(leaseMs, now)
7095
const result = await database.prepare(`UPDATE wp_codebox_state
7196
SET lease_token = ?, lease_base_revision = revision, lease_version = version, lease_expires_at = ?
72-
WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?)`)
73-
.bind(token, expiresAt, SITE_ID, now).run()
97+
WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?)
98+
AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)`)
99+
.bind(token, expiresAt, SITE_ID, now, SITE_ID, now).run()
74100
if (result.meta.changes !== 1) {
75101
const active = await readRow(database)
102+
const fence = await readFenceRow(database)
103+
if (fence && fence.expires_at > now) throw new RevisionConflict("Canonical WordPress mutations are fenced for coordinator cutover.", fence.expires_at)
76104
throw new RevisionConflict("A canonical WordPress lease is active.", active.lease_expires_at ?? undefined)
77105
}
78106
const row = await readRow(database)
@@ -85,6 +113,18 @@ async function releaseStateLease(database: D1Database, lease: RevisionLease): Pr
85113
return { released: true }
86114
}
87115

116+
async function renewStateLease(database: D1Database, lease: RevisionLease, leaseMs: number): Promise<RevisionLease> {
117+
await ensureSchema(database)
118+
const now = Date.now()
119+
const expiresAt = revisionLeaseExpiresAt(leaseMs, now)
120+
const result = await database.prepare(`UPDATE wp_codebox_state SET lease_expires_at = ?
121+
WHERE site_id = ? AND lease_token = ? AND lease_expires_at > ?
122+
AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)`)
123+
.bind(expiresAt, SITE_ID, lease.token, now, SITE_ID, now).run()
124+
if (result.meta.changes !== 1) throw new RevisionConflict("The canonical WordPress lease cannot renew because it expired or changed.")
125+
return { ...lease, expiresAt }
126+
}
127+
88128
async function abortStateLease(database: D1Database, lease: RevisionLease): Promise<{ aborted: true }> {
89129
await finishLease(database, lease, "abort")
90130
return { aborted: true }
@@ -120,6 +160,49 @@ async function readCommittedPointer(database: D1Database, version: number): Prom
120160
return pointer
121161
}
122162

163+
async function readMutationFence(database: D1Database): Promise<MutationFenceStatus> {
164+
await ensureSchema(database)
165+
const row = await readFenceRow(database)
166+
return row && row.expires_at > Date.now() ? { active: true, expiresAt: row.expires_at } : { active: false }
167+
}
168+
169+
async function acquireMutationFence(database: D1Database, ttlMs: number): Promise<MutationFence> {
170+
await ensureSchema(database)
171+
const now = Date.now()
172+
const fence = { token: crypto.randomUUID(), expiresAt: mutationFenceExpiresAt(ttlMs, now) }
173+
const result = await database.prepare(`INSERT INTO wp_codebox_fences (site_id, token, expires_at)
174+
SELECT ?, ?, ? WHERE EXISTS (
175+
SELECT 1 FROM wp_codebox_state WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?)
176+
)
177+
ON CONFLICT(site_id) DO UPDATE SET token = excluded.token, expires_at = excluded.expires_at
178+
WHERE wp_codebox_fences.expires_at <= ?`)
179+
.bind(SITE_ID, fence.token, fence.expiresAt, SITE_ID, now, now).run()
180+
if (result.meta.changes !== 1) {
181+
const [state, active] = await Promise.all([readRow(database), readFenceRow(database)])
182+
if (state.lease_token && state.lease_expires_at && state.lease_expires_at > now) throw new RevisionConflict("A canonical WordPress lease is active.", state.lease_expires_at)
183+
throw new RevisionConflict("A coordinator cutover fence is already active.", active?.expires_at)
184+
}
185+
return fence
186+
}
187+
188+
async function renewMutationFence(database: D1Database, token: string, ttlMs: number): Promise<MutationFence> {
189+
await ensureSchema(database)
190+
const now = Date.now()
191+
const fence = { token, expiresAt: mutationFenceExpiresAt(ttlMs, now) }
192+
const result = await database.prepare(`UPDATE wp_codebox_fences SET expires_at = ?
193+
WHERE site_id = ? AND token = ? AND expires_at > ?`).bind(fence.expiresAt, SITE_ID, token, now).run()
194+
if (result.meta.changes !== 1) throw new RevisionConflict("The coordinator cutover fence token is invalid or expired.")
195+
return fence
196+
}
197+
198+
async function releaseMutationFence(database: D1Database, token: string): Promise<{ released: true }> {
199+
await ensureSchema(database)
200+
const result = await database.prepare(`DELETE FROM wp_codebox_fences
201+
WHERE site_id = ? AND token = ? AND expires_at > ?`).bind(SITE_ID, token, Date.now()).run()
202+
if (result.meta.changes !== 1) throw new RevisionConflict("The coordinator cutover fence token is invalid or expired.")
203+
return { released: true }
204+
}
205+
123206
async function adoptWordPressState(database: D1Database, pointer: MarkdownPointer, version: number): Promise<{ pointer: MarkdownPointer; version: number }> {
124207
validatePointer(pointer)
125208
if (!Number.isSafeInteger(version) || version < 1) throw new RevisionConflict("A positive canonical version is required for D1 adoption.")
@@ -130,35 +213,47 @@ async function adoptWordPressState(database: D1Database, pointer: MarkdownPointe
130213
SET revision = ?, manifest_key = ?, persisted_at = ?, version = ?,
131214
lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL
132215
WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?)
216+
AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)
133217
AND ((revision IS NULL AND manifest_key IS NULL AND persisted_at IS NULL)
134218
OR (version = ? AND revision = ? AND manifest_key = ? AND persisted_at = ?))
135219
AND (NOT EXISTS (SELECT 1 FROM wp_codebox_commits WHERE site_id = ? AND version = ?)
136220
OR EXISTS (SELECT 1 FROM wp_codebox_commits WHERE site_id = ? AND version = ?
137221
AND revision = ? AND manifest_key = ? AND persisted_at = ?))`)
138-
.bind(pointer.revision, pointer.manifestKey, pointer.persistedAt, version, SITE_ID, now,
222+
.bind(pointer.revision, pointer.manifestKey, pointer.persistedAt, version, SITE_ID, now, SITE_ID, now,
139223
version, pointer.revision, pointer.manifestKey, pointer.persistedAt,
140224
SITE_ID, version, SITE_ID, version, pointer.revision, pointer.manifestKey, pointer.persistedAt),
141225
database.prepare(`INSERT OR IGNORE INTO wp_codebox_commits (site_id, version, revision, manifest_key, persisted_at)
142226
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),
227+
WHERE site_id = ? AND version = ? AND revision = ? AND manifest_key = ? AND persisted_at = ? AND lease_token IS NULL
228+
AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)`)
229+
.bind(SITE_ID, version, pointer.revision, pointer.manifestKey, pointer.persistedAt, SITE_ID, now),
145230
])
146231
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)
232+
if (state.version !== version || !samePointer(pointerFromRow(state), pointer) || !samePointer(committed, pointer)) {
233+
throw new RevisionConflict("D1 coordinator adoption requires empty or exactly matching state without an active lease or fence.")
149234
}
150235
return { pointer, version }
151236
}
152237

153238
async function resetWordPressState(database: D1Database): Promise<{ reset: true }> {
154239
await ensureSchema(database)
155-
await database.batch([
240+
const now = Date.now()
241+
const [reset] = await database.batch([
156242
database.prepare(`UPDATE wp_codebox_state
157243
SET revision = NULL, manifest_key = NULL, persisted_at = NULL, version = version + 1,
158244
lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL
159-
WHERE site_id = ?`).bind(SITE_ID),
160-
database.prepare(`DELETE FROM wp_codebox_commits WHERE site_id = ?`).bind(SITE_ID),
245+
WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?)
246+
AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)`)
247+
.bind(SITE_ID, now, SITE_ID, now),
248+
database.prepare(`DELETE FROM wp_codebox_commits WHERE site_id = ?
249+
AND EXISTS (SELECT 1 FROM wp_codebox_state WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?))
250+
AND NOT EXISTS (SELECT 1 FROM wp_codebox_fences WHERE site_id = ? AND expires_at > ?)`)
251+
.bind(SITE_ID, SITE_ID, now, SITE_ID, now),
161252
])
253+
if (reset.meta.changes !== 1) {
254+
const [state, fence] = await Promise.all([readRow(database), readFenceRow(database)])
255+
throw new RevisionConflict("Coordinator reset is blocked by an active canonical lease or cutover fence.", fence?.expires_at ?? state.lease_expires_at ?? undefined)
256+
}
162257
return { reset: true }
163258
}
164259

@@ -179,6 +274,10 @@ async function readRow(database: D1Database): Promise<StateRow> {
179274
return row
180275
}
181276

277+
async function readFenceRow(database: D1Database): Promise<FenceRow | null> {
278+
return database.prepare(`SELECT token, expires_at FROM wp_codebox_fences WHERE site_id = ?`).bind(SITE_ID).first<FenceRow>()
279+
}
280+
182281
function pointerFromRow(row: StateRow): MarkdownPointer | null {
183282
if (row.revision === null && row.manifest_key === null && row.persisted_at === null) return null
184283
const pointer = { revision: row.revision, manifestKey: row.manifest_key, persistedAt: row.persisted_at }
@@ -216,6 +315,7 @@ function ensureSchema(database: D1Database): Promise<void> {
216315
)`).run()
217316
await database.prepare(`INSERT OR IGNORE INTO wp_codebox_state (site_id, version) VALUES (?, 0)`).bind(SITE_ID).run()
218317
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()
318+
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_fences (site_id TEXT PRIMARY KEY, token TEXT NOT NULL, expires_at INTEGER NOT NULL)`).run()
219319
})()
220320
schemaReady.set(key, pending)
221321
pending.catch(() => schemaReady.delete(key))

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type WorkerRequestRoute =
66
| { kind: "operator-reset" }
77
| { kind: "operator-restore" }
88
| { kind: "operator-adopt" }
9+
| { kind: "operator-fence"; action: "status" | "acquire" | "renew" | "release" }
910
| { kind: "operator-static-artifact-import" }
1011
| { kind: "operator-publish" }
1112
| { kind: "probe"; phase: string }
@@ -19,6 +20,10 @@ export function routeWorkerRequest(request: Request): WorkerRequestRoute {
1920
if (phase === "operator-reset") return { kind: "operator-reset" }
2021
if (phase === "operator-restore") return { kind: "operator-restore" }
2122
if (phase === "operator-adopt") return { kind: "operator-adopt" }
23+
if (phase === "operator-fence-status") return { kind: "operator-fence", action: "status" }
24+
if (phase === "operator-fence-acquire") return { kind: "operator-fence", action: "acquire" }
25+
if (phase === "operator-fence-renew") return { kind: "operator-fence", action: "renew" }
26+
if (phase === "operator-fence-release") return { kind: "operator-fence", action: "release" }
2227
if (phase === "operator-static-artifact-import") return { kind: "operator-static-artifact-import" }
2328
if (phase === "operator-publish") return { kind: "operator-publish" }
2429
return { kind: "probe", phase }

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,44 @@ export interface RevisionState {
1818
version: number
1919
}
2020

21+
export interface MutationFence {
22+
token: string
23+
expiresAt: number
24+
}
25+
26+
export interface MutationFenceStatus {
27+
active: boolean
28+
expiresAt?: number
29+
}
30+
31+
export const MIN_MUTATION_FENCE_MS = 30_000
32+
export const MAX_MUTATION_FENCE_MS = 600_000
33+
export const MAX_REVISION_LEASE_MS = 600_000
34+
35+
export function mutationFenceExpiresAt(ttlMs: number, now = Date.now()): number {
36+
if (!Number.isSafeInteger(ttlMs) || ttlMs < MIN_MUTATION_FENCE_MS || ttlMs > MAX_MUTATION_FENCE_MS) {
37+
throw new RevisionConflict(`Mutation fence TTL must be between ${MIN_MUTATION_FENCE_MS / 1_000} and ${MAX_MUTATION_FENCE_MS / 1_000} seconds.`)
38+
}
39+
return now + ttlMs
40+
}
41+
42+
export function revisionLeaseExpiresAt(ttlMs: number, now = Date.now()): number {
43+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1 || ttlMs > MAX_REVISION_LEASE_MS) throw new RevisionConflict(`Revision lease TTL must be between 1 and ${MAX_REVISION_LEASE_MS} milliseconds.`)
44+
return now + ttlMs
45+
}
46+
2147
export interface RevisionCoordinator {
2248
state(): Promise<RevisionState>
23-
acquire(): Promise<RevisionLease>
49+
acquire(ttlMs?: number): Promise<RevisionLease>
50+
renew(lease: RevisionLease, ttlMs?: number): Promise<RevisionLease>
2451
release(lease: RevisionLease): Promise<void>
2552
abort(lease: RevisionLease): Promise<void>
2653
commit(lease: RevisionLease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }>
2754
committed(version: number): Promise<MarkdownPointer | null>
55+
fenceStatus(): Promise<MutationFenceStatus>
56+
acquireFence(ttlMs: number): Promise<MutationFence>
57+
renewFence(token: string, ttlMs: number): Promise<MutationFence>
58+
releaseFence(token: string): Promise<void>
2859
adopt(pointer: MarkdownPointer, version: number): Promise<{ pointer: MarkdownPointer; version: number }>
2960
reset(): Promise<void>
3061
}

0 commit comments

Comments
 (0)