Skip to content

Commit baa3abd

Browse files
juliusmarmingecodex
andcommitted
fix(relay): guard unlink teardown generation
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent b2203fa commit baa3abd

8 files changed

Lines changed: 318 additions & 27 deletions

infra/relay/src/environments/EnvironmentConnector.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ function makeAllocations(
198198
recordDns: () => Effect.die("unused"),
199199
markReady: () => Effect.die("unused"),
200200
claimRelease: () => Effect.die("unused"),
201+
claimDeprovision: () => Effect.die("unused"),
201202
remove: () => Effect.die("unused"),
203+
removeClaimed: () => Effect.die("unused"),
202204
};
203205
}
204206

infra/relay/src/environments/EnvironmentLinker.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function testLayer(input?: {
136136
revokeForEnvironmentPublicKey: () => Effect.succeed(false),
137137
}),
138138
Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, {
139+
prepareDeprovision: () => Effect.succeed(null),
139140
deprovision: input?.deprovision ?? (() => Effect.void),
140141
release: () => Effect.succeed(true),
141142
provision: () =>

infra/relay/src/environments/ManagedEndpointAllocations.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
1010
ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db)));
1111

1212
describe("ManagedEndpointAllocations", () => {
13+
it.effect("returns a claim generation only when deprovision wins the allocation CAS", () => {
14+
let claimedAt: string | undefined;
15+
const fakeDb = {
16+
update: (table: unknown) => {
17+
expect(table).toBe(relayManagedEndpointAllocations);
18+
return {
19+
set: (values: { readonly updatedAt: string }) => {
20+
claimedAt = values.updatedAt;
21+
return {
22+
where: () => ({
23+
returning: () => Effect.succeed([{ userId: "user-1" }]),
24+
}),
25+
};
26+
},
27+
};
28+
},
29+
} as unknown as RelayDb.RelayDb["Service"];
30+
31+
return Effect.gen(function* () {
32+
const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
33+
const generation = yield* allocations.claimDeprovision({
34+
userId: "user-1",
35+
environmentId: "environment-1",
36+
updatedAt: "captured-generation",
37+
});
38+
39+
expect(generation).toBe(claimedAt);
40+
expect(generation).not.toBeNull();
41+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
42+
});
43+
44+
it.effect("does not remove an allocation superseded after a deprovision claim", () => {
45+
const fakeDb = {
46+
delete: (table: unknown) => {
47+
expect(table).toBe(relayManagedEndpointAllocations);
48+
return {
49+
where: () => ({
50+
returning: () => Effect.succeed([]),
51+
}),
52+
};
53+
},
54+
} as unknown as RelayDb.RelayDb["Service"];
55+
56+
return Effect.gen(function* () {
57+
const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
58+
expect(
59+
yield* allocations.removeClaimed({
60+
userId: "user-1",
61+
environmentId: "environment-1",
62+
updatedAt: "outdated-claim-generation",
63+
}),
64+
).toBe(false);
65+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
66+
});
67+
1368
it.effect("retains database failures with allocation operation and identity", () => {
1469
const cause = new Error("database unavailable");
1570
const fakeDb = {

infra/relay/src/environments/ManagedEndpointAllocations.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
5151
"record-dns",
5252
"mark-ready",
5353
"claim-release",
54+
"claim-deprovision",
5455
"remove",
56+
"remove-claimed",
5557
]),
5658
stage: Schema.Literals(["database-request", "resolve-reservation"]),
5759
userId: Schema.String,
@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey
9193
readonly updatedAt: string;
9294
}
9395

96+
interface ClaimManagedEndpointDeprovisionInput extends ManagedEndpointAllocationKey {
97+
readonly updatedAt: string;
98+
}
99+
100+
interface RemoveClaimedManagedEndpointAllocationInput extends ManagedEndpointAllocationKey {
101+
readonly updatedAt: string;
102+
}
103+
94104
export class ManagedEndpointAllocations extends Context.Service<
95105
ManagedEndpointAllocations,
96106
{
@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<
119129
readonly claimRelease: (
120130
input: ClaimManagedEndpointReleaseInput,
121131
) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>;
132+
/**
133+
* Claims the complete allocation for teardown only if its generation still
134+
* matches the snapshot captured by the unlink operation.
135+
*
136+
* Returns the claim generation used by `removeClaimed`, or null when a
137+
* concurrent provision has already superseded the snapshot.
138+
*/
139+
readonly claimDeprovision: (
140+
input: ClaimManagedEndpointDeprovisionInput,
141+
) => Effect.Effect<string | null, ManagedEndpointAllocationPersistenceError>;
122142
readonly remove: (
123143
input: ManagedEndpointAllocationKey,
124144
) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>;
145+
readonly removeClaimed: (
146+
input: RemoveClaimedManagedEndpointAllocationInput,
147+
) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>;
125148
}
126149
>()("t3code-relay/environments/ManagedEndpointAllocations") {}
127150

@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {
321344
);
322345
return claimed;
323346
}),
347+
claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function* (
348+
input: ClaimManagedEndpointDeprovisionInput,
349+
) {
350+
const claimedAt = DateTime.formatIso(yield* DateTime.now);
351+
const claimed = yield* db
352+
.update(relayManagedEndpointAllocations)
353+
.set({ updatedAt: claimedAt })
354+
.where(
355+
and(
356+
whereAllocation(input),
357+
eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt),
358+
),
359+
)
360+
.returning({ userId: relayManagedEndpointAllocations.userId })
361+
.pipe(
362+
Effect.map((rows) => rows.length > 0),
363+
Effect.mapError(
364+
(cause) =>
365+
new ManagedEndpointAllocationPersistenceError({
366+
operation: "claim-deprovision",
367+
stage: "database-request",
368+
userId: input.userId,
369+
environmentId: input.environmentId,
370+
cause,
371+
}),
372+
),
373+
);
374+
return claimed ? claimedAt : null;
375+
}),
324376
remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* (
325377
input: ManagedEndpointAllocationKey,
326378
) {
@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {
339391
),
340392
);
341393
}),
394+
removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function* (
395+
input: RemoveClaimedManagedEndpointAllocationInput,
396+
) {
397+
return yield* db
398+
.delete(relayManagedEndpointAllocations)
399+
.where(
400+
and(
401+
whereAllocation(input),
402+
eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt),
403+
),
404+
)
405+
.returning({ userId: relayManagedEndpointAllocations.userId })
406+
.pipe(
407+
Effect.map((rows) => rows.length > 0),
408+
Effect.mapError(
409+
(cause) =>
410+
new ManagedEndpointAllocationPersistenceError({
411+
operation: "remove-claimed",
412+
stage: "database-request",
413+
userId: input.userId,
414+
environmentId: input.environmentId,
415+
cause,
416+
}),
417+
),
418+
);
419+
}),
342420
});
343421
});
344422

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

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ interface AllocationCall {
5050
| "recordDns"
5151
| "markReady"
5252
| "claimRelease"
53-
| "remove";
53+
| "claimDeprovision"
54+
| "remove"
55+
| "removeClaimed";
5456
readonly input: unknown;
5557
}
5658

