From 11938eaec6d57588db28d4edf06859458f56cdba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 3 Oct 2026 18:09:10 -0700 Subject: [PATCH] fix(relay): T3 Connect links no longer fail on colliding prepared statements Since #9386 added transactions to managed endpoint provisioning, about 40% of provisions fail with `bind message supplies 5 parameters, but prepared statement "effectN" requires M`. @effect/sql-pg rc.115 names prepared statements from a per-connection counter, so behind Hyperdrive's transaction-mode pool a Bind inside a transaction can reach another request's statement (Effect-TS/effect#8320). Pass `prepare: false` to the relay's PgClient so every statement is unnamed. Alchemy's Drizzle.Postgres only forwards url and ssl, so the existing alchemy patch gains an optional PgClient options argument. Provisioning failures without an underlying error now carry a `reason` (claim-lost, endpoint-mismatch, invalid-tunnel-response) so their spans say why the stage failed. Fixes #14070 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ManagedEndpointProvider.test.ts | 3 + .../environments/ManagedEndpointProvider.ts | 18 ++++ infra/relay/src/worker.ts | 8 +- patches/alchemy@2.0.0-beta.79.patch | 84 +++++++++++++++++++ pnpm-lock.yaml | 6 +- 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index d7cb7d67f9dd..becaf11488c8 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -1280,6 +1280,7 @@ describe("ManagedEndpointProvider", () => { expect(error).toMatchObject({ _tag: "ManagedEndpointProvisioningFailed", stage: "record-tunnel", + reason: "claim-lost", }); expect(tunnelCalls.map((call) => call.operation)).toEqual(["list", "create"]); expect((yield* provider.provision(input)).runtime.tunnelId).toBe("tunnel-id"); @@ -1336,6 +1337,7 @@ describe("ManagedEndpointProvider", () => { expect(error).toMatchObject({ _tag: "ManagedEndpointProvisioningFailed", stage: "configure-tunnel", + reason: "claim-lost", }); expect(tunnelCalls.map((call) => call.operation)).not.toContain("putConfiguration"); }).pipe(Effect.provide(layer)); @@ -1391,6 +1393,7 @@ describe("ManagedEndpointProvider", () => { expect(error).toMatchObject({ _tag: "ManagedEndpointProvisioningFailed", stage: "mark-allocation-ready", + reason: "claim-lost", }); }).pipe(Effect.provide(layer)); }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 892ac09a6e50..13494dd49678 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -58,10 +58,20 @@ const ManagedEndpointProvisioningStage = Schema.Literals([ "sync-origin", ]); +// Why a stage failed without an underlying error. `claim-lost` means another +// provision, release, or deprovision changed the allocation's generation or +// tunnel after this one loaded it. +const ManagedEndpointProvisioningFailureReason = Schema.Literals([ + "claim-lost", + "endpoint-mismatch", + "invalid-tunnel-response", +]); + export class ManagedEndpointProvisioningFailed extends Schema.TaggedError()( "ManagedEndpointProvisioningFailed", { stage: ManagedEndpointProvisioningStage, + reason: Schema.optionalKey(ManagedEndpointProvisioningFailureReason), userId: Schema.String, environmentId: Schema.String, hostname: Schema.optionalKey(Schema.String), @@ -543,6 +553,7 @@ export const make = Effect.gen(function* () { return yield* new ManagedEndpointProvisioningFailed({ ...input, stage: "verify-endpoint", + reason: "endpoint-mismatch", hostname: allocation.hostname, }); } @@ -609,6 +620,7 @@ export const make = Effect.gen(function* () { return yield* new ManagedEndpointProvisioningFailed({ ...input, stage: "sync-origin", + reason: "claim-lost", }); } return updated.value === "configured" ? "ready" : "recovery_required"; @@ -995,6 +1007,7 @@ export const make = Effect.gen(function* () { userId: input.userId, environmentId: input.environmentId, stage: "validate-tunnel-response", + reason: "invalid-tunnel-response", hostname, tunnelName, ...(tunnelResponse.id ? { returnedTunnelId: tunnelResponse.id } : {}), @@ -1030,6 +1043,7 @@ export const make = Effect.gen(function* () { userId: input.userId, environmentId: input.environmentId, stage: "record-tunnel", + reason: "claim-lost", hostname, tunnelName, tunnelId: tunnel.id, @@ -1090,6 +1104,7 @@ export const make = Effect.gen(function* () { userId: input.userId, environmentId: input.environmentId, stage: "configure-tunnel", + reason: "claim-lost", hostname, tunnelName, tunnelId: tunnel.id, @@ -1162,6 +1177,7 @@ export const make = Effect.gen(function* () { userId: input.userId, environmentId: input.environmentId, stage: "record-dns", + reason: "claim-lost", hostname, tunnelName, tunnelId: tunnel.id, @@ -1192,6 +1208,7 @@ export const make = Effect.gen(function* () { userId: input.userId, environmentId: input.environmentId, stage: "record-dns", + reason: "claim-lost", hostname, tunnelName, tunnelId: tunnel.id, @@ -1242,6 +1259,7 @@ export const make = Effect.gen(function* () { userId: input.userId, environmentId: input.environmentId, stage: "mark-allocation-ready", + reason: "claim-lost", hostname, tunnelName, tunnelId: tunnel.id, diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index d4d674d3342b..c5df3150c40f 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -175,7 +175,13 @@ export const ApiLive = Api.make( const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; const hyperdrive = yield* Cloudflare.Hyperdrive.Connect(yield* RelayDb.RelayHyperdrive); - const db = yield* Drizzle.Postgres(hyperdrive.connectionString); + // Named prepared statements collide behind Hyperdrive's transaction-mode + // pool: sql-pg < 4.0.0-rc.117 names them `effect1..N` per connection, and + // inside a transaction a Bind can reach another request's statement + // (#14070, Effect-TS/effect#8320). Unnamed statements cannot collide. + const db = yield* Drizzle.Postgres(hyperdrive.connectionString, undefined, { + prepare: false, + }); const managedEndpointTunnelBinding = yield* Cloudflare.Tunnel.ReadWriteTunnel(); // Keep Worker custom-domain reconciliation ordered after API zone provisioning. diff --git a/patches/alchemy@2.0.0-beta.79.patch b/patches/alchemy@2.0.0-beta.79.patch index f6a85ccac90e..0f0f81d8da27 100644 --- a/patches/alchemy@2.0.0-beta.79.patch +++ b/patches/alchemy@2.0.0-beta.79.patch @@ -1,3 +1,50 @@ +diff --git a/lib/Drizzle/Postgres.d.ts b/lib/Drizzle/Postgres.d.ts +index ddba2e1e6ae85ca24e626ce2d7ea2bde92ef6f86..31335e2039a52ad2fe7a12d6bc820dbb04fe15a8 100644 +--- a/lib/Drizzle/Postgres.d.ts ++++ b/lib/Drizzle/Postgres.d.ts +@@ -4,6 +4,11 @@ import type { EffectPgDatabase } from "drizzle-orm/effect-postgres"; + import type { EffectDrizzlePgConfig } from "drizzle-orm/pg-core/effect/utils"; + import * as Effect from "effect/Effect"; + import type * as Redacted from "effect/Redacted"; ++/** ++ * `@effect/sql-pg` client settings that cannot be carried in the URL, e.g. ++ * `prepare: false` for transaction-mode poolers such as Hyperdrive. ++ */ ++export type PostgresClientOptions = Omit; + /** + * Open a Drizzle/Postgres database from a connection URL using the + * `drizzle-orm/effect-postgres` integration. +@@ -39,7 +44,7 @@ import type * as Redacted from "effect/Redacted"; + * + * @binding + */ +-export declare const Postgres: (connectionString: Effect.Effect, E, R>, config?: EffectDrizzlePgConfig) => Effect.Effect & { ++export declare const Postgres: (connectionString: Effect.Effect, E, R>, config?: EffectDrizzlePgConfig, pgClient?: PostgresClientOptions) => Effect.Effect & { + $client: PgClient.PgClient; + }, never, never>; + //# sourceMappingURL=Postgres.d.ts.map +\ No newline at end of file +diff --git a/lib/Drizzle/Postgres.js b/lib/Drizzle/Postgres.js +index fdd0942fc9da4f220e135bd9ce6c891dd837a948..e7452288b6b92e0aeacc26f37a10e85a3ba232e5 100644 +--- a/lib/Drizzle/Postgres.js ++++ b/lib/Drizzle/Postgres.js +@@ -38,13 +38,13 @@ import { proxyChain } from "../Util/proxy-chain.js"; + * + * @binding + */ +-export const Postgres = (connectionString, config) => Effect.map(makeExecutionMemo(Effect.gen(function* () { ++export const Postgres = (connectionString, config, pgClient) => Effect.map(makeExecutionMemo(Effect.gen(function* () { + const [PgClient, PgDrizzle] = yield* Effect.promise(() => Promise.all([ + import("@effect/sql-pg/PgClient"), + import("drizzle-orm/effect-postgres"), + ])); + const url = yield* connectionString; +- const pgCtx = yield* Layer.build(PgClient.layer(resolveConnectionOptions(url))); ++ const pgCtx = yield* Layer.build(PgClient.layer({ ...pgClient, ...resolveConnectionOptions(url) })); + return yield* PgDrizzle.makeWithDefaults(config).pipe(Effect.provideContext(pgCtx)); + })), (db) => proxyChain(db)); + //# sourceMappingURL=Postgres.js.map +\ No newline at end of file diff --git a/lib/Test/Vitest.js b/lib/Test/Vitest.js index 0ecf9ccff01bb6556bf3e471d5a93254d16224a7..c0f1237e6a98e698718b0bbde5de8eb58c859e8e 100644 --- a/lib/Test/Vitest.js @@ -11,6 +58,43 @@ index 0ecf9ccff01bb6556bf3e471d5a93254d16224a7..c0f1237e6a98e698718b0bbde5de8eb5 import * as Core from "./Core.js"; export { executeWhenReady, getWhenReady, guardContentType, guardedFetchLayer, rpcClientLayer, WorkerNotReady, } from "./Http.js"; export const defaultStage = Core.defaultStage; +diff --git a/src/Drizzle/Postgres.ts b/src/Drizzle/Postgres.ts +index 2d138914e834d5c47078c42b756129897506f51d..61ae549db6ee20e8809295e8d1543b3bc78f63ad 100644 +--- a/src/Drizzle/Postgres.ts ++++ b/src/Drizzle/Postgres.ts +@@ -48,6 +48,15 @@ import { proxyChain } from "../Util/proxy-chain.ts"; + * @binding + */ + ++/** ++ * `@effect/sql-pg` client settings that cannot be carried in the URL, e.g. ++ * `prepare: false` for transaction-mode poolers such as Hyperdrive. ++ */ ++export type PostgresClientOptions = Omit< ++ PgClient.PgPoolConfig, ++ "url" | "ssl" ++>; ++ + export const Postgres = < + TRelations extends AnyRelations = EmptyRelations, + E = never, +@@ -55,6 +64,7 @@ export const Postgres = < + >( + connectionString: Effect.Effect, E, R>, + config?: EffectDrizzlePgConfig, ++ pgClient?: PostgresClientOptions, + ) => + Effect.map( + makeExecutionMemo( +@@ -67,7 +77,7 @@ export const Postgres = < + ); + const url = yield* connectionString; + const pgCtx = yield* Layer.build( +- PgClient.layer(resolveConnectionOptions(url)), ++ PgClient.layer({ ...pgClient, ...resolveConnectionOptions(url) }), + ); + return yield* PgDrizzle.makeWithDefaults(config).pipe( + Effect.provideContext(pgCtx), diff --git a/src/Test/Vitest.ts b/src/Test/Vitest.ts index c321e6d4924d0f6b3ae53292a12af88167692ccb..673929a81d066bdd6a2dd94e9c1d95bb69f3e3cd 100644 --- a/src/Test/Vitest.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1590a7223b3b..632485b2ee22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,7 +101,7 @@ patchedDependencies: '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 - alchemy@2.0.0-beta.79: d1a03d902083946ee58121cc2787d88d1da561e9e30a5bcfcb8654692a489fb8 + alchemy@2.0.0-beta.79: 2ad1ff594ebadb1d8653c94b9ac2762703788acdbd8432c22273e6c19da1f7a6 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 effect@4.0.0-rc.115: 0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5 expo-audio@58.0.4: c20cb927d6848c53e7bf6ec14fe12f19daae9210165d2dbaf4e0f97ce31e8742 @@ -848,7 +848,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.79 - version: 2.0.0-beta.79(patch_hash=d1a03d902083946ee58121cc2787d88d1da561e9e30a5bcfcb8654692a489fb8)(@effect/platform-node@4.0.0-rc.115(bufferutil@4.1.0)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/sql-pg@4.0.0-rc.115(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)))(@types/node@24.12.4)(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(5f52a5feaac0b02f5e556737028925ac))(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))(mongodb@6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9))(pg@8.23.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@7.0.2)(utf-8-validate@6.0.6)(ws@8.21.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + version: 2.0.0-beta.79(patch_hash=2ad1ff594ebadb1d8653c94b9ac2762703788acdbd8432c22273e6c19da1f7a6)(@effect/platform-node@4.0.0-rc.115(bufferutil@4.1.0)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/sql-pg@4.0.0-rc.115(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)))(@types/node@24.12.4)(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(5f52a5feaac0b02f5e556737028925ac))(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))(mongodb@6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9))(pg@8.23.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@7.0.2)(utf-8-validate@6.0.6)(ws@8.21.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(5f52a5feaac0b02f5e556737028925ac) @@ -16495,7 +16495,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ? alchemy@2.0.0-beta.79(patch_hash=d1a03d902083946ee58121cc2787d88d1da561e9e30a5bcfcb8654692a489fb8)(@effect/platform-node@4.0.0-rc.115(bufferutil@4.1.0)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/sql-pg@4.0.0-rc.115(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)))(@types/node@24.12.4)(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(5f52a5feaac0b02f5e556737028925ac))(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))(mongodb@6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9))(pg@8.23.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@7.0.2)(utf-8-validate@6.0.6)(ws@8.21.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + ? alchemy@2.0.0-beta.79(patch_hash=2ad1ff594ebadb1d8653c94b9ac2762703788acdbd8432c22273e6c19da1f7a6)(@effect/platform-node@4.0.0-rc.115(bufferutil@4.1.0)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))(redis@6.2.1)(utf-8-validate@6.0.6))(@effect/sql-pg@4.0.0-rc.115(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)))(@types/node@24.12.4)(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(5f52a5feaac0b02f5e556737028925ac))(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))(mongodb@6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9))(pg@8.23.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@7.0.2)(utf-8-validate@6.0.6)(ws@8.21.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)) : dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.79(@distilled.cloud/cloudflare@1.0.0-rc.12(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)))(@effect/platform-node@4.0.0-rc.115(bufferutil@4.1.0)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))(rolldown@1.2.5)(typescript@7.0.2) '@alchemy.run/floci': 2.0.0-beta.79(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))