| 1 | import * as AWS from "@/AWS"; |
| 2 | import { encodeDurableEnvelope } from "@/AWS/Lambda/DurableBridge.ts"; |
| 3 | import * as Core from "@/Test/Core"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as Lambda from "@distilled.cloud/aws/lambda"; |
| 6 | import { describe, expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Redacted from "effect/Redacted"; |
| 9 | import * as Result from "effect/Result"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | import DurableFlowLive, { DurableFlow } from "./fixtures/durable-handler"; |
| 12 | |
| 13 | const testOptions = { providers: AWS.providers() }; |
| 14 | const { test, beforeAll, afterAll } = Test.make(testOptions); |
| 15 | const sharedStack = Core.scratchStack(testOptions, "LambdaDurable"); |
| 16 | |
| 17 | const unwrapSensitive = ( |
| 18 | value: string | Redacted.Redacted<string> | undefined, |
| 19 | ): string | undefined => |
| 20 | value === undefined |
| 21 | ? undefined |
| 22 | : Redacted.isRedacted(value) |
| 23 | ? Redacted.value(value) |
| 24 | : value; |
| 25 | |
| 26 | describe( |
| 27 | "Lambda DurableFunction", |
| 28 | { tags: ["provider:aws", "provider:aws:lambda", "live"] }, |
| 29 | () => { |
| 30 | // Ungated typed-error probe: proves the durable-execution management API is |
| 31 | // reachable in this region and that its errors are typed tags in the |
| 32 | // distilled union — at near-zero cost, forever. |
| 33 | test.provider( |
| 34 | "durable management API returns typed errors", |
| 35 | (_stack) => |
| 36 | Effect.gen(function* () { |
| 37 | const result = yield* Effect.result( |
| 38 | Lambda.listDurableExecutionsByFunction({ |
| 39 | FunctionName: "alchemy-durable-probe-nonexistent", |
| 40 | }), |
| 41 | ); |
| 42 | expect(Result.isFailure(result)).toBe(true); |
| 43 | if (Result.isFailure(result)) { |
| 44 | expect(result.failure._tag).toBe("ResourceNotFoundException"); |
| 45 | } |
| 46 | }), |
| 47 | { timeout: 30_000 }, |
| 48 | ); |
| 49 | |
| 50 | // Full lifecycle: a real deploy (bundling + vendoring the Durable Execution |
| 51 | // SDK + waiting out IAM role propagation) plus a live durable execution with |
| 52 | // a suspend/resume. |
| 53 | describe("lifecycle", () => { |
| 54 | let functionName: string; |
| 55 | let qualifier: string; |
| 56 | |
| 57 | beforeAll( |
| 58 | Effect.gen(function* () { |
| 59 | yield* Effect.logInfo( |
| 60 | "Durable test setup: destroying previous stack", |
| 61 | ); |
| 62 | yield* sharedStack.destroy(); |
| 63 | yield* Effect.logInfo( |
| 64 | "Durable test setup: deploying the DurableFunction", |
| 65 | ); |
| 66 | const outputs = yield* sharedStack.deploy( |
| 67 | Effect.gen(function* () { |
| 68 | const flow = yield* DurableFlow; |
| 69 | const version = yield* AWS.Lambda.Version("DurableFlowVersion", { |
| 70 | function: flow.function, |
| 71 | }); |
| 72 | const live = yield* AWS.Lambda.Alias("DurableFlowLive", { |
| 73 | version, |
| 74 | aliasName: "live", |
| 75 | }); |
| 76 | return { |
| 77 | functionName: flow.functionName, |
| 78 | qualifier: live.aliasName, |
| 79 | }; |
| 80 | }).pipe(Effect.provide(DurableFlowLive)), |
| 81 | ); |
| 82 | functionName = outputs.functionName; |
| 83 | qualifier = outputs.qualifier; |
| 84 | expect(functionName).toBeTruthy(); |
| 85 | expect(qualifier).toBe("live"); |
| 86 | }), |
| 87 | // ~35s in isolation, but the deploy bundles + npm-vendors the Durable |
| 88 | // Execution SDK in-process; under full-suite CPU saturation the |
| 89 | // reconcile alone has been observed to exceed 420s. |
| 90 | { timeout: 600_000 }, |
| 91 | ); |
| 92 | |
| 93 | afterAll(sharedStack.destroy(), { timeout: 120_000 }); |
| 94 | |
| 95 | test.provider( |
| 96 | "runs a 2-step + sleep orchestration to completion", |
| 97 | (_stack) => |
| 98 | Effect.gen(function* () { |
| 99 | // Start: async Invoke with the alchemy envelope and an idempotent |
| 100 | // execution name (safe to re-run — same name + same payload |
| 101 | // reattaches to the existing execution). |
| 102 | const started = yield* Lambda.invoke({ |
| 103 | FunctionName: functionName, |
| 104 | Qualifier: qualifier, |
| 105 | InvocationType: "Event", |
| 106 | DurableExecutionName: "durable-test-flow-1", |
| 107 | Payload: encodeDurableEnvelope("DurableFlow", { |
| 108 | orderId: "order-1", |
| 109 | }), |
| 110 | }); |
| 111 | expect(started.StatusCode).toBe(202); |
| 112 | |
| 113 | // Resolve the execution ARN — from the Invoke response when |
| 114 | // present, otherwise via the list API (bounded). |
| 115 | const executionArn = started.DurableExecutionArn |
| 116 | ? started.DurableExecutionArn |
| 117 | : yield* Lambda.listDurableExecutionsByFunction({ |
| 118 | FunctionName: functionName, |
| 119 | DurableExecutionName: "durable-test-flow-1", |
| 120 | }).pipe( |
| 121 | Effect.map( |
| 122 | (r) => r.DurableExecutions?.[0]?.DurableExecutionArn, |
| 123 | ), |
| 124 | Effect.repeat({ |
| 125 | schedule: Schedule.spaced("2 seconds"), |
| 126 | until: (arn) => arn !== undefined, |
| 127 | times: 10, |
| 128 | }), |
| 129 | Effect.map((arn) => arn!), |
| 130 | ); |
| 131 | |
| 132 | // The flow sleeps 5s mid-way (a real suspend/resume), so poll the |
| 133 | // execution to a terminal state with a bounded schedule. |
| 134 | const execution = yield* Lambda.getDurableExecution({ |
| 135 | DurableExecutionArn: executionArn, |
| 136 | }).pipe( |
| 137 | Effect.repeat({ |
| 138 | schedule: Schedule.spaced("3 seconds"), |
| 139 | until: (r) => r.Status !== "RUNNING", |
| 140 | times: 30, |
| 141 | }), |
| 142 | ); |
| 143 | |
| 144 | expect(execution.Status).toBe("SUCCEEDED"); |
| 145 | const result = JSON.parse( |
| 146 | unwrapSensitive(execution.Result) ?? "{}", |
| 147 | ); |
| 148 | expect(result).toEqual({ |
| 149 | orderId: "order-1", |
| 150 | reserved: true, |
| 151 | total: 42, |
| 152 | }); |
| 153 | }), |
| 154 | { timeout: 150_000 }, |
| 155 | ); |
| 156 | }); |
| 157 | }, |
| 158 | ); |