@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {
226228
mutate(allocationKey(input), (current) => current);
227229
return true;
228230
}),
231+
claimDeprovision: (input) =>
232+
Effect.sync(() => {
233+
calls.push({ operation: "claimDeprovision", input });
234+
const allocation = allocations.get(allocationKey(input));
235+
if (allocation === undefined || allocation.updatedAt !== input.updatedAt) {
236+
return null;
237+
}
238+
mutate(allocationKey(input), (current) => current);
239+
return allocations.get(allocationKey(input))?.updatedAt ?? null;
240+
}),
229241
remove: (input) =>
230242
Effect.sync(() => {
231243
calls.push({ operation: "remove", input });
232244
allocations.delete(allocationKey(input));
233245
}),
246+
removeClaimed: (input) =>
247+
Effect.sync(() => {
248+
calls.push({ operation: "removeClaimed", input });
249+
const allocation = allocations.get(allocationKey(input));
250+
if (allocation === undefined || allocation.updatedAt !== input.updatedAt) {
251+
return false;
252+
}
253+
allocations.delete(allocationKey(input));
254+
return true;
255+
}),
234256
});
235257
}
236258

@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {
774796
"recordDns",
775797
"markReady",
776798
"get",
777-
"remove",
799+
"claimDeprovision",
800+
"removeClaimed",
778801
]);
779802
}).pipe(Effect.provide(layer));
780803
},
781804
);
782805

806+
it.effect("does not deprovision an allocation superseded by a concurrent relink", () => {
807+
const tunnelCalls: TunnelCall[] = [];
808+
const dnsCalls: DnsCall[] = [];
809+
const allocationCalls: AllocationCall[] = [];
810+
const layer = providerLayer(
811+
makePersistentTunnelClient(tunnelCalls),
812+
makeDnsClient(dnsCalls),
813+
makeAllocations(allocationCalls),
814+
);
815+
816+
return Effect.gen(function* () {
817+
const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
818+
const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
819+
const request = {
820+
...key,
821+
origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
822+
} as const;
823+
yield* provider.provision(request);
824+
const unlinkTarget = yield* provider.prepareDeprovision(key);
825+
expect(unlinkTarget).not.toBeNull();
826+
if (unlinkTarget === null) {
827+
return;
828+
}
829+
830+
// A relink refreshes the allocation generation after unlink captured its
831+
// target but before unlink begins external teardown.
832+
yield* provider.provision(request);
833+
const tunnelCallCount = tunnelCalls.length;
834+
const dnsCallCount = dnsCalls.length;
835+
const allocationCallCount = allocationCalls.length;
836+
837+
yield* provider.deprovision({ ...key, target: unlinkTarget });
838+
839+
expect(tunnelCalls).toHaveLength(tunnelCallCount);
840+
expect(dnsCalls).toHaveLength(dnsCallCount);
841+
expect(allocationCalls.slice(allocationCallCount).map((call) => call.operation)).toEqual([
842+
"claimDeprovision",
843+
]);
844+
}).pipe(Effect.provide(layer));
845+
});
846+
783847
it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname", () => {
784848
const tunnelCalls: TunnelCall[] = [];
785849
const dnsCalls: DnsCall[] = [];
@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {
10041068
"recordDns",
10051069
"markReady",
10061070
"get",
1071+
"claimDeprovision",
10071072
"get",
1008-
"remove",
1073+
"claimDeprovision",
1074+
"removeClaimed",
10091075
]);
10101076
}).pipe(Effect.provide(layer));
10111077
});
@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {
10461112
});
10471113
yield* provider.deprovision(key);
10481114

1049-
expect(allocationCalls.map((call) => call.operation)).toContain("remove");
1115+
expect(allocationCalls.map((call) => call.operation)).toContain("removeClaimed");
10501116
}).pipe(Effect.provide(layer));
10511117
});
10521118

0 commit comments

Comments
 (0)