Skip to content

Fix per-request DbService scoping in cloud API#475

Merged
RhysSullivan merged 1 commit intomainfrom
fix/cloud-per-request-db-scope
May 2, 2026
Merged

Fix per-request DbService scoping in cloud API#475
RhysSullivan merged 1 commit intomainfrom
fix/cloud-per-request-db-scope

Conversation

@RhysSullivan
Copy link
Copy Markdown
Owner

Summary

  • Bug: Cloudflare Workers' I/O isolation was crashing every authenticated cloud endpoint with [storage-drizzle] findMany select failed: Cannot perform I/O on behalf of a different request. (I/O type: Writable) (Sentry NODE-CLOUDFLARE-WORKERS-31, first deploy 2026-05-02T05:05:09Z).
  • Cause: Refactor cloud API routing to be Effect v4 native #468 collapsed per-request layers into Layer.provideMerge of an HttpRouter.toWebHandler app. toWebHandler builds the layer ONCE at worker boot, so the postgres.js socket (a Writable) opened during request 1 became unusable from request 2.
  • Fix: New requestScopedMiddleware(layer) whose per-request handler does Layer.build(layer) inside Effect.scoped — the only primitive that actually rebuilds per request (HttpRouter.provideRequest does NOT despite the name). ExecutionStackMiddleware .combine(...)s with it so requires: DbService | UserStoreService collapses to never. Sub-API factories thread the per-request layer through, and makeApiLive(rsLive) lets tests substitute a counting fake.

Test plan

  • New regression suite apps/cloud/src/api.request-scope.node.test.ts (4 cases): Layer.provideMerge captures boot scope (bug); HttpRouter.provideRequest also captures boot scope (misleading name); requestScopedMiddleware rebuilds per request; makeApiLive (prod factory) rebuilds per request — verified to fail (acquires: 1) when the wiring reverts to provideMerge and pass (acquires: 2) with the fix.
  • Full cloud test suite: 42 workerd + 67 node = 109 passing.
  • No new TypeScript errors.
  • Deploy to staging and verify two consecutive authenticated requests to /api/scopes/*/sources both succeed.
  • Watch Sentry/Axiom for "Cannot perform I/O on behalf of a different request" — should drop to zero.

Cloudflare Workers forbids reusing I/O objects across request handlers.
The v4 routing refactor (#468) collapsed per-request layers into
`Layer.provideMerge` of an `HttpRouter.toWebHandler` app, which builds
the layer ONCE at worker boot. The postgres.js socket (`Writable`) opened
during request 1 was then unusable from request 2 — every protected
endpoint 500ed with "Cannot perform I/O on behalf of a different request".

`HttpRouter.provideRequest` doesn't actually rebuild per request despite
the name (its `Layer.build` runs in the OUTER middleware effect at
layer-construction time). The only primitive that does is a custom
middleware whose per-request handler calls `Layer.build` inside
`Effect.scoped`. New `requestScopedMiddleware` helper does that.

`ExecutionStackMiddleware` no longer captures DbService/UserStoreService
at layer-time; it `.combine(...)`s with `requestScopedMiddleware(rsLive)`
so its `requires` collapses to never. Sub-API factories
(`makeNonProtectedApiLive`/`makeOrgApiLive`/`makeProtectedApiLive`)
thread the per-request layer through, and `makeApiLive(rsLive)` lets
tests substitute a counting fake for `DbService.Live`.

Regression coverage in `api.request-scope.node.test.ts` pins down four
cases: `Layer.provideMerge` (bug, captures boot scope),
`HttpRouter.provideRequest` (also boot scope despite the name),
`requestScopedMiddleware` (fix primitive), and `makeApiLive` (prod
factory) — verified to fail when the wiring reverts to `provideMerge`.
@RhysSullivan RhysSullivan merged commit d062f09 into main May 2, 2026
5 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant