Skip to content
Source
Sign inSign up
Open History to see the last commit

FunctionRollout.test.ts

143 lines5.7 KB

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