| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import * as logs from "@distilled.cloud/aws/cloudwatch-logs"; |
| 4 | import * as Lambda from "@distilled.cloud/aws/lambda"; |
| 5 | import { expect } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | import * as HttpClient from "effect/http/HttpClient"; |
| 9 | import ShutdownProbe from "./shutdown-probe.ts"; |
| 10 | |
| 11 | const { test } = Test.make({ providers: AWS.providers() }); |
| 12 | |
| 13 | const filterLogs = Effect.fn(function* ( |
| 14 | logGroupName: string, |
| 15 | filterPattern: string, |
| 16 | ) { |
| 17 | const result = yield* logs |
| 18 | .filterLogEvents({ logGroupName, filterPattern }) |
| 19 | .pipe(Effect.catch(() => Effect.succeed({ events: [] }))); |
| 20 | return (result.events ?? []).length > 0; |
| 21 | }); |
| 22 | |
| 23 | /** |
| 24 | * The fast half of the Shutdown-phase contract: the generated entry |
| 25 | * registers an internal extension with the Extensions API at cold start. |
| 26 | * Registration is the documented precondition for Lambda's SIGTERM window — |
| 27 | * without any registered extension the sandbox is killed with 0 ms and no |
| 28 | * signal; with an internal extension the runtime gets SIGTERM + 500 ms. |
| 29 | * |
| 30 | * A successful invoke proves registration doesn't hang Init (a registered |
| 31 | * extension must signal readiness via the parked `/event/next` long-poll — |
| 32 | * getting that wrong wedges Init and every invoke 502s). Lambda logs the |
| 33 | * registration as an `EXTENSION` platform line, which pins that the |
| 34 | * registration was actually accepted. |
| 35 | * |
| 36 | * The slow half — Lambda delivering SIGTERM at idle spin-down and the |
| 37 | * instance finalizers running — takes many idle minutes and is gated below. |
| 38 | */ |
| 39 | test.provider( |
| 40 | "entry registers the graceful-shutdown internal extension", |
| 41 | (stack) => |
| 42 | Effect.gen(function* () { |
| 43 | yield* stack.destroy(); |
| 44 | |
| 45 | const fn = yield* stack.deploy( |
| 46 | Effect.gen(function* () { |
| 47 | return yield* ShutdownProbe; |
| 48 | }), |
| 49 | ); |
| 50 | |
| 51 | const client = yield* HttpClient.HttpClient; |
| 52 | const res = yield* client.get(fn.functionUrl!).pipe( |
| 53 | Effect.retry({ |
| 54 | schedule: Schedule.exponential("500 millis"), |
| 55 | times: 10, |
| 56 | }), |
| 57 | ); |
| 58 | expect(res.status).toBe(200); |
| 59 | expect(yield* res.text).toBe("ok"); |
| 60 | |
| 61 | const logGroupName = `/aws/lambda/${fn.functionName}`; |
| 62 | const registered = yield* filterLogs( |
| 63 | logGroupName, |
| 64 | '"alchemy-graceful-shutdown"', |
| 65 | ).pipe( |
| 66 | Effect.repeat({ |
| 67 | schedule: Schedule.spaced("3 seconds"), |
| 68 | until: (found) => found, |
| 69 | times: 20, |
| 70 | }), |
| 71 | ); |
| 72 | expect(registered).toBe(true); |
| 73 | |
| 74 | // Request-scope finalizers settle inline per invocation — the marker |
| 75 | // must reach the logs after a normal request. |
| 76 | const requestFinalized = yield* filterLogs( |
| 77 | logGroupName, |
| 78 | '"ALCHEMY_REQUEST_FINALIZED"', |
| 79 | ).pipe( |
| 80 | Effect.repeat({ |
| 81 | schedule: Schedule.spaced("3 seconds"), |
| 82 | until: (found) => found, |
| 83 | times: 20, |
| 84 | }), |
| 85 | ); |
| 86 | expect(requestFinalized).toBe(true); |
| 87 | |
| 88 | yield* stack.destroy(); |
| 89 | |
| 90 | // Out-of-band proof the destroy removed the function from the cloud. |
| 91 | yield* Lambda.getFunction({ FunctionName: fn.functionName }).pipe( |
| 92 | Effect.flatMap(() => |
| 93 | Effect.fail(new Error(`Function ${fn.functionName} still exists`)), |
| 94 | ), |
| 95 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 96 | Effect.retry({ |
| 97 | schedule: Schedule.max([ |
| 98 | Schedule.exponential(500), |
| 99 | Schedule.recurs(8), |
| 100 | ]), |
| 101 | }), |
| 102 | ); |
| 103 | }), |
| 104 | { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 600_000 }, |
| 105 | ); |
| 106 | |
| 107 | /** |
| 108 | * The slow half: leave the sandbox idle until Lambda spins it down, then |
| 109 | * assert the SIGTERM handler closed the instance scope and the init-level |
| 110 | * finalizer's marker reached CloudWatch. Idle spin-down takes several |
| 111 | * minutes and is not under our control, so this only runs when explicitly |
| 112 | * requested: |
| 113 | * |
| 114 | * AWS_LAMBDA_TEST_SHUTDOWN=1 bun vitest run test/AWS/Lambda/Shutdown.test.ts |
| 115 | */ |
| 116 | test.provider.skipIf(!process.env.AWS_LAMBDA_TEST_SHUTDOWN)( |
| 117 | "instance-scope finalizers run at idle spin-down", |
| 118 | (stack) => |
| 119 | Effect.gen(function* () { |
| 120 | const fn = yield* stack.deploy( |
| 121 | Effect.gen(function* () { |
| 122 | return yield* ShutdownProbe; |
| 123 | }), |
| 124 | ); |
| 125 | |
| 126 | const client = yield* HttpClient.HttpClient; |
| 127 | const res = yield* client.get(fn.functionUrl!).pipe( |
| 128 | Effect.retry({ |
| 129 | schedule: Schedule.exponential("500 millis"), |
| 130 | times: 10, |
| 131 | }), |
| 132 | ); |
| 133 | expect(res.status).toBe(200); |
| 134 | |
| 135 | // Do NOT invoke again — every invoke resets the idle clock. Poll the |
| 136 | // log group until the shutdown marker lands. No stack.destroy: keep |
| 137 | // the deployment so a re-run continues observing the same function. |
| 138 | const logGroupName = `/aws/lambda/${fn.functionName}`; |
| 139 | const finalized = yield* filterLogs( |
| 140 | logGroupName, |
| 141 | '"ALCHEMY_INSTANCE_FINALIZED"', |
| 142 | ).pipe( |
| 143 | Effect.repeat({ |
| 144 | schedule: Schedule.spaced("30 seconds"), |
| 145 | until: (found) => found, |
| 146 | times: 17, |
| 147 | }), |
| 148 | ); |
| 149 | expect(finalized).toBe(true); |
| 150 | |
| 151 | yield* stack.destroy(); |
| 152 | }), |
| 153 | { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 600_000 }, |
| 154 | ); |