History

Pin the customer repair as bounded and attribute billing attempts per org

Rhys Sullivan committed 2 months ago

Commit 6813d371 parent 8f3317b1 file · +80 −4Browse files

Unified · Split

e2e/cloud/billing-customer-provisioning.test.ts+80−4
@@ -21,7 +21,7 @@ · 20 unmodified lines
21// 404 `customer_not_found` per billing endpoint — because the emulator, like21// 404 `customer_not_found` per billing endpoint — because the emulator, like
22// real Autumn's SDK flow, otherwise auto-creates customers on contact.22// real Autumn's SDK flow, otherwise auto-creates customers on contact.
23import { expect } from "@effect/vitest";23import { expect } from "@effect/vitest";
24import { Effect } from "effect";24import { Effect, Schedule } from "effect";
25 25
26import { scenario } from "../src/scenario";26import { scenario } from "../src/scenario";
27import { Autumn, Billing, Mcp, Target } from "../src/services";27import { Autumn, Billing, Mcp, Target } from "../src/services";
@@ -119,13 +119,19 @@ scenario( · 91 unmodified lines
119 expect(result.text, "it returns its value").toContain("42");119 expect(result.text, "it returns its value").toContain("42");
120 120
121 // Both billing calls really did reach Autumn and really were rejected —121 // Both billing calls really did reach Autumn and really were rejected —
122 // without this the scenario could pass on a fault that never armed.122 // without this the scenario could pass on a fault that never armed. The
123 const checks = yield* autumn.ledgerFor("balances.check");123 // ledger is shared by the whole run, so attribute to THIS org: another
124 // scenario's faulted call must not stand in for this one's.
125 const checks = (yield* autumn.ledgerFor("balances.check")).filter(
126 (entry) => entry.customerId === customerId,
127 );
124 expect(128 expect(
125 checks.some((entry) => entry.faulted),129 checks.some((entry) => entry.faulted),
126 "the balance check reached Autumn and was answered customer_not_found",130 "the balance check reached Autumn and was answered customer_not_found",
127 ).toBe(true);131 ).toBe(true);
128 const tracks = yield* autumn.ledgerFor("balances.track");132 const tracks = (yield* autumn.ledgerFor("balances.track")).filter(
133 (entry) => entry.customerId === customerId,
134 );
129 expect(135 expect(
130 tracks.some((entry) => entry.faulted),136 tracks.some((entry) => entry.faulted),
131 "the usage track reached Autumn and was answered customer_not_found",137 "the usage track reached Autumn and was answered customer_not_found",
@@ -203,3 +209,73 @@ scenario( · 71 unmodified lines
203 }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore)));209 }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore)));
204 }),210 }),
205);211);
212
213scenario(
214 "Billing · a customer that cannot be provisioned is given up on, not retried in a loop",
215 { timeout: 180_000 },
216 Effect.gen(function* () {
217 yield* Billing;
218 const autumn = yield* Autumn;
219 const target = yield* Target;
220 const mcp = yield* Mcp;
221
222 const identity = yield* target.newIdentity();
223 const bearer = yield* mcp.mintBearer(emailOf(identity));
224 const customerId = orgIdOf(bearer);
225
226 // Attempts against THIS org's meter, polled until the seam has settled on
227 // `atLeast` of them — the ledger is the only place the retry is visible.
228 const trackAttempts = (atLeast: number) =>
229 autumn.ledgerFor("balances.track").pipe(
230 Effect.map((entries) => entries.filter((entry) => entry.customerId === customerId)),
231 Effect.filterOrFail(
232 (entries) => entries.length >= atLeast,
233 (entries) => `only ${entries.length}/${atLeast} usage-track attempts for ${customerId}`,
234 ),
235 Effect.retry(Schedule.both(Schedule.spaced("250 millis"), Schedule.recurs(40))),
236 );
237
238 yield* Effect.gen(function* () {
239 // "No such customer" that does NOT go away when the customer is created —
240 // a provisioning gap the repair genuinely cannot close (a rejected id, a
241 // provider that never materializes the record). The seam must repair,
242 // retry ONCE, report, and stop: a repair loop here runs in a forked,
243 // untimed fibre, so an unbounded one hammers the provider forever for a
244 // single execution.
245 yield* autumn.armFault({
246 match: { operationId: "balances.track" },
247 response: CUSTOMER_NOT_FOUND,
248 times: 20,
249 });
250
251 const session = mcp.session(identity);
252 const stuck = yield* session.call("execute", { code: "return 7 * 6;" });
253 expect(stuck.ok, "the execution runs — billing never blocks a customer").toBe(true);
254 expect(stuck.text, "it returns its value").toContain("42");
255
256 // The original attempt plus the one post-repair retry.
257 yield* trackAttempts(2);
258
259 // Clearing the fault re-opens the meter, and the next execution's usage
260 // landing is the barrier: whatever the first execution was going to do to
261 // the ledger is finished by then, so the count below is settled without
262 // waiting on a clock.
263 yield* autumn.clearFaults();
264 const clean = yield* session.call("execute", { code: "return 2 + 2;" });
265 expect(clean.ok, "the follow-up execution runs too").toBe(true);
266 yield* autumn.expectUsage({ customerId, featureId: "executions", count: 1 });
267
268 const tracks = (yield* autumn.ledgerFor("balances.track")).filter(
269 (entry) => entry.customerId === customerId,
270 );
271 expect(
272 tracks.filter((entry) => entry.faulted).length,
273 "the unrepairable customer is attempted exactly twice: once, then once after the repair",
274 ).toBe(2);
275 expect(tracks.length, "and no further attempt beyond the next execution's own").toBe(3);
276
277 const metered = yield* autumn.usageEvents({ customerId, featureId: "executions" });
278 expect(metered.length, "only the execution Autumn accepted reaches the meter").toBe(1);
279 }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore)));
280 }),
281);