1import { Function } from "@/Neon/Function.ts";
2import { Project } from "@/Neon/Project.ts";
3import { providers } from "@/Neon/Providers.ts";
4import * as Test from "@/Test/Alchemy.ts";
5import { getProject, getProjectBranchFunction } from "@distilled.cloud/neon";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import {
14 functionRolloutSamples,
15 functionRolloutTimeout,
16} from "./FunctionRollout.ts";
17
18const { test } = Test.make({ providers: providers() });
19const Observation = Schema.Struct({
20 version: Schema.String,
21 environment: Schema.String,
22 nonce: Schema.String,
23 instance: Schema.String,
24 requests: Schema.Number,
25});
26
27// Rollout convergence deliberately samples for minutes; skip under --fast.
28test.provider.skipIf(!!process.env.FAST)(
29 "polls warmed and uninvoked functions until the updated deployment serves consistently",
30 (stack) =>
31 Effect.gen(function* () {
32 yield* stack.destroy();
33 const fs = yield* FileSystem.FileSystem;
34 const path = yield* Path.Path;
35 const root = yield* fs.makeTempDirectoryScoped({
36 prefix: "neon-function-rollout-",
37 });
38 for (const version of ["one", "two"]) {
39 const directory = path.join(root, version);
40 yield* fs.makeDirectory(directory);
41 yield* fs.writeFileString(
42 path.join(directory, "index.mjs"),
43 [
44 `const version = ${JSON.stringify(version)};`,
45 "const instance = crypto.randomUUID(); let requests = 0;",
46 "export default { fetch(request) { return Response.json({",
47 "version, instance, requests: ++requests, environment: process.env.ROLLOUT_VERSION,",
48 'nonce: request.headers.get("x-rollout-nonce")',
49 '}, { headers: { "cache-control": "no-store" } }); } };',
50 ].join("\n"),
51 );
52 }
53 const deploy = (version: string) =>
54 stack.deploy(
55 Effect.gen(function* () {
56 const project = yield* Project("Project", {
57 region: "aws-us-east-2",
58 });
59 const warm = yield* Function("Warm", {
60 project,
61 artifact: { directory: path.join(root, version) },
62 env: { ROLLOUT_VERSION: version },
63 });
64 const cold = yield* Function("Cold", {
65 project,
66 artifact: { directory: path.join(root, version) },
67 env: { ROLLOUT_VERSION: version },
68 });
69 return { warm, cold };
70 }),
71 );
72 const first = yield* deploy("one");
73 let request = 0;
74 const sample = Effect.fn(function* (
75 url: string,
76 slug: string,
77 method: "GET" | "POST",
78 ) {
79 const nonce = `${slug}-${method}-${request++}`;
80 const response = yield* HttpClient.execute(
81 HttpClientRequest.make(method)(url).pipe(
82 HttpClientRequest.setHeaders({
83 "x-rollout-nonce": nonce,
84 "cache-control": "no-cache",
85 connection: "close",
86 }),
87 ),
88 );
89 expect(response.status).toBe(200);
90 const body = yield* response.json.pipe(
91 Effect.flatMap(Schema.decodeUnknownEffect(Observation)),
92 );
93 expect(body.nonce).toBe(nonce);
94 yield* Effect.logInfo(
95 JSON.stringify({
96 functionRolloutResponse: { slug, method, ...body },
97 }),
98 );
99 return body;
100 });
101 for (let index = 0; index < 4; index++) {
102 expect(
103 yield* sample(first.warm.url, first.warm.slug, "GET"),
104 ).toMatchObject({ version: "one", environment: "one" });
105 }
106 const updated = yield* deploy("two");
107 for (const key of ["warm", "cold"] as const) {
108 expect(updated[key].url).toBe(first[key].url);
109 expect(updated[key].functionId).toBe(first[key].functionId);
110 expect(updated[key].activeDeploymentId).not.toBe(
111 first[key].activeDeploymentId,
112 );
113 const observed = yield* getProjectBranchFunction({
114 project_id: updated[key].projectId,
115 branch_id: updated[key].branchId,
116 slug: updated[key].slug,
117 });
118 expect(observed.function.active_deployment?.id).toBe(
119 updated[key].activeDeploymentId,
120 );
121 expect(observed.function.active_deployment?.status).toBe("completed");
122 }
123 const samples = yield* functionRolloutSamples(
124 Effect.all(
125 [
126 sample(updated.warm.url, updated.warm.slug, "GET"),
127 sample(updated.warm.url, updated.warm.slug, "POST"),
128 sample(updated.cold.url, updated.cold.slug, "GET"),
129 sample(updated.cold.url, updated.cold.slug, "POST"),
130 ],
131 { concurrency: 4 },
132 ),
133 (responses) =>
134 responses.every(
135 (body) => body.version === "two" && body.environment === "two",
136 ),
137 );
138 for (const body of samples.flat())
139 expect(body).toMatchObject({ version: "two", environment: "two" });
140 const unchanged = yield* deploy("two");
141 for (const key of ["warm", "cold"] as const)
142 expect(unchanged[key].activeDeploymentId).toBe(
143 updated[key].activeDeploymentId,
144 );
145 yield* stack.destroy();
146 expect(
147 yield* getProject({ project_id: updated.warm.projectId }).pipe(
148 Effect.as(false),
149 Effect.catchTag("NotFound", () => Effect.succeed(true)),
150 ),
151 ).toBe(true);
152 }).pipe(Effect.scoped),
153 {
154 tags: [
155 "provider:neon",
156 "provider:neon:function",
157 "provider:neon:project",
158 "live",
159 ],
160 timeout: functionRolloutTimeout,
161 },
162);