1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { describe, expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import { MinimumLogLevel } from "effect/References";
6import * as Schedule from "effect/Schedule";
7import * as HttpClientRequest from "effect/http/HttpClientRequest";
8import { expectUrlContains } from "../Utils/Http.ts";
9import { requestWorker } from "../Utils/WorkerRequest.ts";
10import Stack from "./fixtures/wait-until/stack.ts";
11
12const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
13 providers: Cloudflare.providers(),
14 state: Cloudflare.state(),
15});
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22const stack = beforeAll(deploy(Stack));
23afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
24
25// Cache-busting query param on every request: after destroy+recreate of the
26// same workers.dev subdomain the edge can serve a cached "nothing here yet"
27// placeholder (with a 200) for the bare URL even though busted requests
28// already reach the worker. Route matching uses `url.pathname`, so the
29// param is invisible to the fixture.
30let bust = 0;
31const getText = Effect.fn(function* (url: string) {
32 const request = yield* Effect.sync(() =>
33 HttpClientRequest.get(`${url}?cb=${Date.now()}-${bust++}`).pipe(
34 HttpClientRequest.setHeader("cache-control", "no-cache"),
35 ),
36 );
37 const response = yield* requestWorker(request);
38 const body = yield* response.text;
39 if (response.status !== 200) {
40 return yield* Effect.fail(
41 new Error(`GET ${url}: ${response.status}: ${body}`),
42 );
43 }
44 return body;
45});
46
47describe.skipIf(!!process.env.FAST)(
48 "waitUntil runs background Effects past the response (worker ctx + DO state)",
49 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
50 () => {
51 test(
52 "concurrent background journal writes retain every entry",
53 Effect.gen(function* () {
54 const { url } = yield* stack;
55 yield* expectUrlContains(`${url}/bg-many`, "bg-many-scheduled");
56 const entries = yield* Effect.gen(function* () {
57 const text = yield* getText(`${url}/entries-many`);
58 return yield* Effect.try({
59 try: () => (JSON.parse(text) as { entries: string[] }).entries,
60 catch: (cause) =>
61 new Error(`Invalid journal response: ${text}`, { cause }),
62 });
63 }).pipe(
64 Effect.repeat({
65 schedule: Schedule.spaced("500 millis"),
66 times: 8,
67 until: (entries) => entries.length === 40,
68 }),
69 );
70 expect(entries.toSorted()).toEqual(
71 Array.from({ length: 40 }, (_, i) => `parallel-${i}`).sort(),
72 );
73 }).pipe(logLevel),
74 { timeout: 60_000 },
75 );
76
77 test(
78 "waitUntil runs background Effects past the response (worker ctx + DO state)",
79 Effect.gen(function* () {
80 const { url } = yield* stack;
81
82 // Content-based readiness through workers.dev propagation (the
83 // placeholder page serves 200s); also asserts the raw escape hatch is
84 // the genuine workerd ExecutionContext.
85 yield* expectUrlContains(`${url}/raw`, "ok", {
86 label: "wait-until worker propagation",
87 });
88
89 // Fire both background writes: one from the Worker's ExecutionContext,
90 // one from inside the DO via DurableObjectState.waitUntil. Both routes
91 // respond before the journal entry is persisted. Probed via
92 // expectUrlContains so transient placeholders/5xx during propagation
93 // are retried (a duplicate journal entry from a retry is harmless —
94 // the assertions below use `toContain`).
95 yield* expectUrlContains(`${url}/bg`, "bg-scheduled", {
96 label: "waitUntil /bg",
97 });
98 yield* expectUrlContains(`${url}/bg-do`, "bg-do-scheduled", {
99 label: "waitUntil /bg-do",
100 });
101
102 // The entries only appear if waitUntil kept the invocations alive until
103 // the delayed writes completed.
104 const entries = yield* Effect.gen(function* () {
105 const text = yield* getText(`${url}/entries`);
106 // The workers.dev placeholder serves HTML with a 200 during subdomain
107 // propagation; a bare JSON.parse throw would be a *defect* that the
108 // `Effect.catch` below can't see, so parse as a typed failure.
109 const body = yield* Effect.try(
110 () => JSON.parse(text) as { entries?: string[] },
111 );
112 return body.entries ?? [];
113 }).pipe(
114 Effect.catch(() => Effect.succeed([] as string[])),
115 Effect.repeat({
116 schedule: Schedule.spaced("1 second"),
117 until: (entries) =>
118 entries.includes("from-worker-wait-until") &&
119 entries.includes("from-do-wait-until"),
120 times: 30,
121 }),
122 );
123
124 expect(entries).toContain("from-worker-wait-until");
125 expect(entries).toContain("from-do-wait-until");
126 }).pipe(logLevel),
127 { timeout: 180_000 },
128 );
129
130 // Scope finalizers piggyback on the same mechanism: the bridge closes the
131 // per-event scope after the handler completes and registers the close
132 // promise with waitUntil, so `Effect.addFinalizer` work lands after the
133 // response without blocking it.
134 test(
135 "Effect.addFinalizer runs after the response (request scope + DO call scope)",
136 Effect.gen(function* () {
137 const { url } = yield* stack;
138
139 // Content-based readiness: a fresh workers.dev URL serves Cloudflare's
140 // placeholder page (with a 200) while the subdomain propagates.
141 yield* expectUrlContains(`${url}/raw`, "ok", {
142 label: "wait-until worker propagation",
143 });
144
145 // Both routes respond before their finalizers persist the entries.
146 // Probed via expectUrlContains so transient placeholders/5xx during
147 // propagation are retried (duplicate journal entries are harmless —
148 // the assertions below use `toContain`).
149 yield* expectUrlContains(`${url}/finalizer`, "finalizer-scheduled", {
150 label: "finalizer /finalizer",
151 });
152 yield* expectUrlContains(
153 `${url}/finalizer-do`,
154 "do-finalizer-scheduled",
155 {
156 label: "finalizer /finalizer-do",
157 },
158 );
159
160 const entries = yield* Effect.gen(function* () {
161 const text = yield* getText(`${url}/entries`);
162 // The workers.dev placeholder serves HTML with a 200 during subdomain
163 // propagation; a bare JSON.parse throw would be a *defect* that the
164 // `Effect.catch` below can't see, so parse as a typed failure.
165 const body = yield* Effect.try(
166 () => JSON.parse(text) as { entries?: string[] },
167 );
168 return body.entries ?? [];
169 }).pipe(
170 Effect.catch(() => Effect.succeed([] as string[])),
171 Effect.repeat({
172 schedule: Schedule.spaced("1 second"),
173 until: (entries) =>
174 entries.includes("from-request-finalizer") &&
175 entries.includes("from-do-finalizer"),
176 times: 30,
177 }),
178 );
179
180 expect(entries).toContain("from-request-finalizer");
181 expect(entries).toContain("from-do-finalizer");
182
183 // Init-phase semantics (documented on the Worker resource): the init
184 // closure runs exactly once per isolate — the bridge builds the layer
185 // stack on the first event and every later event reuses the built
186 // context. The build scope is never closed (workerd has no isolate
187 // teardown), so a finalizer added in the init closure NEVER fires;
188 // request-coupled cleanup belongs in handlers, where `addFinalizer`
189 // attaches to the per-event scope (asserted above). Each observation
190 // may come from a different isolate, but every isolate must report
191 // exactly one init run and zero init-finalizer runs no matter how many
192 // events it has served.
193 const observation = yield* Effect.gen(function* () {
194 const init = Number(yield* getText(`${url}/init-runs`));
195 const finalized = Number(
196 yield* getText(`${url}/init-finalizer-runs`),
197 );
198 return { init, finalized };
199 }).pipe(
200 Effect.repeat({
201 schedule: Schedule.spaced("500 millis"),
202 until: (o) => o.init === 1 && o.finalized === 0,
203 times: 10,
204 }),
205 );
206 expect(observation).toEqual({ init: 1, finalized: 0 });
207 }).pipe(logLevel),
208 { timeout: 180_000 },
209 );
210 },
211);