From e237fd57cd2fd2563ebe10e0ecc893285c223dd9 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Wed, 30 Sep 2026 15:39:22 +0200 Subject: [PATCH] fix(cloudflare): give Catalog and commerce-customer-context a measured CPU budget Every BFF request builds and disposes the whole Effect HTTP API runtime, so its CPU grows with the endpoint count. The 100 ms vertical cap cut off most requests to the two verticals with 200 and 115 endpoints (Cloudflare 1102). Stage analytics peaked at 2015 ms for successful requests and 2539 ms for the ones cut off, so they get a 3000 ms cap; every other vertical keeps 100 ms. --- app/docs/architecture/DEPLOYMENT.md | 8 +++--- .../shared-contracts/tooling/modern-config.ts | 26 ++++++++++++++----- .../cloudflare-data-plane-bindings.test.mts | 8 ++++++ app/verticals/catalog/modern.config.ts | 2 +- .../modern.config.ts | 2 ++ 5 files changed, 36 insertions(+), 10 deletions(-) diff --git a/app/docs/architecture/DEPLOYMENT.md b/app/docs/architecture/DEPLOYMENT.md index d241ce810..ec510eedc 100644 --- a/app/docs/architecture/DEPLOYMENT.md +++ b/app/docs/architecture/DEPLOYMENT.md @@ -443,9 +443,11 @@ list of the people Access admits and the usage notification emails) and `STAGE_A (default `false`). - Every Worker config sets `workers_dev: false` and `preview_urls: false`, so the only way in is the - zone routes the guards cover, and caps CPU per request at 200 ms for the Shell and 100 ms for a - vertical (`CLOUDFLARE_WORKER_CPU_MS` in `packages/shared-contracts/tooling/modern-config.ts`). - Nothing has been measured yet; raise a cap when a real request hits it. + zone routes the guards cover, and caps CPU per request at 200 ms for the Shell, 100 ms for a + vertical, and 3000 ms for Catalog and commerce-customer-context (`CLOUDFLARE_WORKER_CPU_MS` in + `packages/shared-contracts/tooling/modern-config.ts`). Each BFF request builds the whole Effect + HTTP API runtime, so CPU grows with the endpoint count, and those two verticals have far more + endpoints than the rest. Raise a cap only when Workers analytics shows real requests hitting it. - A WAF custom rule `ontos_stage_kill_switch` blocks exactly the placed OntOS stage hostnames. It is added next to any rules other projects keep in the zone, created disabled, and re-runs keep its current state. The WAF answers before a Worker runs, so blocked requests are never billed. There is diff --git a/app/packages/shared-contracts/tooling/modern-config.ts b/app/packages/shared-contracts/tooling/modern-config.ts index b23988337..69ea5b024 100644 --- a/app/packages/shared-contracts/tooling/modern-config.ts +++ b/app/packages/shared-contracts/tooling/modern-config.ts @@ -304,11 +304,18 @@ export const createCloudflareDataPlaneBindings = (envValue: ModernBuildContext[' /** * CPU budgets per invocation. Workers Paid includes 30M CPU ms a month, so a runaway request is cut - * off here instead of billed. Nothing has been measured yet: an API vertical does a few database and - * SpiceDB round trips (waiting on I/O is not CPU time), and the Shell also renders SSR, so it gets - * twice the vertical budget. Raise a cap only with a measured p99 from Workers analytics. + * off here instead of billed. An API vertical does a few database and SpiceDB round trips (waiting on + * I/O is not CPU time), and the Shell also renders SSR, so it gets twice the vertical budget. + * + * Every BFF request builds and disposes the whole Effect HTTP API runtime, because workerd ties I/O + * objects to the request that created them, so its CPU cost grows with the API's endpoint count. + * Catalog (about 200 endpoints) and commerce-customer-context (about 115) spend far more than 100 ms + * per request, while the next largest vertical has about 20. Workers analytics on stage (72 h, µs + * rounded to ms): successful requests peaked at 1590 ms (Catalog) and 2015 ms (commerce-customer-context), + * and the requests Cloudflare cut off had already spent up to 2539 ms. `largeApiVertical` is the + * smallest round cap above all of them. Raise a cap only with a measured p99 from Workers analytics. */ -export const CLOUDFLARE_WORKER_CPU_MS = { shell: 200, vertical: 100 } as const; +export const CLOUDFLARE_WORKER_CPU_MS = { largeApiVertical: 3000, shell: 200, vertical: 100 } as const; /** * The data plane plus the cost guards every OntOS Worker carries: it answers only on its reviewed @@ -386,14 +393,18 @@ const readCloudflareUnitServiceBindings = (appId: string): readonly CloudflareUn const createCloudflareDeployment = ( build: ModernBuildContext, - worker: { readonly name: string; readonly unitServiceBindings: readonly CloudflareUnitServiceBinding[] }, + worker: { + readonly cpuMs: number; + readonly name: string; + readonly unitServiceBindings: readonly CloudflareUnitServiceBinding[]; + }, ) => build.cloudflareDeployEnabled ? { deploy: { worker: { ...createCloudflareWorkerConfig(build.envValue, { - cpuMs: CLOUDFLARE_WORKER_CPU_MS.vertical, + cpuMs: worker.cpuMs, publicUrlVariable: build.cloudflarePublicUrlEnvironmentVariable, }), compatibilityDate: '2026-06-02', @@ -467,6 +478,7 @@ export const createModernConfig = ({ build, builderPlugins, chunkLoadingGlobal, + cloudflareCpuMs = CLOUDFLARE_WORKER_CPU_MS.vertical, cloudflareWorkerName, moduleUrl, plugins, @@ -479,6 +491,7 @@ export const createModernConfig = ({ build: ModernBuildContext; builderPlugins?: BuilderPlugin[]; chunkLoadingGlobal: string; + cloudflareCpuMs?: number; cloudflareWorkerName: string; moduleUrl: string; plugins: Plugin[]; @@ -507,6 +520,7 @@ export const createModernConfig = ({ // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- This generic optional field retains the public factory's inferred return shape and its position in the emitted configuration. ...(builderPlugins === undefined ? {} : { builderPlugins }), ...createCloudflareDeployment(build, { + cpuMs: cloudflareCpuMs, name: cloudflareWorkerName, unitServiceBindings: build.cloudflareDeployEnabled ? readCloudflareUnitServiceBindings(appId) : [], }), diff --git a/app/scripts/tests/cloudflare-data-plane-bindings.test.mts b/app/scripts/tests/cloudflare-data-plane-bindings.test.mts index 8ccf6ac0d..27911e9b3 100644 --- a/app/scripts/tests/cloudflare-data-plane-bindings.test.mts +++ b/app/scripts/tests/cloudflare-data-plane-bindings.test.mts @@ -60,6 +60,14 @@ it('serves every Worker only on its custom domain, off workers.dev and preview U ).toEqual({ cpu_ms: 200, }); + expect( + createCloudflareWorkerConfig(values, { + cpuMs: CLOUDFLARE_WORKER_CPU_MS.largeApiVertical, + publicUrlVariable: PUBLIC_URL, + }).wrangler.limits, + ).toEqual({ + cpu_ms: 3000, + }); }); it('refuses a Worker build without its public URL', () => { diff --git a/app/verticals/catalog/modern.config.ts b/app/verticals/catalog/modern.config.ts index bbd4a1573..9a4f84ea7 100644 --- a/app/verticals/catalog/modern.config.ts +++ b/app/verticals/catalog/modern.config.ts @@ -83,7 +83,7 @@ const appDevServerHeaders: NonNullable