diff --git a/apps/api/.gitignore b/apps/api/.gitignore index b3b731385..c4eddc33c 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -1,3 +1,4 @@ -# cold-path measurement bundle output -.coldpath-out +# Cold-path measurement outputs +.coldpath-* +*.cpuprofile diff --git a/apps/api/package.json b/apps/api/package.json index 6aa270fbf..364c546e1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,7 +26,8 @@ "bench:run": "bun run scripts/bench-queries.ts run", "bench:inspect": "bun run scripts/bench-queries.ts inspect", "bench:compare": "bun run scripts/bench-queries.ts compare", - "bench:startup-cpu": "bun run scripts/bench-startup-cpu.ts" + "bench:startup-cpu": "bun run scripts/bench-startup-cpu.ts", + "bench:service-bindings": "bun run scripts/bench-service-bindings.ts" }, "dependencies": { "@clerk/backend": "^2.30.1", diff --git a/apps/api/scripts/bench-service-bindings.ts b/apps/api/scripts/bench-service-bindings.ts new file mode 100644 index 000000000..98967f001 --- /dev/null +++ b/apps/api/scripts/bench-service-bindings.ts @@ -0,0 +1,155 @@ +import { spawn } from "node:child_process" +import { fileURLToPath } from "node:url" + +const samples = Number(process.argv.find((arg) => arg.startsWith("--samples="))?.split("=")[1] ?? 250) +const port = Number(process.argv.find((arg) => arg.startsWith("--port="))?.split("=")[1] ?? 9897) +const benchDir = fileURLToPath(new URL("./service-binding-bench/", import.meta.url)) +const wranglerCli = fileURLToPath(new URL("../node_modules/wrangler/wrangler-dist/cli.js", import.meta.url)) +const baseUrl = `http://127.0.0.1:${port}` + +const configs = ["router", "echo", "monolith", "telemetry"].flatMap((name) => [ + "-c", + `${benchDir}${name}.wrangler.jsonc`, +]) + +// Run Wrangler's real CLI instead of its thin bin wrapper. The wrapper does +// not forward SIGTERM to the CLI process it spawns, which used to orphan both +// Wrangler and workerd after every benchmark run. A detached POSIX process +// group lets cleanup address the whole tree, including workerd descendants. +const wrangler = spawn("node", ["--no-warnings", wranglerCli, "dev", ...configs, "--port", String(port)], { + cwd: fileURLToPath(new URL("../", import.meta.url)), + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], +}) + +if (wrangler.pid === undefined) throw new Error("wrangler did not start") +const wranglerPid = wrangler.pid + +let wranglerLog = "" +for (const stream of [wrangler.stdout, wrangler.stderr]) { + stream.setEncoding("utf8") + stream.on("data", (chunk: string) => { + wranglerLog = `${wranglerLog}${chunk}`.slice(-16_000) + }) +} + +const isMissingProcess = (error: unknown): boolean => + typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH" + +const signalWranglerTree = (signal: NodeJS.Signals | 0): boolean => { + try { + if (process.platform === "win32") { + return signal === 0 ? wrangler.kill(0) : wrangler.kill(signal) + } + process.kill(-wranglerPid, signal) + return true + } catch (error) { + if (isMissingProcess(error)) return false + throw error + } +} + +const waitForWranglerTreeExit = async (timeoutMs: number): Promise => { + const deadline = Date.now() + timeoutMs + while (signalWranglerTree(0) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + return !signalWranglerTree(0) +} + +let stopPromise: Promise | undefined +const stop = (): Promise => + (stopPromise ??= (async () => { + if (!signalWranglerTree(0)) return + signalWranglerTree("SIGTERM") + if (await waitForWranglerTreeExit(5_000)) return + signalWranglerTree("SIGKILL") + if (!(await waitForWranglerTreeExit(1_000))) { + throw new Error(`wrangler process tree ${wranglerPid} did not stop`) + } + })()) + +const stopForSignal = (exitCode: number) => { + void stop().finally(() => process.exit(exitCode)) +} +const onSigint = () => stopForSignal(130) +const onSigterm = () => stopForSignal(143) +process.once("SIGINT", onSigint) +process.once("SIGTERM", onSigterm) + +const waitUntilReady = async () => { + const deadline = Date.now() + 45_000 + while (Date.now() < deadline) { + if (wrangler.exitCode !== null) throw new Error(`wrangler exited early\n${wranglerLog}`) + try { + const response = await fetch(`${baseUrl}/ready`) + if (response.ok) return + } catch { + // The listener is not ready yet. + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`wrangler did not become ready\n${wranglerLog}`) +} + +const percentile = (values: ReadonlyArray, fraction: number): number => { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] ?? 0 +} + +const summarize = (values: ReadonlyArray) => ({ + medianMs: percentile(values, 0.5), + p95Ms: percentile(values, 0.95), + minMs: Math.min(...values), +}) + +const samplePath = async (pathname: string): Promise> => { + const timings: Array = [] + for (let index = 0; index < samples; index++) { + const response = await fetch(`${baseUrl}${pathname}`) + if (!response.ok) throw new Error(`${pathname} returned ${response.status}`) + await response.arrayBuffer() + const timing = Number(response.headers.get("x-service-binding-ms")) + if (!Number.isFinite(timing)) throw new Error(`${pathname} omitted x-service-binding-ms`) + timings.push(timing) + } + return timings +} + +try { + await waitUntilReady() + // Warm both isolates and the local HTTP connection before collecting samples. + await fetch(`${baseUrl}/direct`) + await fetch(`${baseUrl}/bound`) + + const [direct, bound] = await Promise.all([samplePath("/direct"), samplePath("/bound")]) + const monolith = await (await fetch(`${baseUrl}/probe/monolith`)).json() + const telemetry = await (await fetch(`${baseUrl}/probe/telemetry`)).json() + const monolithWarm = await (await fetch(`${baseUrl}/probe/monolith`)).json() + const telemetryWarm = await (await fetch(`${baseUrl}/probe/telemetry`)).json() + + console.log( + JSON.stringify( + { + samples, + warmRouter: { + direct: summarize(direct), + serviceBinding: summarize(bound), + }, + coldModuleGraph: { monolith, telemetryIsland: telemetry }, + warmModuleGraph: { monolith: monolithWarm, telemetryIsland: telemetryWarm }, + notes: [ + "Service-binding timings are measured inside the router around binding.fetch().", + "Module timings measure dynamic import/evaluation, not database or warehouse I/O.", + "Local workerd numbers are relative evidence; production placement still needs a canary.", + ], + }, + null, + 2, + ), + ) +} finally { + process.off("SIGINT", onSigint) + process.off("SIGTERM", onSigterm) + await stop() +} diff --git a/apps/api/scripts/service-binding-bench/README.md b/apps/api/scripts/service-binding-bench/README.md new file mode 100644 index 000000000..e8357b5a0 --- /dev/null +++ b/apps/api/scripts/service-binding-bench/README.md @@ -0,0 +1,95 @@ +# Service-bound route worker benchmark + +Measured on 2026-08-23 with Bun 1.4.0, Wrangler 4.118.0, and local workerd on an Apple Silicon +development machine. These are relative local measurements, not production latency promises. + +## What implementation actually exists + +Hono does **not** create sub-workers for mounted routes. Its +[`HonoBase.route()` implementation](https://github.com/honojs/hono/blob/main/src/hono-base.ts) +copies a sub-application's handlers into the same in-process router. + +The architecture that matches the remembered behavior is Cloudflare's +[HTTP service-binding API gateway pattern](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/http/): +a small public Worker selects a coarse route island and forwards the original `Request` to a private +Worker. Cloudflare documents service bindings as running on the same thread and server by default, +with no additional request charge, while each hop still counts toward the 32-Worker invocation +limit. + +## Existing stack + +Every request sample starts a fresh `wrangler dev` process, waits for the listener, and then sends +the first request. The workerd startup profile is measured separately by `wrangler check startup`. +Medians use five request runs (three for `/v2/services`). + +| Metric | `main` | Lazy HTTP routes | Cold bootstrap | +| ----------------------------------------------- | -----------------------: | -----------------------: | -----------------------: | +| workerd active startup CPU | 114.2 ms | 117.8 ms | 30.7 ms | +| bundle (raw / gzip) | 12,895.31 / 2,544.05 KiB | 12,895.60 / 2,544.19 KiB | 12,832.11 / 2,534.29 KiB | +| desktop startup graph eval | 98 ms | 100 ms | 34 ms | +| desktop startup heap delta | 30.85 MB | 30.85 MB | 5.03 MB | +| cold `GET /health` request | 202.3 ms | 125.6 ms | 7.4 ms | +| cold unauthenticated `GET /v2/services` request | 207.3 ms | 129.0 ms | 217.5 ms | +| startup CPU + `/v2/services` local proxy | 321.5 ms | 246.8 ms | 248.2 ms | + +Interpretation: + +- Lazy endpoint compilation cuts the first real-route proxy by about 23%, while leaving upload-time + startup unchanged as designed. +- The cold-bootstrap layer removes 73% of active startup CPU, 84% of startup heap, and 96% of the + cold health-request time. +- The generated anticipated-error list moves domain-schema evaluation from startup to the first + real route. Consequently, request-only `/v2/services` time rises, but the combined local cold + proxy stays flat versus the lazy-route layer. It solves startup-budget and liveness risk; it does + not claim a further real-route cold win. + +## Route-island experiment + +Run: + +```bash +bun run --cwd apps/api bench:service-bindings --samples=250 +``` + +The router measures `binding.fetch()` inside workerd. The two probe Workers compare today's complete +HTTP/service module graph with the smallest useful telemetry-route module graph using current module +boundaries. Seven fresh-process runs produced: + +| Graph | Cold module-evaluation samples | Median | +| ---------------- | ------------------------------------ | -----: | +| monolith | 153, 154, 150, 151, 157, 152, 152 ms | 152 ms | +| telemetry island | 107, 101, 101, 101, 103, 102, 102 ms | 102 ms | + +The telemetry island reduces module evaluation by **32.9%**. A representative 250-request warm run +measured the service-binding call at **0 ms median / 1 ms p95** at workerd's timer resolution. + +This is enough evidence to build a functional canary, not enough evidence to route production +traffic immediately. The probe evaluates real Maple modules, but intentionally excludes Effect +Layer acquisition, authentication results, database dialing, and warehouse I/O. + +## Recommended rollout and acceptance gate + +This benchmark is intentionally separate from the two runtime optimizations it measured. The +implementation sequence, topology, developer experience, production canary, and rollback design +live in [`docs/cloudflare-native-api.md`](../../../../docs/cloudflare-native-api.md). + +Use coarse islands, not one Worker per endpoint. Start with the read-heavy v2 telemetry surface; it +has a coherent dependency graph and the benchmarked 34% module-evaluation reduction. Keep health and +preflight handling in the tiny public router. Let the target Worker own auth, request scope, +telemetry, and its per-invocation Hyperdrive connection. A shared "database Worker" would violate +Maple's request-bound connection lifecycle and add a hop without preserving a socket between +invocations. + +Deploy the private target before the router, then canary a small share of telemetry requests and +compare against the monolith for at least one full traffic cycle. Ship the split only if all of these +hold: + +1. Cold telemetry-route p50 and p95 improve by at least 15%. +2. Warm route p95 regresses by less than 2 ms. +3. Error rate, response bytes, CORS headers, auth envelopes, and tracing are equivalent. +4. Router plus target CPU is lower at cold p95 and no higher at warm p95. +5. Target startup, heap, compressed size, subrequests, and Worker-invocation depth remain inside + Cloudflare limits with at least 25% headroom. + +If the functional canary misses either latency gate, keep the first two stack layers and drop the +route-worker layer; the benchmark harness remains useful for future module-boundary changes. diff --git a/apps/api/scripts/service-binding-bench/echo.worker.ts b/apps/api/scripts/service-binding-bench/echo.worker.ts new file mode 100644 index 000000000..532f319b9 --- /dev/null +++ b/apps/api/scripts/service-binding-bench/echo.worker.ts @@ -0,0 +1,8 @@ +export default { + fetch(): Response { + const startedAt = performance.now() + const response = new Response("ok") + response.headers.set("x-target-handler-ms", (performance.now() - startedAt).toFixed(4)) + return response + }, +} diff --git a/apps/api/scripts/service-binding-bench/echo.wrangler.jsonc b/apps/api/scripts/service-binding-bench/echo.wrangler.jsonc new file mode 100644 index 000000000..98116b24c --- /dev/null +++ b/apps/api/scripts/service-binding-bench/echo.wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "maple-api-route-echo-bench", + "main": "./echo.worker.ts", + "compatibility_date": "2026-04-08", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/apps/api/scripts/service-binding-bench/monolith-probe.worker.ts b/apps/api/scripts/service-binding-bench/monolith-probe.worker.ts new file mode 100644 index 000000000..0e41b8abf --- /dev/null +++ b/apps/api/scripts/service-binding-bench/monolith-probe.worker.ts @@ -0,0 +1,28 @@ +let initialized = false +let initialization: Promise | undefined + +const initialize = (): Promise => { + if (initialization !== undefined) return initialization + initialization = Promise.all([ + import("../../src/runtime/service-graph"), + import("../../src/runtime/http-graph"), + import("../../src/platform/DatabasePgLive"), + import("../../src/platform/pg-connection-scope"), + ]).then(() => undefined) + return initialization +} + +export default { + async fetch(): Promise { + const wasInitialized = initialized + const startedAt = performance.now() + await initialize() + const moduleEvaluationMs = performance.now() - startedAt + initialized = true + return Response.json({ + graph: "monolith", + cached: wasInitialized, + moduleEvaluationMs, + }) + }, +} diff --git a/apps/api/scripts/service-binding-bench/monolith.wrangler.jsonc b/apps/api/scripts/service-binding-bench/monolith.wrangler.jsonc new file mode 100644 index 000000000..59514660b --- /dev/null +++ b/apps/api/scripts/service-binding-bench/monolith.wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "maple-api-route-monolith-bench", + "main": "./monolith-probe.worker.ts", + "compatibility_date": "2026-04-08", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/apps/api/scripts/service-binding-bench/router.worker.ts b/apps/api/scripts/service-binding-bench/router.worker.ts new file mode 100644 index 000000000..58fa5a8f2 --- /dev/null +++ b/apps/api/scripts/service-binding-bench/router.worker.ts @@ -0,0 +1,41 @@ +interface ServiceBinding { + fetch(request: Request): Promise +} + +interface Env { + ECHO: ServiceBinding + MONOLITH: ServiceBinding + TELEMETRY: ServiceBinding +} + +const withBindingTiming = async (service: ServiceBinding, request: Request): Promise => { + const startedAt = performance.now() + const response = await service.fetch(request) + const bindingMs = performance.now() - startedAt + const headers = new Headers(response.headers) + headers.set("x-service-binding-ms", bindingMs.toFixed(4)) + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +export default { + async fetch(request: Request, env: Env): Promise { + const pathname = new URL(request.url).pathname + switch (pathname) { + case "/ready": + case "/direct": + return new Response("ok", { headers: { "x-service-binding-ms": "0" } }) + case "/bound": + return withBindingTiming(env.ECHO, request) + case "/probe/monolith": + return withBindingTiming(env.MONOLITH, request) + case "/probe/telemetry": + return withBindingTiming(env.TELEMETRY, request) + default: + return new Response("Not found", { status: 404 }) + } + }, +} diff --git a/apps/api/scripts/service-binding-bench/router.wrangler.jsonc b/apps/api/scripts/service-binding-bench/router.wrangler.jsonc new file mode 100644 index 000000000..a8fb53077 --- /dev/null +++ b/apps/api/scripts/service-binding-bench/router.wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "maple-api-route-router-bench", + "main": "./router.worker.ts", + "compatibility_date": "2026-04-08", + "compatibility_flags": ["nodejs_compat"], + "services": [ + { "binding": "ECHO", "service": "maple-api-route-echo-bench" }, + { "binding": "MONOLITH", "service": "maple-api-route-monolith-bench" }, + { "binding": "TELEMETRY", "service": "maple-api-route-telemetry-bench" }, + ], +} diff --git a/apps/api/scripts/service-binding-bench/telemetry-probe.worker.ts b/apps/api/scripts/service-binding-bench/telemetry-probe.worker.ts new file mode 100644 index 000000000..5bc77b1f2 --- /dev/null +++ b/apps/api/scripts/service-binding-bench/telemetry-probe.worker.ts @@ -0,0 +1,39 @@ +let initialized = false +let initialization: Promise | undefined + +/** + * The smallest useful telemetry-route island with today's module boundaries. + * It deliberately loads the route, warehouse, auth, cache, and DB modules but + * not unrelated billing, alerts, email, OAuth-provider, MCP, or webhook code. + */ +const initialize = (): Promise => { + if (initialization !== undefined) return initialization + initialization = Promise.all([ + import("../../src/routes/v2/telemetry.http"), + import("../../src/services/warehouse/WarehouseQueryService"), + import("../../src/services/warehouse/QueryEngineService"), + import("../../src/services/auth/ApiAuthorizationV2Layer"), + import("../../src/services/auth/ApiV2RateLimiter"), + import("../../src/services/auth/OrgMembershipService"), + import("../../src/services/org/ApiKeysService"), + import("../../src/platform/CacheBackendLive"), + import("../../src/platform/DatabasePgLive"), + import("../../src/platform/pg-connection-scope"), + ]).then(() => undefined) + return initialization +} + +export default { + async fetch(): Promise { + const wasInitialized = initialized + const startedAt = performance.now() + await initialize() + const moduleEvaluationMs = performance.now() - startedAt + initialized = true + return Response.json({ + graph: "telemetry-island", + cached: wasInitialized, + moduleEvaluationMs, + }) + }, +} diff --git a/apps/api/scripts/service-binding-bench/telemetry.wrangler.jsonc b/apps/api/scripts/service-binding-bench/telemetry.wrangler.jsonc new file mode 100644 index 000000000..b46235327 --- /dev/null +++ b/apps/api/scripts/service-binding-bench/telemetry.wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "maple-api-route-telemetry-bench", + "main": "./telemetry-probe.worker.ts", + "compatibility_date": "2026-04-08", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/docs/cloudflare-native-api.md b/docs/cloudflare-native-api.md new file mode 100644 index 000000000..d2f3016a1 --- /dev/null +++ b/docs/cloudflare-native-api.md @@ -0,0 +1,227 @@ +# Cloudflare-native API plan + +Status: proposed follow-up after the lazy-route and cold-bootstrap optimization stack merges. + +## Outcome + +Keep `https://api.maple.dev` and the public v2 contract unchanged, while making the runtime a +small public gateway backed by coarse, private Worker islands. A route author continues to define +an Effect `HttpApiGroup` and its handler once; the topology decides where that group runs. + +The first production slice is the read-heavy v2 telemetry surface. It is a good boundary because +traces, logs, metrics, services, and the service map share warehouse dependencies but do not need +the rest of Maple's HTTP, MCP, alerting, webhook, or workflow graph. + +```text +api.maple.dev + | + v +api gateway Worker + |-- /health and OPTIONS ---------------------- local + |-- /v2/{traces,logs,metrics,services,...} --- telemetry Worker + `-- everything else -------------------------- core Worker +``` + +The gateway is the only public Worker. The telemetry and core Workers have no custom domain or +stable `workers.dev` URL and are reachable through Cloudflare service bindings only. + +## Design rules + +1. **One public contract.** Worker placement must not change URLs, schemas, error envelopes, + authentication, CORS, pagination, or OpenAPI output. +2. **Coarse islands.** Split by dependency graph, not by endpoint. A normal request should invoke + the gateway and one target, leaving ample room under Cloudflare's 32-Worker invocation limit. +3. **No shared database Worker.** The target owns authentication and the request-scoped Hyperdrive + connection. A Postgres socket must never outlive the target invocation that created it. +4. **Workers-native transport.** Forward the original `Request` with an HTTP service binding. Do + not serialize it into an internal JSON/RPC envelope, buffer response bodies, or make a public + network request between Workers. +5. **Tiny global scope.** The gateway imports no Maple domain barrel, Effect API AST, database + driver, or route implementation. Its route table is generated as literals at build time. +6. **One middleware implementation.** Target Workers share the same factory for tracing, error + handling, CORS, handler memoization, and Postgres scoping, so islands cannot drift semantically. +7. **Explicit fallback and rollback.** Until an island passes the production gates, the core Worker + remains deployable and the gateway can send that entire route family back to it. + +## Developer experience + +The intended route-authoring flow remains the current one: + +1. Add an endpoint to an Effect `HttpApiGroup` in `@maple/domain`. +2. Add the handler to the matching `HttpApiBuilder.group` in `apps/api`. +3. Run the normal route tests. + +An endpoint added to an existing group inherits that group's Worker automatically. Only a new API +group needs one topology decision. + +The source of truth should resemble: + +```typescript +export const ApiTopology = defineApiTopology({ + telemetry: defineApiIsland({ + groups: [ + [V2TracesApiGroup, HttpV2TracesLive], + [V2LogsApiGroup, HttpV2LogsLive], + [V2MetricsApiGroup, HttpV2MetricsLive], + [V2ServicesApiGroup, HttpV2ServicesLive], + [V2ServiceMapApiGroup, HttpV2ServiceMapLive], + ], + services: TelemetryServicesLive, + }), + core: defineApiIsland({ fallback: true }), +}) +``` + +Build-time tooling derives these artifacts rather than asking a developer to maintain them: + +- the gateway's literal method/path-to-binding table; +- the island-specific Effect APIs and route Layers; +- typed Worker binding names; +- Alchemy Worker resources and dependency order; +- local multi-Worker configuration; +- the combined public OpenAPI document; +- an exhaustiveness report proving that every public group is assigned exactly once. + +The shared target entry point should be declarative: + +```typescript +export default makeApiWorker({ + api: TelemetryApi, + routes: TelemetryRoutesLive, + services: TelemetryServicesLive, + serviceName: "maple-api-telemetry", +}) +``` + +`makeApiWorker` owns the code currently repeated or load-bearing in `worker.ts`: an isolate-wide +memoized Effect handler, request-local environment, telemetry flush, server-error span mapping, +and one lazily-created Postgres connection scope per invocation. + +Local development stays one command and one origin. `bun dev` starts the gateway and all private +targets through local workerd service bindings; callers continue to use `https://api.localhost`. +Tests call the same `fetch(Request)` boundary whether the target is local or deployed. + +## Repository shape + +Keep the multiple deployables in `apps/api`; their code and contract still form one product API. + +```text +apps/api/src/cloudflare/ + gateway.ts + make-api-worker.ts + topology.ts + topology.generated.ts +apps/api/src/islands/ + telemetry/ + api.ts + routes.ts + services.ts + worker.ts + core/ + worker.ts +packages/domain/src/http/v2/islands/ + telemetry.ts +``` + +The `@maple/domain/http/v2/islands/telemetry` subpath exports only the five telemetry groups and +their shared boundary middleware. The telemetry Worker must not import the root v2 barrel, because +that would evaluate every group's schema and erase the cold-start benefit. + +## Delivery phases + +### 0. Merge and freeze the baseline + +- Merge the lazy HTTP route and cold-bootstrap PRs first. +- Re-run startup CPU, compressed bundle, module evaluation, cold `/health`, cold authenticated and + unauthenticated `/v2/services`, and warm route latency on the merge SHA. +- Store the raw samples and machine/runtime versions so later comparisons use the same baseline. + +### 1. Build the topology foundation without moving traffic + +- Extract `makeApiWorker` from the current Worker without changing behavior. +- Define the typed topology and generate the literal gateway route manifest. +- Add the telemetry-only domain API, route Layer, and service Layer using existing endpoint groups + and handlers; do not copy endpoint schemas or business logic. +- Extend Alchemy to deploy a private telemetry Worker and bind it to the existing API Worker. +- Add topology checks: every route exactly once, no collisions, no public private-Worker URL, and + the full OpenAPI document unchanged. + +### 2. Prove one functional vertical slice + +- Forward `/v2/services` through the binding behind a disabled-by-default deployment switch. +- Run the real authorization, org selection, rate limit, query engine, cache, tracing, CORS, and + error middleware in the telemetry Worker. +- Keep all other telemetry routes on the core Worker during this phase. +- Add differential tests that send the same request to core and telemetry handlers and compare + status, response bytes, selected headers, error envelope, and recorded span fields. +- Cover success plus schema failure, 401, 403, 404, 429, warehouse failure, disconnect, and + streaming/abort behavior. + +### 3. Deploy a controlled Cloudflare canary + +- Deploy the private target before the gateway, keeping changes backward-compatible across + versions. +- Upload a gateway version that sends the selected route to the target and initially assign it 0% + production traffic. +- Smoke-test that exact gateway and target version using Cloudflare version overrides. +- Increase the gateway deployment gradually while using version affinity for a stable caller key. +- Stamp gateway and target version IDs on spans so comparisons distinguish both halves of a call. +- Observe at least one full traffic cycle before promotion. Rollback is a gateway deployment change; + it must not require deleting or redeploying the target. + +### 4. Expand only after the gate passes + +- Move the rest of services, traces, logs, metrics, and service-map groups together. +- Delete their imports from the core Worker's runtime graph after the canary is fully promoted. +- Re-measure the core Worker as well as the telemetry Worker; the split only wins if combined CPU + and operational complexity improve. +- Consider another island only when an import/dependency profile demonstrates a material boundary. + +## Verification and acceptance gates + +Local workerd measurements are screening evidence. Deployed Cloudflare measurements decide whether +traffic moves. + +Correctness must be exact for: + +- status and response bytes; +- content type, cache, CORS, rate-limit, and request-ID headers; +- authentication and organization-selection behavior; +- public error tags/codes and retry metadata; +- span name, kind, status, `query.context`, tenant, and Worker-version attribution; +- disconnect cancellation, streaming, and `waitUntil` telemetry flushing; +- one Hyperdrive scope per target invocation with no request-bound object crossing the binding. + +Performance gates, evaluated against the post-merge baseline: + +1. Cold telemetry-route p50 and p95 improve by at least 15% in deployed measurements. +2. Warm telemetry-route p95 regresses by less than 2 ms. +3. Gateway plus target cold CPU is lower, and combined warm CPU is no higher. +4. Error rate and timeout rate do not regress. +5. Each Worker stays below 75% of the startup, memory, compressed-size, subrequest, connection, and + invocation-depth limits under the tested load. +6. A normal API request uses exactly two Worker invocations and one service-binding subrequest. + +If correctness differs, combined CPU increases, or either latency gate fails, keep the first two +optimization PRs, route the family to core, and retain the benchmark/topology tooling for a later +module-boundary improvement. + +## Non-goals + +- one Worker per endpoint; +- a database, authentication, or query-engine Worker shared across requests; +- changing the public v2 HTTP contract to fit the internal topology; +- replacing Effect schemas or handlers solely to obtain the Worker split; +- moving webhooks, MCP, queues, cron, Workflows, or Durable Objects in the first slice; +- enabling Smart Placement before measuring whether its backend-oriented placement helps Maple's + user-to-edge and edge-to-warehouse latency together. + +## Cloudflare references + +- [Service bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) +- [HTTP service bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/http/) +- [Workers limits](https://developers.cloudflare.com/workers/platform/limits/) +- [Versions and deployments](https://developers.cloudflare.com/workers/versions-and-deployments/) +- [Gradual deployments](https://developers.cloudflare.com/workers/versions-and-deployments/gradual-deployments/) +- [Version affinity](https://developers.cloudflare.com/workers/versions-and-deployments/gradual-deployments/version-affinity/) +- [Version overrides](https://developers.cloudflare.com/workers/versions-and-deployments/version-overrides/) diff --git a/knip.json b/knip.json index 967c435e1..093651bce 100644 --- a/knip.json +++ b/knip.json @@ -23,7 +23,12 @@ "ignoreDependencies": ["@paper-design/shaders"] }, "apps/api": { - "entry": ["alchemy.run.ts", "autumn.config.ts", "scripts/cold-path/*.mjs"], + "entry": [ + "alchemy.run.ts", + "autumn.config.ts", + "scripts/cold-path/*.mjs", + "scripts/service-binding-bench/*.worker.ts" + ], "ignoreDependencies": ["cloudflare"] }, "apps/alerting": {