1import * as emr from "@distilled.cloud/aws/emr-serverless";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5
6/**
7 * Raised when an EMR Serverless application never settles into the expected
8 * state within the bounded polling budget.
9 */
10export class EmrServerlessStateTimeout extends Data.TaggedError(
11 "EmrServerlessStateTimeout",
12)<{
13 readonly applicationId: string;
14 readonly expected: readonly string[];
15 readonly actual: string | undefined;
16 readonly stateDetails: string | undefined;
17}> {}
18
19/**
20 * Poll `getApplication` (bounded, spaced 5s, ~2 min budget) until the
21 * application leaves the given transitional state. Explicitly typed so
22 * `Effect.repeat`'s conditional return type does not widen the provider layer
23 * in declaration emit (see PATTERNS §7).
24 */
25const untilNotInState = <E, R>(
26 self: Effect.Effect<emr.GetApplicationResponse, E, R>,
27 transitional: readonly string[],
28): Effect.Effect<emr.Application, E, R> =>
29 Effect.repeat(
30 Effect.map(self, (response) => response.application),
31 {
32 schedule: Schedule.spaced("5 seconds"),
33 until: (application) => !transitional.includes(application.state),
34 times: 24,
35 },
36 );
37
38/**
39 * Await an application leaving `CREATING`, failing with
40 * `EmrServerlessStateTimeout` if it does not settle into `CREATED` (or an
41 * auto-started `STARTING`/`STARTED`) within the budget.
42 */
43export const awaitApplicationCreated = Effect.fn(
44 "AWS.EMRServerless.awaitApplicationCreated",
45)(function* (applicationId: string) {
46 const application = yield* untilNotInState(
47 emr.getApplication({ applicationId }),
48 ["CREATING"],
49 );
50 const settled = ["CREATED", "STARTING", "STARTED"];
51 if (!settled.includes(application.state)) {
52 return yield* Effect.fail(
53 new EmrServerlessStateTimeout({
54 applicationId,
55 expected: settled,
56 actual: application.state,
57 stateDetails: application.stateDetails,
58 }),
59 );
60 }
61 return application;
62});
63
64/**
65 * Await an application settling into an updatable/deletable state (`CREATED`
66 * or `STOPPED`), draining `STARTING`/`STOPPING` transitions. The caller is
67 * responsible for issuing `stopApplication` first when the application is
68 * `STARTED`.
69 */
70export const awaitApplicationStopped = Effect.fn(
71 "AWS.EMRServerless.awaitApplicationStopped",
72)(function* (applicationId: string) {
73 const application = yield* untilNotInState(
74 emr.getApplication({ applicationId }),
75 ["CREATING", "STARTING", "STOPPING"],
76 );
77 // TERMINATED is tolerated: a concurrent delete winning the race is a settled
78 // outcome for both the update path (the follow-up call surfaces the truth)
79 // and the delete path (nothing left to delete).
80 const settled = ["CREATED", "STOPPED", "TERMINATED"];
81 if (!settled.includes(application.state)) {
82 return yield* Effect.fail(
83 new EmrServerlessStateTimeout({
84 applicationId,
85 expected: settled,
86 actual: application.state,
87 stateDetails: application.stateDetails,
88 }),
89 );
90 }
91 return application;
92});