| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import { describe, expect } from "alchemy-test"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import { MinimumLogLevel } from "effect/References"; |
| 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 8 | import { expectUrlContains } from "../Utils/Http.ts"; |
| 9 | import { requestWorker } from "../Utils/WorkerRequest.ts"; |
| 10 | import Stack from "./fixtures/wait-until/stack.ts"; |
| 11 | |
| 12 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 13 | providers: Cloudflare.providers(), |
| 14 | state: Cloudflare.state(), |
| 15 | }); |
| 16 | |
| 17 | const logLevel = Effect.provideService( |
| 18 | MinimumLogLevel, |
| 19 | process.env.DEBUG ? "Debug" : "Info", |
| 20 | ); |
| 21 | |
| 22 | const stack = beforeAll(deploy(Stack)); |
| 23 | afterAll.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. |
| 30 | let bust = 0; |
| 31 | const 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 | |
| 47 | describe.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 | ); |