Skip to content

Commit 4059607

Browse files
fix(relay): T3 Connect links no longer fail on colliding prepared statements (#15411)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 930aec1 commit 4059607

5 files changed

Lines changed: 115 additions & 4 deletions

File tree

‎infra/relay/src/environments/ManagedEndpointProvider.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,7 @@ describe("ManagedEndpointProvider", () => {
12801280
expect(error).toMatchObject({
12811281
_tag: "ManagedEndpointProvisioningFailed",
12821282
stage: "record-tunnel",
1283+
reason: "claim-lost",
12831284
});
12841285
expect(tunnelCalls.map((call) => call.operation)).toEqual(["list", "create"]);
12851286
expect((yield* provider.provision(input)).runtime.tunnelId).toBe("tunnel-id");
@@ -1336,6 +1337,7 @@ describe("ManagedEndpointProvider", () => {
13361337
expect(error).toMatchObject({
13371338
_tag: "ManagedEndpointProvisioningFailed",
13381339
stage: "configure-tunnel",
1340+
reason: "claim-lost",
13391341
});
13401342
expect(tunnelCalls.map((call) => call.operation)).not.toContain("putConfiguration");
13411343
}).pipe(Effect.provide(layer));
@@ -1391,6 +1393,7 @@ describe("ManagedEndpointProvider", () => {
13911393
expect(error).toMatchObject({
13921394
_tag: "ManagedEndpointProvisioningFailed",
13931395
stage: "mark-allocation-ready",
1396+
reason: "claim-lost",
13941397
});
13951398
}).pipe(Effect.provide(layer));
13961399
});

‎infra/relay/src/environments/ManagedEndpointProvider.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,20 @@ const ManagedEndpointProvisioningStage = Schema.Literals([
5858
"sync-origin",
5959
]);
6060

61+
// Why a stage failed without an underlying error. `claim-lost` means another
62+
// provision, release, or deprovision changed the allocation's generation or
63+
// tunnel after this one loaded it.
64+
const ManagedEndpointProvisioningFailureReason = Schema.Literals([
65+
"claim-lost",
66+
"endpoint-mismatch",
67+
"invalid-tunnel-response",
68+
]);
69+
6170
export class ManagedEndpointProvisioningFailed extends Schema.TaggedError<ManagedEndpointProvisioningFailed>()(
6271
"ManagedEndpointProvisioningFailed",
6372
{
6473
stage: ManagedEndpointProvisioningStage,
74+
reason: Schema.optionalKey(ManagedEndpointProvisioningFailureReason),
6575
userId: Schema.String,
6676
environmentId: Schema.String,
6777
hostname: Schema.optionalKey(Schema.String),
@@ -543,6 +553,7 @@ export const make = Effect.gen(function* () {
543553
return yield* new ManagedEndpointProvisioningFailed({
544554
...input,
545555
stage: "verify-endpoint",
556+
reason: "endpoint-mismatch",
546557
hostname: allocation.hostname,
547558
});
548559
}
@@ -609,6 +620,7 @@ export const make = Effect.gen(function* () {
609620
return yield* new ManagedEndpointProvisioningFailed({
610621
...input,
611622
stage: "sync-origin",
623+
reason: "claim-lost",
612624
});
613625
}
614626
return updated.value === "configured" ? "ready" : "recovery_required";
@@ -995,6 +1007,7 @@ export const make = Effect.gen(function* () {
9951007
userId: input.userId,
9961008
environmentId: input.environmentId,
9971009
stage: "validate-tunnel-response",
1010+
reason: "invalid-tunnel-response",
9981011
hostname,
9991012
tunnelName,
10001013
...(tunnelResponse.id ? { returnedTunnelId: tunnelResponse.id } : {}),
@@ -1030,6 +1043,7 @@ export const make = Effect.gen(function* () {
10301043
userId: input.userId,
10311044
environmentId: input.environmentId,
10321045
stage: "record-tunnel",
1046+
reason: "claim-lost",
10331047
hostname,
10341048
tunnelName,
10351049
tunnelId: tunnel.id,
@@ -1090,6 +1104,7 @@ export const make = Effect.gen(function* () {
10901104
userId: input.userId,
10911105
environmentId: input.environmentId,
10921106
stage: "configure-tunnel",
1107+
reason: "claim-lost",
10931108
hostname,
10941109
tunnelName,
10951110
tunnelId: tunnel.id,
@@ -1162,6 +1177,7 @@ export const make = Effect.gen(function* () {
11621177
userId: input.userId,
11631178
environmentId: input.environmentId,
11641179
stage: "record-dns",
1180+
reason: "claim-lost",
11651181
hostname,
11661182
tunnelName,
11671183
tunnelId: tunnel.id,
@@ -1192,6 +1208,7 @@ export const make = Effect.gen(function* () {
11921208
userId: input.userId,
11931209
environmentId: input.environmentId,
11941210
stage: "record-dns",
1211+
reason: "claim-lost",
11951212
hostname,
11961213
tunnelName,
11971214
tunnelId: tunnel.id,
@@ -1242,6 +1259,7 @@ export const make = Effect.gen(function* () {
12421259
userId: input.userId,
12431260
environmentId: input.environmentId,
12441261
stage: "mark-allocation-ready",
1262+
reason: "claim-lost",
12451263
hostname,
12461264
tunnelName,
12471265
tunnelId: tunnel.id,

‎infra/relay/src/worker.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,13 @@ export const ApiLive = Api.make(
175175
const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey;
176176
const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey;
177177
const hyperdrive = yield* Cloudflare.Hyperdrive.Connect(yield* RelayDb.RelayHyperdrive);
178-
const db = yield* Drizzle.Postgres(hyperdrive.connectionString);
178+
// Named prepared statements collide behind Hyperdrive's transaction-mode
179+
// pool: sql-pg < 4.0.0-rc.117 names them `effect1..N` per connection, and
180+
// inside a transaction a Bind can reach another request's statement
181+
// (#14070, Effect-TS/effect#8320). Unnamed statements cannot collide.
182+
const db = yield* Drizzle.Postgres(hyperdrive.connectionString, undefined, {
183+
prepare: false,
184+
});
179185

180186
const managedEndpointTunnelBinding = yield* Cloudflare.Tunnel.ReadWriteTunnel();
181187
// Keep Worker custom-domain reconciliation ordered after API zone provisioning.

‎patches/alchemy@2.0.0-beta.79.patch‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,50 @@
1+
diff --git a/lib/Drizzle/Postgres.d.ts b/lib/Drizzle/Postgres.d.ts
2+
index ddba2e1e6ae85ca24e626ce2d7ea2bde92ef6f86..31335e2039a52ad2fe7a12d6bc820dbb04fe15a8 100644
3+
--- a/lib/Drizzle/Postgres.d.ts
4+
+++ b/lib/Drizzle/Postgres.d.ts
5+
@@ -4,6 +4,11 @@ import type { EffectPgDatabase } from "drizzle-orm/effect-postgres";
6+
import type { EffectDrizzlePgConfig } from "drizzle-orm/pg-core/effect/utils";
7+
import * as Effect from "effect/Effect";
8+
import type * as Redacted from "effect/Redacted";
9+
+/**
10+
+ * `@effect/sql-pg` client settings that cannot be carried in the URL, e.g.
11+
+ * `prepare: false` for transaction-mode poolers such as Hyperdrive.
12+
+ */
13+
+export type PostgresClientOptions = Omit<PgClient.PgPoolConfig, "url" | "ssl">;
14+
/**
15+
* Open a Drizzle/Postgres database from a connection URL using the
16+
* `drizzle-orm/effect-postgres` integration.
17+
@@ -39,7 +44,7 @@ import type * as Redacted from "effect/Redacted";
18+
*
19+
* @binding
20+
*/
21+
-export declare const Postgres: <TRelations extends AnyRelations = EmptyRelations, E = never, R = never>(connectionString: Effect.Effect<Redacted.Redacted<string>, E, R>, config?: EffectDrizzlePgConfig<TRelations>) => Effect.Effect<EffectPgDatabase<TRelations> & {
22+
+export declare const Postgres: <TRelations extends AnyRelations = EmptyRelations, E = never, R = never>(connectionString: Effect.Effect<Redacted.Redacted<string>, E, R>, config?: EffectDrizzlePgConfig<TRelations>, pgClient?: PostgresClientOptions) => Effect.Effect<EffectPgDatabase<TRelations> & {
23+
$client: PgClient.PgClient;
24+
}, never, never>;
25+
//# sourceMappingURL=Postgres.d.ts.map
26+
\ No newline at end of file
27+
diff --git a/lib/Drizzle/Postgres.js b/lib/Drizzle/Postgres.js
28+
index fdd0942fc9da4f220e135bd9ce6c891dd837a948..e7452288b6b92e0aeacc26f37a10e85a3ba232e5 100644
29+
--- a/lib/Drizzle/Postgres.js
30+
+++ b/lib/Drizzle/Postgres.js
31+
@@ -38,13 +38,13 @@ import { proxyChain } from "../Util/proxy-chain.js";
32+
*
33+
* @binding
34+
*/
35+
-export const Postgres = (connectionString, config) => Effect.map(makeExecutionMemo(Effect.gen(function* () {
36+
+export const Postgres = (connectionString, config, pgClient) => Effect.map(makeExecutionMemo(Effect.gen(function* () {
37+
const [PgClient, PgDrizzle] = yield* Effect.promise(() => Promise.all([
38+
import("@effect/sql-pg/PgClient"),
39+
import("drizzle-orm/effect-postgres"),
40+
]));
41+
const url = yield* connectionString;
42+
- const pgCtx = yield* Layer.build(PgClient.layer(resolveConnectionOptions(url)));
43+
+ const pgCtx = yield* Layer.build(PgClient.layer({ ...pgClient, ...resolveConnectionOptions(url) }));
44+
return yield* PgDrizzle.makeWithDefaults(config).pipe(Effect.provideContext(pgCtx));
45+
})), (db) => proxyChain(db));
46+
//# sourceMappingURL=Postgres.js.map
47+
\ No newline at end of file
148
diff --git a/lib/Test/Vitest.js b/lib/Test/Vitest.js
249
index 0ecf9ccff01bb6556bf3e471d5a93254d16224a7..c0f1237e6a98e698718b0bbde5de8eb58c859e8e 100644
350
--- a/lib/Test/Vitest.js
@@ -11,6 +58,43 @@ index 0ecf9ccff01bb6556bf3e471d5a93254d16224a7..c0f1237e6a98e698718b0bbde5de8eb5
1158
import * as Core from "./Core.js";
1259
export { executeWhenReady, getWhenReady, guardContentType, guardedFetchLayer, rpcClientLayer, WorkerNotReady, } from "./Http.js";
1360
export const defaultStage = Core.defaultStage;
61+
diff --git a/src/Drizzle/Postgres.ts b/src/Drizzle/Postgres.ts
62+
index 2d138914e834d5c47078c42b756129897506f51d..61ae549db6ee20e8809295e8d1543b3bc78f63ad 100644
63+
--- a/src/Drizzle/Postgres.ts
64+
+++ b/src/Drizzle/Postgres.ts
65+
@@ -48,6 +48,15 @@ import { proxyChain } from "../Util/proxy-chain.ts";
66+
* @binding
67+
*/
68+
69+
+/**
70+
+ * `@effect/sql-pg` client settings that cannot be carried in the URL, e.g.
71+
+ * `prepare: false` for transaction-mode poolers such as Hyperdrive.
72+
+ */
73+
+export type PostgresClientOptions = Omit<
74+
+ PgClient.PgPoolConfig,
75+
+ "url" | "ssl"
76+
+>;
77+
+
78+
export const Postgres = <
79+
TRelations extends AnyRelations = EmptyRelations,
80+
E = never,
81+
@@ -55,6 +64,7 @@ export const Postgres = <
82+
>(
83+
connectionString: Effect.Effect<Redacted.Redacted<string>, E, R>,
84+
config?: EffectDrizzlePgConfig<TRelations>,
85+
+ pgClient?: PostgresClientOptions,
86+
) =>
87+
Effect.map(
88+
makeExecutionMemo(
89+
@@ -67,7 +77,7 @@ export const Postgres = <
90+
);
91+
const url = yield* connectionString;
92+
const pgCtx = yield* Layer.build(
93+
- PgClient.layer(resolveConnectionOptions(url)),
94+
+ PgClient.layer({ ...pgClient, ...resolveConnectionOptions(url) }),
95+
);
96+
return yield* PgDrizzle.makeWithDefaults(config).pipe(
97+
Effect.provideContext(pgCtx),
1498
diff --git a/src/Test/Vitest.ts b/src/Test/Vitest.ts
1599
index c321e6d4924d0f6b3ae53292a12af88167692ccb..673929a81d066bdd6a2dd94e9c1d95bb69f3e3cd 100644
16100
--- a/src/Test/Vitest.ts

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)