From 40596072ebabf045414f54ca6ffec3911543d8ff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 3 Oct 2026 22:46:09 -0700 Subject: [PATCH] fix(relay): T3 Connect links no longer fail on colliding prepared statements (#15411) 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 d7cb7d67f9dd6af3758eecbf551bd2b2a97ee048..becaf11488c8d30ef14eccdc403581c84cabfd47 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 892ac09a6e50aed57319a5da18809bef03b8020d..13494dd496782f127f5bf19bde81955337b57716 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 d4d674d3342bcb8e0a9991b343bed3cf598d8a96..c5df3150c40f9177d2719aac2adb4c1c3101c745 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 f6a85ccac90ef84cfd6b9f4f6509165ceedd3fed..0f0f81d8da2797fb89fb946277996576cfeb58d3 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 1590a7223b3bc0c1506b065b84be80b7c4cc29b7..632485b2ee228a5d7316dee6ffe2acf1410a86cd 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)) -- 2.54.0