Skip to content

Commit a811982

Browse files
committed
feat(runtime-cloudflare): add allocation lifecycle foundation
1 parent b6661f3 commit a811982

6 files changed

Lines changed: 184 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
"test:redaction": "tsx tests/redaction.test.ts",
111111
"test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts",
112112
"test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts",
113-
"test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit",
113+
"test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-allocation-lifecycle.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit",
114114
"test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts",
115115
"test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts",
116116
"test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts",
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { SiteContext } from "./site-context.js"
2+
3+
export type AllocationLifecycleState = "active" | "deleting" | "tombstoned"
4+
export interface AllocationIdentity { siteId: string; generation: number }
5+
export interface AllocationRetentionPolicy { ttlMs: number; retainMs: number }
6+
export interface AllocationDeletionReceipt { schema: "wp-codebox/cloudflare-allocation-deletion-receipt/v1"; siteId: string; generation: number; deletedObjects: number; completedAt: string }
7+
export interface AllocationLifecycle { identity: AllocationIdentity; principal: string; state: AllocationLifecycleState; expiresAt: number; retainUntil: number; mutationFence: number; operationFence: number; cleanupCursor: string | null; deletedObjects: number; receipt: AllocationDeletionReceipt | null }
8+
9+
const RECEIPT_SCHEMA = "wp-codebox/cloudflare-allocation-deletion-receipt/v1" as const
10+
const schemaReady = new WeakMap<object, Promise<void>>()
11+
12+
/** Durable lifecycle policy for elastic Cloudflare site allocations. */
13+
export class CloudflareAllocationLifecycle {
14+
constructor(private readonly database: D1Database, private readonly policy: AllocationRetentionPolicy = { ttlMs: 24 * 60 * 60 * 1000, retainMs: 60 * 60 * 1000 }) {
15+
if (!Number.isSafeInteger(policy.ttlMs) || !Number.isSafeInteger(policy.retainMs) || policy.ttlMs < 1 || policy.retainMs < 0) throw new Error("Allocation lifecycle policy is invalid.")
16+
}
17+
18+
async initialize(): Promise<void> { await ensureSchema(this.database) }
19+
async create(site: SiteContext, principal: string, now = Date.now()): Promise<AllocationLifecycle> {
20+
await this.initialize()
21+
const identity = allocationIdentity(site.id)
22+
await this.database.prepare("INSERT OR IGNORE INTO wp_codebox_site_lifecycles (site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, 1, 1, NULL, 0, ?, ?)").bind(site.id, identity.generation, principal, now + this.policy.ttlMs, now + this.policy.ttlMs + this.policy.retainMs, now, now).run()
23+
const lifecycle = await this.get(identity)
24+
if (!lifecycle || lifecycle.principal !== principal) throw new AllocationLifecycleConflict("Allocation identity conflicts with an existing owner.")
25+
return lifecycle
26+
}
27+
async get(identity: AllocationIdentity): Promise<AllocationLifecycle | null> {
28+
await this.initialize()
29+
const row = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, receipt_json FROM wp_codebox_site_lifecycles WHERE site_id = ? AND generation = ?").bind(identity.siteId, identity.generation).first<LifecycleRow>()
30+
return row ? hydrate(row) : null
31+
}
32+
async renew(identity: AllocationIdentity, principal: string, now = Date.now()): Promise<AllocationLifecycle> {
33+
await this.initialize()
34+
const result = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET expires_at = ?, retain_until = ?, updated_at = ? WHERE site_id = ? AND generation = ? AND principal = ? AND state = 'active' AND expires_at > ?").bind(now + this.policy.ttlMs, now + this.policy.ttlMs + this.policy.retainMs, now, identity.siteId, identity.generation, principal, now).run()
35+
if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not renewable by this owner.")
36+
return (await this.get(identity))!
37+
}
38+
async beginMutation(identity: AllocationIdentity, principal: string, now = Date.now()): Promise<number> {
39+
await this.initialize()
40+
const result = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET mutation_fence = mutation_fence + 1, updated_at = ? WHERE site_id = ? AND generation = ? AND principal = ? AND state = 'active' AND expires_at > ?").bind(now, identity.siteId, identity.generation, principal, now).run()
41+
if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not mutable by this owner.")
42+
return (await this.get(identity))!.mutationFence
43+
}
44+
async assertMutation(identity: AllocationIdentity, fence: number, now = Date.now()): Promise<void> {
45+
const lifecycle = await this.get(identity)
46+
if (!lifecycle || lifecycle.state !== "active" || lifecycle.expiresAt <= now || lifecycle.mutationFence !== fence) throw new AllocationLifecycleConflict("Allocation mutation fence changed.")
47+
}
48+
async beginDeletion(identity: AllocationIdentity, principal: string | null, now = Date.now()): Promise<number> {
49+
await this.initialize()
50+
const owner = principal === null ? "" : " AND principal = ?"
51+
const values: unknown[] = [now, identity.siteId, identity.generation]
52+
if (principal !== null) values.push(principal)
53+
const result = await this.database.prepare(`UPDATE wp_codebox_site_lifecycles SET state = 'deleting', mutation_fence = mutation_fence + 1, operation_fence = operation_fence + 1, cleanup_cursor = NULL, updated_at = ? WHERE site_id = ? AND generation = ?${owner} AND state = 'active'`).bind(...values).run()
54+
if (result.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation is not deletable by this owner.")
55+
return (await this.get(identity))!.operationFence
56+
}
57+
async expire(now = Date.now(), limit = 8): Promise<AllocationIdentity[]> {
58+
await this.initialize()
59+
const rows = await this.database.prepare("SELECT site_id, generation FROM wp_codebox_site_lifecycles WHERE state = 'active' AND expires_at <= ? ORDER BY expires_at LIMIT ?").bind(now, limit).all<Pick<LifecycleRow, "site_id" | "generation">>()
60+
const expired: AllocationIdentity[] = []
61+
for (const row of rows.results) {
62+
try { await this.beginDeletion({ siteId: row.site_id, generation: row.generation }, null, now); expired.push({ siteId: row.site_id, generation: row.generation }) } catch { /* A competing owner or sweeper already fenced it. */ }
63+
}
64+
return expired
65+
}
66+
async pendingDeletions(limit = 8): Promise<Array<AllocationLifecycle>> {
67+
await this.initialize()
68+
const rows = await this.database.prepare("SELECT site_id, generation, principal, state, expires_at, retain_until, mutation_fence, operation_fence, cleanup_cursor, deleted_objects, receipt_json FROM wp_codebox_site_lifecycles WHERE state = 'deleting' ORDER BY updated_at LIMIT ?").bind(limit).all<LifecycleRow>()
69+
return rows.results.map(hydrate)
70+
}
71+
async reclaim(bucket: Pick<R2Bucket, "list" | "delete">, identity: AllocationIdentity, operationFence: number, limit = 100, now = Date.now()): Promise<AllocationLifecycle> {
72+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("Allocation reclamation limit is invalid.")
73+
const lifecycle = await this.get(identity)
74+
if (!lifecycle || lifecycle.state !== "deleting" || lifecycle.operationFence !== operationFence) throw new AllocationLifecycleConflict("Allocation deletion fence changed.")
75+
const page = await bucket.list({ prefix: `sites/${identity.siteId}/`, cursor: lifecycle.cleanupCursor ?? undefined, limit })
76+
if (page.objects.length) await bucket.delete(page.objects.map((object) => object.key))
77+
const cursor = page.truncated ? page.cursor : null
78+
const updated = await this.database.prepare("UPDATE wp_codebox_site_lifecycles SET cleanup_cursor = ?, deleted_objects = deleted_objects + ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(cursor, page.objects.length, now, identity.siteId, identity.generation, operationFence).run()
79+
if (updated.meta.changes !== 1) throw new AllocationLifecycleConflict("Allocation deletion fence changed.")
80+
if (cursor) return (await this.get(identity))!
81+
const receipt: AllocationDeletionReceipt = { schema: RECEIPT_SCHEMA, siteId: identity.siteId, generation: identity.generation, deletedObjects: lifecycle.deletedObjects + page.objects.length, completedAt: new Date(now).toISOString() }
82+
await this.database.batch([
83+
this.database.prepare("INSERT OR IGNORE INTO wp_codebox_site_deletion_receipts (site_id, generation, receipt_json, created_at) VALUES (?, ?, ?, ?)").bind(identity.siteId, identity.generation, JSON.stringify(receipt), now),
84+
this.database.prepare("UPDATE wp_codebox_site_lifecycles SET state = 'tombstoned', cleanup_cursor = NULL, receipt_json = ?, terminal_at = ?, updated_at = ? WHERE site_id = ? AND generation = ? AND state = 'deleting' AND operation_fence = ?").bind(JSON.stringify(receipt), now, now, identity.siteId, identity.generation, operationFence),
85+
])
86+
return (await this.get(identity))!
87+
}
88+
}
89+
90+
export class AllocationLifecycleConflict extends Error {}
91+
export function allocationIdentity(siteId: string): AllocationIdentity {
92+
const match = /-g([1-9][0-9]*)-[a-f0-9]{16}$/.exec(siteId)
93+
return { siteId, generation: match ? Number(match[1]) : 1 }
94+
}
95+
96+
interface LifecycleRow { site_id: string; generation: number; principal: string; state: AllocationLifecycleState; expires_at: number; retain_until: number; mutation_fence: number; operation_fence: number; cleanup_cursor: string | null; deleted_objects: number; receipt_json: string | null }
97+
function hydrate(row: LifecycleRow): AllocationLifecycle { return { identity: { siteId: row.site_id, generation: row.generation }, principal: row.principal, state: row.state, expiresAt: row.expires_at, retainUntil: row.retain_until, mutationFence: row.mutation_fence, operationFence: row.operation_fence, cleanupCursor: row.cleanup_cursor, deletedObjects: row.deleted_objects, receipt: row.receipt_json ? JSON.parse(row.receipt_json) as AllocationDeletionReceipt : null } }
98+
async function ensureSchema(database: D1Database): Promise<void> {
99+
let pending = schemaReady.get(database as object)
100+
if (!pending) {
101+
pending = (async () => {
102+
await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('active','deleting','tombstoned')), expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run()
103+
await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_deletion_receipts (site_id TEXT NOT NULL, generation INTEGER NOT NULL, receipt_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run()
104+
await database.prepare("CREATE INDEX IF NOT EXISTS wp_codebox_site_lifecycle_expiry ON wp_codebox_site_lifecycles(state, expires_at)").run()
105+
})()
106+
schemaReady.set(database as object, pending)
107+
pending.catch(() => schemaReady.delete(database as object))
108+
}
109+
await pending
110+
}

packages/runtime-cloudflare/src/provisioning-api.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { D1OperationRepository, OperationConflict, type StaticArtifactOperation,
22
import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactImport, StaticArtifactImportError, validateStaticArtifact } from "./static-artifact-import.js"
33
import { allocatePreviewSiteContext, parseSiteContexts, previewDomain, siteStorageKeys, type SiteContext } from "./site-context.js"
44
import { deriveSiteCredential } from "./wordpress-auth.js"
5+
import { CloudflareAllocationLifecycle } from "./allocation-lifecycle.js"
56

67
export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1"
78
export const PROVISIONING_CREATE_REQUEST_SCHEMA = "wp-codebox/provisioning-create-request/v1"
@@ -255,7 +256,7 @@ class AllocationError extends Error { constructor(readonly code: "quota_exceeded
255256
class AllocationStore {
256257
constructor(private readonly db: D1Database) {}
257258
async allocate(token: Token, input: CreateInput, domain: ReturnType<typeof previewDomain>, configured: SiteContext[]): Promise<ProvisioningAllocation> {
258-
await this.schema(); const existing = await this.byRequest(token.principal, input.key)
259+
await this.schema(); const lifecycle = new CloudflareAllocationLifecycle(this.db); await lifecycle.initialize(); const existing = await this.byRequest(token.principal, input.key)
259260
if (existing) { if (existing.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return existing }
260261
if (domain && token.sites) throw new AllocationError("quota_exceeded")
261262
const attempts = domain ? 8 : configured.length
@@ -264,17 +265,20 @@ class AllocationStore {
264265
const now = Date.now()
265266
try {
266267
const [allocation, reservation] = await this.db.batch([
267-
this.db.prepare("INSERT OR IGNORE INTO wp_codebox_api_sites (site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id, created_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ? WHERE (SELECT COUNT(*) FROM wp_codebox_api_sites WHERE principal = ?) < ? AND NOT EXISTS (SELECT 1 FROM wp_codebox_sites WHERE site_id = ?)").bind(site.id, token.principal, input.key, input.fingerprint, input.artifactSha256, input.artifactSize, input.options.slug, input.options.name, input.options.siteTitle, now, token.principal, token.maxSites, site.id),
268+
this.db.prepare("INSERT OR IGNORE INTO wp_codebox_api_sites (site_id, principal, idempotency_key, fingerprint, artifact_sha256, artifact_size, slug, name, site_title, operation_id, created_at) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ? WHERE (SELECT COUNT(*) FROM wp_codebox_api_sites a LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = a.site_id WHERE a.principal = ? AND (l.site_id IS NULL OR l.state != 'tombstoned')) < ? AND NOT EXISTS (SELECT 1 FROM wp_codebox_sites WHERE site_id = ?)").bind(site.id, token.principal, input.key, input.fingerprint, input.artifactSha256, input.artifactSize, input.options.slug, input.options.name, input.options.siteTitle, now, token.principal, token.maxSites, site.id),
268269
this.db.prepare("INSERT INTO wp_codebox_sites (site_id, hostname, origin, state, created_at, activated_at, updated_at) SELECT ?, ?, ?, 'active', ?, ?, ? WHERE EXISTS (SELECT 1 FROM wp_codebox_api_sites WHERE site_id = ? AND principal = ? AND idempotency_key = ? AND fingerprint = ?)").bind(site.id, site.hostname, site.origin, now, now, now, site.id, token.principal, input.key, input.fingerprint),
269270
])
270-
if (allocation.meta.changes === 1 && reservation.meta.changes === 1) return { siteId: site.id, principal: token.principal, key: input.key, fingerprint: input.fingerprint, operationId: null, artifactSha256: input.artifactSha256, artifactSize: input.artifactSize, options: input.options }
271+
if (allocation.meta.changes === 1 && reservation.meta.changes === 1) {
272+
await lifecycle.create(site, token.principal, now)
273+
return { siteId: site.id, principal: token.principal, key: input.key, fingerprint: input.fingerprint, operationId: null, artifactSha256: input.artifactSha256, artifactSize: input.artifactSize, options: input.options }
274+
}
271275
} catch (error) {
272276
if (!(error instanceof Error) || !/unique|constraint/i.test(error.message)) throw error
273277
}
274278
const raced = await this.byRequest(token.principal, input.key)
275279
if (raced) { if (raced.fingerprint !== input.fingerprint) throw new OperationConflict("The idempotency key is already bound to a different immutable input."); return raced }
276280
}
277-
const count = await this.db.prepare("SELECT COUNT(*) AS count FROM wp_codebox_api_sites WHERE principal = ?").bind(token.principal).first<{ count: number }>()
281+
const count = await this.db.prepare("SELECT COUNT(*) AS count FROM wp_codebox_api_sites a LEFT JOIN wp_codebox_site_lifecycles l ON l.site_id = a.site_id WHERE a.principal = ? AND (l.site_id IS NULL OR l.state != 'tombstoned')").bind(token.principal).first<{ count: number }>()
278282
throw new AllocationError((count?.count ?? 0) >= token.maxSites ? "quota_exceeded" : "capacity_exhausted")
279283
}
280284
async context(siteId: string): Promise<SiteContext | null> { await this.schema(); const row = await this.db.prepare("SELECT site_id, hostname, origin FROM wp_codebox_sites WHERE site_id = ? AND state = 'active'").bind(siteId).first<{ site_id: string; hostname: string; origin: string }>(); return row ? { id: row.site_id, hostname: row.hostname, origin: row.origin } : null }

packages/runtime-cloudflare/src/worker.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { routeWorkerRequest } from "./request-routing.js"
1515
import { readStaticArtifactImport, STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA, StaticArtifactImportError, type StaticArtifactImport } from "./static-artifact-import.js"
1616
import { D1OperationRepository, OperationConflict, STATIC_ARTIFACT_OPERATION_SCHEMA, shouldRecoverPreparedCommit } from "./d1-operation-repository.js"
1717
import { resumeProvisioningAllocation, routeProvisioningApi } from "./provisioning-api.js"
18+
import { CloudflareAllocationLifecycle } from "./allocation-lifecycle.js"
1819
import { toFetchResponse, toPHPRequest } from "./request-translation.js"
1920
import { DEFAULT_SITE_CONTEXT, parseSiteContexts, previewDomain, resolvePreviewSiteContextFromRequest, resolveSiteContextFromRequest, siteStorageKeys, type SiteContext } from "./site-context.js"
2021
import { validateUploadManifestFiles, validateUploadMetadata } from "./upload-persistence.js"
@@ -279,6 +280,12 @@ export function createCloudflareRuntime<Env extends RuntimeEnv>(
279280
}
280281
},
281282
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
283+
if (env.WORDPRESS_STATE_DATABASE) {
284+
const lifecycle = new CloudflareAllocationLifecycle(env.WORDPRESS_STATE_DATABASE)
285+
await lifecycle.expire(controller.scheduledTime, 1)
286+
const deleting = (await lifecycle.pendingDeletions(1))[0]
287+
if (deleting) await lifecycle.reclaim(env.WORDPRESS_STATE_BUCKET, deleting.identity, deleting.operationFence, 100, controller.scheduledTime)
288+
}
282289
const configured = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS)
283290
const registered = env.WORDPRESS_STATE_DATABASE && resolveOperations?.(env) ? await resolveOperations(env)!.activeSites() : []
284291
const sites = [...new Map([...configured, ...registered].map((site) => [site.id, site])).values()].sort((left, right) => left.id.localeCompare(right.id))

0 commit comments

Comments
 (0)