Skip to content

Commit b0525f3

Browse files
committed
Fence Cloudflare coordinator cutovers
1 parent 8d01722 commit b0525f3

8 files changed

Lines changed: 485 additions & 57 deletions

File tree

packages/runtime-cloudflare/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ The bundled MDI source is pinned to immutable commit `bf6d434d1673fdd86d777501f7
2525
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.
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.
28+
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+
2835
## Verification
2936

3037
1. Run `npm run generate:cloudflare-canonical-mdi-seed` and `npm run generate:cloudflare-wordpress-runtime-corpus` to regenerate deterministic runtime artifacts and manifests.

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-publish" }
1011
| { kind: "probe"; phase: string }
1112

@@ -18,6 +19,10 @@ export function routeWorkerRequest(request: Request): WorkerRequestRoute {
1819
if (phase === "operator-reset") return { kind: "operator-reset" }
1920
if (phase === "operator-restore") return { kind: "operator-restore" }
2021
if (phase === "operator-adopt") return { kind: "operator-adopt" }
22+
if (phase === "operator-fence-status") return { kind: "operator-fence", action: "status" }
23+
if (phase === "operator-fence-acquire") return { kind: "operator-fence", action: "acquire" }
24+
if (phase === "operator-fence-renew") return { kind: "operator-fence", action: "renew" }
25+
if (phase === "operator-fence-release") return { kind: "operator-fence", action: "release" }
2126
if (phase === "operator-publish") return { kind: "operator-publish" }
2227
return { kind: "probe", phase }
2328
}

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)