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