Skip to content

Commit 43d38da

Browse files
committed
Add Cloudflare queue dispatch foundation
1 parent 4a3ae64 commit 43d38da

8 files changed

Lines changed: 147 additions & 31 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
"test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts",
113113
"test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts",
114114
"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 && tsx tests/cloudflare-public-reader.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit",
115+
"test:cloudflare-queue": "tsx tests/cloudflare-d1-operation-repository.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit",
115116
"test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts",
116117
"test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts",
117118
"test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts",

packages/runtime-cloudflare/src/d1-operation-repository.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { MarkdownPointer } from "./revision-coordinator.js"
22
import type { SiteContext } from "./site-context.js"
3+
import { runtimeQueueMessage, type RuntimeQueueMessage } from "./queue-dispatch.js"
34

45
export const STATIC_ARTIFACT_OPERATION_SCHEMA = "wp-codebox/cloudflare-static-artifact-operation/v1"
56
export type StaticArtifactOperationState = "queued" | "running" | "retryable" | "publication-pending" | "succeeded" | "failed"
@@ -77,6 +78,31 @@ export class D1OperationRepository {
7778
return rows.results.map((row) => ({ id: row.site_id, hostname: row.hostname, origin: row.origin }))
7879
}
7980

81+
async stageDispatch(site: SiteContext, kind: RuntimeQueueMessage["kind"], identity: string): Promise<RuntimeQueueMessage> {
82+
await ensureSchema(this.database)
83+
const row = await this.database.prepare("SELECT generation FROM wp_codebox_sites WHERE site_id = ? AND state = 'active'").bind(site.id).first<{ generation: number }>()
84+
if (!row) throw new OperationConflict("The site is not active for queue dispatch.")
85+
const message = runtimeQueueMessage(site, row.generation, kind, identity)
86+
const now = Date.now()
87+
await this.database.prepare("INSERT OR IGNORE INTO wp_codebox_runtime_dispatches (site_id, generation, kind, identity, state, attempts, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)").bind(site.id, row.generation, kind, identity, now, now).run()
88+
return message
89+
}
90+
91+
async matchesGeneration(siteId: string, generation: number): Promise<boolean> { await ensureSchema(this.database); const row = await this.database.prepare("SELECT generation FROM wp_codebox_sites WHERE site_id = ? AND state = 'active'").bind(siteId).first<{ generation: number }>(); return row?.generation === generation }
92+
93+
async deliveredDispatch(message: RuntimeQueueMessage): Promise<void> { await ensureSchema(this.database); await this.database.prepare("UPDATE wp_codebox_runtime_dispatches SET state = 'sent', attempts = attempts + 1, last_error = NULL, updated_at = ? WHERE site_id = ? AND generation = ? AND kind = ? AND identity = ? AND state != 'done'").bind(Date.now(), message.siteId, message.generation, message.kind, message.identity).run() }
94+
async failedDispatch(message: RuntimeQueueMessage, error: unknown): Promise<void> { await ensureSchema(this.database); await this.database.prepare("UPDATE wp_codebox_runtime_dispatches SET state = 'pending', attempts = attempts + 1, last_error = ?, updated_at = ? WHERE site_id = ? AND generation = ? AND kind = ? AND identity = ? AND state != 'done'").bind(sanitizeError(error).message, Date.now(), message.siteId, message.generation, message.kind, message.identity).run() }
95+
async completeDispatch(message: RuntimeQueueMessage): Promise<void> { await ensureSchema(this.database); await this.database.prepare("UPDATE wp_codebox_runtime_dispatches SET state = 'done', updated_at = ? WHERE site_id = ? AND generation = ? AND kind = ? AND identity = ?").bind(Date.now(), message.siteId, message.generation, message.kind, message.identity).run() }
96+
async pendingDispatches(limit = 32): Promise<RuntimeQueueMessage[]> { await ensureSchema(this.database); const rows = await this.database.prepare("SELECT d.site_id, d.generation, d.kind, d.identity FROM wp_codebox_runtime_dispatches d JOIN wp_codebox_sites s ON s.site_id = d.site_id AND s.generation = d.generation WHERE d.state = 'pending' AND s.state = 'active' ORDER BY d.updated_at LIMIT ?").bind(limit).all<{ site_id: string; generation: number; kind: RuntimeQueueMessage["kind"]; identity: string }>(); return rows.results.map((row) => runtimeQueueMessage({ id: row.site_id }, row.generation, row.kind, row.identity)) }
97+
async deadLetter(message: RuntimeQueueMessage, attempts: number, error: unknown): Promise<void> {
98+
await ensureSchema(this.database)
99+
const failure = sanitizeError(error)
100+
const input = message.kind === "operation" ? await this.database.prepare("SELECT fingerprint FROM wp_codebox_operations WHERE site_id = ? AND operation_id = ?").bind(message.siteId, message.identity).first<{ fingerprint: string }>() : null
101+
const digest = input?.fingerprint ?? message.identity.split("-").at(-1) ?? message.identity
102+
const command = `wp-codebox queue recover --site ${message.siteId} --generation ${message.generation} --${message.kind} ${message.identity}`
103+
await this.database.prepare("INSERT INTO wp_codebox_runtime_dead_letters (site_id, generation, kind, identity, attempts, input_digest, last_error, recovery_command, recorded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").bind(message.siteId, message.generation, message.kind, message.identity, attempts, digest, failure.message, command, Date.now()).run()
104+
}
105+
80106
async claimNext(siteId: string, now = Date.now()): Promise<(StaticArtifactOperation & { claimToken: string }) | null> {
81107
await ensureSchema(this.database)
82108
// A dynamic allocation may be fenced between queue deliveries. Configured
@@ -204,6 +230,9 @@ async function ensureSchema(database: D1Database): Promise<void> {
204230
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operation_attempts (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, attempt_number INTEGER NOT NULL, claim_token TEXT NOT NULL, started_at TEXT NOT NULL, completed_at TEXT, state TEXT NOT NULL, stage TEXT NOT NULL, error_code TEXT, error_message TEXT, PRIMARY KEY (site_id, operation_id, attempt_number), FOREIGN KEY (site_id, operation_id) REFERENCES wp_codebox_operations(site_id, operation_id))`).run()
205231
await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_one_active_operation ON wp_codebox_operations(site_id) WHERE state IN ('queued','running','retryable')`).run()
206232
await database.prepare(`CREATE INDEX IF NOT EXISTS wp_codebox_operation_ready ON wp_codebox_operations(site_id, state, retry_at, claim_expires_at, created_at)`).run()
233+
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_runtime_dispatches (site_id TEXT NOT NULL, generation INTEGER NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('operation','publication')), identity TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending','sent','done')), attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation, kind, identity))`).run()
234+
await database.prepare(`CREATE INDEX IF NOT EXISTS wp_codebox_runtime_dispatch_pending ON wp_codebox_runtime_dispatches(state, updated_at)`).run()
235+
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_runtime_dead_letters (site_id TEXT NOT NULL, generation INTEGER NOT NULL, kind TEXT NOT NULL, identity TEXT NOT NULL, attempts INTEGER NOT NULL, input_digest TEXT NOT NULL, last_error TEXT NOT NULL, recovery_command TEXT NOT NULL, recorded_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation, kind, identity, attempts))`).run()
207236
})()
208237
schemaReady.set(database as object, pending)
209238
pending.catch(() => schemaReady.delete(database as object))

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { D1OperationRepository, OperationConflict, type StaticArtifactOperation, type StaticArtifactOperationInput } from "./d1-operation-repository.js"
2+
import type { RuntimeQueue } from "./queue-dispatch.js"
23
import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactImport, StaticArtifactImportError, validateStaticArtifact } from "./static-artifact-import.js"
34
import { allocatePreviewSiteContext, parseSiteContexts, previewDomain, siteStorageKeys, type SiteContext } from "./site-context.js"
45
import { deriveSiteCredential } from "./wordpress-auth.js"
@@ -17,7 +18,7 @@ export interface ProvisioningAllocation {
1718
artifactSha256: string; artifactSize: number; options: StaticArtifactOperationInput["options"]
1819
}
1920
interface CreateInput { key: string; fingerprint: string; artifactSha256: string; artifactSize: number; options: StaticArtifactOperationInput["options"] }
20-
export interface ProvisioningEnv { WORDPRESS_STATE_DATABASE: D1Database; WORDPRESS_STATE_BUCKET: R2Bucket; WORDPRESS_SITE_CONTEXTS?: string; WORDPRESS_PREVIEW_DOMAIN?: string; WORDPRESS_PREVIEW_HOST_SECRET?: string; WORDPRESS_API_TOKENS?: string; WORDPRESS_ADMIN_CLAIM_SECRET?: string; WORDPRESS_ADMIN_PASSWORD?: string }
21+
export interface ProvisioningEnv { WORDPRESS_STATE_DATABASE: D1Database; WORDPRESS_STATE_BUCKET: R2Bucket; WORDPRESS_RUNTIME_QUEUE?: RuntimeQueue; WORDPRESS_SITE_CONTEXTS?: string; WORDPRESS_PREVIEW_DOMAIN?: string; WORDPRESS_PREVIEW_HOST_SECRET?: string; WORDPRESS_API_TOKENS?: string; WORDPRESS_ADMIN_CLAIM_SECRET?: string; WORDPRESS_ADMIN_PASSWORD?: string }
2122

2223
export async function routeProvisioningApi(request: Request, env: ProvisioningEnv, operations: D1OperationRepository): Promise<Response> {
2324
const parts = new URL(request.url).pathname.split("/").filter(Boolean)
@@ -38,6 +39,11 @@ export async function routeProvisioningApi(request: Request, env: ProvisioningEn
3839
return notFound()
3940
}
4041

42+
async function dispatchOperation(env: ProvisioningEnv, operations: D1OperationRepository, site: SiteContext, operation: StaticArtifactOperation): Promise<void> {
43+
const message = await operations.stageDispatch(site, "operation", operation.operationId)
44+
try { if (!env.WORDPRESS_RUNTIME_QUEUE) throw new Error("Runtime queue binding is unavailable."); await env.WORDPRESS_RUNTIME_QUEUE.send(message); await operations.deliveredDispatch(message) } catch (error) { await operations.failedDispatch(message, error); console.error("Runtime queue producer is backpressured; reconciliation will retry.", error) }
45+
}
46+
4147
async function lifecycleStatus(request: Request, env: ProvisioningEnv, siteId: string): Promise<Response> {
4248
const token = await authenticate(request, env, "sites:read"); if (token instanceof Response) return token
4349
const allocation = await new AllocationStore(env.WORDPRESS_STATE_DATABASE).bySite(siteId)
@@ -153,6 +159,7 @@ async function importSite(request: Request, env: ProvisioningEnv, operations: D1
153159
const input = await readStaticArtifactImport(request, env.WORDPRESS_STATE_BUCKET, site)
154160
if (input.idempotencyKey !== key) return apiError(409, "idempotency_conflict", "Idempotency-Key must match the import request.")
155161
const result = await operations.createOrConverge(site, { ...input, artifact: input.artifactReference })
162+
await dispatchOperation(env, operations, site, result.operation)
156163
await store.linkOperation(token.principal, siteId, result.operation.operationId, "import", key)
157164
return operationResource(siteId, result.operation, 202)
158165
} catch (error) { return error instanceof OperationConflict ? apiError(409, "operation_conflict", error.message) : importError(error) }
@@ -187,6 +194,7 @@ export async function resumeProvisioningAllocation(env: ProvisioningEnv, site: S
187194
const result = await operations.createOrConverge(site, input)
188195
await store.bindOperation(allocation, result.operation.operationId)
189196
await store.linkOperation(allocation.principal, site.id, result.operation.operationId, "provision", allocation.key)
197+
await dispatchOperation(env, operations, site, result.operation)
190198
return result.operation
191199
}
192200

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { SiteContext } from "./site-context.js"
2+
3+
export const RUNTIME_QUEUE_MESSAGE_SCHEMA = "wp-codebox/runtime-dispatch/v1"
4+
export const RUNTIME_QUEUE_MAX_ATTEMPTS = 3
5+
6+
/** Queue messages wake durable work; they never own canonical state. */
7+
export type RuntimeQueueMessage = { schema: typeof RUNTIME_QUEUE_MESSAGE_SCHEMA; siteId: string; generation: number; kind: "operation" | "publication"; identity: string }
8+
9+
export function runtimeQueueMessage(site: Pick<SiteContext, "id">, generation: number, kind: RuntimeQueueMessage["kind"], identity: string): RuntimeQueueMessage {
10+
if (!/^[a-z0-9][a-z0-9-]{0,127}$/.test(site.id) || !Number.isSafeInteger(generation) || generation < 1 || !identity || identity.length > 1024) throw new Error("Runtime queue dispatch identity is invalid.")
11+
return { schema: RUNTIME_QUEUE_MESSAGE_SCHEMA, siteId: site.id, generation, kind, identity }
12+
}
13+
14+
export function parseRuntimeQueueMessage(value: unknown): RuntimeQueueMessage | null {
15+
if (!value || typeof value !== "object") return null
16+
const message = value as Partial<RuntimeQueueMessage>
17+
if (message.schema !== RUNTIME_QUEUE_MESSAGE_SCHEMA || (message.kind !== "operation" && message.kind !== "publication") || typeof message.siteId !== "string" || !/^[a-z0-9][a-z0-9-]{0,127}$/.test(message.siteId) || !Number.isSafeInteger(message.generation) || message.generation! < 1 || typeof message.identity !== "string" || !message.identity || message.identity.length > 1024) return null
18+
return message as RuntimeQueueMessage
19+
}
20+
21+
export interface RuntimeQueue { send(message: RuntimeQueueMessage): Promise<void> }

0 commit comments

Comments
 (0)