diff --git a/.changeset/cloudflare-cluster.md b/.changeset/cloudflare-cluster.md new file mode 100644 index 00000000000..8c3d8098f37 --- /dev/null +++ b/.changeset/cloudflare-cluster.md @@ -0,0 +1,16 @@ +--- +"effect": patch +--- + +Add the `@effect/platform-cloudflare` package, running Effect Cluster on +Cloudflare Workers and Durable Objects. + +One entity instance is one Durable Object with its SQLite storage as the +system of record. The package provides the four Durable Object classes +(entity, workflow, durable queue, singleton), the length-prefixed entity name +encoding, and `CloudflareCluster.layer`, which wires the cluster `Sharding` +service, the `WorkflowEngine`, and the `PersistedQueueFactory` from the +same-Worker namespace bindings. The `Entity`, `Workflow`, `Activity`, +`DurableClock`, `DurableQueue`, `Singleton`, and `ClusterCron` user APIs are +unchanged on this path; every `DurableClock` is durable through the object's +alarm. diff --git a/.changeset/config.json b/.changeset/config.json index 0a755e5f060..a7fa61180d5 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -28,6 +28,7 @@ "@effect/opentelemetry", "@effect/platform-browser", "@effect/platform-bun", + "@effect/platform-cloudflare", "@effect/platform-deno", "@effect/platform-node", "@effect/platform-node-shared", diff --git a/deno.json b/deno.json index 38de0651a08..9792d34079e 100644 --- a/deno.json +++ b/deno.json @@ -27,6 +27,7 @@ "packages/opentelemetry/", "packages/platform/browser/", "packages/platform/bun/", + "packages/platform/cloudflare/", "packages/platform/node/", "packages/platform/node-shared/", "packages/tools/", diff --git a/packages/effect/src/unstable/cluster/Entity.ts b/packages/effect/src/unstable/cluster/Entity.ts index 0e4847dcd07..a1c9dfc5802 100644 --- a/packages/effect/src/unstable/cluster/Entity.ts +++ b/packages/effect/src/unstable/cluster/Entity.ts @@ -713,6 +713,11 @@ export const keepAlive: ( never, Sharding | CurrentAddress > = Effect.fnUntraced(function*(enabled: boolean) { + const ohandler = yield* Effect.serviceOption(KeepAliveHandler) + if (ohandler._tag === "Some") { + yield* ohandler.value(enabled) + return + } const olatch = yield* Effect.serviceOption(KeepAliveLatch) if (olatch._tag === "None") return if (!enabled) { @@ -780,3 +785,21 @@ export const KeepAliveRpc = Rpc.make("Cluster/Entity/keepAlive") export class KeepAliveLatch extends Context.Service()( "effect/cluster/Entity/KeepAliveLatch" ) {} + +/** + * Service tag for the runtime hook behind {@link keepAlive}. + * + * **Details** + * + * Runtimes that support pinning an entity in memory provide this service; the + * handler receives `true` while at least one keep-alive holder exists and + * `false` once the last holder is released. When the service is absent, + * `keepAlive` is a no-op. + * + * @category services + * @since 4.0.0 + */ +export class KeepAliveHandler extends Context.Service< + KeepAliveHandler, + (enabled: boolean) => Effect.Effect +>()("effect/cluster/Entity/KeepAliveHandler") {} diff --git a/packages/effect/src/unstable/workflow/DurableClock.ts b/packages/effect/src/unstable/workflow/DurableClock.ts index 7029c863c3d..6256c8e1941 100644 --- a/packages/effect/src/unstable/workflow/DurableClock.ts +++ b/packages/effect/src/unstable/workflow/DurableClock.ts @@ -60,6 +60,24 @@ const InstanceTag = Context.Service< "effect/workflow/WorkflowEngine/WorkflowInstance" satisfies typeof WorkflowInstance.key ) +/** + * Context reference containing the default `inMemoryThreshold` used by + * {@link sleep} when the option is not passed. + * + * **Details** + * + * Workflow engines whose timers are always durable (for example the Cloudflare + * Durable Object engine) provide `Duration.zero` so every `sleep` without an + * explicit `inMemoryThreshold` schedules a durable clock. + * + * @category services + * @since 4.0.0 + */ +export const InMemoryThreshold = Context.Reference( + "effect/workflow/DurableClock/InMemoryThreshold", + { defaultValue: () => Duration.seconds(60) } +) + /** * Waits inside a workflow, using an in-memory activity for durations at or * below the threshold and scheduling a durable clock for longer durations. @@ -93,9 +111,9 @@ export const sleep: ( return } - const inMemoryThreshold = options.inMemoryThreshold + const inMemoryThreshold = options.inMemoryThreshold !== undefined ? Duration.fromInputUnsafe(options.inMemoryThreshold) - : defaultInMemoryThreshold + : yield* InMemoryThreshold if (Duration.isLessThanOrEqualTo(duration, inMemoryThreshold)) { return yield* Activity.make({ @@ -113,5 +131,3 @@ export const sleep: ( }) return yield* DurableDeferred.await(clock.deferred) }) - -const defaultInMemoryThreshold = Duration.seconds(60) diff --git a/packages/platform/cloudflare/LICENSE b/packages/platform/cloudflare/LICENSE new file mode 100644 index 00000000000..be1f5c14c7b --- /dev/null +++ b/packages/platform/cloudflare/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Effectful Technologies Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/platform/cloudflare/README.md b/packages/platform/cloudflare/README.md new file mode 100644 index 00000000000..66159021789 --- /dev/null +++ b/packages/platform/cloudflare/README.md @@ -0,0 +1,187 @@ +# @effect/platform-cloudflare + +Runs Effect Cluster on [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/). Every entity instance is one Durable Object, the Worker is the edge, and each object's SQLite storage is the system of record. + +## Installation + +```sh +npm install effect@rc @effect/platform-cloudflare@rc +``` + +## Usage + +The package ships four Durable Object classes. Re-export them from your Worker entry module and bind each one as a SQLite-backed class: + +```jsonc +// wrangler.jsonc +{ + "name": "my-worker", + "main": "src/worker.ts", + "compatibility_date": "2026-08-01", + "durable_objects": { + "bindings": [ + { "name": "CLUSTER_ENTITY", "class_name": "ClusterEntity" }, + { "name": "CLUSTER_WORKFLOW", "class_name": "ClusterWorkflow" }, + { "name": "CLUSTER_QUEUE", "class_name": "ClusterDurableQueue" }, + { "name": "CLUSTER_SINGLETON", "class_name": "ClusterSingleton" }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": [ + "ClusterEntity", + "ClusterWorkflow", + "ClusterDurableQueue", + "ClusterSingleton", + ], + }, + ], + "triggers": { + "crons": ["0 * * * *"], + }, +} +``` + +```ts +// src/worker.ts +import { CloudflareCluster } from "@effect/platform-cloudflare" +import { Effect, Layer, Schema } from "effect" +import { Entity, Singleton } from "effect/unstable/cluster" +import { Rpc } from "effect/unstable/rpc" + +export { + ClusterDurableQueue, + ClusterEntity, + ClusterSingleton, + ClusterWorkflow +} from "@effect/platform-cloudflare/CloudflareDurableObjects" + +// The same Entity + RpcGroup definitions as on every other cluster path +const Counter = Entity.make("Counter", [ + Rpc.make("Increment", { success: Schema.Number }) +]) + +const CounterLayer = Counter.toLayer({ + Increment: () => Effect.succeed(1) +}) + +const MaintenanceLayer = Singleton.make( + "hourly-maintenance", + Effect.logInfo("Running hourly maintenance") +) + +const clusterLayer = (env: Env) => + Layer.merge(CounterLayer, MaintenanceLayer).pipe( + Layer.provideMerge(CloudflareCluster.layer({ + entities: [Counter], + entityNamespace: env.CLUSTER_ENTITY, + workflowNamespace: env.CLUSTER_WORKFLOW, + queueNamespace: env.CLUSTER_QUEUE, + singletonNamespace: env.CLUSTER_SINGLETON + })) + ) +``` + +The Cron Trigger wakes the named singleton through its same-Worker binding. +The call returns after one run, allowing the Durable Object to hibernate; do +not make the singleton effect a forever loop. + +```ts +export default { + scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext) { + const singleton = env.CLUSTER_SINGLETON.getByName("Singleton/hourly-maintenance") + ctx.waitUntil(singleton.wake()) + } +} +``` + +`Entity.client` stays the user API. The Worker encodes `(type, id)` into the Durable Object name and resolves the object with `getByName`; an unknown entity type fails at the Worker before any Durable Object is contacted. + +## Worker routes + +The existing `EntityProxy` / `EntityProxyServer` and `WorkflowProxy` / +`WorkflowProxyServer` modules remain the route helpers. Define an HTTP or RPC +surface with the proxy module, then provide its server layer with +`CloudflareCluster.layer`. Entity proxy handlers call `Entity.client`, and +workflow proxy handlers call the workflow API, so the Cloudflare layers encode +the entity or workflow name and resolve the corresponding Durable Object stub. +There is no runner-fleet proxy on this path. + +These are Worker routes, not Durable Object routes. The Durable Object classes +are internal transport: they trust the same-Worker namespace bindings and must +not be exposed on a public route. HTTP or RPC authentication and authorization +are user code on the Worker. + +## Long waits and delivery + +- A long ask pins the caller. A delayed ask made directly by a Worker also + keeps the destination RPC open, so it pins the destination too. +- Caller eviction or deployment drops the in-memory wait even though the + destination may still run the persisted request. +- Prefer a tell when no response is needed. For durable long waits, prefer a + workflow with `DurableClock` and `DurableDeferred`. +- Stream asks with a future `DeliverAt` are outside v1. + +## Handler concurrency + +`Entity.toLayer(..., { concurrency })` applies inside the entity Durable +Object. The default of 1 runs one handler at a time, a number allows that many +in-flight handlers per entity, and `"unbounded"` removes the limit. Durable +Object isolates are single-threaded, so this is interleaving of suspended +handlers, not parallelism. + +- Envelope decode, persist-before-run, dedupe, duplicate resume, and alarm + arming stay serialized at any setting. +- With `concurrency` above 1, strict mailbox ordering holds per permit, the + same as the classic runner path: in-flight handlers interleave at every + suspension point. +- An ask cycle (entity A asks B while B's handler asks A back) needs + `concurrency` of at least 2 on the entity receiving the second ask. At the + default of 1 the cycle deadlocks, matching the classic contract. +- Replayed mailbox rows and alarm-due runs draw from the same budget as live + requests. + +## v1 compatibility + +The status vocabulary is **maps 1:1**, **adapted**, and **out of scope**. + +| Capability | Status | Rationale | +| ----------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------- | +| `Entity` + `RpcGroup` definition | adapted | Same definition; handlers register at Worker init onto one shared Durable Object class | +| `Entity.client` / location-transparent ask-tell | adapted | Worker encodes `(type, id)` and calls `getByName`; there is no `ShardId` routing | +| `EntityProxy` / `EntityProxyServer` and workflow equivalents | adapted | Worker route helpers encode the name and stub the Durable Object through the Cloudflare layers | +| Non-`Persisted` RPC | adapted | Best-effort in-request only; it can be lost on hibernation or a crash | +| `Persisted` ask/tell + mailbox | adapted | Per-entity Durable Object SQLite, persist-before-run, and uuidv7 request ids | +| `PrimaryKey` dedupe / `Duplicate` resume | maps 1:1 | Same contract | +| Stream ask `Chunk` / `AckChunk` / `lastSentChunk` / `WithExit` | maps 1:1 | Same reply protocol on Durable Object storage | +| `clearReplies` / `reset` | maps 1:1 | Same re-run semantics | +| `DeliverAt` mailbox delivery | adapted | Destination due column and alarm instead of storage polling | +| Ask + future `DeliverAt` | adapted | Destination may hibernate through `replyTo`; ask pins its caller, and a Worker ask pins the destination too | +| `MailboxFull` / 4096 cap / 2 MB row rejection | maps 1:1 | Same limits; the SQLite row is the hard ceiling | +| `defectRetryPolicy` then terminal defect | adapted | Rebuilds handlers in the wake; crash or deployment wipes memory and replays unprocessed rows | +| `Entity.toLayer` `concurrency` | maps 1:1 | Same per-entity handler interleaving contract; storage entry stays serialized at any setting | +| `Entity.keepAlive` | adapted | Pins while holders exist; hibernation is allowed with no holders | +| `CurrentRunnerAddress` | adapted | Synthetic address for identity and telemetry; no peer dialing | +| `EntityResource.make` | adapted | External lifetimes such as a browser; close or idle TTL unpins | +| `EntityResource.makeK8sPod` | out of scope | Requires `K8sHttpClient` | +| `Workflow` / `Activity` / `DurableDeferred` user APIs | maps 1:1 | Unchanged; the engine behind them changes | +| `CloudflareWorkflowEngine` (`WorkflowEngine.Encoded`) | adapted | Dedicated workflow Durable Object, SQLite, and one alarm | +| `DurableClock` | adapted | Always durable; there is no short in-memory timer path | +| `DurableQueue` | adapted | One Durable Object per queue name with SQLite and an alarm watchdog | +| `Singleton` | adapted | Named Durable Object; runs once per wake and then may hibernate | +| `ClusterCron` | adapted | Per-fire entity ids, `DeliverAt` destination alarms, and a singleton seed | +| Address `(EntityType, EntityId)` | adapted | Length-prefixed Durable Object name; cold first contact is normal | +| `ShardId` / shard locks / runner ring | out of scope | One-instance-per-id replaces ownership | +| `MessageStorage` / `RunnerStorage` / `RunnerHealth` / `Runners` as user seams | out of scope | The Durable Object path owns persistence and alarms internally | +| `HttpRunner` / `SocketRunner` / peer runner fleet | out of scope | Worker edge only | +| `EntityReaper` / `maxIdleTime` | out of scope | Cloudflare hibernation owns sleep; `keepAlive` holders provide pinning | +| Activate/deactivate / shard handoff / `EntityNotAssignedToRunner` | out of scope | Whole-wake handlers with no handoff | +| Park-and-replay caller hibernation | out of scope | Not part of v1 | +| Stream ask + future `DeliverAt` | out of scope | Delayed asks support non-stream `WithExit` only | +| External SQL as the system of record | out of scope | Durable Object SQLite is the system of record | +| Non-Worker long-lived runners as a first-class edge | out of scope | The Worker is the supported edge model | + +## Documentation + +- [Effect website](https://effect.website) diff --git a/packages/platform/cloudflare/package.json b/packages/platform/cloudflare/package.json new file mode 100644 index 00000000000..f9af75b6823 --- /dev/null +++ b/packages/platform/cloudflare/package.json @@ -0,0 +1,76 @@ +{ + "name": "@effect/platform-cloudflare", + "type": "module", + "version": "4.0.0-rc.110", + "license": "MIT", + "description": "Platform specific implementations for Cloudflare Workers and Durable Objects", + "homepage": "https://effect.website", + "repository": { + "type": "git", + "url": "https://github.com/Effect-TS/effect.git", + "directory": "packages/platform/cloudflare" + }, + "bugs": { + "url": "https://github.com/Effect-TS/effect/issues" + }, + "tags": [ + "cloudflare", + "typescript", + "algebraic-data-types", + "functional-programming" + ], + "keywords": [ + "cloudflare", + "typescript", + "algebraic-data-types", + "functional-programming" + ], + "sideEffects": [], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./internal/*": null, + "./index": null, + "./*/index": null + }, + "files": [ + "src/**/*.ts", + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map", + "AGENTS.md", + "CLAUDE.md", + "ai-docs/**/*" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + "./package.json": "./package.json", + ".": "./dist/index.js", + "./*": "./dist/*.js", + "./internal/*": null, + "./index": null, + "./*/index": null + } + }, + "scripts": { + "codegen": "effect-utils codegen", + "build": "tsc -b tsconfig.json && pnpm babel", + "babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps", + "check": "tsc -b tsconfig.json" + }, + "peerDependencies": { + "effect": "workspace:^" + }, + "dependencies": { + "@cloudflare/workers-types": "^5.20260816.1" + }, + "devDependencies": { + "effect": "workspace:^", + "esbuild": "^0.25.12", + "miniflare": "^4.20260730.0" + } +} diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts new file mode 100644 index 00000000000..f103644ba80 --- /dev/null +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -0,0 +1,510 @@ +/** + * Runs Effect Cluster entities on Cloudflare Durable Objects. + * + * On this path every entity instance is one Durable Object: the Worker encodes + * an `(entityType, entityId)` address into a Durable Object name, resolves the + * object stub with `getByName`, and the object's SQLite storage is the system + * of record. There is no shard routing, no runner fleet, and no external + * message storage; `layer` provides the cluster `Sharding` service on top of + * the Durable Object namespace bindings instead of `Sharding.layer`. + * + * @since 4.0.0 + */ +import { Clock } from "effect/Clock" +import * as Context from "effect/Context" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import * as Stream from "effect/Stream" +import { MailboxFull, PersistenceError } from "effect/unstable/cluster/ClusterError" +import { Persisted, Uninterruptible } from "effect/unstable/cluster/ClusterSchema" +import * as DeliverAt from "effect/unstable/cluster/DeliverAt" +import type * as Entity from "effect/unstable/cluster/Entity" +import * as EntityAddress from "effect/unstable/cluster/EntityAddress" +import * as EntityId from "effect/unstable/cluster/EntityId" +import * as Envelope from "effect/unstable/cluster/Envelope" +import * as ShardId from "effect/unstable/cluster/ShardId" +import { Sharding } from "effect/unstable/cluster/Sharding" +import type { PersistedQueueFactory } from "effect/unstable/persistence/PersistedQueue" +import type * as Rpc from "effect/unstable/rpc/Rpc" +import * as RpcClient from "effect/unstable/rpc/RpcClient" +import { type FromClient, RequestId } from "effect/unstable/rpc/RpcMessage" +import * as RpcSchema from "effect/unstable/rpc/RpcSchema" +import type { WorkflowEngine } from "effect/unstable/workflow/WorkflowEngine" +import * as CloudflarePersistedQueue from "./CloudflarePersistedQueue.ts" +import * as CloudflareWorkflowEngine from "./CloudflareWorkflowEngine.ts" +import { setWithEviction } from "./internal/boundedMap.ts" +import * as Internal from "./internal/clusterName.ts" +import { registerEntity as registerEntityHandler, unregisterEntity } from "./internal/entityRegistry.ts" +import { CurrentEntityName, CurrentReplyRegistry } from "./internal/entityReply.ts" +import { decodeInvokeResult, decodeReplyFor, encodeRequest } from "./internal/entityWire.ts" +import { registerSingleton as registerSingletonHandler, unregisterSingleton } from "./internal/singletonRegistry.ts" + +/** + * A Durable Object name decoded back into its entity address parts. + * + * @category models + * @since 4.0.0 + */ +export interface ClusterName { + readonly type: string + readonly id: string +} + +/** + * Encodes an entity address into the Durable Object name used with + * `getByName`. + * + * **Details** + * + * The name is the entity type length-prefixed as `` `${type.length}:${type}${id}` ``, + * which keeps `(type, id)` pairs collision-free without restricting the + * characters an entity id may contain. Workflow, queue, and singleton names use + * the same scheme on their own namespaces. + * + * @category encoding + * @since 4.0.0 + */ +export const encodeName: (type: string, id: string) => string = Internal.encodeName + +/** + * Decodes a Durable Object name produced by {@link encodeName} back into its + * entity address parts. + * + * **Details** + * + * Returns `undefined` for names that were not produced by {@link encodeName}, + * including non-canonical length prefixes. A Durable Object uses this to + * recover its own address from `ctx.id.name`. + * + * @category decoding + * @since 4.0.0 + */ +export const decodeName: (name: string) => ClusterName | undefined = Internal.decodeName + +/** + * The Durable Object namespace bindings and entity definitions the cluster + * layer is built from. + * + * **Details** + * + * `entities` is the complete set of entity definitions the Worker serves. + * Handlers are attached per entity type with `Entity.toLayer`; a client or + * handler registration for an entity type outside this set fails at the + * Worker, before any Durable Object is contacted. + * + * @category layers + * @since 4.0.0 + */ +export interface LayerOptions { + readonly entities: ReadonlyArray> + readonly entityNamespace: DurableObjectNamespace + readonly workflowNamespace: DurableObjectNamespace + readonly queueNamespace: DurableObjectNamespace + readonly singletonNamespace: DurableObjectNamespace +} + +const notImplemented = (method: string) => + Effect.die( + new Error(`CloudflareCluster: ${method} is not implemented yet on the Cloudflare Durable Object path`) + ) + +interface EntityStub { + readonly invoke: (envelope: string, discard: boolean, delivery?: { + readonly deliverAt?: number | undefined + readonly primaryKey?: string | null | undefined + readonly replyTo?: string | undefined + }) => Promise + readonly acknowledge: (requestId: string, replyId: string) => Promise> + readonly interrupt: (storageRequestId: string, clientRequestId?: string) => Promise + readonly reset: (requestId: string) => Promise +} + +interface ClientTargetValue { + readonly address: EntityAddress.EntityAddress + readonly stub: EntityStub +} + +class ClientTarget extends Context.Service()( + "@effect/platform-cloudflare/CloudflareCluster/ClientTarget" +) {} + +const uuidV7 = (timestamp: number): string => { + const bytes = crypto.getRandomValues(new Uint8Array(16)) + bytes[0] = Math.floor(timestamp / 2 ** 40) + bytes[1] = Math.floor(timestamp / 2 ** 32) & 0xff + bytes[2] = Math.floor(timestamp / 2 ** 24) & 0xff + bytes[3] = Math.floor(timestamp / 2 ** 16) & 0xff + bytes[4] = Math.floor(timestamp / 2 ** 8) & 0xff + bytes[5] = timestamp & 0xff + bytes[6] = (bytes[6] & 0x0f) | 0x70 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = (byte: number) => byte.toString(16).padStart(2, "0") + return [bytes.subarray(0, 4), bytes.subarray(4, 6), bytes.subarray(6, 8), bytes.subarray(8, 10), bytes.subarray(10)] + .map((part) => Array.from(part, hex).join("")) + .join("-") +} + +const requestTargetCapacity = 4096 + +// ClusterCron owns this entity inside its layer, so callers cannot include it +// in LayerOptions.entities. Its reserved shape is the only implicit entity. +const isClusterCronEntity = (entity: Entity.Entity): boolean => { + if (!entity.type.startsWith("ClusterCron/") || entity.protocol.requests.size !== 1) return false + const run = entity.protocol.requests.get("run") + return run !== undefined && + Context.get(run.annotations, Persisted) && + Context.get(run.annotations, Uninterruptible) === true +} + +const make = Effect.fnUntraced(function*(options: LayerOptions) { + const entities = new Map>() + for (const entity of options.entities) { + entities.set(entity.type, entity) + } + const clock = yield* Clock + const requestTargets = new Map() + + const unknownEntity = (entity: Entity.Entity) => + Effect.die( + new Error( + `CloudflareCluster: entity type "${entity.type}" is not part of the entities bound at Worker init` + ) + ) + + const makeClient = Effect.fnUntraced(function*(entity: Entity.Entity) { + if (!entities.has(entity.type)) return yield* unknownEntity(entity) + type ClientEntry = { + readonly rpc: Rpc.AnyWithProps + readonly context: Context.Context + readonly clientRequestId: string + storageRequestId: string + lastChunkId?: string + } + const entries = new Map() + + // `handleFromClient` is hoisted and only invoked once requests are made, + // after `client` is constructed. + const client = yield* RpcClient.makeNoSerialization(entity.protocol, { + spanPrefix: `${entity.type}.client`, + supportsAck: true, + generateRequestId: () => RequestId(uuidV7(clock.currentTimeMillisUnsafe())), + onFromClient: (options) => handleFromClient(options) + }) + + const deliverReplies = (entry: ClientEntry, replyTexts: ReadonlyArray): Effect.Effect => + Effect.forEach( + replyTexts, + (replyText) => + Effect.flatMap(decodeReplyFor(entry.rpc, entry.context, replyText), (reply) => { + if (reply._tag === "Chunk") { + entry.lastChunkId = String(reply.id) + return client.write({ + _tag: "Chunk", + clientId: 0, + requestId: RequestId(entry.clientRequestId), + values: reply.values + }) + } + entries.delete(entry.clientRequestId) + return client.write({ + _tag: "Exit", + clientId: 0, + requestId: RequestId(entry.clientRequestId), + exit: reply.exit + }) + }), + { discard: true } + ) + + function handleFromClient({ context, discard, message }: { + readonly message: FromClient + readonly context: Context.Context + readonly discard: boolean + }): Effect.Effect { + const target = Context.getUnsafe(context, ClientTarget) + switch (message._tag) { + case "Request": { + const rpc = entity.protocol.requests.get(message.tag)! as Rpc.AnyWithProps + const clientRequestId = String(message.id) + const encode = Schema.encodeUnknownEffect(Schema.toCodecJson(rpc.payloadSchema))(message.payload).pipe( + Effect.provideContext(context as any), + Effect.orDie + ) as unknown as Effect.Effect + return Effect.flatMap(encode, (payload) => { + const envelope = encodeRequest({ + requestId: clientRequestId, + address: target.address, + tag: message.tag, + payload, + headers: message.headers, + ...(message.traceId === undefined ? undefined : { + traceId: message.traceId, + spanId: message.spanId, + sampled: message.sampled + }) + }) + const entry: ClientEntry = { + rpc, + context, + clientRequestId, + storageRequestId: clientRequestId + } + if (!discard) entries.set(clientRequestId, entry) + const deliverAt = DeliverAt.toMillis(message.payload) + const delayed = deliverAt !== null && deliverAt > clock.currentTimeMillisUnsafe() + const persisted = Context.get(rpc.annotations, Persisted) + const primaryKey = Envelope.primaryKey({ + ...message, + requestId: message.id, + address: target.address, + headers: message.headers + } as any) + const replyTo = discard ? undefined : Option.getOrUndefined(Context.getOption(context, CurrentEntityName)) + const replyRegistry = discard + ? undefined + : Option.getOrUndefined(Context.getOption(context, CurrentReplyRegistry)) + if (delayed && (!persisted || (!discard && primaryKey === null))) { + entries.delete(clientRequestId) + return Effect.fail( + new PersistenceError({ + cause: new Error( + !persisted + ? "Future DeliverAt requests must be persisted" + : "Future DeliverAt asks must define a PrimaryKey" + ) + }) + ) + } + if (delayed && RpcSchema.isStreamSchema(rpc.successSchema)) { + entries.delete(clientRequestId) + return Effect.fail( + new PersistenceError({ + cause: new Error("Stream asks with a future DeliverAt are not supported") + }) + ) + } + const delivery = delayed + ? { deliverAt: deliverAt!, primaryKey, ...(replyTo === undefined ? undefined : { replyTo }) } + : replyTo === undefined + ? undefined + : { replyTo } + // A pinned caller receives the scheduled reply pushed over its own + // Durable Object's `deliverReply` RPC, which completes this waiter. + const pushedReply = delivery?.replyTo !== undefined && replyRegistry !== undefined + ? Deferred.makeUnsafe() + : undefined + if (pushedReply !== undefined) replyRegistry!.register(clientRequestId, pushedReply) + const send = Effect.promise(() => target.stub.invoke(envelope, discard, delivery)).pipe( + Effect.flatMap(decodeInvokeResult), + Effect.flatMap((result): Effect.Effect => { + switch (result._tag) { + case "MailboxFull": + return Effect.fail(new MailboxFull({ address: target.address })) + case "EncodedMessageTooLarge": + return Effect.fail( + new PersistenceError({ cause: new Error("Encoded entity message exceeds 2 MB") }) + ) + case "AskDeduplicatedToTell": + return Effect.fail( + new PersistenceError({ + cause: new Error("Cannot deduplicate an ask onto a tell with the same PrimaryKey") + }) + ) + case "Success": { + entry.storageRequestId = result.requestId + if (pushedReply !== undefined && result.requestId !== clientRequestId) { + replyRegistry!.register(result.requestId, pushedReply) + } + if (!discard && persisted) { + setWithEviction(requestTargets, clientRequestId, { + stub: target.stub, + storageRequestId: result.requestId + }, requestTargetCapacity) + } + if (discard) return Effect.void + if (pushedReply !== undefined && result.replies.length === 0) { + return Effect.flatMap( + Deferred.await(pushedReply), + (replyText) => deliverReplies(entry, [replyText]) + ) + } + return deliverReplies(entry, result.replies) + } + } + }) + ) + return pushedReply === undefined ? send : Effect.ensuring( + send, + Effect.sync(() => { + replyRegistry!.unregister(clientRequestId, pushedReply) + replyRegistry!.unregister(entry.storageRequestId, pushedReply) + }) + ) + }) + } + case "Ack": { + const entry = entries.get(String(message.requestId)) + if (entry === undefined || entry.lastChunkId === undefined) return Effect.void + return Effect.promise(() => target.stub.acknowledge(entry.storageRequestId, entry.lastChunkId!)).pipe( + Effect.flatMap((replies) => deliverReplies(entry, replies)) + ) + } + case "Interrupt": { + const clientRequestId = String(message.requestId) + const entry = entries.get(clientRequestId) + entries.delete(clientRequestId) + requestTargets.delete(clientRequestId) + if (entry === undefined) return Effect.void + if (Context.get(entry.rpc.annotations, Uninterruptible) === true) return Effect.void + return Effect.promise(() => target.stub.interrupt(entry.storageRequestId, clientRequestId)) + } + default: + return Effect.void + } + } + + return (entityId: string) => { + const id = EntityId.make(entityId) + const target = ClientTarget.context({ + address: EntityAddress.make({ + shardId: ShardId.make(entity.getShardGroup(id), 1), + entityId: id, + entityType: entity.type + }), + stub: options.entityNamespace.getByName(Internal.encodeName(entity.type, entityId)) as unknown as EntityStub + }) + const result: Record = {} + for (const tag of entity.protocol.requests.keys()) { + const rpc = entity.protocol.requests.get(tag)! as Rpc.AnyWithProps + const contextFor = ( + ambient: Context.Context, + methodOptions?: { readonly context?: Context.Context } + ) => { + const currentEntityName = Option.getOrUndefined(Context.getOption(ambient, CurrentEntityName)) + const replyRegistry = Option.getOrUndefined(Context.getOption(ambient, CurrentReplyRegistry)) + let requestContext = currentEntityName === undefined + ? target + : Context.add(target, CurrentEntityName, currentEntityName) + if (replyRegistry !== undefined) { + requestContext = Context.add(requestContext, CurrentReplyRegistry, replyRegistry) + } + if (methodOptions?.context !== undefined) { + requestContext = Context.merge(methodOptions.context, requestContext) + } + return requestContext + } + result[tag] = RpcSchema.isStreamSchema(rpc.successSchema) + ? (payload: unknown, methodOptions?: { readonly context?: Context.Context }) => + Stream.unwrap( + Effect.map(Effect.context(), (ambient) => + (client.client as any)[tag](payload, { + ...methodOptions, + context: contextFor(ambient, methodOptions) + })) + ) + : (payload: unknown, methodOptions?: { readonly context?: Context.Context }) => + Effect.contextWith((ambient) => + (client.client as any)[tag](payload, { + ...methodOptions, + context: contextFor(ambient, methodOptions) + }) + ) + } + return result as any + } + }) + + const registerEntity = Effect.fnUntraced(function*( + entity: Entity.Entity, + build: Effect.Effect, + buildOptions?: Record + ) { + const declared = entities.has(entity.type) + if (!declared && !isClusterCronEntity(entity)) { + return yield* unknownEntity(entity) + } + const context = yield* Effect.context() + const registration = { entity, build: build as any, options: buildOptions, context } + if (!registerEntityHandler(entity.type, registration)) return + if (!declared) entities.set(entity.type, entity) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + unregisterEntity(entity.type, registration) + if (!declared && entities.get(entity.type) === entity) entities.delete(entity.type) + }) + ) + }) + + const registerSingleton = Effect.fnUntraced(function*( + name: string, + run: Effect.Effect + ) { + // Fails fast at registration when the singleton namespace binding is + // missing, before any Cron Trigger fires. + options.singletonNamespace.getByName(`Singleton/${name}`) + const context = yield* Effect.context() + const registration = { + run: run as Effect.Effect, + context + } + if (!registerSingletonHandler(name, registration)) { + return yield* Effect.die(`Singleton '${name}' is already registered`) + } + yield* Effect.addFinalizer(() => + Effect.sync(() => { + unregisterSingleton(name, registration) + }) + ) + }) + + return Sharding.of({ + getRegistrationEvents: Stream.never, + getShardId: (_entityId, group) => ShardId.make(group, 1), + hasShardId: () => true, + getSnowflake: Effect.sync(() => uuidV7(clock.currentTimeMillisUnsafe()) as any), + isShutdown: Effect.succeed(false), + makeClient: makeClient as Sharding["Service"]["makeClient"], + registerEntity: registerEntity as Sharding["Service"]["registerEntity"], + registerSingleton: registerSingleton as Sharding["Service"]["registerSingleton"], + send: () => notImplemented("Sharding.send"), + sendOutgoing: () => notImplemented("Sharding.sendOutgoing"), + notify: () => notImplemented("Sharding.notify"), + reset: (requestId) => { + const target = requestTargets.get(String(requestId)) + if (target === undefined) return Effect.succeed(false) + return Effect.as(Effect.promise(() => target.stub.reset(target.storageRequestId)), true) + }, + pollStorage: notImplemented("Sharding.pollStorage"), + activeEntityCount: Effect.succeed(0) + }) +}) + +/** + * Builds the cluster on Cloudflare Durable Objects. + * + * **Details** + * + * Provides the cluster `Sharding` service on top of the four same-Worker + * Durable Object namespace bindings, plus the `WorkflowEngine` backed by the + * workflow class and the `PersistedQueueFactory` backed by the queue class, so + * the `DurableQueue` user API works out of the box. `Entity.client` resolves + * an entity to its Durable Object by + * encoding `(type, id)` with {@link encodeName} and calling `getByName`; an + * unknown entity type or a bad encode fails at the Worker before any Durable + * Object is contacted. Entity handlers registered with `Entity.toLayer` are + * recorded per `EntityType` at Worker init and built once per Durable Object + * wake; workflow handlers registered with `Workflow.toLayer` follow the same + * pattern on the workflow class. + * + * @category layers + * @since 4.0.0 + */ +export const layer = (options: LayerOptions): Layer.Layer => + Layer.mergeAll( + Layer.effect(Sharding)(make(options)), + CloudflareWorkflowEngine.layer({ workflowNamespace: options.workflowNamespace }), + CloudflarePersistedQueue.layer({ queueNamespace: options.queueNamespace }) + ) diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts new file mode 100644 index 00000000000..97a0278c06a --- /dev/null +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -0,0 +1,426 @@ +/** + * The Durable Object classes behind `CloudflareCluster.layer`. + * + * A Worker using the Cloudflare cluster re-exports these four classes from its + * entry module and binds each one in `wrangler.jsonc` as a SQLite-backed + * Durable Object class. The cluster resolves objects through the same-Worker + * namespace bindings only; none of these classes serve a public route, and any + * direct `fetch` of an object is rejected. + * + * @since 4.0.0 + */ +import { DurableObject } from "cloudflare:workers" +import * as Effect from "effect/Effect" +import * as ClusterMetrics from "effect/unstable/cluster/ClusterMetrics" +import * as EntityAddress from "effect/unstable/cluster/EntityAddress" +import * as EntityId from "effect/unstable/cluster/EntityId" +import * as EntityType from "effect/unstable/cluster/EntityType" +import * as ShardId from "effect/unstable/cluster/ShardId" +import { decodeName, encodeName } from "./internal/clusterName.ts" +import { makeEntityKeepAlive } from "./internal/entityKeepAlive.ts" +import { makeEntityManager } from "./internal/entityRuntime.ts" +import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/entityStorage.ts" +import { makeQueueRuntime } from "./internal/queueRuntime.ts" +import { earliestLeaseExpiry, type QueueItem } from "./internal/queueStorage.ts" +import { getSingletonRegistration } from "./internal/singletonRegistry.ts" +import { makeSingletonRuntime } from "./internal/singletonRuntime.ts" +import { ensureSingletonStorage, loadSingletonState, rememberSingletonName } from "./internal/singletonStorage.ts" +import type { WorkflowRunOptions, WorkflowStub } from "./internal/workflowRegistry.ts" +import { makeWorkflowRuntime } from "./internal/workflowRuntime.ts" +import { earliestClockWakeUp, ensureWorkflowStorage, loadExecution } from "./internal/workflowStorage.ts" + +const notExposed = (className: string) => () => { + throw new Error( + `@effect/platform-cloudflare: ${className} is not exposed over fetch, use the same-Worker namespace binding` + ) +} + +const exportedNamespace = ( + state: DurableObjectState, + className: string +): { readonly getByName: (name: string) => Stub } | undefined => + (state.exports as Record)[className] as + | { readonly getByName: (name: string) => Stub } + | undefined + +type EntityManager = ReturnType + +type WorkflowRuntime = ReturnType + +type QueueRuntime = ReturnType + +type SingletonRuntime = ReturnType + +// Mirrors entityWire's InvokeResult and entityRuntime's DeliveryOptions: the +// exported class cannot reference an @internal type in its method signatures. +type InvokeResult = { + readonly _tag: "Success" + readonly requestId: string + readonly replies: ReadonlyArray +} | { + readonly _tag: "MailboxFull" +} | { + readonly _tag: "EncodedMessageTooLarge" +} | { + readonly _tag: "AskDeduplicatedToTell" +} + +interface DeliveryOptions { + readonly deliverAt?: number | undefined + readonly primaryKey?: string | null | undefined + readonly replyTo?: string | undefined +} + +/** + * The shared entity class. One instance holds one entity address; the handlers + * for every `EntityType` are registered at Worker init. + * + * **Details** + * + * The constructor stays cheap: it opens SQLite, ensures the mailbox tables, + * and re-arms the single alarm from the earliest pending `deliver_at`. User + * handlers are never built in the constructor; they are built once per wake. + * + * @category durable objects + * @since 4.0.0 + */ +export class ClusterEntity extends DurableObject { + readonly #keepAlive + readonly #manager: EntityManager + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env) + const entityName = ctx.id.name ?? "" + const name = decodeName(entityName) + if (name === undefined) throw new Error("ClusterEntity requires a canonical entity Durable Object name") + this.#keepAlive = makeEntityKeepAlive(() => { + const namespace = exportedNamespace<{ readonly hold: () => Promise }>(ctx, "ClusterEntity") + if (namespace === undefined) { + return Promise.reject( + new Error("CloudflareCluster: ClusterEntity export is unavailable for keep-alive") + ) + } + return namespace.getByName(entityName).hold() + }) + this.#manager = makeEntityManager({ + storage: ctx.storage, + address: EntityAddress.make({ + shardId: ShardId.make("default", 1), + entityType: EntityType.make(name.type), + entityId: EntityId.make(name.id) + }), + entityName, + keepAlive: this.#keepAlive, + waitUntil: (effect) => ctx.waitUntil(Effect.runPromise(effect)), + getNamespace: () => + exportedNamespace<{ + readonly deliverReply: (requestId: string, reply: string) => Promise + }>(ctx, "ClusterEntity") + }) + const sql = ctx.storage.sql + ensureEntityStorage(sql) + const deliverAt = earliestDeliverAt(sql) + if (deliverAt !== undefined) { + void ctx.blockConcurrencyWhile(() => Effect.runPromise(armAlarm(ctx.storage, deliverAt))) + } + } + + override alarm(): Promise { + return Effect.runPromise(this.#manager.alarm) + } + + /** @internal Keeps this object non-hibernateable while entity resources have holders. */ + hold(): Promise { + return Effect.runPromise(this.#keepAlive.await) + } + + /** @internal Same-Worker RPC transport used by `CloudflareCluster.layer`. */ + invoke(envelopeText: string, discard: boolean, delivery?: DeliveryOptions): Promise { + return Effect.runPromise(this.#manager.invoke(envelopeText, discard, delivery)) + } + + /** @internal Acknowledges a streamed chunk. */ + acknowledge(requestId: string, replyId: string): Promise> { + return Effect.runPromise(this.#manager.acknowledge(requestId, replyId)) + } + + /** @internal Interrupts a handler execution and completes its persisted ask, if any. */ + interrupt(storageRequestId: string, clientRequestId = storageRequestId): Promise { + return Effect.runPromise(this.#manager.interrupt(storageRequestId, clientRequestId)) + } + + /** @internal Clears stored replies so a reset request replays from scratch. */ + reset(requestId: string): Promise { + return Effect.runPromise(this.#manager.reset(requestId)) + } + + /** @internal Completes an in-memory delayed ask owned by this entity object. */ + deliverReply(requestId: string, reply: string): Promise { + return Effect.runPromise(this.#manager.deliverReply(requestId, reply)) + } + + override fetch: () => never = notExposed("ClusterEntity") +} + +/** + * The workflow execution class behind `CloudflareWorkflowEngine`. One + * instance holds one workflow execution: run state, activity results keyed + * `${name}/${attempt}`, durable deferred exits, and the clock due table. + * + * **Details** + * + * The constructor stays cheap: it opens SQLite, ensures the workflow tables, + * and re-arms the single alarm from the earliest pending clock. Workflow + * handlers are looked up in the module-level registry and built once per + * wake. + * + * @category durable objects + * @since 4.0.0 + */ +export class ClusterWorkflow extends DurableObject { + readonly #state: DurableObjectState + readonly #name: string | undefined + #runtime: WorkflowRuntime | undefined + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env) + this.#state = ctx + ensureWorkflowStorage(ctx.storage.sql) + if (ctx.id.name !== undefined) { + if (decodeName(ctx.id.name) === undefined) { + throw new Error("ClusterWorkflow requires a canonical workflow Durable Object name") + } + this.#name = ctx.id.name + } else { + // An alarm wake carries no `id.name`; recover it from the stored + // execution so due clocks still fire after eviction. + const stored = loadExecution(ctx.storage.sql) + this.#name = stored === undefined ? undefined : encodeName(stored.workflowName, stored.executionId) + } + const wakeUp = earliestClockWakeUp(ctx.storage.sql) + if (wakeUp !== undefined) { + void ctx.blockConcurrencyWhile(() => Effect.runPromise(armAlarm(ctx.storage, wakeUp))) + } + } + + #getRuntime(): WorkflowRuntime { + if (this.#runtime === undefined) { + if (this.#name === undefined) { + throw new Error("ClusterWorkflow requires a canonical workflow Durable Object name") + } + this.#runtime = makeWorkflowRuntime({ + name: this.#name, + sql: this.#state.storage.sql, + alarm: this.#state.storage, + now: () => Date.now(), + waitUntil: (promise) => this.#state.waitUntil(promise), + getStub: (name) => { + const namespace = exportedNamespace(this.#state, "ClusterWorkflow") + if (namespace === undefined) { + throw new Error("CloudflareCluster: ClusterWorkflow export is unavailable for workflow delivery") + } + return namespace.getByName(name) + } + }) + } + return this.#runtime + } + + /** @internal Same-Worker RPC transport used by `CloudflareWorkflowEngine`. */ + run(payload: string, options: WorkflowRunOptions): Promise { + return this.#getRuntime().run(payload, options) + } + + /** @internal */ + poll(): Promise { + return this.#getRuntime().poll() + } + + /** @internal */ + resume(): Promise { + return this.#getRuntime().resume() + } + + /** @internal */ + interrupt(): Promise { + return this.#getRuntime().interrupt() + } + + /** @internal */ + interruptUnsafe(): Promise { + return this.#getRuntime().interruptUnsafe() + } + + /** @internal Records a durable deferred exit and resumes the execution. */ + deferredDone(name: string, exit: string): Promise { + return this.#getRuntime().deferredDone(name, exit) + } + + /** @internal Persists a durable clock and arms the single alarm. */ + scheduleClock(name: string, deferredName: string, wakeUp: number): Promise { + return this.#getRuntime().scheduleClock(name, deferredName, wakeUp) + } + + override alarm(): Promise { + // No stored execution means no clock could have armed this alarm. + if (this.#name === undefined) return Promise.resolve() + return this.#getRuntime().runAlarm() + } + + override fetch: () => never = notExposed("ClusterWorkflow") +} + +/** + * The durable queue class behind the `PersistedQueue` implementation used by + * `DurableQueue`. One instance holds one named queue. + * + * **Details** + * + * The constructor stays cheap: it opens SQLite, ensures the queue table, and + * re-arms the single alarm from the earliest pending lease expiry. Items are + * leased to takers for a bounded time; the alarm watchdog expires overdue + * leases so an item whose worker died is redelivered. + * + * @category durable objects + * @since 4.0.0 + */ +export class ClusterDurableQueue extends DurableObject { + readonly #runtime: QueueRuntime + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env) + if (ctx.id.name !== undefined && decodeName(ctx.id.name) === undefined) { + throw new Error("ClusterDurableQueue requires a canonical queue Durable Object name") + } + this.#runtime = makeQueueRuntime({ + sql: ctx.storage.sql, + alarm: ctx.storage, + now: () => Date.now() + }) + const expiry = earliestLeaseExpiry(ctx.storage.sql) + if (expiry !== undefined) { + void ctx.blockConcurrencyWhile(() => Effect.runPromise(armAlarm(ctx.storage, expiry))) + } + } + + /** @internal Same-Worker RPC transport used by `CloudflarePersistedQueue.layer`. */ + offer(id: string, element: string): Promise { + return this.#runtime.offer(id, element) + } + + /** @internal Waits until an item is available, then leases it to the caller. */ + take(takerId: string, maxAttempts: number, leaseMillis: number): Promise { + return this.#runtime.take(takerId, maxAttempts, leaseMillis) + } + + /** @internal Cancels a waiting take, releasing an item already leased to it. */ + cancelTake(takerId: string): Promise { + return this.#runtime.cancelTake(takerId) + } + + /** @internal */ + complete(id: string): Promise { + return this.#runtime.complete(id) + } + + /** @internal Records a failed attempt and requeues the item. */ + fail(id: string, lastFailure: string): Promise { + return this.#runtime.fail(id, lastFailure) + } + + /** @internal Requeues the item without counting an attempt. */ + release(id: string): Promise { + return this.#runtime.release(id) + } + + /** @internal Extends the lease of an item still being processed. */ + extend(id: string, leaseMillis: number): Promise { + return this.#runtime.extend(id, leaseMillis) + } + + override alarm(): Promise { + return this.#runtime.runAlarm() + } + + override fetch: () => never = notExposed("ClusterDurableQueue") +} + +/** + * The singleton class. One object holds one registered singleton under the + * name `Singleton/` and is woken by a Worker Cron Trigger. + * + * **Details** + * + * The constructor opens SQLite, ensures the singleton state table, and + * re-arms the watchdog alarm for a wake interrupted by isolate loss. `wake()` + * runs the registered effect once and returns; it never appends + * `Effect.never`, so Cloudflare may hibernate the object afterward. + * + * @category durable objects + * @since 4.0.0 + */ +export class ClusterSingleton extends DurableObject { + readonly #state: DurableObjectState + readonly #name: string | undefined + #runtime: SingletonRuntime | undefined + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env) + this.#state = ctx + const sql = ctx.storage.sql + ensureSingletonStorage(sql) + if (ctx.id.name !== undefined) { + if (!ctx.id.name.startsWith("Singleton/")) { + throw new Error("ClusterSingleton requires a Singleton/ Durable Object name") + } + rememberSingletonName(sql, ctx.id.name) + } + const stored = loadSingletonState(sql) + this.#name = ctx.id.name ?? stored.name + if (stored.wakeAt !== undefined) { + void ctx.blockConcurrencyWhile(() => Effect.runPromise(armAlarm(ctx.storage, stored.wakeAt!))) + } + } + + #getRuntime(): SingletonRuntime { + if (this.#runtime !== undefined) return this.#runtime + if (this.#name === undefined) { + throw new Error("ClusterSingleton requires a Singleton/ Durable Object name") + } + const name = this.#name.slice("Singleton/".length) + const registration = getSingletonRegistration(name) + if (registration === undefined) { + throw new Error(`CloudflareCluster: no singleton registered under the name "${name}"`) + } + const run = Effect.sync(() => { + ClusterMetrics.singletons.modifyUnsafe(BigInt(1), registration.context) + }).pipe( + Effect.andThen(registration.run), + Effect.scoped, + Effect.ensuring(Effect.sync(() => { + ClusterMetrics.singletons.modifyUnsafe(BigInt(-1), registration.context) + })), + Effect.provideContext(registration.context), + Effect.orDie + ) + this.#runtime = makeSingletonRuntime({ + sql: this.#state.storage.sql, + alarm: this.#state.storage, + now: () => Date.now(), + run + }) + return this.#runtime + } + + /** @internal Runs one Cron Trigger fire, coalescing a concurrent duplicate. */ + wake(): Promise { + return this.#getRuntime().wake() + } + + override alarm(): Promise { + if (loadSingletonState(this.#state.storage.sql).wakeAt === undefined) return Promise.resolve() + return this.#getRuntime().runAlarm() + } + + override fetch: () => never = notExposed("ClusterSingleton") +} diff --git a/packages/platform/cloudflare/src/CloudflarePersistedQueue.ts b/packages/platform/cloudflare/src/CloudflarePersistedQueue.ts new file mode 100644 index 00000000000..b9a8d75839e --- /dev/null +++ b/packages/platform/cloudflare/src/CloudflarePersistedQueue.ts @@ -0,0 +1,140 @@ +/** + * Runs persisted queues on the dedicated queue Durable Object class. + * + * On this path one queue name is one Durable Object: the Worker encodes the + * queue name into a Durable Object name with the same length-prefix scheme as + * entities and resolves the object through the queue namespace binding. Items, + * their attempt counts, and their in-flight leases live on the object's SQLite + * storage behind its single alarm; a take with no available item waits inside + * the object until an offer, a retry, or an expired lease produces one. + * + * Delivery is at-least-once: a taken item is leased for a bounded time and the + * lease is refreshed while the handler runs, so an item whose worker died is + * redelivered once the alarm watchdog expires the lease. + * + * This backs the `DurableQueue` user API; `CloudflareCluster.layer` already + * includes this layer. + * + * @since 4.0.0 + */ +import * as Cause from "effect/Cause" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Layer from "effect/Layer" +import * as Schedule from "effect/Schedule" +import * as PersistedQueue from "effect/unstable/persistence/PersistedQueue" +import { encodeName } from "./internal/clusterName.ts" +import type { QueueItem } from "./internal/queueStorage.ts" + +/** + * The queue Durable Object namespace binding the store is built from. + * + * @category layers + * @since 4.0.0 + */ +export interface LayerOptions { + readonly queueNamespace: DurableObjectNamespace +} + +interface QueueStub { + readonly offer: (id: string, element: string) => Promise + readonly take: (takerId: string, maxAttempts: number, leaseMillis: number) => Promise + readonly cancelTake: (takerId: string) => Promise + readonly complete: (id: string) => Promise + readonly fail: (id: string, lastFailure: string) => Promise + readonly release: (id: string) => Promise + readonly extend: (id: string, leaseMillis: number) => Promise +} + +const leaseMillis = 120_000 +const leaseRefreshMillis = 30_000 + +const attempt = (run: () => Promise) => + Effect.promise(run).pipe( + Effect.sandbox, + Effect.retry({ times: 5, schedule: Schedule.exponential(100, 1.5) }) + ) + +const finalize = (run: () => Promise): Effect.Effect => Effect.orDie(attempt(run)) + +/** + * Creates the `PersistedQueueStore` backed by the queue Durable Object + * namespace binding. + * + * @category constructors + * @since 4.0.0 + */ +export const make = (options: LayerOptions): PersistedQueue.PersistedQueueStore["Service"] => { + const stubFor = (name: string): QueueStub => + options.queueNamespace.getByName(encodeName("PersistedQueue", name)) as unknown as QueueStub + + return PersistedQueue.PersistedQueueStore.of({ + offer: ({ element, id, name }) => + Effect.tryPromise({ + try: () => stubFor(name).offer(id, JSON.stringify(element)), + catch: (cause) => + new PersistedQueue.PersistedQueueError({ + message: "Failed to offer element to persisted queue", + cause + }) + }), + take: ({ maxAttempts, name }) => + // Uninterruptible outside `restore` so the release finalizer is always + // registered once an item is leased; an interrupt while still waiting + // cancels the take by taker id, releasing an item that was already + // leased to it on the object. + Effect.uninterruptibleMask((restore) => + Effect.gen(function*() { + const takerId = crypto.randomUUID() + // A broken take RPC means the object was evicted while this taker + // waited; retrying re-enters the queue with nothing lost. + const item = yield* restore( + Effect.promise(() => stubFor(name).take(takerId, maxAttempts, leaseMillis)).pipe( + Effect.sandbox, + Effect.tapCause((cause) => Effect.logWarning("PersistedQueue take failed, retrying", cause)), + Effect.retry(Schedule.spaced(500)), + Effect.orDie + ) + ).pipe( + Effect.onInterrupt(() => Effect.ignore(attempt(() => stubFor(name).cancelTake(takerId)))) + ) + yield* Effect.addFinalizer(Exit.match({ + onFailure: (cause) => + Cause.hasInterruptsOnly(cause) + ? finalize(() => stubFor(name).release(item.id)) + : finalize(() => stubFor(name).fail(item.id, Cause.pretty(cause))), + onSuccess: () => finalize(() => stubFor(name).complete(item.id)) + })) + yield* Effect.promise(() => stubFor(name).extend(item.id, leaseMillis)).pipe( + Effect.sandbox, + Effect.ignore, + Effect.schedule(Schedule.spaced(leaseRefreshMillis)), + Effect.forkScoped, + Effect.interruptible + ) + return { + id: item.id, + attempts: item.attempts, + element: JSON.parse(item.element) + } + }) + ) + }) +} + +/** + * Layer that provides the `PersistedQueueFactory` backed by the queue Durable + * Object namespace binding. + * + * **Details** + * + * `CloudflareCluster.layer` already includes this layer; use it directly only + * when persisted queues are needed without the rest of the cluster. + * + * @category layers + * @since 4.0.0 + */ +export const layer = (options: LayerOptions): Layer.Layer => + PersistedQueue.layer.pipe( + Layer.provide(Layer.succeed(PersistedQueue.PersistedQueueStore)(make(options))) + ) diff --git a/packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts b/packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts new file mode 100644 index 00000000000..18cb9892494 --- /dev/null +++ b/packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts @@ -0,0 +1,180 @@ +/** + * Runs durable workflows on the dedicated workflow Durable Object class. + * + * On this path one workflow execution is one Durable Object: the engine + * encodes `(workflowName, executionId)` into a Durable Object name with the + * same length-prefix scheme as entities and resolves the object through the + * workflow namespace binding. Execution state, activity results keyed + * `${name}/${attempt}`, durable deferred exits, and the clock due table all + * live on the object's SQLite storage behind its single alarm. + * + * Every `DurableClock` is durable on this engine: the in-memory short-sleep + * path is disabled, so even sub-minute sleeps persist a due row and arm the + * alarm. + * + * @since 4.0.0 + */ +import { Clock } from "effect/Clock" +import * as Context from "effect/Context" +import * as Duration from "effect/Duration" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Workflow from "effect/unstable/workflow/Workflow" +import * as WorkflowEngine from "effect/unstable/workflow/WorkflowEngine" +import { encodeName } from "./internal/clusterName.ts" +import { + CurrentExecutionHandle, + deferredState, + registerWorkflow, + unregisterWorkflow, + type WorkflowRegistration, + type WorkflowStub +} from "./internal/workflowRegistry.ts" +import { decodeExit, decodeResult, encodeExit, encodePayload } from "./internal/workflowWire.ts" + +/** + * The workflow Durable Object namespace binding the engine is built from. + * + * @category layers + * @since 4.0.0 + */ +export interface LayerOptions { + readonly workflowNamespace: DurableObjectNamespace +} + +/** + * Creates the `WorkflowEngine` service backed by the workflow Durable Object + * namespace binding. + * + * **Details** + * + * Inside a workflow Durable Object the engine operates on the local execution + * handle, so activities and deferred reads never leave the object. Everywhere + * else it resolves the target execution's object with `getByName` and drives + * it over the same-Worker binding. + * + * @category constructors + * @since 4.0.0 + */ +export const make = Effect.fnUntraced(function*(options: LayerOptions) { + const clock = yield* Clock + + // Inside a run the execution's own handle avoids a self-RPC; every other + // target resolves to its Durable Object stub through the namespace binding. + const stubFor = Effect.fnUntraced(function*(workflowName: string, executionId: string) { + const handle = yield* Effect.serviceOption(CurrentExecutionHandle) + if (Option.isSome(handle) && handle.value.executionId === executionId) { + return handle.value as WorkflowStub + } + return options.workflowNamespace.getByName(encodeName(workflowName, executionId)) as unknown as WorkflowStub + }) + + const localHandle = Effect.fnUntraced(function*(operation: string) { + const instance = yield* WorkflowEngine.WorkflowInstance + const handle = yield* Effect.serviceOption(CurrentExecutionHandle) + if (Option.isNone(handle)) { + return yield* Effect.die( + `CloudflareWorkflowEngine: ${operation} is only available inside a workflow Durable Object execution` + ) + } + return { instance, handle: handle.value } as const + }) + + return WorkflowEngine.makeUnsafe({ + register: Effect.fnUntraced(function*(workflow, execute) { + const context = yield* Effect.context() + const registration: WorkflowRegistration = { workflow, execute, context } + if (!registerWorkflow(workflow._tag, registration)) return + yield* Effect.addFinalizer(() => + Effect.sync(() => { + unregisterWorkflow(workflow._tag, registration) + }) + ) + }), + + execute: Effect.fnUntraced(function*(workflow, opts) { + const context = yield* Effect.context() + const payload = yield* encodePayload(workflow, opts.payload, context) + const stub = yield* stubFor(workflow._tag, opts.executionId) + const parent = opts.parent === undefined + ? undefined + : { workflowName: opts.parent.workflow._tag, executionId: opts.parent.executionId } + const text = yield* Effect.promise(() => stub.run(payload, { discard: opts.discard, parent })) + if (opts.discard) return undefined + return yield* decodeResult(workflow, text, context) + }) as WorkflowEngine.Encoded["execute"], + + poll: Effect.fnUntraced(function*(workflow, executionId) { + const context = yield* Effect.context() + const stub = yield* stubFor(workflow._tag, executionId) + const text = yield* Effect.promise(() => stub.poll()) + if (text === undefined) return Option.none() + return Option.some(yield* decodeResult(workflow, text, context)) + }), + + interrupt: (workflow, executionId) => + Effect.flatMap(stubFor(workflow._tag, executionId), (stub) => Effect.promise(() => stub.interrupt())), + + interruptUnsafe: (workflow, executionId) => + Effect.flatMap(stubFor(workflow._tag, executionId), (stub) => Effect.promise(() => stub.interruptUnsafe())), + + resume: (workflow, executionId) => + Effect.flatMap(stubFor(workflow._tag, executionId), (stub) => Effect.promise(() => stub.resume())), + + activityExecute: Effect.fnUntraced(function*(activity, attempt) { + const { handle, instance } = yield* localHandle("Activity execution") + const key = `${activity.name}/${attempt}` + const stored = handle.loadActivity(key) + if (stored !== undefined) { + return new Workflow.Complete({ exit: yield* decodeExit(stored, Context.empty()) }) + } + const activityInstance = WorkflowEngine.WorkflowInstance.initial(instance.workflow, instance.executionId) + activityInstance.interrupted = instance.interrupted + const result = yield* activity.executeEncoded.pipe( + Workflow.intoResult, + Effect.provideService(WorkflowEngine.WorkflowInstance, activityInstance) + ) + if (result._tag === "Complete") { + handle.saveActivity(key, yield* encodeExit(result.exit, Context.empty())) + } + return result + }), + + deferredResult: Effect.fnUntraced(function*(deferred) { + const { handle, instance } = yield* localHandle("DurableDeferred reads") + const pending = deferredState.pendingResult(instance.executionId, deferred.name) + if (pending !== undefined) return Option.some(pending) + const stored = handle.loadDeferred(deferred.name) + if (stored === undefined) return Option.none() + return Option.some(yield* decodeExit(stored, Context.empty())) + }), + + deferredDone: Effect.fnUntraced(function*({ deferredName, executionId, exit, workflowName }) { + const text = yield* encodeExit(exit, Context.empty()) + const stub = yield* stubFor(workflowName, executionId) + yield* Effect.promise(() => stub.deferredDone(deferredName, text)) + }), + + scheduleClock: Effect.fnUntraced(function*(workflow, opts) { + const wakeUp = clock.currentTimeMillisUnsafe() + Math.ceil(Duration.toMillis(opts.clock.duration)) + const stub = yield* stubFor(workflow._tag, opts.executionId) + yield* Effect.promise(() => stub.scheduleClock(opts.clock.name, opts.clock.deferred.name, wakeUp)) + }) + }) +}) + +/** + * Layer that provides the `WorkflowEngine` backed by the workflow Durable + * Object namespace binding. + * + * **Details** + * + * `CloudflareCluster.layer` already includes this layer; use it directly only + * when the workflow engine is needed without the rest of the cluster. + * + * @category layers + * @since 4.0.0 + */ +export const layer = (options: LayerOptions): Layer.Layer => + Layer.effect(WorkflowEngine.WorkflowEngine)(make(options)) diff --git a/packages/platform/cloudflare/src/index.ts b/packages/platform/cloudflare/src/index.ts new file mode 100644 index 00000000000..0a2a6ebee87 --- /dev/null +++ b/packages/platform/cloudflare/src/index.ts @@ -0,0 +1,25 @@ +/** + * @since 4.0.0 + */ + +// @barrel: Auto-generated exports. Do not edit manually. + +/** + * @since 4.0.0 + */ +export * as CloudflareCluster from "./CloudflareCluster.ts" + +/** + * @since 4.0.0 + */ +export * as CloudflareDurableObjects from "./CloudflareDurableObjects.ts" + +/** + * @since 4.0.0 + */ +export * as CloudflarePersistedQueue from "./CloudflarePersistedQueue.ts" + +/** + * @since 4.0.0 + */ +export * as CloudflareWorkflowEngine from "./CloudflareWorkflowEngine.ts" diff --git a/packages/platform/cloudflare/src/internal/boundedMap.ts b/packages/platform/cloudflare/src/internal/boundedMap.ts new file mode 100644 index 00000000000..62a3bb39f08 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/boundedMap.ts @@ -0,0 +1,15 @@ +/** @internal */ + +/** + * Inserts refreshing the key's recency, then evicts the oldest entry once the + * map exceeds `capacity`. + * + * @internal + */ +export const setWithEviction = (map: Map, key: K, value: V, capacity: number): void => { + map.delete(key) + map.set(key, value) + if (map.size <= capacity) return + const oldest = map.keys().next().value + if (oldest !== undefined) map.delete(oldest) +} diff --git a/packages/platform/cloudflare/src/internal/clusterName.ts b/packages/platform/cloudflare/src/internal/clusterName.ts new file mode 100644 index 00000000000..20634c5f60f --- /dev/null +++ b/packages/platform/cloudflare/src/internal/clusterName.ts @@ -0,0 +1,19 @@ +import type { ClusterName } from "../CloudflareCluster.ts" + +/** @internal */ +export const encodeName = (type: string, id: string): string => `${type.length}:${type}${id}` + +const lengthPrefix = /^([1-9]\d*):/ + +/** @internal */ +export const decodeName = (name: string): ClusterName | undefined => { + const match = lengthPrefix.exec(name) + if (match === null) return undefined + const typeLength = Number(match[1]) + const payload = name.slice(match[0].length) + if (typeLength > payload.length) return undefined + return { + type: payload.slice(0, typeLength), + id: payload.slice(typeLength) + } +} diff --git a/packages/platform/cloudflare/src/internal/entityKeepAlive.ts b/packages/platform/cloudflare/src/internal/entityKeepAlive.ts new file mode 100644 index 00000000000..0a82f083dbb --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityKeepAlive.ts @@ -0,0 +1,44 @@ +/** @internal */ +import * as Effect from "effect/Effect" +import * as Latch from "effect/Latch" + +/** @internal */ +export interface EntityKeepAlive { + readonly update: (enabled: boolean) => Effect.Effect + readonly await: Effect.Effect + readonly holderCount: () => number +} + +/** @internal */ +export const makeEntityKeepAlive = (startHold: () => Promise): EntityKeepAlive => { + const latch = Latch.makeUnsafe(true) + let holders = 0 + let generation = 0 + + const update = (enabled: boolean): Effect.Effect => + Effect.sync(() => { + if (enabled) { + holders++ + if (holders !== 1) return + latch.closeUnsafe() + const currentGeneration = ++generation + void startHold().catch(() => { + if (currentGeneration !== generation) return + holders = 0 + latch.openUnsafe() + }) + return + } + if (holders === 0) return + holders-- + if (holders !== 0) return + generation++ + latch.openUnsafe() + }) + + return { + update, + await: latch.await, + holderCount: () => holders + } +} diff --git a/packages/platform/cloudflare/src/internal/entityMailbox.ts b/packages/platform/cloudflare/src/internal/entityMailbox.ts new file mode 100644 index 00000000000..11bc99953a5 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityMailbox.ts @@ -0,0 +1,317 @@ +/** + * SQLite mailbox primitives for a single entity Durable Object. Every + * operation returns an Effect whose body runs synchronously, so callers can + * compose them and wrap the composition in a storage transaction. + * + * @internal + */ +import type { SqlStorage } from "@cloudflare/workers-types" +import * as Effect from "effect/Effect" + +/** @internal */ +export const mailboxCapacity = 4096 + +/** @internal */ +export const maximumEncodedSize = 2 * 1024 * 1024 + +/** @internal */ +export class MailboxFullError extends Error { + readonly _tag = "MailboxFull" +} + +/** @internal */ +export class EncodedMessageTooLargeError extends Error { + readonly _tag = "EncodedMessageTooLarge" +} + +/** @internal */ +export type PersistResult = { + readonly _tag: "Success" +} | { + readonly _tag: "Duplicate" + readonly originalId: string + readonly processed: boolean +} + +type ExistingMessageRow = { + readonly request_id: string + readonly discard: number + readonly processed: number + readonly reply_to: string | null +} + +type CountRow = { + readonly count: number +} + +const textEncoder = new TextEncoder() + +// A UTF-16 code unit encodes to at most 3 UTF-8 bytes, so most strings skip +// the byte-length copy. +const exceedsMaximumEncodedSize = (text: string): boolean => + text.length * 3 > maximumEncodedSize && textEncoder.encode(text).byteLength > maximumEncodedSize + +/** @internal */ +export const persistRequest = ( + sql: SqlStorage, + envelopeText: string, + primaryKey: string | null, + discard = false, + deliverAt: number | null = null, + replyTo: string | null = null +): Effect.Effect => + Effect.suspend((): Effect.Effect => { + if (exceedsMaximumEncodedSize(envelopeText)) { + return Effect.fail(new EncodedMessageTooLargeError("Encoded entity request exceeds 2 MB")) + } + const envelope = JSON.parse(envelopeText) as { readonly _tag?: unknown; readonly requestId?: unknown } + if (envelope._tag !== "Request" || typeof envelope.requestId !== "string") { + throw new TypeError("Expected an encoded Request envelope") + } + + const existing = sql.exec( + `SELECT m.request_id, m.discard, m.processed, m.reply_to + FROM cluster_messages m + WHERE m.request_id = ? OR (? IS NOT NULL AND m.message_id = ?) + LIMIT 1`, + envelope.requestId, + primaryKey, + primaryKey + ).toArray()[0] + if (existing !== undefined) { + if (replyTo !== null && existing.discard === 0 && existing.processed === 0) { + const replyTos = decodeReplyTargets(existing.reply_to) + if (!replyTos.includes(replyTo)) replyTos.push(replyTo) + sql.exec( + "UPDATE cluster_messages SET reply_to = ? WHERE request_id = ?", + JSON.stringify(replyTos), + existing.request_id + ) + } + return Effect.succeed({ + _tag: "Duplicate", + originalId: existing.request_id, + processed: existing.processed === 1 + }) + } + + // A request counts against capacity until it is processed and, for streams, + // until its chunks are acknowledged. Two indexed counts instead of one + // `OR EXISTS` scan over the ever-growing dedup history. + const pending = sql.exec( + "SELECT COUNT(*) AS count FROM cluster_messages WHERE processed = 0" + ).toArray()[0]?.count ?? 0 + const unacked = pending >= mailboxCapacity ? + 0 : + sql.exec( + `SELECT COUNT(DISTINCT r.request_id) AS count + FROM cluster_replies r + JOIN cluster_messages m ON m.request_id = r.request_id + WHERE r.kind = 'Chunk' AND r.acked = 0 AND m.processed = 1` + ).toArray()[0]?.count ?? 0 + if (pending + unacked >= mailboxCapacity) { + return Effect.fail(new MailboxFullError("Entity mailbox has reached its 4096 request capacity")) + } + + sql.exec( + `INSERT INTO cluster_messages + (request_id, message_id, envelope, discard, processed, last_reply_id, deliver_at, reply_to) + VALUES (?, ?, ?, ?, 0, NULL, ?, ?)`, + envelope.requestId, + primaryKey, + envelopeText, + discard ? 1 : 0, + deliverAt, + replyTo === null ? null : JSON.stringify([replyTo]) + ) + return Effect.succeed({ _tag: "Success" }) + }) + +/** @internal */ +export const saveReply = (sql: SqlStorage, replyText: string): Effect.Effect => + Effect.suspend(() => { + if (exceedsMaximumEncodedSize(replyText)) { + return Effect.fail(new EncodedMessageTooLargeError("Encoded entity reply chunk exceeds 2 MB")) + } + const reply = JSON.parse(replyText) as { + readonly _tag?: unknown + readonly requestId?: unknown + readonly id?: unknown + readonly sequence?: unknown + } + if ( + (reply._tag !== "Chunk" && reply._tag !== "WithExit") || + typeof reply.requestId !== "string" || + typeof reply.id !== "string" + ) { + throw new TypeError("Expected an encoded Chunk or WithExit reply") + } + sql.exec( + `INSERT OR IGNORE INTO cluster_replies + (reply_id, request_id, reply, kind, sequence, acked) + VALUES (?, ?, ?, ?, ?, 0)`, + reply.id, + reply.requestId, + replyText, + reply._tag, + reply._tag === "Chunk" ? reply.sequence : null + ) + sql.exec( + `UPDATE cluster_messages + SET last_reply_id = ?, processed = CASE WHEN ? = 'WithExit' THEN 1 ELSE processed END + WHERE request_id = ?`, + reply.id, + reply._tag, + reply.requestId + ) + return Effect.void + }) + +/** @internal */ +export interface StoredMessage { + readonly requestId: string + readonly envelope: string + readonly lastSentChunk: string | undefined + readonly discard: boolean + readonly deliverAt?: number | undefined + readonly replyTos?: ReadonlyArray | undefined +} + +type StoredMessageRow = { + readonly request_id: string + readonly envelope: string + readonly discard: number + readonly deliver_at: number | null + readonly reply_to: string | null + readonly last_reply: string | null +} + +const decodeReplyTargets = (value: unknown): Array => { + if (typeof value !== "string") return [] + try { + const decoded = JSON.parse(value) + if (Array.isArray(decoded) && decoded.every((item) => typeof item === "string")) return decoded + } catch { + // Malformed rows deliver nowhere instead of poisoning the replay. + } + return [] +} + +const rowToMessage = (row: StoredMessageRow): StoredMessage => { + const replyTos = decodeReplyTargets(row.reply_to) + const message: StoredMessage = { + requestId: row.request_id, + envelope: row.envelope, + lastSentChunk: row.last_reply ?? undefined, + discard: row.discard === 1, + ...(row.deliver_at === null ? undefined : { deliverAt: row.deliver_at }), + ...(replyTos.length === 0 ? undefined : { replyTos }) + } + return message +} + +/** @internal */ +export const loadUnprocessed = (sql: SqlStorage, now?: number): Effect.Effect> => + Effect.sync(() => + sql.exec( + `SELECT m.request_id, m.envelope, m.discard, m.deliver_at, m.reply_to, r.reply AS last_reply + FROM cluster_messages m + LEFT JOIN cluster_replies r ON r.reply_id = m.last_reply_id + WHERE m.processed = 0 AND (m.deliver_at IS NULL OR m.deliver_at <= ?) + ORDER BY m.rowid ASC`, + now ?? Date.now() + ).toArray().map(rowToMessage) + ) + +/** @internal */ +export const loadDue = (sql: SqlStorage, now?: number): Effect.Effect> => + Effect.sync(() => + sql.exec( + `SELECT m.request_id, m.envelope, m.discard, m.deliver_at, m.reply_to, r.reply AS last_reply + FROM cluster_messages m + LEFT JOIN cluster_replies r ON r.reply_id = m.last_reply_id + WHERE m.processed = 0 AND m.deliver_at IS NOT NULL AND m.deliver_at <= ? + ORDER BY m.rowid ASC`, + now ?? Date.now() + ).toArray().map(rowToMessage) + ) + +/** @internal */ +export const loadMessage = (sql: SqlStorage, requestId: string): Effect.Effect => + Effect.sync(() => { + const row = sql.exec( + `SELECT m.request_id, m.envelope, m.discard, m.deliver_at, m.reply_to, r.reply AS last_reply + FROM cluster_messages m + LEFT JOIN cluster_replies r ON r.reply_id = m.last_reply_id + WHERE m.request_id = ? + LIMIT 1`, + requestId + ).toArray()[0] + return row === undefined ? undefined : rowToMessage(row) + }) + +/** @internal */ +export interface NextReply { + readonly reply: string + readonly kind: "Chunk" | "WithExit" +} + +type NextReplyRow = { + readonly reply: string + readonly kind: NextReply["kind"] +} + +/** @internal */ +export const loadNextReply = (sql: SqlStorage, requestId: string): Effect.Effect => + Effect.sync(() => { + const row = sql.exec( + `SELECT reply, kind + FROM cluster_replies + WHERE request_id = ? AND kind = 'Chunk' AND acked = 0 + ORDER BY sequence ASC + LIMIT 1`, + requestId + ).toArray()[0] ?? sql.exec( + `SELECT reply, kind + FROM cluster_replies + WHERE request_id = ? AND kind = 'WithExit' + LIMIT 1`, + requestId + ).toArray()[0] + return row + }) + +/** @internal */ +export const completeTell = (sql: SqlStorage, requestId: string): Effect.Effect => + Effect.sync(() => { + sql.exec( + `UPDATE cluster_messages + SET processed = 1, last_reply_id = NULL + WHERE request_id = ?`, + requestId + ) + }) + +/** @internal */ +export const ackChunk = (sql: SqlStorage, requestId: string, replyId: string): Effect.Effect => + Effect.sync(() => { + sql.exec( + `UPDATE cluster_replies + SET acked = 1 + WHERE request_id = ? AND reply_id = ? AND kind = 'Chunk'`, + requestId, + replyId + ) + }) + +/** @internal */ +export const clearReplies = (sql: SqlStorage, requestId: string): Effect.Effect => + Effect.sync(() => { + sql.exec("DELETE FROM cluster_replies WHERE request_id = ?", requestId) + sql.exec( + `UPDATE cluster_messages + SET processed = 0, last_reply_id = NULL + WHERE request_id = ?`, + requestId + ) + }) diff --git a/packages/platform/cloudflare/src/internal/entityRegistry.ts b/packages/platform/cloudflare/src/internal/entityRegistry.ts new file mode 100644 index 00000000000..c49d8ef14ff --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityRegistry.ts @@ -0,0 +1,28 @@ +/** @internal */ +import type * as Context from "effect/Context" +import type * as Effect from "effect/Effect" +import type * as Entity from "effect/unstable/cluster/Entity" +import { makeRegistry } from "./registry.ts" + +export interface EntityRegistration { + readonly entity: Entity.Entity + readonly build: Effect.Effect any>, never, never> + readonly options: { + readonly concurrency?: number | "unbounded" | undefined + readonly disableFatalDefects?: boolean | undefined + readonly defectRetryPolicy?: unknown + readonly spanAttributes?: Record | undefined + } | undefined + readonly context: Context.Context +} + +const registry = makeRegistry() + +/** @internal */ +export const getEntityRegistration: (type: string) => EntityRegistration | undefined = registry.get + +/** @internal */ +export const registerEntity: (type: string, registration: EntityRegistration) => boolean = registry.register + +/** @internal */ +export const unregisterEntity: (type: string, registration: EntityRegistration) => void = registry.unregister diff --git a/packages/platform/cloudflare/src/internal/entityReply.ts b/packages/platform/cloudflare/src/internal/entityReply.ts new file mode 100644 index 00000000000..5445a849089 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityReply.ts @@ -0,0 +1,54 @@ +/** @internal */ +import * as Context from "effect/Context" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" + +/** @internal */ +export class CurrentEntityName extends Context.Service()( + "@effect/platform-cloudflare/CurrentEntityName" +) {} + +/** + * Completes the delayed asks made by one entity Durable Object's handlers. + * The destination object pushes the stored reply back over the caller's + * `deliverReply` RPC, which finds the waiting `Deferred` here. + * + * @internal + */ +export interface EntityReplyRegistry { + readonly register: (requestId: string, waiter: Deferred.Deferred) => void + readonly unregister: (requestId: string, waiter: Deferred.Deferred) => void + readonly deliver: (requestId: string, reply: string) => boolean +} + +/** @internal */ +export const makeReplyRegistry = (): EntityReplyRegistry => { + const waiters = new Map>>() + return { + register(requestId, waiter) { + const registered = waiters.get(requestId) ?? new Set() + registered.add(waiter) + waiters.set(requestId, registered) + }, + unregister(requestId, waiter) { + const registered = waiters.get(requestId) + if (registered === undefined) return + registered.delete(waiter) + if (registered.size === 0) waiters.delete(requestId) + }, + deliver(requestId, reply) { + const registered = waiters.get(requestId) + if (registered === undefined) return false + waiters.delete(requestId) + for (const waiter of registered) { + Deferred.doneUnsafe(waiter, Effect.succeed(reply)) + } + return true + } + } +} + +/** @internal */ +export class CurrentReplyRegistry extends Context.Service()( + "@effect/platform-cloudflare/CurrentReplyRegistry" +) {} diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts new file mode 100644 index 00000000000..3bed82758b4 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -0,0 +1,841 @@ +/** @internal */ +import type { DurableObjectStorage } from "@cloudflare/workers-types" +import * as Cause from "effect/Cause" +import * as Context from "effect/Context" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Fiber from "effect/Fiber" +import { identity } from "effect/Function" +import * as Metric from "effect/Metric" +import * as Option from "effect/Option" +import * as Pull from "effect/Pull" +import * as Queue from "effect/Queue" +import * as Result from "effect/Result" +import type * as Schedule from "effect/Schedule" +import * as Scope from "effect/Scope" +import * as Semaphore from "effect/Semaphore" +import * as Stream from "effect/Stream" +import * as ClusterMetrics from "effect/unstable/cluster/ClusterMetrics" +import { Persisted } from "effect/unstable/cluster/ClusterSchema" +import { CurrentAddress, CurrentRunnerAddress, KeepAliveHandler, Request } from "effect/unstable/cluster/Entity" +import type * as EntityAddress from "effect/unstable/cluster/EntityAddress" +import * as Envelope from "effect/unstable/cluster/Envelope" +import * as Reply from "effect/unstable/cluster/Reply" +import * as RunnerAddress from "effect/unstable/cluster/RunnerAddress" +import * as Rpc from "effect/unstable/rpc/Rpc" +import * as RpcSchema from "effect/unstable/rpc/RpcSchema" +import { encodeName } from "./clusterName.ts" +import type { EntityKeepAlive } from "./entityKeepAlive.ts" +import { + ackChunk, + clearReplies, + completeTell, + loadDue, + loadMessage, + loadNextReply, + loadUnprocessed, + persistRequest, + type PersistResult, + saveReply, + type StoredMessage +} from "./entityMailbox.ts" +import { type EntityRegistration, getEntityRegistration } from "./entityRegistry.ts" +import { CurrentEntityName, CurrentReplyRegistry, type EntityReplyRegistry, makeReplyRegistry } from "./entityReply.ts" +import { armAlarm, earliestDeliverAt, withTransaction } from "./entityStorage.ts" +import { decodeReplyFor, decodeRequest, encodeReplyFor, type InvokeResult, peekEnvelopeTag } from "./entityWire.ts" + +interface CachedHandlers { + readonly handlers: Record any> + readonly context: Context.Context + readonly scope: Scope.Closeable +} + +/** @internal */ +export const makeEntityRuntime = Effect.fnUntraced(function*( + registration: EntityRegistration, + address: EntityAddress.EntityAddress, + nextId: () => string, + entityName = encodeName(address.entityType, address.entityId), + keepAlive?: (enabled: boolean) => Effect.Effect, + replyRegistry?: EntityReplyRegistry +) { + let cached: CachedHandlers | undefined + let building: Deferred.Deferred | undefined + const metricContext = Context.merge( + registration.context, + Metric.CurrentMetricAttributes.context({ type: registration.entity.type }) + ) + // Bounds concurrent handler executions from the entity's `concurrency` + // build option; `"unbounded"` leaves handlers unlimited. + const concurrency = registration.options?.concurrency ?? 1 + const handlerSemaphore = concurrency === "unbounded" ? undefined : Semaphore.makeUnsafe(concurrency) + + const invalidate = Effect.fnUntraced(function*() { + if (cached === undefined) return + const scope = cached.scope + cached = undefined + yield* Scope.close(scope, Exit.void).pipe( + Effect.ensuring(Effect.sync(() => { + ClusterMetrics.entities.modifyUnsafe(BigInt(-1), metricContext) + })) + ) + }) + + const buildHandlers = Effect.fnUntraced(function*() { + const scope = yield* Scope.make() + let context = registration.context.pipe( + Context.add(CurrentAddress, address), + Context.add(CurrentRunnerAddress, RunnerAddress.make(`${address.entityType}/${address.entityId}`, 0)), + Context.add(CurrentEntityName, entityName), + Context.add(Scope.Scope, scope) + ) + if (keepAlive !== undefined) { + context = Context.add(context, KeepAliveHandler, keepAlive) + } + if (replyRegistry !== undefined) { + context = Context.add(context, CurrentReplyRegistry, replyRegistry) + } + const handlers = yield* Effect.provideContext(registration.build, context).pipe( + Effect.tapCause((cause) => Scope.close(scope, Exit.failCause(cause))) + ) + ClusterMetrics.entities.modifyUnsafe(BigInt(1), metricContext) + return { handlers, context, scope } + }) + + const getHandlers = (): Effect.Effect => + Effect.suspend(() => { + if (cached !== undefined) return Effect.succeed(cached) + if (building !== undefined) return Deferred.await(building) + const deferred = Deferred.makeUnsafe() + building = deferred + return Effect.onExit(buildHandlers(), (exit) => + Effect.sync(() => { + building = undefined + if (Exit.isSuccess(exit)) cached = exit.value + Deferred.doneUnsafe(deferred, exit) + })) + }) + + const runWithDefectRetry = (effect: Effect.Effect) => { + const policy = registration.options?.defectRetryPolicy + if (policy === undefined) return Effect.exit(effect) + const retryable = Effect.flatMap(Effect.exit(effect), (exit) => + Exit.isFailure(exit) && Cause.hasDies(exit.cause) + ? Effect.fail(exit.cause) + : Effect.succeed(exit)) + return Effect.retryOrElse( + retryable, + policy as Schedule.Schedule>, + (cause) => Effect.succeed(Exit.failCause(cause)) + ) + } + + const rebuildAfterDefect = invalidate().pipe( + Effect.andThen(Effect.catchCause(getHandlers(), () => Effect.void)) + ) + + const run = Effect.fnUntraced(function*( + envelope: Envelope.Request.Any, + lastSentChunk: Option.Option>, + discard: boolean, + respond: (reply: Reply.Reply) => Effect.Effect + ) { + const entry = yield* getHandlers() + const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps | undefined + const handler = entry.handlers[envelope.tag] + if (rpc === undefined || handler === undefined) { + const exit = Exit.die(`Unknown entity RPC tag: ${envelope.tag}`) + if (!discard) { + yield* respond( + new Reply.WithExit({ + requestId: envelope.requestId, + id: nextId() as any, + exit + }) + ) + } + return + } + + const streamSchema = RpcSchema.isStreamSchema(rpc.successSchema) ? rpc.successSchema : undefined + let currentLastSentChunk = lastSentChunk + let sequence = Option.match(lastSentChunk, { + onNone: () => 0, + onSome: (chunk) => chunk.sequence + 1 + }) + + const execute = Effect.suspend(() => { + const request = new Request({ ...envelope, lastSentChunk: currentLastSentChunk }) + const result = handler(request) + const unwrapped = Rpc.isWrapper(result as object) ? result.value : result + if (streamSchema === undefined) return unwrapped as Effect.Effect + return Stream.runForEachArray(unwrapped as Stream.Stream, (values) => { + if (discard) return Effect.void + const reply = new Reply.Chunk({ + requestId: envelope.requestId, + id: nextId() as any, + sequence: sequence++, + values: values as any + }) + return Effect.tap(respond(reply), () => + Effect.sync(() => { + currentLastSentChunk = Option.some(reply) + })) + }) + }).pipe( + Effect.withSpan("CloudflareCluster.handler", { + attributes: { + entityType: registration.entity.type, + entityId: String(address.entityId), + rpc: envelope.tag + } + }, { captureStackTrace: false }) + ) + const exit = yield* Effect.provideContext(runWithDefectRetry(execute), entry.context) + if (!discard) { + yield* respond( + new Reply.WithExit({ + requestId: envelope.requestId, + id: nextId() as any, + exit: streamSchema !== undefined && Exit.isSuccess(exit) ? Exit.void : exit as any + }) + ) + } + if (Exit.isFailure(exit) && Cause.hasDies(exit.cause)) { + yield* rebuildAfterDefect + } + }) + + return { run, invalidate, handlerSemaphore } as const +}) + +type EntityRuntime = Effect.Success> + +interface SessionReply { + readonly text: string + readonly terminal: boolean +} + +interface Session { + readonly queue: Queue.Queue + readonly completeInterrupt: Effect.Effect + ack: { + readonly replyId: string + readonly deferred: Deferred.Deferred + } | undefined + fiber: Fiber.Fiber | undefined +} + +interface WorkerWaiter { + readonly clientRequestId: string + readonly deferred: Deferred.Deferred +} + +interface RunOptions { + readonly scheduled?: boolean + readonly replyTos?: ReadonlyArray | undefined +} + +/** @internal */ +export interface DeliveryOptions { + readonly deliverAt?: number | undefined + readonly primaryKey?: string | null | undefined + readonly replyTo?: string | undefined +} + +/** @internal */ +export interface EntityManagerOptions { + readonly storage: DurableObjectStorage + readonly address: EntityAddress.EntityAddress + readonly entityName: string + readonly keepAlive: EntityKeepAlive + readonly waitUntil: (effect: Effect.Effect) => void + readonly getNamespace: () => { + readonly getByName: (name: string) => { + readonly deliverReply: (requestId: string, reply: string) => Promise + } + } | undefined +} + +/** @internal */ +export interface EntityManager { + readonly invoke: ( + envelopeText: string, + discard: boolean, + delivery?: DeliveryOptions | undefined + ) => Effect.Effect + readonly acknowledge: (requestId: string, replyId: string) => Effect.Effect> + readonly interrupt: (storageRequestId: string, clientRequestId?: string) => Effect.Effect + readonly reset: (requestId: string) => Effect.Effect + readonly alarm: Effect.Effect + readonly deliverReply: (requestId: string, reply: string) => Effect.Effect +} + +const success = (requestId: string, replies: ReadonlyArray): InvokeResult => ({ + _tag: "Success", + requestId, + replies +}) + +interface HandlerPermit { + readonly acquire: (effect: Effect.Effect) => Effect.Effect + readonly pause: (effect: Effect.Effect) => Effect.Effect +} + +const unlimitedHandlerPermit: HandlerPermit = { acquire: identity, pause: identity } + +/** + * One permit per handler execution. `acquire` holds the permit for the whole + * run; `pause` gives it back while a chunk is parked on its client + * acknowledgement so an unacked stream cannot starve other handlers. The + * `held` flag keeps take/release balanced when interruption lands inside a + * paused section: a pause that never re-acquires leaves `held` false, and + * `acquire` then skips its release. + */ +const makeHandlerPermit = (semaphore: Semaphore.Semaphore | undefined): HandlerPermit => { + if (semaphore === undefined) return unlimitedHandlerPermit + let held = false + return { + acquire: (effect) => + Effect.uninterruptibleMask((restore) => + restore(Semaphore.take(semaphore, 1)).pipe( + Effect.flatMap(() => { + held = true + return restore(effect) + }), + Effect.onExit(() => + Effect.suspend(() => { + if (!held) return Effect.void + held = false + return Effect.asVoid(Semaphore.release(semaphore, 1)) + }) + ) + ) + ), + pause: (effect) => + Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (!held) return restore(effect) + held = false + return Semaphore.release(semaphore, 1).pipe( + Effect.flatMap(() => restore(effect)), + Effect.flatMap((value) => + Effect.map(restore(Semaphore.take(semaphore, 1)), () => { + held = true + return value + }) + ) + ) + }) + ) + } +} + +/** + * The Effect-land state machine behind one `ClusterEntity` Durable Object. + * The class methods are one-line `Effect.runPromise` adapters over the + * effects returned here; all serialization, sessions, and waiters live in + * Effect primitives. + * + * @internal + */ +export const makeEntityManager = (options: EntityManagerOptions): EntityManager => { + const storage = options.storage + const sql = storage.sql + // Serializes storage entry: envelope decode, persist-before-run, dedupe, + // duplicate resume, and alarm arming. Handler execution runs in forked + // fibers governed by the handler concurrency semaphore below. + const semaphore = Semaphore.makeUnsafe(1) + const sessions = new Map() + // Persisted discard/scheduled requests with an in-flight handler; guards + // against a second execution from replay or duplicate delivery in the same + // wake, the way `sessions` does for asks. + const pendingRuns = new Set() + const workerWaiters = new Map>() + const replyRegistry = makeReplyRegistry() + let runtime: EntityRuntime | undefined + + const getRuntime = (registration: EntityRegistration): Effect.Effect => + runtime !== undefined ? Effect.succeed(runtime) : Effect.map( + makeEntityRuntime( + registration, + options.address, + () => crypto.randomUUID(), + options.entityName, + options.keepAlive.update, + replyRegistry + ), + (built) => runtime = built + ) + + const armEarliestAlarm = Effect.suspend(() => { + const deliverAt = earliestDeliverAt(sql) + return deliverAt === undefined ? Effect.void : armAlarm(storage, deliverAt) + }) + + const takeReply = (requestId: string, session: Session): Effect.Effect> => + Queue.take(session.queue).pipe( + Effect.map((reply) => { + if (reply.terminal) sessions.delete(requestId) + return [reply.text] + }), + Pull.catchDone(() => Effect.succeed([])) + ) + + const deliverScheduledReply = ( + requestId: string, + reply: string, + replyTos: ReadonlyArray | undefined + ): Effect.Effect => + Effect.suspend(() => { + const waiters = workerWaiters.get(requestId) + if (waiters !== undefined) { + workerWaiters.delete(requestId) + for (const waiter of waiters) { + Deferred.doneUnsafe(waiter.deferred, Effect.succeed(success(requestId, [reply]))) + } + } + if (replyTos === undefined) return Effect.void + const namespace = options.getNamespace() + if (namespace === undefined) { + return Effect.logError( + "Scheduled entity reply delivery failed", + new Error("CloudflareCluster: ClusterEntity export is unavailable for scheduled reply delivery") + ) + } + return Effect.forEach( + replyTos, + (replyTo) => + Effect.promise(() => namespace.getByName(replyTo).deliverReply(requestId, reply)).pipe( + Effect.flatMap((delivered) => + delivered + ? Effect.void + : Effect.logError( + "Scheduled entity reply delivery failed", + new Error(`Scheduled entity reply target is unavailable: ${replyTo}`) + ) + ), + Effect.catchCause((cause) => Effect.logError("Scheduled entity reply delivery failed", cause)) + ), + { concurrency: "unbounded", discard: true } + ) + }) + + // Runs one request under the entry permit up to forking its handler fiber, + // then returns a continuation that awaits the handler output. Callers run + // the continuation after the entry permit is released so handlers governed + // by the concurrency semaphore can interleave with later storage entries. + const run = Effect.fnUntraced(function*( + registration: EntityRegistration, + entityRuntime: EntityRuntime, + envelope: Envelope.Request.Any, + lastSentChunkText: string | undefined, + discard: boolean, + persisted: boolean, + runOptions?: RunOptions + ) { + const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps + let lastSentChunk = Option.none>() + if (lastSentChunkText !== undefined) { + const reply = yield* decodeReplyFor(rpc, registration.context, lastSentChunkText) + if (reply._tag === "Chunk") lastSentChunk = Option.some(reply) + } + const requestId = String(envelope.requestId) + if (!discard) { + const active = sessions.get(requestId) + if (active !== undefined) { + const nextReply = persisted ? yield* loadNextReply(sql, requestId) : undefined + return Effect.succeed(nextReply === undefined ? [] : [nextReply.reply]) + } + } + + const scheduled = runOptions?.scheduled === true + if (discard || scheduled) { + if (persisted) { + if (pendingRuns.has(requestId)) return Effect.succeed([]) + pendingRuns.add(requestId) + } + // No stream acks on this path, so a plain permit is enough. + const execute = entityRuntime.run(envelope, lastSentChunk, discard, (reply) => + Effect.gen(function*() { + const encoded = yield* encodeReplyFor(registration, rpc, reply) + if (persisted) { + yield* Effect.orDie(withTransaction(storage, saveReply(sql, encoded))) + } + if (scheduled && reply._tag === "WithExit") { + yield* deliverScheduledReply(requestId, encoded, runOptions?.replyTos) + } + })) + const fiber = yield* Effect.forkDetach( + (entityRuntime.handlerSemaphore === undefined + ? execute + : Semaphore.withPermit(entityRuntime.handlerSemaphore, execute)).pipe( + Effect.onExit((exit) => + Effect.suspend(() => { + if (!persisted) return Effect.void + pendingRuns.delete(requestId) + return discard && Exit.isSuccess(exit) ? completeTell(sql, requestId) : Effect.void + }) + ) + ) + ) + options.waitUntil(Fiber.await(fiber)) + return Effect.as(Fiber.join(fiber), []) + } + + const queue = yield* Queue.make() + const completeInterrupt = persisted + ? Effect.flatMap( + encodeReplyFor( + registration, + rpc, + new Reply.WithExit({ + requestId: envelope.requestId, + id: crypto.randomUUID() as any, + exit: Exit.interrupt() + }) + ), + (reply) => + Effect.orDie( + withTransaction(storage, Effect.andThen(clearReplies(sql, requestId), saveReply(sql, reply))) + ) + ) + : Effect.void + const session: Session = { queue, completeInterrupt, ack: undefined, fiber: undefined } + sessions.set(requestId, session) + const permit = makeHandlerPermit(entityRuntime.handlerSemaphore) + const respond = (reply: Reply.Reply) => + Effect.gen(function*() { + const encoded = yield* encodeReplyFor(registration, rpc, reply) + if (persisted) { + yield* Effect.orDie(withTransaction(storage, saveReply(sql, encoded))) + } + if (reply._tag === "Chunk") { + const acknowledged = Deferred.makeUnsafe() + session.ack = { replyId: String(reply.id), deferred: acknowledged } + // A false offer means the session was interrupted and the queue + // ended; awaiting the acknowledgement would then never resume. + const offered = yield* Queue.offer(queue, { text: encoded, terminal: false }) + if (offered) yield* permit.pause(Deferred.await(acknowledged)) + else session.ack = undefined + } else { + yield* Queue.offer(queue, { text: encoded, terminal: true }) + } + }) + session.fiber = yield* Effect.forkDetach( + permit.acquire(entityRuntime.run(envelope, lastSentChunk, discard, respond)).pipe( + Effect.onExit((exit) => + Effect.sync(() => { + if (Exit.isSuccess(exit)) Queue.endUnsafe(queue) + else Queue.failCauseUnsafe(queue, exit.cause) + if (Queue.sizeUnsafe(queue) === 0 && session.ack === undefined) { + sessions.delete(requestId) + } + }) + ) + ) + ) + options.waitUntil(Fiber.await(session.fiber)) + return takeReply(requestId, session) + }) + + const runStored = ( + registration: EntityRegistration, + entityRuntime: EntityRuntime, + envelopeText: string, + lastSentChunk: string | undefined, + discard: boolean, + runOptions?: RunOptions + ) => + Effect.flatMap( + decodeRequest(registration, envelopeText), + (envelope) => run(registration, entityRuntime, envelope, lastSentChunk, discard, true, runOptions) + ) + + const completeReplayFailure = ( + registration: EntityRegistration, + row: StoredMessage, + cause: Cause.Cause + ): Effect.Effect => + Effect.suspend(() => peekEnvelopeTag(row.envelope)).pipe( + Effect.flatMap((tag) => { + if (row.discard || tag === undefined) { + return completeTell(sql, row.requestId) + } + const rpc = registration.entity.protocol.requests.get(tag) as Rpc.AnyWithProps | undefined + if (rpc === undefined) { + return completeTell(sql, row.requestId) + } + return Effect.flatMap( + encodeReplyFor( + registration, + rpc, + new Reply.WithExit({ + requestId: row.requestId as any, + id: crypto.randomUUID() as any, + exit: Exit.failCause(cause) + }) + ), + (reply) => + withTransaction(storage, saveReply(sql, reply)).pipe( + Effect.andThen(deliverScheduledReply(row.requestId, reply, row.replyTos)) + ) + ).pipe( + Effect.catchCause(() => completeTell(sql, row.requestId)) + ) + }) + ) + + // Phase one runs each row up to forking its handler under the entry + // permit; the returned fibers observe the handler output and route + // failures through `completeReplayFailure`. + const replayRows = ( + registration: EntityRegistration, + entityRuntime: EntityRuntime, + rows: ReadonlyArray + ): Effect.Effect>> => + rows.length === 0 ? Effect.succeed([]) : Effect.forEach( + rows, + (row) => + // An active session or in-flight run already owns this request; + // replaying it would start a second execution in the same wake. + sessions.has(row.requestId) || pendingRuns.has(row.requestId) ? Effect.succeed(undefined) : runStored( + registration, + entityRuntime, + row.envelope, + row.lastSentChunk, + row.discard, + row.deliverAt === undefined + ? undefined + : { + scheduled: true, + ...(row.replyTos === undefined ? undefined : { replyTos: row.replyTos }) + } + ).pipe( + Effect.flatMap((continuation) => + Effect.forkDetach( + Effect.catchCause( + Effect.asVoid(continuation), + (cause) => completeReplayFailure(registration, row, cause) + ) + ) + ), + Effect.catchCause((cause) => Effect.as(completeReplayFailure(registration, row, cause), undefined)) + ) + ).pipe( + Effect.map((fibers) => fibers.filter((fiber) => fiber !== undefined)), + Effect.tap((fibers) => + Effect.sync(() => { + if (fibers.length > 0) options.waitUntil(Fiber.awaitAll(fibers)) + }) + ) + ) + + // Registers the waiter eagerly (under the entry permit) and returns the + // await for the caller to run once the permit is released. + const delayedOutcome = ( + requestId: string, + discard: boolean, + replyTo: string | undefined, + clientRequestId = requestId + ): Effect.Effect => { + if (discard || replyTo !== undefined) return Effect.succeed(success(requestId, [])) + const deferred = Deferred.makeUnsafe() + const waiters = workerWaiters.get(requestId) ?? [] + waiters.push({ clientRequestId, deferred }) + workerWaiters.set(requestId, waiters) + return Deferred.await(deferred) + } + + // Runs the storage entry under the caller-held permit and returns the + // effect that produces the invoke result after the permit is released. + const invokeEntry = ( + envelopeText: string, + discard: boolean, + delivery: DeliveryOptions | undefined + ): Effect.Effect> => { + const registration = getEntityRegistration(options.address.entityType) + if (registration === undefined) { + return Effect.die(`No handlers registered for entity type: ${options.address.entityType}`) + } + return Effect.gen(function*() { + const entityRuntime = yield* getRuntime(registration) + const replayFibers = yield* Effect.flatMap( + loadUnprocessed(sql), + (rows) => replayRows(registration, entityRuntime, rows) + ) + // Preserves the pre-split ordering: the invoke result is delivered + // only after the replayed rows this entry kicked off have finished. + const finish = ( + requestId: string, + continuation: Effect.Effect> + ): Effect.Effect => + Effect.andThen(Fiber.awaitAll(replayFibers), continuation).pipe( + Effect.map((replies) => success(requestId, replies)) + ) + + const envelope = yield* decodeRequest(registration, envelopeText) + const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps + const isPersisted = Context.get(rpc.annotations, Persisted) + if (!isPersisted) { + const continuation = yield* run(registration, entityRuntime, envelope, undefined, discard, false) + return finish(String(envelope.requestId), continuation) + } + + const persistedResult = yield* Effect.result( + withTransaction( + storage, + persistRequest( + sql, + envelopeText, + delivery?.primaryKey ?? Envelope.primaryKey(envelope), + discard, + delivery?.deliverAt, + delivery?.replyTo + ) + ).pipe( + Effect.withSpan("CloudflareCluster.persist", { + attributes: { + entityType: registration.entity.type, + entityId: String(options.address.entityId), + rpc: envelope.tag + } + }, { captureStackTrace: false }), + Effect.provideContext(registration.context) + ) + ) + if (Result.isFailure(persistedResult)) { + return Effect.succeed( + persistedResult.failure._tag === "MailboxFull" + ? { _tag: "MailboxFull" } + : { _tag: "EncodedMessageTooLarge" } + ) + } + const persisted: PersistResult = persistedResult.success + if (persisted._tag === "Duplicate") { + const original = yield* loadMessage(sql, persisted.originalId) + if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") + if (original.discard && !discard) { + return Effect.succeed({ _tag: "AskDeduplicatedToTell" }) + } + const nextReply = yield* loadNextReply(sql, persisted.originalId) + if (nextReply !== undefined) { + if (nextReply.kind === "WithExit") sessions.delete(persisted.originalId) + return Effect.succeed(success(persisted.originalId, [nextReply.reply])) + } + if (persisted.processed) { + return Effect.succeed(success(persisted.originalId, [])) + } + if ( + delivery?.deliverAt !== undefined || + (original.deliverAt !== undefined && original.deliverAt > Date.now()) + ) { + yield* armEarliestAlarm + return delayedOutcome( + persisted.originalId, + discard, + delivery?.replyTo, + String(envelope.requestId) + ) + } + const continuation = yield* runStored( + registration, + entityRuntime, + original.envelope, + original.lastSentChunk, + original.discard + ) + return finish(persisted.originalId, continuation) + } + if (delivery?.deliverAt !== undefined) { + yield* armEarliestAlarm + return delayedOutcome(String(envelope.requestId), discard, delivery.replyTo) + } + const continuation = yield* run(registration, entityRuntime, envelope, undefined, discard, true) + return finish(String(envelope.requestId), continuation) + }) + } + + const invoke = ( + envelopeText: string, + discard: boolean, + delivery?: DeliveryOptions | undefined + ): Effect.Effect => + Effect.flatten(Semaphore.withPermit(semaphore, invokeEntry(envelopeText, discard, delivery))) + + const acknowledge = (requestId: string, replyId: string): Effect.Effect> => + withTransaction(storage, ackChunk(sql, requestId, replyId)).pipe( + Effect.flatMap(() => { + const session = sessions.get(requestId) + if (session?.ack?.replyId === replyId) { + const acknowledged = session.ack.deferred + session.ack = undefined + Deferred.doneUnsafe(acknowledged, Effect.void) + return takeReply(requestId, session) + } + return Effect.map( + loadNextReply(sql, requestId), + (nextReply) => nextReply === undefined ? [] : [nextReply.reply] + ) + }) + ) + + const interrupt = (storageRequestId: string, clientRequestId = storageRequestId): Effect.Effect => + Effect.suspend(() => { + const waiters = workerWaiters.get(storageRequestId) + if (waiters !== undefined) { + const remaining = waiters.filter((waiter) => { + if (waiter.clientRequestId !== clientRequestId) return true + Deferred.doneUnsafe(waiter.deferred, Effect.die(new Error("Delayed entity request interrupted"))) + return false + }) + if (remaining.length === 0) workerWaiters.delete(storageRequestId) + else workerWaiters.set(storageRequestId, remaining) + } + const session = sessions.get(storageRequestId) + if (session === undefined) return Effect.void + sessions.delete(storageRequestId) + if (session.ack !== undefined) { + Deferred.doneUnsafe(session.ack.deferred, Effect.void) + session.ack = undefined + } + Queue.endUnsafe(session.queue) + const stop = session.fiber === undefined ? Effect.void : Effect.asVoid(Fiber.interrupt(session.fiber)) + return Effect.andThen(stop, session.completeInterrupt) + }) + + const reset = (requestId: string): Effect.Effect => withTransaction(storage, clearReplies(sql, requestId)) + + const alarm = Effect.suspend(() => { + const registration = getEntityRegistration(options.address.entityType) + if (registration === undefined) { + return Effect.die(`No handlers registered for entity type: ${options.address.entityType}`) + } + // The entry permit covers replay setup and alarm arming; awaiting the + // due handlers happens between the two so they can draw handler permits + // while later invokes still enter storage. + return Semaphore.withPermit( + semaphore, + Effect.flatMap( + getRuntime(registration), + (entityRuntime) => Effect.flatMap(loadDue(sql), (rows) => replayRows(registration, entityRuntime, rows)) + ) + ).pipe( + Effect.flatMap((fibers) => Effect.asVoid(Fiber.awaitAll(fibers))), + Effect.andThen(Semaphore.withPermit(semaphore, armEarliestAlarm)), + Effect.withSpan("CloudflareCluster.alarm", { + attributes: { + entityType: registration.entity.type, + entityId: String(options.address.entityId) + } + }, { captureStackTrace: false }), + Effect.provideContext(registration.context) + ) + }) + + const deliverReply = (requestId: string, reply: string): Effect.Effect => + Effect.sync(() => replyRegistry.deliver(requestId, reply)) + + return { invoke, acknowledge, interrupt, reset, alarm, deliverReply } +} diff --git a/packages/platform/cloudflare/src/internal/entityStorage.ts b/packages/platform/cloudflare/src/internal/entityStorage.ts new file mode 100644 index 00000000000..4b4b006d117 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityStorage.ts @@ -0,0 +1,85 @@ +/** + * Storage glue for the entity Durable Object constructor. The constructor must + * stay cheap: open SQLite, ensure the mailbox tables, and re-arm the single + * alarm. No user handlers are built here. + * + * @internal + */ +import type { DurableObjectStorage, SqlStorage } from "@cloudflare/workers-types" +import * as Effect from "effect/Effect" +import * as Result from "effect/Result" + +/** @internal */ +export type EntityAlarm = Pick + +type DeliverAtRow = { + readonly deliver_at: number | null +} + +/** + * Runs a synchronous storage effect inside `transactionSync`. A defect throws + * out of the callback and rolls the transaction back; a typed failure happens + * before any write in the mailbox operations, so it is carried out as a plain + * failure. + * + * @internal + */ +export const withTransaction = ( + storage: Pick, + effect: Effect.Effect +): Effect.Effect => + Effect.suspend(() => { + const result = storage.transactionSync(() => Effect.runSync(Effect.result(effect))) + return Result.isSuccess(result) ? Effect.succeed(result.success) : Effect.fail(result.failure) + }) + +const ddl = [ + `CREATE TABLE IF NOT EXISTS cluster_messages ( + request_id TEXT PRIMARY KEY, + message_id TEXT UNIQUE, + envelope TEXT NOT NULL, + discard INTEGER NOT NULL DEFAULT 0, + processed INTEGER NOT NULL DEFAULT 0, + last_reply_id TEXT, + deliver_at INTEGER, + reply_to TEXT + )`, + `CREATE TABLE IF NOT EXISTS cluster_replies ( + reply_id TEXT PRIMARY KEY, + request_id TEXT NOT NULL, + reply TEXT NOT NULL, + kind TEXT NOT NULL, + sequence INTEGER, + acked INTEGER NOT NULL DEFAULT 0, + UNIQUE (request_id, sequence) + )`, + `CREATE INDEX IF NOT EXISTS cluster_messages_deliver_at_idx + ON cluster_messages (processed, deliver_at)`, + `CREATE INDEX IF NOT EXISTS cluster_replies_unacked_idx + ON cluster_replies (request_id) WHERE kind = 'Chunk' AND acked = 0` +] + +/** @internal */ +export const ensureEntityStorage = (sql: SqlStorage): void => { + for (const statement of ddl) { + sql.exec(statement) + } +} + +/** @internal */ +export const earliestDeliverAt = (sql: SqlStorage): number | undefined => { + const rows = sql.exec( + "SELECT min(deliver_at) AS deliver_at FROM cluster_messages WHERE processed = 0 AND deliver_at IS NOT NULL" + ).toArray() + return rows[0]?.deliver_at ?? undefined +} + +/** @internal */ +export const armAlarm = (alarm: EntityAlarm, deliverAt: number): Effect.Effect => + Effect.promise(() => alarm.getAlarm()).pipe( + Effect.flatMap((current) => + current === null || current > deliverAt + ? Effect.promise(() => alarm.setAlarm(deliverAt)) + : Effect.void + ) + ) diff --git a/packages/platform/cloudflare/src/internal/entityWire.ts b/packages/platform/cloudflare/src/internal/entityWire.ts new file mode 100644 index 00000000000..0fe00687af6 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityWire.ts @@ -0,0 +1,206 @@ +/** @internal */ +import type * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import * as EntityAddress from "effect/unstable/cluster/EntityAddress" +import * as EntityId from "effect/unstable/cluster/EntityId" +import * as EntityType from "effect/unstable/cluster/EntityType" +import * as Envelope from "effect/unstable/cluster/Envelope" +import * as Reply from "effect/unstable/cluster/Reply" +import * as ShardId from "effect/unstable/cluster/ShardId" +import * as Headers from "effect/unstable/http/Headers" +import * as Rpc from "effect/unstable/rpc/Rpc" +import * as RpcSchema from "effect/unstable/rpc/RpcSchema" +import type { EntityRegistration } from "./entityRegistry.ts" + +type EncodedRequest = Extract + +/** + * The result of a `ClusterEntity.invoke` RPC. Constructed in the Durable + * Object as this schema's `Type` (its JSON encoding is the identity) and + * decoded once on the Worker side. + * + * @internal + */ +export const InvokeResult = Schema.Union([ + Schema.Struct({ + _tag: Schema.Literal("Success"), + requestId: Schema.String, + replies: Schema.Array(Schema.String) + }), + Schema.Struct({ _tag: Schema.Literal("MailboxFull") }), + Schema.Struct({ _tag: Schema.Literal("EncodedMessageTooLarge") }), + Schema.Struct({ _tag: Schema.Literal("AskDeduplicatedToTell") }) +]) + +/** @internal */ +export type InvokeResult = typeof InvokeResult.Type + +/** @internal */ +export const decodeInvokeResult = (value: unknown): Effect.Effect => + Effect.orDie(Schema.decodeUnknownEffect(InvokeResult)(value)) + +const EnvelopePeek = Schema.Struct({ tag: Schema.optional(Schema.String) }) + +/** + * Reads the RPC tag out of a stored envelope without decoding the payload, + * for rows whose full decode already failed. + * + * @internal + */ +export const peekEnvelopeTag = (envelopeText: string): Effect.Effect => + Schema.decodeUnknownEffect(EnvelopePeek)(JSON.parse(envelopeText)).pipe( + Effect.match({ onFailure: () => undefined, onSuccess: ({ tag }) => tag }) + ) + +/** @internal */ +export const runWith = ( + effect: Effect.Effect, + context: Context.Context +): Effect.Effect => effect.pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect + +// Cached per rpc so the derived AST stays stable and the memoized parser +// compiler can hit on repeated chunks. +const chunkValuesCache = new WeakMap() + +const chunkValuesCodec = (rpc: Rpc.AnyWithProps): Schema.Top | undefined => { + if (chunkValuesCache.has(rpc)) return chunkValuesCache.get(rpc) + const codec = RpcSchema.isStreamSchema(rpc.successSchema) + ? Schema.toCodecJson(Schema.NonEmptyArray(rpc.successSchema.success)) + : undefined + chunkValuesCache.set(rpc, codec) + return codec +} + +/** @internal */ +export const encodeRequest = (options: { + readonly requestId: string + readonly address: EntityAddress.EntityAddress + readonly tag: string + readonly payload: unknown + readonly headers: unknown + readonly traceId?: string | undefined + readonly spanId?: string | undefined + readonly sampled?: boolean | undefined +}): string => + JSON.stringify({ + _tag: "Request", + requestId: options.requestId, + address: { + shardId: options.address.shardId, + entityType: options.address.entityType, + entityId: options.address.entityId + }, + tag: options.tag, + payload: options.payload, + headers: options.headers, + ...(options.traceId === undefined ? undefined : { + traceId: options.traceId, + spanId: options.spanId, + sampled: options.sampled + }) + }) + +/** @internal */ +export const decodeRequest = ( + registration: EntityRegistration, + envelopeText: string +): Effect.Effect => + runWith( + Effect.gen(function*() { + const encoded = JSON.parse(envelopeText) as EncodedRequest + if (encoded._tag !== "Request" || typeof encoded.requestId !== "string") { + return yield* Effect.die("Expected an encoded Request envelope") + } + const rpc = registration.entity.protocol.requests.get(encoded.tag) as Rpc.AnyWithProps | undefined + if (rpc === undefined) return yield* Effect.die(`Unknown entity RPC tag: ${encoded.tag}`) + const payload = yield* Schema.decodeUnknownEffect(Schema.toCodecJson(rpc.payloadSchema))(encoded.payload) + return Envelope.makeRequest({ + requestId: encoded.requestId as any, + address: EntityAddress.make({ + shardId: ShardId.make(encoded.address.shardId.group, encoded.address.shardId.id), + entityType: EntityType.make(encoded.address.entityType), + entityId: EntityId.make(encoded.address.entityId) + }), + tag: encoded.tag, + payload, + headers: Headers.fromInput(encoded.headers), + ...(encoded.traceId === undefined ? undefined : { + traceId: encoded.traceId, + spanId: encoded.spanId!, + sampled: encoded.sampled! + }) + }) as Envelope.Request.Any + }), + registration.context + ) + +/** @internal */ +export const encodeReplyFor = ( + registration: EntityRegistration, + rpc: Rpc.AnyWithProps, + reply: Reply.Reply +): Effect.Effect => { + if (reply._tag === "WithExit") { + return runWith( + Effect.map( + Schema.encodeUnknownEffect(Schema.toCodecJson(Rpc.exitSchema(rpc)))(reply.exit), + (exit) => JSON.stringify({ _tag: "WithExit", requestId: String(reply.requestId), id: String(reply.id), exit }) + ), + registration.context + ) + } + const codec = chunkValuesCodec(rpc) + if (codec === undefined) { + return Effect.die(`Expected a stream RPC: ${rpc._tag}`) + } + return runWith( + Effect.map( + Schema.encodeUnknownEffect(codec)(reply.values), + (values) => + JSON.stringify({ + _tag: "Chunk" as const, + requestId: String(reply.requestId), + id: String(reply.id), + sequence: reply.sequence, + values + }) + ), + registration.context + ) +} + +/** @internal */ +export const decodeReplyFor = ( + rpc: Rpc.AnyWithProps, + context: Context.Context, + replyText: string +): Effect.Effect> => { + const encoded = JSON.parse(replyText) as Reply.Encoded + if (encoded._tag === "WithExit") { + return runWith( + Effect.map( + Schema.decodeUnknownEffect(Schema.toCodecJson(Rpc.exitSchema(rpc)))(encoded.exit), + (exit) => new Reply.WithExit({ requestId: encoded.requestId as any, id: encoded.id as any, exit: exit as any }) + ), + context + ) + } + const codec = chunkValuesCodec(rpc) + if (codec === undefined) { + return Effect.die(`Expected a stream RPC: ${rpc._tag}`) + } + return runWith( + Effect.map( + Schema.decodeUnknownEffect(codec)(encoded.values), + (values) => + new Reply.Chunk({ + requestId: encoded.requestId as any, + id: encoded.id as any, + sequence: encoded.sequence, + values: values as any + }) + ), + context + ) +} diff --git a/packages/platform/cloudflare/src/internal/queueRuntime.ts b/packages/platform/cloudflare/src/internal/queueRuntime.ts new file mode 100644 index 00000000000..48516b164d6 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/queueRuntime.ts @@ -0,0 +1,150 @@ +/** + * The durable queue Durable Object runtime. One object holds one named queue: + * items live in SQLite, and every lease arms the single alarm at its expiry so + * an item whose worker died is redelivered once the watchdog fires. Takers + * with no available item wait in memory until an offer, a retry, or a lease + * expiry wakes them; a crash or hibernation drops those waiters, whose broken + * RPCs are retried from the Worker side. An interrupted taker cancels its wait + * by taker id, releasing an item that was already leased to it. + * + * @internal + */ +import type { SqlStorage } from "@cloudflare/workers-types" +import * as Effect from "effect/Effect" +import { setWithEviction } from "./boundedMap.ts" +import { armAlarm, type EntityAlarm } from "./entityStorage.ts" +import { + completeItem, + earliestLeaseExpiry, + ensureQueueStorage, + expireLeases, + extendLease, + failItem, + leaseNextItem, + offerItem, + type QueueItem, + releaseItem +} from "./queueStorage.ts" + +/** @internal */ +export interface QueueRuntimeOptions { + readonly sql: SqlStorage + readonly alarm: EntityAlarm + readonly now: () => number +} + +/** @internal */ +export interface QueueRuntime { + readonly offer: (id: string, element: string) => Promise + readonly take: (takerId: string, maxAttempts: number, leaseMillis: number) => Promise + readonly cancelTake: (takerId: string) => Promise + readonly complete: (id: string) => Promise + readonly fail: (id: string, lastFailure: string) => Promise + readonly release: (id: string) => Promise + readonly extend: (id: string, leaseMillis: number) => Promise + readonly runAlarm: () => Promise +} + +interface Waiter { + readonly takerId: string + readonly maxAttempts: number + readonly leaseMillis: number + readonly resolve: (item: QueueItem) => void +} + +const deliveredCapacity = 4096 + +/** @internal */ +export const makeQueueRuntime = (options: QueueRuntimeOptions): QueueRuntime => { + const sql = options.sql + ensureQueueStorage(sql) + + const waiters: Array = [] + // Which item each taker holds, so a cancel that races the delivery can + // release the already-leased item instead of stranding it. + const delivered = new Map() + + // Waiters differ in maxAttempts, so one waiter finding nothing does not mean + // a later one will; every waiter gets its own lease attempt. Leasing stays + // fully synchronous so concurrent wake-ups cannot interleave on the waiter + // list; only the final alarm arm is asynchronous. + const wakeWaiters = Effect.suspend(() => { + const now = options.now() + let earliest: number | undefined + let index = 0 + while (index < waiters.length) { + const waiter = waiters[index] + const item = leaseNextItem(sql, now, now + waiter.leaseMillis, waiter.maxAttempts) + if (item === undefined) { + index++ + continue + } + waiters.splice(index, 1) + setWithEviction(delivered, waiter.takerId, item.id, deliveredCapacity) + const expiry = now + waiter.leaseMillis + if (earliest === undefined || expiry < earliest) earliest = expiry + waiter.resolve(item) + } + return earliest === undefined ? Effect.void : armAlarm(options.alarm, earliest) + }) + + const mutateAndWake = (mutate: () => void): Promise => + Effect.runPromise(Effect.suspend(() => { + mutate() + return wakeWaiters + })) + + return { + offer: (id, element) => + mutateAndWake(() => { + offerItem(sql, id, element) + }), + + // The taker joins the waiter list and the shared wake pass leases to it, + // so an immediate take and a woken one follow the same code path. + take: (takerId, maxAttempts, leaseMillis) => { + const item = new Promise((resolve) => { + waiters.push({ takerId, maxAttempts, leaseMillis, resolve }) + }) + return Effect.runPromise(wakeWaiters).then(() => item) + }, + + cancelTake: (takerId) => { + const index = waiters.findIndex((waiter) => waiter.takerId === takerId) + if (index >= 0) { + waiters.splice(index, 1) + return Promise.resolve() + } + const itemId = delivered.get(takerId) + if (itemId === undefined) return Promise.resolve() + delivered.delete(takerId) + return mutateAndWake(() => { + releaseItem(sql, itemId) + }) + }, + + complete: (id) => Promise.resolve(completeItem(sql, id)), + + fail: (id, lastFailure) => + mutateAndWake(() => { + failItem(sql, id, lastFailure) + }), + + release: (id) => + mutateAndWake(() => { + releaseItem(sql, id) + }), + + extend: (id, leaseMillis) => Promise.resolve(extendLease(sql, id, options.now() + leaseMillis)), + + runAlarm: () => + Effect.runPromise( + Effect.gen(function*() { + expireLeases(sql, options.now()) + yield* wakeWaiters + const next = earliestLeaseExpiry(sql) + if (next !== undefined) yield* armAlarm(options.alarm, next) + }) + ) + } +} diff --git a/packages/platform/cloudflare/src/internal/queueStorage.ts b/packages/platform/cloudflare/src/internal/queueStorage.ts new file mode 100644 index 00000000000..ef337672e3b --- /dev/null +++ b/packages/platform/cloudflare/src/internal/queueStorage.ts @@ -0,0 +1,127 @@ +/** + * Storage glue for the durable queue Durable Object. The constructor must stay + * cheap: open SQLite, ensure the queue table, and re-arm the single alarm from + * the earliest pending lease expiry. + * + * Completed rows are retained (not deleted) so custom-id deduplication + * survives completion, matching the SQL-backed store. A failed item moves to + * the back of the queue, so a poisoned item cannot hot-loop the head while + * still being retried ahead of items offered after its failure. + * + * @internal + */ +import type { SqlStorage } from "@cloudflare/workers-types" + +const ddl = [ + `CREATE TABLE IF NOT EXISTS queue_items ( + id TEXT PRIMARY KEY, + element TEXT NOT NULL, + position INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + completed INTEGER NOT NULL DEFAULT 0, + lease_until INTEGER, + last_failure TEXT + )`, + `CREATE INDEX IF NOT EXISTS queue_items_take_idx + ON queue_items (completed, position)`, + `CREATE INDEX IF NOT EXISTS queue_items_lease_idx + ON queue_items (lease_until)`, + // MAX(position) in offerItem/failItem needs a bare position index; the + // (completed, position) index cannot serve it and completed rows are + // retained forever for dedup. + `CREATE INDEX IF NOT EXISTS queue_items_position_idx + ON queue_items (position)` +] + +/** @internal */ +export const ensureQueueStorage = (sql: SqlStorage): void => { + for (const statement of ddl) { + sql.exec(statement) + } +} + +/** @internal */ +export interface QueueItem { + readonly id: string + readonly element: string + readonly attempts: number +} + +type QueueItemRow = { + readonly id: string + readonly element: string + readonly attempts: number +} + +type LeaseExpiryRow = { + readonly lease_until: number | null +} + +/** @internal */ +export const offerItem = (sql: SqlStorage, id: string, element: string): void => { + sql.exec( + `INSERT OR IGNORE INTO queue_items (id, element, position) + VALUES (?, ?, (SELECT IFNULL(MAX(position), 0) + 1 FROM queue_items))`, + id, + element + ) +} + +/** @internal */ +export const leaseNextItem = ( + sql: SqlStorage, + now: number, + leaseUntil: number, + maxAttempts: number +): QueueItem | undefined => { + const row = sql.exec( + `SELECT id, element, attempts FROM queue_items + WHERE completed = 0 AND attempts < ? AND (lease_until IS NULL OR lease_until <= ?) + ORDER BY position ASC LIMIT 1`, + maxAttempts, + now + ).toArray()[0] + if (row === undefined) return undefined + sql.exec("UPDATE queue_items SET lease_until = ? WHERE id = ?", leaseUntil, row.id) + return row +} + +/** @internal */ +export const completeItem = (sql: SqlStorage, id: string): void => { + sql.exec("UPDATE queue_items SET completed = 1, lease_until = NULL WHERE id = ?", id) +} + +/** @internal */ +export const failItem = (sql: SqlStorage, id: string, lastFailure: string): void => { + sql.exec( + `UPDATE queue_items + SET attempts = attempts + 1, lease_until = NULL, last_failure = ?, + position = (SELECT IFNULL(MAX(position), 0) + 1 FROM queue_items) + WHERE id = ?`, + lastFailure, + id + ) +} + +/** @internal */ +export const releaseItem = (sql: SqlStorage, id: string): void => { + sql.exec("UPDATE queue_items SET lease_until = NULL WHERE id = ?", id) +} + +/** @internal */ +export const extendLease = (sql: SqlStorage, id: string, leaseUntil: number): void => { + sql.exec("UPDATE queue_items SET lease_until = ? WHERE id = ? AND lease_until IS NOT NULL", leaseUntil, id) +} + +/** @internal */ +export const expireLeases = (sql: SqlStorage, now: number): void => { + sql.exec("UPDATE queue_items SET lease_until = NULL WHERE lease_until IS NOT NULL AND lease_until <= ?", now) +} + +/** @internal */ +export const earliestLeaseExpiry = (sql: SqlStorage): number | undefined => { + const row = sql.exec( + "SELECT min(lease_until) AS lease_until FROM queue_items WHERE lease_until IS NOT NULL" + ).toArray()[0] + return row?.lease_until ?? undefined +} diff --git a/packages/platform/cloudflare/src/internal/registry.ts b/packages/platform/cloudflare/src/internal/registry.ts new file mode 100644 index 00000000000..da05583d536 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/registry.ts @@ -0,0 +1,31 @@ +/** + * A module-level registration map shared between the Worker layer and the + * Durable Object instances of the same isolate. Unregistering only removes + * the exact registration that was added, so a finalizer racing a re-register + * cannot drop the replacement. + * + * @internal + */ + +/** @internal */ +export interface Registry { + readonly get: (key: string) => A | undefined + readonly register: (key: string, value: A) => boolean + readonly unregister: (key: string, value: A) => void +} + +/** @internal */ +export const makeRegistry = (): Registry => { + const registrations = new Map() + return { + get: (key) => registrations.get(key), + register: (key, value) => { + if (registrations.has(key)) return false + registrations.set(key, value) + return true + }, + unregister: (key, value) => { + if (registrations.get(key) === value) registrations.delete(key) + } + } +} diff --git a/packages/platform/cloudflare/src/internal/singletonRegistry.ts b/packages/platform/cloudflare/src/internal/singletonRegistry.ts new file mode 100644 index 00000000000..e7dcadee917 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/singletonRegistry.ts @@ -0,0 +1,21 @@ +/** @internal */ +import type * as Context from "effect/Context" +import type * as Effect from "effect/Effect" +import { makeRegistry } from "./registry.ts" + +/** @internal */ +export interface SingletonRegistration { + readonly run: Effect.Effect + readonly context: Context.Context +} + +const registry = makeRegistry() + +/** @internal */ +export const getSingletonRegistration: (name: string) => SingletonRegistration | undefined = registry.get + +/** @internal */ +export const registerSingleton: (name: string, registration: SingletonRegistration) => boolean = registry.register + +/** @internal */ +export const unregisterSingleton: (name: string, registration: SingletonRegistration) => void = registry.unregister diff --git a/packages/platform/cloudflare/src/internal/singletonRuntime.ts b/packages/platform/cloudflare/src/internal/singletonRuntime.ts new file mode 100644 index 00000000000..bfcfbe22be6 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/singletonRuntime.ts @@ -0,0 +1,73 @@ +/** + * Runs one singleton effect for each Worker Cron Trigger wake. Concurrent + * duplicate wakes are ignored while the accepted wake is still running; once + * it returns, the object has no live work and Cloudflare may hibernate it. + * + * @internal + */ +import type { DurableObjectStorage, SqlStorage } from "@cloudflare/workers-types" +import * as Effect from "effect/Effect" +import { armAlarm } from "./entityStorage.ts" +import { + beginSingletonWake, + completeSingletonWake, + ensureSingletonStorage, + loadSingletonState +} from "./singletonStorage.ts" + +/** @internal */ +export interface SingletonRuntimeOptions { + readonly sql: SqlStorage + readonly alarm: Pick + readonly now: () => number + readonly run: Effect.Effect +} + +/** @internal */ +export interface SingletonRuntime { + readonly wake: () => Promise + readonly runAlarm: () => Promise +} + +/** @internal */ +export const makeSingletonRuntime = (options: SingletonRuntimeOptions): SingletonRuntime => { + ensureSingletonStorage(options.sql) + let inFlight: Promise | undefined + + const runPending = (armAt?: number): Promise => { + if (inFlight !== undefined) return Promise.resolve() + const run = options.run.pipe( + Effect.ensuring( + Effect.promise(() => options.alarm.deleteAlarm()).pipe( + Effect.ensuring(Effect.sync(() => completeSingletonWake(options.sql))) + ) + ) + ) + const operation = Effect.runPromise( + armAt === undefined ? run : Effect.andThen(armAlarm(options.alarm, armAt), run) + ) + inFlight = operation + void operation.then( + () => { + inFlight = undefined + }, + () => { + inFlight = undefined + } + ) + return operation + } + + return { + wake: () => { + if (inFlight !== undefined) return Promise.resolve() + const pending = loadSingletonState(options.sql).wakeAt + if (pending !== undefined) return runPending() + const now = options.now() + if (!beginSingletonWake(options.sql, now)) return Promise.resolve() + return runPending(now) + }, + + runAlarm: () => loadSingletonState(options.sql).wakeAt === undefined ? Promise.resolve() : runPending() + } +} diff --git a/packages/platform/cloudflare/src/internal/singletonStorage.ts b/packages/platform/cloudflare/src/internal/singletonStorage.ts new file mode 100644 index 00000000000..1f97b9c45e9 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/singletonStorage.ts @@ -0,0 +1,64 @@ +/** + * Storage glue for the singleton Durable Object. A pending row is a watchdog: + * a normal wake clears it after the effect returns, while an isolate crash + * leaves it for the constructor to re-arm on the next alarm wake. + * + * @internal + */ +import type { SqlStorage } from "@cloudflare/workers-types" + +const ddl = `CREATE TABLE IF NOT EXISTS singleton_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT, + wake_at INTEGER +)` + +/** @internal */ +export const ensureSingletonStorage = (sql: SqlStorage): void => { + sql.exec(ddl) +} + +/** @internal */ +export interface SingletonState { + readonly name: string | undefined + readonly wakeAt: number | undefined +} + +type SingletonStateRow = { + readonly name: string | null + readonly wake_at: number | null +} + +/** @internal */ +export const loadSingletonState = (sql: SqlStorage): SingletonState => { + const row = sql.exec("SELECT name, wake_at FROM singleton_state WHERE id = 1").toArray()[0] + return { + name: row?.name ?? undefined, + wakeAt: row?.wake_at ?? undefined + } +} + +/** @internal */ +export const rememberSingletonName = (sql: SqlStorage, name: string): void => { + sql.exec( + `INSERT INTO singleton_state (id, name) VALUES (1, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name`, + name + ) +} + +/** @internal */ +export const beginSingletonWake = (sql: SqlStorage, wakeAt: number): boolean => { + if (loadSingletonState(sql).wakeAt !== undefined) return false + sql.exec( + `INSERT INTO singleton_state (id, wake_at) VALUES (1, ?) + ON CONFLICT(id) DO UPDATE SET wake_at = excluded.wake_at`, + wakeAt + ) + return true +} + +/** @internal */ +export const completeSingletonWake = (sql: SqlStorage): void => { + sql.exec("UPDATE singleton_state SET wake_at = NULL WHERE id = 1") +} diff --git a/packages/platform/cloudflare/src/internal/workflowRegistry.ts b/packages/platform/cloudflare/src/internal/workflowRegistry.ts new file mode 100644 index 00000000000..ff236ca20f2 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/workflowRegistry.ts @@ -0,0 +1,78 @@ +/** + * Module-level workflow state shared between the Worker layer and the workflow + * Durable Object instances of the same isolate. Registrations are recorded at + * Worker init; a running execution provides its own handle through + * `CurrentExecutionHandle` so engine operations inside the run hit local + * SQLite instead of a self-RPC. + * + * @internal + */ +import * as Context from "effect/Context" +import type * as Effect from "effect/Effect" +import type * as Workflow from "effect/unstable/workflow/Workflow" +import * as WorkflowEngine from "effect/unstable/workflow/WorkflowEngine" +import { makeRegistry } from "./registry.ts" + +/** @internal */ +export interface WorkflowRegistration { + readonly workflow: Workflow.Any + readonly execute: ( + payload: object, + executionId: string + ) => Effect.Effect + readonly context: Context.Context +} + +const registry = makeRegistry() + +/** @internal */ +export const getWorkflowRegistration: (name: string) => WorkflowRegistration | undefined = registry.get + +/** @internal */ +export const registerWorkflow: (name: string, registration: WorkflowRegistration) => boolean = registry.register + +/** @internal */ +export const unregisterWorkflow: (name: string, registration: WorkflowRegistration) => void = registry.unregister + +/** @internal */ +export interface WorkflowRunOptions { + readonly discard: boolean + readonly parent?: { readonly workflowName: string; readonly executionId: string } | undefined +} + +/** + * The transport shared by workflow Durable Object stubs and same-isolate + * execution handles. All payloads are JSON text. + * + * @internal + */ +export interface WorkflowStub { + readonly run: (payload: string, options: WorkflowRunOptions) => Promise + readonly poll: () => Promise + readonly resume: () => Promise + readonly interrupt: () => Promise + readonly interruptUnsafe: () => Promise + readonly deferredDone: (name: string, exit: string) => Promise + readonly scheduleClock: (name: string, deferredName: string, wakeUp: number) => Promise +} + +/** @internal */ +export interface WorkflowExecutionHandle extends WorkflowStub { + readonly executionId: string + readonly loadActivity: (key: string) => string | undefined + readonly saveActivity: (key: string, exit: string) => void + readonly loadDeferred: (name: string) => string | undefined +} + +/** + * The handle of the workflow execution currently running in this fiber, + * provided by the workflow Durable Object runtime around each run attempt. + * + * @internal + */ +export class CurrentExecutionHandle extends Context.Service()( + "@effect/platform-cloudflare/CloudflareWorkflowEngine/CurrentExecutionHandle" +) {} + +/** @internal */ +export const deferredState = WorkflowEngine.makeDeferredState() diff --git a/packages/platform/cloudflare/src/internal/workflowRuntime.ts b/packages/platform/cloudflare/src/internal/workflowRuntime.ts new file mode 100644 index 00000000000..e9d64ecbc9e --- /dev/null +++ b/packages/platform/cloudflare/src/internal/workflowRuntime.ts @@ -0,0 +1,302 @@ +/** + * The workflow Durable Object runtime. One object holds one workflow + * execution: its payload, run result, activity results keyed + * `${name}/${attempt}`, durable deferred exits, and the clock due table behind + * the single alarm. Handlers are looked up in the module-level workflow + * registry and built once per wake; a crash or hibernation wipes RAM and the + * next contact replays the execution from SQLite. + * + * @internal + */ +import type { SqlStorage } from "@cloudflare/workers-types" +import * as Cause from "effect/Cause" +import * as Context from "effect/Context" +import * as Duration from "effect/Duration" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Fiber from "effect/Fiber" +import * as Schedule from "effect/Schedule" +import * as DurableClock from "effect/unstable/workflow/DurableClock" +import * as Workflow from "effect/unstable/workflow/Workflow" +import * as WorkflowEngine from "effect/unstable/workflow/WorkflowEngine" +import { decodeName, encodeName } from "./clusterName.ts" +import { armAlarm, type EntityAlarm } from "./entityStorage.ts" +import { + CurrentExecutionHandle, + deferredState, + getWorkflowRegistration, + type WorkflowRunOptions, + type WorkflowStub +} from "./workflowRegistry.ts" +import * as WorkflowStorage from "./workflowStorage.ts" +import { decodeExit, decodePayload, encodeExit, encodeResult } from "./workflowWire.ts" + +/** @internal */ +export const InterruptSignalName = "Workflow/InterruptSignal" + +const resumeGuardMillis = 60_000 + +let voidExitCache: Promise | undefined +const voidExitText = (): Promise< + string +> => (voidExitCache ??= Effect.runPromise(encodeExit(Exit.void, Context.empty()))) + +/** @internal */ +export interface WorkflowRuntimeOptions { + readonly name: string + readonly sql: SqlStorage + readonly alarm: EntityAlarm + readonly now: () => number + readonly waitUntil: (promise: Promise) => void + readonly getStub: (name: string) => WorkflowStub +} + +/** @internal */ +export interface WorkflowRuntime extends WorkflowStub { + readonly executionId: string + readonly loadActivity: (key: string) => string | undefined + readonly saveActivity: (key: string, exit: string) => void + readonly loadDeferred: (name: string) => string | undefined + readonly runAlarm: () => Promise +} + +interface Inflight { + readonly instance: WorkflowEngine.WorkflowInstance["Service"] + readonly fiber: Fiber.Fiber<{ + readonly result: Workflow.Result + readonly text: string + }, unknown> + readonly promise: Promise +} + +/** @internal */ +export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRuntime => { + const name = decodeName(options.name) + if (name === undefined) { + throw new Error("ClusterWorkflow requires a canonical workflow Durable Object name") + } + const workflowName = name.type + const executionId = name.id + const sql = options.sql + + WorkflowStorage.ensureWorkflowStorage(sql) + + let inflight: Inflight | undefined + let resumeRequested = false + + const detach = (promise: Promise): void => { + options.waitUntil(promise.then(() => undefined, () => undefined)) + } + + const isComplete = (result: string | undefined): boolean => + result !== undefined && (JSON.parse(result) as { readonly _tag?: unknown })._tag === "Complete" + + // Losing this wake would strand the parent forever, so it retries; there is + // no persisted-message transport to make it exactly-once on this path. + const resumeParent = (parent: { readonly workflowName: string; readonly executionId: string }): Promise => + Effect.runPromise( + Effect.promise(() => options.getStub(encodeName(parent.workflowName, parent.executionId)).resume()).pipe( + Effect.sandbox, + Effect.retry({ times: 5, schedule: Schedule.exponential(200) }), + Effect.catchCause((cause) => Effect.logError("Workflow parent resume failed", cause)) + ) + ) + + const startAttempt = (row: WorkflowStorage.ExecutionRow): Promise => { + const registration = getWorkflowRegistration(workflowName) + if (registration === undefined) { + return Promise.reject(new Error(`No workflow registered for name: ${workflowName}`)) + } + const workflow = registration.workflow + const instance = WorkflowEngine.WorkflowInstance.initial(workflow, executionId) + const execute = decodePayload(workflow, row.payload, registration.context).pipe( + Effect.flatMap((payload) => registration.execute(payload, executionId) as Effect.Effect), + Effect.onExit((exit) => { + const suspendOnFailure = Context.get(workflow.annotations, Workflow.SuspendOnFailure) + if (!instance.suspended && !(suspendOnFailure && exit._tag === "Failure")) { + return Effect.void + } + if (WorkflowStorage.loadDeferred(sql, InterruptSignalName) === undefined) { + return Effect.void + } + instance.suspended = false + instance.interrupted = true + return Effect.withFiber((fiber) => Effect.interruptible(Fiber.interrupt(fiber))) + }), + Workflow.intoResult, + (effect) => deferredState.trackRun(instance, effect), + Effect.flatMap((result) => + Effect.map(encodeResult(workflow, result, registration.context), (text) => ({ result, text })) + ), + Effect.provideService(DurableClock.InMemoryThreshold, Duration.zero), + Effect.provideService(CurrentExecutionHandle, runtime) + ) + const finish = ({ result, text }: { + readonly result: Workflow.Result + readonly text: string + }): string => { + WorkflowStorage.saveResult(sql, text) + if (result._tag === "Complete") { + WorkflowStorage.setResumePending(sql, false) + resumeRequested = false + const parent = WorkflowStorage.loadExecution(sql)?.parent + if (parent !== undefined) options.waitUntil(resumeParent(parent)) + } else if (resumeRequested) { + resumeRequested = false + detach(startAttempt(row)) + } else { + // This attempt observed every persisted deferred and still suspended, + // so the pending resume (if any) has been serviced. + WorkflowStorage.setResumePending(sql, false) + } + return text + } + const fiber = Effect.runFork(execute) + const promise = Effect.runPromise(Fiber.await(fiber)).then((exit) => { + inflight = undefined + if (Exit.isSuccess(exit)) return finish(exit.value) + if (Cause.hasInterruptsOnly(exit.cause) && (instance.interrupted || instance.suspended)) { + // A hard interrupt (or a completion preempting the fiber after it + // already produced its result) kills the fiber before it can encode; + // synthesize the result from the instance state so it still persists. + const result: Workflow.Result = instance.interrupted + ? new Workflow.Complete({ exit: exit as Exit.Exit }) + : new Workflow.Suspended({}) + return Effect.runPromise(encodeResult(workflow, result, registration.context)) + .then((text) => finish({ result, text })) + } + // No auto-replay after a defect: it would hot-loop a defecting + // workflow. The next external contact replays from storage. + resumeRequested = false + const parent = WorkflowStorage.loadExecution(sql)?.parent + if (parent !== undefined) options.waitUntil(resumeParent(parent)) + return Effect.runPromise(Effect.logError("Workflow execution failed", exit.cause)).then(() => + Promise.reject(Cause.squash(exit.cause)) + ) + }) + inflight = { instance, fiber, promise } + return promise + } + + const run = (payload: string, opts: WorkflowRunOptions): Promise => { + let row = WorkflowStorage.loadExecution(sql) + if (row === undefined) { + WorkflowStorage.createExecution(sql, workflowName, executionId, payload, opts.parent) + row = { workflowName, executionId, payload, parent: opts.parent, result: undefined, resumePending: false } + } else if (opts.parent !== undefined && row.parent === undefined) { + // An execution started standalone can gain a parent later; keep the + // first parent so its completion still wakes that parent. + WorkflowStorage.setParent(sql, opts.parent) + row = { ...row, parent: opts.parent } + } + if (opts.discard) { + if (inflight === undefined && row.result === undefined) detach(startAttempt(row)) + return Promise.resolve("") + } + if (inflight !== undefined) return inflight.promise + if (row.result !== undefined) return Promise.resolve(row.result) + return startAttempt(row) + } + + const resume = (): Promise => { + const row = WorkflowStorage.loadExecution(sql) + if (row === undefined || isComplete(row.result)) return Promise.resolve() + if (inflight !== undefined) { + resumeRequested = true + return Promise.resolve() + } + detach(startAttempt(row)) + return Promise.resolve() + } + + const deferredDone = (deferredName: string, exitText: string): Promise => { + if (!WorkflowStorage.saveDeferred(sql, deferredName, exitText)) return Promise.resolve() + // Persisted with the exit in the same write batch: if this wake is lost + // before the replay finishes, the next wake (or the guard alarm) replays + // the execution. The in-flight flag is set synchronously so a settling + // attempt cannot clear the pending resume without a restart. + WorkflowStorage.setResumePending(sql, true) + if (inflight !== undefined) resumeRequested = true + return Effect.runPromise( + armAlarm(options.alarm, options.now() + resumeGuardMillis).pipe( + Effect.andThen(decodeExit(exitText, Context.empty())), + Effect.flatMap((exit) => deferredState.deferredDone(executionId, deferredName, exit)) + ) + ).then(() => resume()) + } + + const interrupt = (): Promise => { + const row = WorkflowStorage.loadExecution(sql) + if (row === undefined || isComplete(row.result)) return Promise.resolve() + return voidExitText().then((exitText) => deferredDone(InterruptSignalName, exitText)) + } + + const interruptUnsafe = (): Promise => { + const current = inflight + const signalled = interrupt() + if (current === undefined) return signalled + current.instance.interrupted = true + return signalled + .then(() => Effect.runPromise(Fiber.interrupt(current.fiber))) + .then(() => undefined) + } + + const scheduleClock = (clockName: string, deferredName: string, wakeUp: number): Promise => { + WorkflowStorage.saveClock(sql, clockName, deferredName, wakeUp) + const earliest = WorkflowStorage.earliestClockWakeUp(sql) + return earliest === undefined ? Promise.resolve() : Effect.runPromise(armAlarm(options.alarm, earliest)) + } + + const runAlarm = (): Promise => + voidExitText().then((voidExit) => { + const completed = WorkflowStorage.dueClocks(sql, options.now()).filter((clock) => { + WorkflowStorage.markClockFired(sql, clock.name) + return WorkflowStorage.saveDeferred(sql, clock.deferredName, voidExit) + }) + if (completed.length > 0) { + WorkflowStorage.setResumePending(sql, true) + if (inflight !== undefined) resumeRequested = true + } + return Effect.runPromise(Effect.forEach( + completed, + (clock) => deferredState.deferredDone(executionId, clock.deferredName, Exit.void), + { discard: true } + )).then(() => { + const row = WorkflowStorage.loadExecution(sql) + const pending = row !== undefined && row.resumePending && !isComplete(row.result) + return (pending ? resume() : Promise.resolve()).then(() => { + // While a resume is pending a guard alarm stays armed, so a replay + // lost with this isolate is retried instead of sleeping forever. + const targets = [ + WorkflowStorage.earliestClockWakeUp(sql), + pending ? options.now() + resumeGuardMillis : undefined + ].filter((target) => target !== undefined) + if (targets.length === 0) return undefined + return Effect.runPromise(armAlarm(options.alarm, Math.min(...targets))) + }) + }) + }).then(() => undefined) + + const runtime: WorkflowRuntime = { + executionId, + run, + poll: () => Promise.resolve(WorkflowStorage.loadExecution(sql)?.result), + resume, + interrupt, + interruptUnsafe, + deferredDone, + scheduleClock, + runAlarm, + loadActivity: (key) => WorkflowStorage.loadActivity(sql, key), + saveActivity: (key, exit) => WorkflowStorage.saveActivity(sql, key, exit), + loadDeferred: (deferredName) => WorkflowStorage.loadDeferred(sql, deferredName) + } + + // Self-heal on wake: a resume recorded by deferredDone but lost with the + // previous isolate replays now instead of waiting for external contact. + if (WorkflowStorage.loadExecution(sql)?.resumePending === true) { + void resume() + } + + return runtime +} diff --git a/packages/platform/cloudflare/src/internal/workflowStorage.ts b/packages/platform/cloudflare/src/internal/workflowStorage.ts new file mode 100644 index 00000000000..01afbecbd83 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/workflowStorage.ts @@ -0,0 +1,206 @@ +/** + * Storage glue for the workflow Durable Object. The constructor must stay + * cheap: open SQLite, ensure the tables, and re-arm the single alarm from the + * earliest pending clock. No workflow handlers are built here. + * + * @internal + */ +import type { SqlStorage } from "@cloudflare/workers-types" + +const ddl = [ + `CREATE TABLE IF NOT EXISTS workflow_execution ( + id INTEGER PRIMARY KEY CHECK (id = 0), + workflow_name TEXT NOT NULL, + execution_id TEXT NOT NULL, + payload TEXT NOT NULL, + parent_name TEXT, + parent_execution_id TEXT, + result TEXT, + resume_pending INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS workflow_activities ( + key TEXT PRIMARY KEY, + exit TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS workflow_deferreds ( + name TEXT PRIMARY KEY, + exit TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS workflow_clocks ( + name TEXT PRIMARY KEY, + deferred_name TEXT NOT NULL, + wake_up INTEGER NOT NULL, + fired INTEGER NOT NULL DEFAULT 0 + )` +] + +/** @internal */ +export const ensureWorkflowStorage = (sql: SqlStorage): void => { + for (const statement of ddl) { + sql.exec(statement) + } +} + +/** + * The stored `(workflowName, executionId)` also serve to recover the object + * name on an alarm wake, where `ctx.id.name` is undefined. + * + * @internal + */ +export interface ExecutionRow { + readonly workflowName: string + readonly executionId: string + readonly payload: string + readonly parent: { readonly workflowName: string; readonly executionId: string } | undefined + readonly result: string | undefined + readonly resumePending: boolean +} + +type StoredExecutionRow = { + readonly workflow_name: string + readonly execution_id: string + readonly payload: string + readonly parent_name: string | null + readonly parent_execution_id: string | null + readonly result: string | null + readonly resume_pending: number +} + +type ExitRow = { + readonly exit: string +} + +type ClockWakeUpRow = { + readonly wake_up: number | null +} + +type ClockRow = { + readonly name: string + readonly deferred_name: string +} + +/** @internal */ +export const loadExecution = (sql: SqlStorage): ExecutionRow | undefined => { + const row = sql.exec( + `SELECT workflow_name, execution_id, payload, parent_name, parent_execution_id, result, resume_pending + FROM workflow_execution WHERE id = 0` + ).toArray()[0] + if (row === undefined) return undefined + return { + workflowName: row.workflow_name, + executionId: row.execution_id, + payload: row.payload, + parent: row.parent_name !== null && row.parent_execution_id !== null + ? { workflowName: row.parent_name, executionId: row.parent_execution_id } + : undefined, + result: row.result ?? undefined, + resumePending: row.resume_pending === 1 + } +} + +/** @internal */ +export const createExecution = ( + sql: SqlStorage, + workflowName: string, + executionId: string, + payload: string, + parent: { readonly workflowName: string; readonly executionId: string } | undefined +): void => { + sql.exec( + `INSERT OR IGNORE INTO workflow_execution + (id, workflow_name, execution_id, payload, parent_name, parent_execution_id, result) + VALUES (0, ?, ?, ?, ?, ?, NULL)`, + workflowName, + executionId, + payload, + parent?.workflowName ?? null, + parent?.executionId ?? null + ) +} + +/** @internal */ +export const setParent = ( + sql: SqlStorage, + parent: { readonly workflowName: string; readonly executionId: string } +): void => { + sql.exec( + "UPDATE workflow_execution SET parent_name = ?, parent_execution_id = ? WHERE id = 0 AND parent_name IS NULL", + parent.workflowName, + parent.executionId + ) +} + +/** @internal */ +export const saveResult = (sql: SqlStorage, result: string): void => { + sql.exec("UPDATE workflow_execution SET result = ? WHERE id = 0", result) +} + +/** @internal */ +export const setResumePending = (sql: SqlStorage, pending: boolean): void => { + sql.exec("UPDATE workflow_execution SET resume_pending = ? WHERE id = 0", pending ? 1 : 0) +} + +/** @internal */ +export const loadActivity = (sql: SqlStorage, key: string): string | undefined => { + const row = sql.exec("SELECT exit FROM workflow_activities WHERE key = ?", key).toArray()[0] + return row?.exit +} + +/** @internal */ +export const saveActivity = (sql: SqlStorage, key: string, exit: string): void => { + sql.exec("INSERT OR IGNORE INTO workflow_activities (key, exit) VALUES (?, ?)", key, exit) +} + +/** @internal */ +export const loadDeferred = (sql: SqlStorage, name: string): string | undefined => { + const row = sql.exec("SELECT exit FROM workflow_deferreds WHERE name = ?", name).toArray()[0] + return row?.exit +} + +/** + * First write wins; safe without a conflict clause because a Durable Object's + * SQLite access is single-threaded. + * + * @internal + */ +export const saveDeferred = (sql: SqlStorage, name: string, exit: string): boolean => { + if (loadDeferred(sql, name) !== undefined) return false + sql.exec("INSERT INTO workflow_deferreds (name, exit) VALUES (?, ?)", name, exit) + return true +} + +/** @internal */ +export const saveClock = (sql: SqlStorage, name: string, deferredName: string, wakeUp: number): void => { + sql.exec( + "INSERT OR IGNORE INTO workflow_clocks (name, deferred_name, wake_up, fired) VALUES (?, ?, ?, 0)", + name, + deferredName, + wakeUp + ) +} + +/** @internal */ +export const earliestClockWakeUp = (sql: SqlStorage): number | undefined => { + const row = sql.exec( + "SELECT min(wake_up) AS wake_up FROM workflow_clocks WHERE fired = 0" + ).toArray()[0] + return row?.wake_up ?? undefined +} + +/** @internal */ +export const dueClocks = ( + sql: SqlStorage, + now: number +): Array<{ readonly name: string; readonly deferredName: string }> => + sql.exec( + "SELECT name, deferred_name FROM workflow_clocks WHERE fired = 0 AND wake_up <= ?", + now + ).toArray().map((row) => ({ + name: row.name, + deferredName: row.deferred_name + })) + +/** @internal */ +export const markClockFired = (sql: SqlStorage, name: string): void => { + sql.exec("UPDATE workflow_clocks SET fired = 1 WHERE name = ?", name) +} diff --git a/packages/platform/cloudflare/src/internal/workflowWire.ts b/packages/platform/cloudflare/src/internal/workflowWire.ts new file mode 100644 index 00000000000..a3510b5ea1d --- /dev/null +++ b/packages/platform/cloudflare/src/internal/workflowWire.ts @@ -0,0 +1,105 @@ +/** @internal */ +import type * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import type * as Exit from "effect/Exit" +import * as Schema from "effect/Schema" +import * as Workflow from "effect/unstable/workflow/Workflow" +import { runWith } from "./entityWire.ts" + +const AnyOrVoid = Schema.Union([Schema.Undefined, Schema.Any]) + +const ExitJson = Schema.toCodecJson(Schema.Exit(AnyOrVoid, AnyOrVoid, Schema.Defect())) + +/** @internal */ +export const encodeExit = ( + exit: Exit.Exit, + context: Context.Context +): Effect.Effect => + runWith( + Effect.map(Schema.encodeUnknownEffect(ExitJson)(exit), (encoded) => JSON.stringify(encoded)), + context + ) + +/** @internal */ +export const decodeExit = ( + text: string, + context: Context.Context +): Effect.Effect> => + runWith( + Schema.decodeUnknownEffect(ExitJson)(JSON.parse(text)) as Effect.Effect, any>, + context + ) + +const cachedCodec = (compute: (workflow: Workflow.Any) => Schema.Top): (workflow: Workflow.Any) => Schema.Top => { + const cache = new WeakMap() + return (workflow) => { + let codec = cache.get(workflow) + if (codec === undefined) { + codec = compute(workflow) + cache.set(workflow, codec) + } + return codec + } +} + +const resultCodec = cachedCodec((workflow) => + Schema.toCodecJson(Workflow.Result({ + success: workflow.successSchema as any, + error: workflow.errorSchema as any + })) +) + +const payloadCodec = cachedCodec((workflow) => Schema.toCodecJson(workflow.payloadSchema)) + +/** @internal */ +export const encodeResult = ( + workflow: Workflow.Any, + result: Workflow.Result, + context: Context.Context +): Effect.Effect => + runWith( + Effect.map( + Schema.encodeUnknownEffect(resultCodec(workflow))(result), + (encoded) => JSON.stringify(encoded) + ), + context + ) + +/** @internal */ +export const decodeResult = ( + workflow: Workflow.Any, + text: string, + context: Context.Context +): Effect.Effect> => + runWith( + Schema.decodeUnknownEffect(resultCodec(workflow))(JSON.parse(text)) as Effect.Effect< + Workflow.Result, + any + >, + context + ) + +/** @internal */ +export const encodePayload = ( + workflow: Workflow.Any, + payload: object, + context: Context.Context +): Effect.Effect => + runWith( + Effect.map( + Schema.encodeUnknownEffect(payloadCodec(workflow))(payload), + (encoded) => JSON.stringify(encoded) + ), + context + ) + +/** @internal */ +export const decodePayload = ( + workflow: Workflow.Any, + text: string, + context: Context.Context +): Effect.Effect => + runWith( + Schema.decodeUnknownEffect(payloadCodec(workflow))(JSON.parse(text)) as Effect.Effect, + context + ) diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts new file mode 100644 index 00000000000..f14932e40ec --- /dev/null +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -0,0 +1,719 @@ +import * as CloudflareCluster from "@effect/platform-cloudflare/CloudflareCluster" +import { + CurrentEntityName, + CurrentReplyRegistry, + makeReplyRegistry +} from "@effect/platform-cloudflare/internal/entityReply" +import { assert, describe, it } from "@effect/vitest" +import { DateTime, Effect, Exit, Fiber, Layer, PrimaryKey, Schema, Stream } from "effect" +import { + ClusterSchema, + DeliverAt, + Entity, + EntityProxy, + EntityProxyServer, + Sharding, + Singleton +} from "effect/unstable/cluster" +import { Rpc, RpcSchema, RpcTest } from "effect/unstable/rpc" + +const User = Entity.make("User", [ + Rpc.make("Ping", { success: Schema.String }) +]) + +const UninterruptibleUser = Entity.make("UninterruptibleUser", [ + Rpc.make("Ping", { success: Schema.String }).annotate(ClusterSchema.Uninterruptible, true) +]) + +const PersistedUser = Entity.make("PersistedUser", [ + Rpc.make("Ping", { success: Schema.String }).annotate(ClusterSchema.Persisted, true) +]) + +const Counter = Entity.make("Counter", [ + Rpc.make("Increment") +]) + +const Events = Entity.make("Events", [ + Rpc.make("Numbers", { success: RpcSchema.Stream(Schema.Number, Schema.Never) }) +]) + +class ScheduledPayload extends Schema.Class("CloudflareScheduledPayload")({ + deliverAt: Schema.Number, + id: Schema.String +}) { + [PrimaryKey.symbol]() { + return this.id + } + + [DeliverAt.symbol]() { + return DateTime.makeUnsafe(this.deliverAt) + } +} + +class UnkeyedScheduledPayload extends Schema.Class("CloudflareUnkeyedScheduledPayload")({ + deliverAt: Schema.Number +}) { + [DeliverAt.symbol]() { + return DateTime.makeUnsafe(this.deliverAt) + } +} + +const Scheduled = Entity.make("Scheduled", [ + Rpc.make("Ask", { payload: ScheduledPayload, success: Schema.String }).annotate(ClusterSchema.Persisted, true), + Rpc.make("Tell", { payload: ScheduledPayload }).annotate(ClusterSchema.Persisted, true), + Rpc.make("Unkeyed", { payload: UnkeyedScheduledPayload, success: Schema.String }).annotate( + ClusterSchema.Persisted, + true + ), + Rpc.make("Stream", { + payload: ScheduledPayload, + success: RpcSchema.Stream(Schema.Number, Schema.Never) + }).annotate(ClusterSchema.Persisted, true) +]) + +class FakeNamespace { + readonly names: Array = [] + constructor(readonly stub: object = {}) {} + + getByName(name: string) { + this.names.push(name) + return this.stub + } +} + +const makeOptions = () => { + const entityNamespace = new FakeNamespace() + const options: CloudflareCluster.LayerOptions = { + entities: [User], + entityNamespace: entityNamespace as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + return { entityNamespace, options } +} + +describe("CloudflareCluster", () => { + describe("layer", () => { + it.effect("registers singleton effects under named Durable Objects without running them forever", () => { + const singletonNamespace = new FakeNamespace() + const options: CloudflareCluster.LayerOptions = { + entities: [User], + entityNamespace: new FakeNamespace() as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: singletonNamespace as any + } + + return Effect.gen(function*() { + yield* Layer.build( + Singleton.make("hourly", Effect.void).pipe( + Layer.provide(CloudflareCluster.layer(options)) + ) + ) + assert.deepStrictEqual(singletonNamespace.names, ["Singleton/hourly"]) + }) + }) + + it.effect("resolves entity clients through the namespace binding", () => + Effect.gen(function*() { + const { entityNamespace, options } = makeOptions() + const makeClient = yield* User.client.pipe( + Effect.provide(CloudflareCluster.layer(options)) + ) + makeClient("42") + assert.deepStrictEqual(entityNamespace.names, ["4:User42"]) + })) + + it.effect("routes generated entity proxy handlers through the encoded Durable Object name", () => { + const stub = { + invoke(envelopeText: string) { + const envelope = JSON.parse(envelopeText) + return Promise.resolve({ + _tag: "Success", + requestId: envelope.requestId, + replies: [JSON.stringify({ + _tag: "WithExit", + requestId: envelope.requestId, + id: "proxy-reply", + exit: { _tag: "Success", value: "pong" } + })] + }) + }, + acknowledge() { + return Promise.resolve([]) + } + } + const entityNamespace = new FakeNamespace(stub) + const options: CloudflareCluster.LayerOptions = { + entities: [User], + entityNamespace: entityNamespace as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + const proxy = EntityProxy.toRpcGroup(User) + + return Effect.gen(function*() { + const client = yield* RpcTest.makeClient(proxy) + const result = yield* client["User.Ping"]({ entityId: "proxy:id", payload: undefined }) + + assert.strictEqual(result, "pong") + assert.deepStrictEqual(entityNamespace.names, ["4:Userproxy:id"]) + }).pipe( + Effect.provide(EntityProxyServer.layerRpcHandlers(User)), + Effect.provide(CloudflareCluster.layer(options)) + ) + }) + + it.effect("uses uuidv7 request ids and decodes replies from the entity Durable Object", () => { + const envelopes: Array = [] + const stub = { + invoke(envelopeText: string) { + const envelope = JSON.parse(envelopeText) + envelopes.push(envelope) + return Promise.resolve({ + _tag: "Success", + requestId: envelope.requestId, + replies: [JSON.stringify({ + _tag: "WithExit", + requestId: envelope.requestId, + id: "reply-1", + exit: { _tag: "Success", value: "pong" } + })] + }) + }, + acknowledge() { + return Promise.resolve([]) + } + } + const entityNamespace = new FakeNamespace(stub) + const options: CloudflareCluster.LayerOptions = { + entities: [User], + entityNamespace: entityNamespace as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* User.client + const result = yield* makeClient("42").Ping(void 0) + assert.strictEqual(result, "pong") + assert.match(envelopes[0].requestId, /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + assert.strictEqual(envelopes[0].address.entityType, "User") + assert.strictEqual(envelopes[0].address.entityId, "42") + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("acknowledges each persisted stream chunk before requesting the next reply", () => { + const acknowledgements: Array = [] + let requestId = "" + const reply = (value: object) => JSON.stringify({ requestId, ...value }) + const stub = { + invoke(envelopeText: string) { + requestId = JSON.parse(envelopeText).requestId + return Promise.resolve({ + _tag: "Success", + requestId, + replies: [reply({ _tag: "Chunk", id: "chunk-0", sequence: 0, values: [1] })] + }) + }, + acknowledge(_requestId: string, replyId: string) { + acknowledgements.push(replyId) + return Promise.resolve( + acknowledgements.length === 1 + ? [reply({ _tag: "Chunk", id: "chunk-1", sequence: 1, values: [2] })] + : [reply({ _tag: "WithExit", id: "terminal", exit: { _tag: "Success", value: null } })] + ) + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [Events], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* Events.client + const values = yield* makeClient("one").Numbers(void 0).pipe(Stream.runCollect) + + assert.deepStrictEqual(Array.from(values), [1, 2]) + assert.deepStrictEqual(acknowledgements, ["chunk-0", "chunk-1"]) + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("interrupts the Durable Object handler when the client request is interrupted", () => { + let requestId = "" + let resumeInvoked!: () => void + const invoked = new Promise((resolve) => { + resumeInvoked = resolve + }) + const interruptions: Array> = [] + const stub = { + invoke(envelopeText: string) { + requestId = JSON.parse(envelopeText).requestId + resumeInvoked() + return new Promise(() => {}) + }, + acknowledge() { + return Promise.resolve([]) + }, + interrupt(storageRequestId: string, clientRequestId?: string) { + interruptions.push([storageRequestId, clientRequestId]) + return Promise.resolve() + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [User], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* User.client + const fiber = yield* Effect.forkChild(makeClient("42").Ping(void 0)) + yield* Effect.promise(() => invoked) + yield* Fiber.interrupt(fiber) + + assert.deepStrictEqual(interruptions, [[requestId, requestId]]) + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("does not interrupt an Uninterruptible Durable Object handler", () => { + let resumeInvoked!: () => void + const invoked = new Promise((resolve) => { + resumeInvoked = resolve + }) + const interruptions: Array> = [] + const stub = { + invoke() { + resumeInvoked() + return new Promise(() => {}) + }, + acknowledge() { + return Promise.resolve([]) + }, + interrupt(storageRequestId: string, clientRequestId?: string) { + interruptions.push([storageRequestId, clientRequestId]) + return Promise.resolve() + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [UninterruptibleUser], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* UninterruptibleUser.client + const fiber = yield* Effect.forkChild(makeClient("42").Ping(void 0)) + yield* Effect.promise(() => invoked) + yield* Fiber.interrupt(fiber) + + assert.isEmpty(interruptions) + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("passes future DeliverAt metadata with a destination-scoped primary key", () => { + const deliveries: Array = [] + const stub = { + invoke(envelopeText: string, discard: boolean, delivery: unknown) { + const envelope = JSON.parse(envelopeText) + deliveries.push({ discard, delivery }) + return Promise.resolve({ + _tag: "Success", + requestId: envelope.requestId, + replies: discard ? [] : [JSON.stringify({ + _tag: "WithExit", + requestId: envelope.requestId, + id: "terminal", + exit: { _tag: "Success", value: "done" } + })] + }) + }, + acknowledge() { + return Promise.resolve([]) + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [Scheduled], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* Scheduled.client + const client = makeClient("one") + const deliverAt = Date.now() + 60_000 + assert.strictEqual(yield* client.Ask({ deliverAt, id: "operation" }), "done") + yield* client.Tell({ deliverAt, id: "tell" }, { discard: true }) + + assert.deepStrictEqual(deliveries, [ + { + discard: false, + delivery: { + deliverAt, + primaryKey: "Scheduled/one/Ask/operation" + } + }, + { + discard: true, + delivery: { + deliverAt, + primaryKey: "Scheduled/one/Tell/tell" + } + } + ]) + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("keeps a Worker delayed ask open until the destination RPC returns", () => { + let resolve!: (result: any) => void + const response = new Promise((resume) => { + resolve = resume + }) + let requestId = "" + const stub = { + invoke(envelopeText: string) { + requestId = JSON.parse(envelopeText).requestId + return response + }, + acknowledge() { + return Promise.resolve([]) + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [Scheduled], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* Scheduled.client + const fiber = yield* Effect.forkChild(makeClient("one").Ask({ deliverAt: Date.now() + 60_000, id: "worker" })) + yield* Effect.yieldNow + assert.isUndefined(fiber.pollUnsafe()) + resolve({ + _tag: "Success", + requestId, + replies: [JSON.stringify({ + _tag: "WithExit", + requestId, + id: "terminal", + exit: { _tag: "Success", value: "done" } + })] + }) + assert.strictEqual(yield* Fiber.join(fiber), "done") + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("delivers a delayed reply back to a pinned caller entity", () => { + const registry = makeReplyRegistry() + const stub = { + invoke(envelopeText: string, _discard: boolean, delivery: { readonly replyTo?: string }) { + const envelope = JSON.parse(envelopeText) + assert.strictEqual(delivery.replyTo, "6:Callerone") + queueMicrotask(() => { + registry.deliver( + envelope.requestId, + JSON.stringify({ + _tag: "WithExit", + requestId: envelope.requestId, + id: "terminal", + exit: { _tag: "Success", value: "callback" } + }) + ) + }) + return Promise.resolve({ _tag: "Success", requestId: envelope.requestId, replies: [] }) + }, + acknowledge() { + return Promise.resolve([]) + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [Scheduled], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* Scheduled.client + const result = yield* makeClient("one").Ask({ deliverAt: Date.now() + 60_000, id: "caller" }).pipe( + Effect.provideService(CurrentEntityName, "6:Callerone"), + Effect.provideService(CurrentReplyRegistry, registry) + ) + assert.strictEqual(result, "callback") + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("delivers a deduplicated delayed reply to every pinned caller", () => { + const registry = makeReplyRegistry() + const pending: Array<(value: any) => void> = [] + let storageRequestId = "" + let bothInvoked!: () => void + const invoked = new Promise((resolve) => { + bothInvoked = resolve + }) + const stub = { + invoke(envelopeText: string, _discard: boolean, delivery: { readonly replyTo?: string }) { + assert.strictEqual(delivery.replyTo, "6:Callerone") + const envelope = JSON.parse(envelopeText) + if (storageRequestId === "") storageRequestId = envelope.requestId + return new Promise((resolve) => { + pending.push(resolve) + if (pending.length === 2) bothInvoked() + }) + }, + acknowledge() { + return Promise.resolve([]) + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [Scheduled], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* Scheduled.client + const client = makeClient("one") + const request = { deliverAt: Date.now() + 60_000, id: "shared" } + const pinned = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(CurrentEntityName, "6:Callerone"), + Effect.provideService(CurrentReplyRegistry, registry) + ) + const first = yield* Effect.forkChild(pinned(client.Ask(request))) + const second = yield* Effect.forkChild(pinned(client.Ask(request))) + yield* Effect.promise(() => invoked) + for (const resolve of pending) resolve({ _tag: "Success", requestId: storageRequestId, replies: [] }) + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))) + const delivered = registry.deliver( + storageRequestId, + JSON.stringify({ + _tag: "WithExit", + requestId: storageRequestId, + id: "terminal", + exit: { _tag: "Success", value: "callback" } + }) + ) + + assert.isTrue(delivered) + assert.deepStrictEqual(yield* Fiber.join(first), "callback") + assert.deepStrictEqual(yield* Fiber.join(second), "callback") + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("rejects unkeyed and streaming asks with a future DeliverAt", () => { + let invoked = 0 + const stub = { + invoke() { + invoked++ + return Promise.resolve({ _tag: "Success", requestId: "unused", replies: [] }) + }, + acknowledge() { + return Promise.resolve([]) + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [Scheduled], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* Scheduled.client + const client = makeClient("one") + const deliverAt = Date.now() + 60_000 + assert.isTrue(Exit.isFailure(yield* client.Unkeyed({ deliverAt }).pipe(Effect.exit))) + assert.isTrue( + Exit.isFailure(yield* client.Stream({ deliverAt, id: "stream" }).pipe(Stream.runDrain, Effect.exit)) + ) + assert.strictEqual(invoked, 0) + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("surfaces ask-to-tell deduplication as a persistence failure", () => { + const registry = makeReplyRegistry() + const stub = { + invoke() { + return Promise.resolve({ _tag: "AskDeduplicatedToTell" as const }) + }, + acknowledge() { + return Promise.resolve([]) + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [Scheduled], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* Scheduled.client + const exit = yield* makeClient("one").Ask({ deliverAt: Date.now() + 60_000, id: "tell" }).pipe( + Effect.provideService(CurrentEntityName, "6:Callerone"), + Effect.provideService(CurrentReplyRegistry, registry), + Effect.exit + ) + assert.isTrue(Exit.isFailure(exit)) + assert.isFalse(registry.deliver("original-tell", "unused")) + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("does not retain reset targets for volatile requests", () => { + let requestId = "" + let resets = 0 + const stub = { + invoke(envelopeText: string) { + const envelope = JSON.parse(envelopeText) + requestId = envelope.requestId + return Promise.resolve({ + _tag: "Success", + requestId, + replies: [JSON.stringify({ + _tag: "WithExit", + requestId, + id: "terminal", + exit: { _tag: "Success", value: "pong" } + })] + }) + }, + acknowledge() { + return Promise.resolve([]) + }, + reset() { + resets++ + return Promise.resolve() + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [User], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* User.client + const sharding = yield* Sharding.Sharding + yield* makeClient("42").Ping(void 0) + const reset = yield* sharding.reset(requestId as any) + + assert.isFalse(reset) + assert.strictEqual(resets, 0) + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("bounds retained reset targets for persisted requests", () => { + const requestIds: Array = [] + const resets: Array = [] + const stub = { + invoke(envelopeText: string) { + const requestId = JSON.parse(envelopeText).requestId + requestIds.push(requestId) + return Promise.resolve({ + _tag: "Success", + requestId, + replies: [JSON.stringify({ + _tag: "WithExit", + requestId, + id: `terminal-${requestIds.length}`, + exit: { _tag: "Success", value: "pong" } + })] + }) + }, + acknowledge() { + return Promise.resolve([]) + }, + reset(requestId: string) { + resets.push(requestId) + return Promise.resolve() + } + } + const options: CloudflareCluster.LayerOptions = { + entities: [PersistedUser], + entityNamespace: new FakeNamespace(stub) as any, + workflowNamespace: new FakeNamespace() as any, + queueNamespace: new FakeNamespace() as any, + singletonNamespace: new FakeNamespace() as any + } + + return Effect.gen(function*() { + const makeClient = yield* PersistedUser.client + const client = makeClient("42") + const sharding = yield* Sharding.Sharding + for (let index = 0; index < 4097; index++) { + yield* client.Ping(void 0) + } + + assert.isFalse(yield* sharding.reset(requestIds[0] as any)) + assert.isTrue(yield* sharding.reset(requestIds.at(-1)! as any)) + assert.deepStrictEqual(resets, [requestIds.at(-1)]) + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + + it.effect("fails for an entity type not bound at Worker init", () => + Effect.gen(function*() { + const { entityNamespace, options } = makeOptions() + const exit = yield* Counter.client.pipe( + Effect.provide(CloudflareCluster.layer(options)), + Effect.exit + ) + assert.isTrue(Exit.isFailure(exit)) + assert.deepStrictEqual(entityNamespace.names, []) + })) + + it.effect("registers entity handlers", () => + Effect.gen(function*() { + const { options } = makeOptions() + yield* Layer.build( + User.toLayer({ Ping: () => Effect.succeed("pong") }).pipe( + Layer.provide(CloudflareCluster.layer(options)) + ) + ) + })) + + it.effect("ignores duplicate handler registration", () => + Effect.gen(function*() { + const { options } = makeOptions() + const handlers = User.toLayer({ Ping: () => Effect.succeed("pong") }) + yield* Layer.build( + Layer.merge(handlers, User.toLayer({ Ping: () => Effect.succeed("pong2") })).pipe( + Layer.provide(CloudflareCluster.layer(options)) + ) + ) + })) + + it.effect("fails when registering an entity type not bound at Worker init", () => + Effect.gen(function*() { + const { options } = makeOptions() + const exit = yield* Layer.build( + Counter.toLayer({ Increment: () => Effect.void }).pipe( + Layer.provide(CloudflareCluster.layer(options)) + ) + ).pipe(Effect.exit) + assert.isTrue(Exit.isFailure(exit)) + })) + }) +}) diff --git a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts new file mode 100644 index 00000000000..6432e47464b --- /dev/null +++ b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts @@ -0,0 +1,341 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" +import * as esbuild from "esbuild" +import { Miniflare } from "miniflare" +import * as path from "node:path" + +const bindings = ["CLUSTER_ENTITY", "CLUSTER_WORKFLOW", "CLUSTER_QUEUE", "CLUSTER_SINGLETON"] + +const makeMiniflare = Effect.acquireRelease( + Effect.promise(async () => { + const bundle = await esbuild.build({ + entryPoints: [path.join(import.meta.dirname, "fixtures", "worker.ts")], + bundle: true, + format: "esm", + write: false, + external: ["cloudflare:workers"], + alias: { + "@effect/platform-cloudflare": path.join(import.meta.dirname, "..", "src") + } + }) + return new Miniflare({ + modules: [{ type: "ESModule", path: "worker.mjs", contents: bundle.outputFiles[0].text }], + compatibilityDate: "2026-08-01", + durableObjects: { + CLUSTER_ENTITY: { className: "ClusterEntity", useSQLite: true }, + CLUSTER_WORKFLOW: { className: "ClusterWorkflow", useSQLite: true }, + CLUSTER_QUEUE: { className: "ClusterDurableQueue", useSQLite: true }, + CLUSTER_SINGLETON: { className: "ClusterSingleton", useSQLite: true } + } + }) + }), + (miniflare) => Effect.promise(() => miniflare.dispose()) +) + +describe("CloudflareDurableObjects", () => { + it.effect("binds the four Durable Object classes", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + for (const binding of bindings) { + const response = yield* Effect.promise(async () => { + const response = await miniflare.dispatchFetch(`http://placeholder/${binding}`) + return { status: response.status, body: await response.text() } + }) + assert.strictEqual(response.status, 200, `${binding}: ${response.body}`) + assert.include(response.body, "not exposed over fetch", binding) + } + }), 60_000) + + it.effect("journals only persisted RPCs and deduplicates primary keys", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const call = (tag: string, operationId: string) => + Effect.promise(() => + miniflare.dispatchFetch( + `http://placeholder/mailbox?tag=${tag}&operationId=${operationId}` + ).then(async (response) => { + const body = await response.text() + assert.strictEqual(response.status, 200, body) + return JSON.parse(body) + }) + ) + + yield* call("Add", "same-operation") + yield* call("Add", "same-operation") + yield* call("AddVolatile", "volatile-1") + yield* call("AddVolatile", "volatile-2") + const result = yield* call("Get", "read") + const terminal = JSON.parse(result.replies[0]) + + assert.strictEqual(terminal._tag, "WithExit") + assert.deepStrictEqual(terminal.exit, { _tag: "Success", value: 3 }) + }), 60_000) + + it.effect("acknowledges stream chunks without holding the entity lock", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const call = (path: string) => + Effect.promise(() => + Promise.race([ + miniflare.dispatchFetch(`http://placeholder${path}`).then(async (response) => { + const body = await response.text() + assert.strictEqual(response.status, 200, body) + return JSON.parse(body) + }), + new Promise((_, reject) => setTimeout(() => reject(new Error(`${path} did not return`)), 2_000)) + ]) + ) + + const result = yield* call("/mailbox?tag=Watch") + const first = JSON.parse(result.replies[0]) + assert.deepStrictEqual(first, { + _tag: "Chunk", + requestId: result.requestId, + id: first.id, + sequence: 0, + values: [1] + }) + + const getResult = yield* call("/mailbox?tag=Get") + const getReply = JSON.parse(getResult.replies[0]) + assert.deepStrictEqual(getReply.exit, { _tag: "Success", value: 1 }) + + const secondReplies = yield* call(`/ack?requestId=${result.requestId}&replyId=${first.id}`) + const second = JSON.parse(secondReplies[0]) + assert.deepStrictEqual(second, { + _tag: "Chunk", + requestId: result.requestId, + id: second.id, + sequence: 1, + values: [2] + }) + + const terminalReplies = yield* call(`/ack?requestId=${result.requestId}&replyId=${second.id}`) + const terminal = JSON.parse(terminalReplies[0]) + assert.strictEqual(terminal._tag, "WithExit") + assert.strictEqual(terminal.requestId, result.requestId) + assert.deepStrictEqual(terminal.exit, { _tag: "Success", value: null }) + }), 60_000) + + it.effect("isolates an undecodable replay row from later mailbox requests", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + yield* Effect.promise(() => miniflare.dispatchFetch("http://placeholder/seed-poison")) + const response = yield* Effect.promise(() => + miniflare.dispatchFetch("http://placeholder/mailbox?tag=Get").then(async (response) => ({ + status: response.status, + body: await response.text() + })) + ) + + assert.strictEqual(response.status, 200, response.body) + const result = JSON.parse(response.body) + const terminal = JSON.parse(result.replies[0]) + assert.deepStrictEqual(terminal.exit, { _tag: "Success", value: 0 }) + }), 60_000) + + it.effect("persists DeliverAt rows and runs and re-arms the entity alarm", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const fetchJson = (path: string) => + Effect.promise(() => + miniflare.dispatchFetch(`http://placeholder${path}`).then(async (response) => { + const body = await response.text() + assert.strictEqual(response.status, 200, body) + return JSON.parse(body) + }) + ) + const id = "scheduled-alarm" + const firstAt = Date.now() + 500 + const secondAt = firstAt + 600 + + const first = yield* fetchJson( + `/delayed?id=${id}&operationId=first&discard=true&deliverAt=${firstAt}` + ) + const second = yield* fetchJson( + `/delayed?id=${id}&operationId=second&discard=true&deliverAt=${secondAt}` + ) + assert.deepStrictEqual(first.replies, []) + assert.deepStrictEqual(second.replies, []) + + const persisted = yield* fetchJson(`/scheduled-rows?id=${id}`) + assert.deepStrictEqual( + persisted.rows.map((row: any) => ({ messageId: row.message_id, deliverAt: row.deliver_at })), + [ + { messageId: `Mailbox/${id}/Add/first`, deliverAt: firstAt }, + { messageId: `Mailbox/${id}/Add/second`, deliverAt: secondAt } + ] + ) + assert.strictEqual(persisted.alarm, firstAt) + + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 700))) + const afterFirst = yield* fetchJson(`/mailbox?id=${id}&tag=Get`) + assert.deepStrictEqual(JSON.parse(afterFirst.replies[0]).exit, { _tag: "Success", value: 1 }) + const rearmed = yield* fetchJson(`/scheduled-rows?id=${id}`) + assert.strictEqual(rearmed.alarm, secondAt) + + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 500))) + const afterSecond = yield* fetchJson(`/mailbox?id=${id}&tag=Get`) + assert.deepStrictEqual(JSON.parse(afterSecond.replies[0]).exit, { _tag: "Success", value: 2 }) + }), 60_000) + + it.effect("keeps a Worker delayed ask open and deduplicates its primary key", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const fetchJson = (path: string) => + Effect.promise(() => + miniflare.dispatchFetch(`http://placeholder${path}`).then(async (response) => { + const body = await response.text() + assert.strictEqual(response.status, 200, body) + return JSON.parse(body) + }) + ) + const id = "scheduled-ask" + const deliverAt = Date.now() + 300 + const [first, duplicate] = yield* Effect.promise(() => + Promise.all([ + Effect.runPromise(fetchJson(`/delayed?id=${id}&operationId=same&discard=false&deliverAt=${deliverAt}`)), + Effect.runPromise(fetchJson(`/delayed?id=${id}&operationId=same&discard=false&deliverAt=${deliverAt}`)) + ]) + ) + const terminal = JSON.parse(first.replies[0]) + assert.strictEqual(terminal._tag, "WithExit") + assert.strictEqual(duplicate.requestId, first.requestId) + const result = yield* fetchJson(`/mailbox?id=${id}&tag=Get`) + assert.deepStrictEqual(JSON.parse(result.replies[0]).exit, { _tag: "Success", value: 1 }) + }), 60_000) + + it.effect("interrupts only the matching deduplicated Worker waiter", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const result = yield* Effect.promise(() => + miniflare.dispatchFetch("http://placeholder/interrupt-delayed?id=interrupt-waiter").then(async (response) => { + const body = await response.text() + assert.strictEqual(response.status, 200, body) + return JSON.parse(body) + }) + ) + + assert.deepStrictEqual(result, { firstStatus: "pending", secondStatus: "rejected" }) + }), 60_000) + + it.effect( + "rejects an ask deduplicated onto a future tell without hanging or running it early", + () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const fetchJson = (path: string) => + Effect.promise(() => + Promise.race([ + miniflare.dispatchFetch(`http://placeholder${path}`).then(async (response) => { + const body = await response.text() + assert.strictEqual(response.status, 200, body) + return JSON.parse(body) + }), + new Promise((_, reject) => setTimeout(() => reject(new Error(`${path} did not return`)), 2_000)) + ]) + ) + const id = "scheduled-dedup" + yield* fetchJson( + `/delayed?id=${id}&operationId=same&discard=true&deliverAt=${Date.now() + 60_000}` + ) + const duplicate = yield* fetchJson(`/mailbox?id=${id}&tag=Add&operationId=same&discard=false`) + assert.strictEqual(duplicate._tag, "AskDeduplicatedToTell") + + const result = yield* fetchJson(`/mailbox?id=${id}&tag=Get`) + assert.deepStrictEqual(JSON.parse(result.replies[0]).exit, { _tag: "Success", value: 0 }) + }), + 60_000 + ) + + it.effect("rejects an ask deduplicated onto a processed tell without hanging", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const fetchJson = (path: string) => + Effect.promise(() => + Promise.race([ + miniflare.dispatchFetch(`http://placeholder${path}`).then(async (response) => { + const body = await response.text() + assert.strictEqual(response.status, 200, body) + return JSON.parse(body) + }), + new Promise((_, reject) => setTimeout(() => reject(new Error(`${path} did not return`)), 2_000)) + ]) + ) + const id = "processed-tell-dedup" + yield* fetchJson(`/mailbox?id=${id}&tag=Add&operationId=same`) + const duplicate = yield* fetchJson(`/mailbox?id=${id}&tag=Add&operationId=same&discard=false`) + + assert.strictEqual(duplicate._tag, "AskDeduplicatedToTell") + }), 60_000) + + it.effect("honors the entity concurrency option for in-flight handlers", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const gate = (type: string) => + Effect.promise(() => + miniflare.dispatchFetch(`http://placeholder/gate?type=${type}&id=g1`).then(async (response) => { + const body = await response.text() + assert.strictEqual(response.status, 200, body) + return JSON.parse(body) + }) + ) + + // Default concurrency serializes: Open cannot run while WaitTurn is + // still in flight, so WaitTurn times out and Open finds no waiter. + const serial = yield* gate("GateSerial") + assert.deepStrictEqual(serial, { wait: "timeout", open: "no-waiter" }) + + // With two permits the second ask interleaves while the first handler + // is suspended, and releases it. + const concurrent = yield* gate("GateConcurrent") + assert.deepStrictEqual(concurrent, { wait: "opened", open: "opened" }) + + const unbounded = yield* gate("GateUnbounded") + assert.deepStrictEqual(unbounded, { wait: "opened", open: "opened" }) + }), 60_000) + + it.effect("completes an ask cycle between entities given sufficient concurrency", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const result = yield* Effect.promise(() => + Promise.race([ + miniflare.dispatchFetch("http://placeholder/cycle?id=c1").then(async (response) => { + const body = await response.text() + assert.strictEqual(response.status, 200, body) + return JSON.parse(body) + }), + new Promise((_, reject) => setTimeout(() => reject(new Error("ask cycle did not complete")), 5_000)) + ]) + ) + assert.deepStrictEqual(result, { value: "cycle:pong" }) + }), 60_000) + + it.effect("delivers a scheduled ask reply to the caller Durable Object", () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const fetchJson = (path: string) => + Effect.promise(() => + miniflare.dispatchFetch(`http://placeholder${path}`).then(async (response) => { + const body = await response.text() + assert.strictEqual(response.status, 200, body) + return JSON.parse(body) + }) + ) + const targetId = "callback-target" + const callerId = "callback-caller" + const replyTo = `7:Mailbox${callerId}` + const accepted = yield* fetchJson( + `/delayed?id=${targetId}&operationId=callback&discard=false&deliverAt=${Date.now() + 100}` + + `&replyTo=${encodeURIComponent(replyTo)}` + ) + assert.deepStrictEqual(accepted.replies, []) + + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 200))) + const received = yield* fetchJson(`/delayed-reply?id=${callerId}`) + assert.strictEqual(received.reply.requestId, accepted.requestId) + const terminal = JSON.parse(received.reply.reply) + assert.strictEqual(terminal._tag, "WithExit") + assert.deepStrictEqual(terminal.exit, { _tag: "Success", value: null }) + }), 60_000) +}) diff --git a/packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts b/packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts new file mode 100644 index 00000000000..89dcecf3652 --- /dev/null +++ b/packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts @@ -0,0 +1,453 @@ +import type { SqlStorage } from "@cloudflare/workers-types" +import * as CloudflarePersistedQueue from "@effect/platform-cloudflare/CloudflarePersistedQueue" +import { encodeName } from "@effect/platform-cloudflare/internal/clusterName" +import { armAlarm, type EntityAlarm } from "@effect/platform-cloudflare/internal/entityStorage" +import { makeQueueRuntime, type QueueRuntime } from "@effect/platform-cloudflare/internal/queueRuntime" +import { earliestLeaseExpiry } from "@effect/platform-cloudflare/internal/queueStorage" +import { assert, describe, it } from "@effect/vitest" +import { Effect, Fiber, Layer, Schema } from "effect" +import * as PersistedQueueTest from "effect-test/unstable/persistence/PersistedQueueTest" +import { PersistedQueue } from "effect/unstable/persistence" + +interface ItemRow { + readonly id: string + readonly element: string + position: number + attempts: number + completed: number + lease_until: number | null + last_failure: string | null +} + +class FakeSql { + readonly items = new Map() + + #nextPosition() { + return Math.max(0, ...Array.from(this.items.values(), (item) => item.position)) + 1 + } + + exec(query: string, ...bindings: Array) { + if (query.startsWith("CREATE")) return this.rows([]) + if (query.includes("INSERT OR IGNORE INTO queue_items")) { + const [id, element] = bindings as [string, string] + if (!this.items.has(id)) { + this.items.set(id, { + id, + element, + position: this.#nextPosition(), + attempts: 0, + completed: 0, + lease_until: null, + last_failure: null + }) + } + return this.rows([]) + } + if (query.includes("SELECT id, element, attempts")) { + const [maxAttempts, now] = bindings as [number, number] + const row = Array.from(this.items.values()) + .filter((item) => + item.completed === 0 && item.attempts < maxAttempts && + (item.lease_until === null || item.lease_until <= now) + ) + .sort((left, right) => left.position - right.position)[0] + return this.rows(row === undefined ? [] : [{ id: row.id, element: row.element, attempts: row.attempts }]) + } + if (query.includes("SET completed = 1")) { + const item = this.items.get(String(bindings[0])) + if (item !== undefined) { + item.completed = 1 + item.lease_until = null + } + return this.rows([]) + } + if (query.includes("SET lease_until = NULL WHERE lease_until")) { + const now = Number(bindings[0]) + for (const item of this.items.values()) { + if (item.lease_until !== null && item.lease_until <= now) item.lease_until = null + } + return this.rows([]) + } + if (query.includes("SET lease_until = NULL WHERE id")) { + const item = this.items.get(String(bindings[0])) + if (item !== undefined) item.lease_until = null + return this.rows([]) + } + if (query.includes("AND lease_until IS NOT NULL")) { + const item = this.items.get(String(bindings[1])) + if (item !== undefined && item.lease_until !== null) item.lease_until = Number(bindings[0]) + return this.rows([]) + } + if (query.includes("SET lease_until = ? WHERE id = ?")) { + const item = this.items.get(String(bindings[1])) + if (item !== undefined) item.lease_until = Number(bindings[0]) + return this.rows([]) + } + if (query.includes("SET attempts = attempts + 1")) { + const item = this.items.get(String(bindings[1])) + if (item !== undefined) { + item.attempts++ + item.lease_until = null + item.last_failure = String(bindings[0]) + item.position = this.#nextPosition() + } + return this.rows([]) + } + if (query.includes("min(lease_until)")) { + const pending = Array.from(this.items.values()) + .filter((item) => item.lease_until !== null) + .map((item) => item.lease_until!) + return this.rows([{ lease_until: pending.length === 0 ? null : Math.min(...pending) }]) + } + throw new Error(`Unexpected SQL: ${query}`) + } + + private rows(rows: Array>) { + return { toArray: () => rows } + } + + get sql(): SqlStorage { + return this as unknown as SqlStorage + } +} + +class FakeAlarm { + current: number | null = null + + getAlarm() { + return Promise.resolve(this.current) + } + setAlarm(scheduledTime: number) { + this.current = scheduledTime + return Promise.resolve() + } + + get alarm(): EntityAlarm { + return this as unknown as EntityAlarm + } +} + +class FakeQueueNamespace { + readonly stores = new Map() + readonly runtimes = new Map() + now = 0 + + store(name: string) { + let store = this.stores.get(name) + if (store === undefined) { + store = { sql: new FakeSql(), alarm: new FakeAlarm() } + this.stores.set(name, store) + } + return store + } + + getByName(name: string): QueueRuntime { + let runtime = this.runtimes.get(name) + if (runtime === undefined) { + const store = this.store(name) + // Mirrors the ClusterDurableQueue constructor: build the runtime and + // re-arm the single alarm from the earliest pending lease expiry. + runtime = makeQueueRuntime({ + sql: store.sql.sql, + alarm: store.alarm.alarm, + now: () => this.now + }) + const expiry = earliestLeaseExpiry(store.sql.sql) + if (expiry !== undefined) { + void Effect.runPromise(armAlarm(store.alarm.alarm, expiry)) + } + this.runtimes.set(name, runtime) + } + return runtime + } + + /** Drops every in-memory runtime, as a crashed or hibernated isolate would. */ + crash() { + this.runtimes.clear() + } + + fireDueAlarms(): Promise { + const fired: Array> = [] + for (const [name, store] of this.stores) { + if (store.alarm.current !== null && store.alarm.current <= this.now) { + store.alarm.current = null + fired.push(this.getByName(name).runAlarm()) + } + } + return Promise.all(fired).then(() => undefined) + } + + get layer() { + return CloudflarePersistedQueue.layer({ queueNamespace: this as never }) + } +} + +const settle = () => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 1))) + +const objectName = (queueName: string) => encodeName("PersistedQueue", queueName) + +describe("CloudflarePersistedQueue", () => { + it.effect("offers and takes items in order, deduplicating by id", () => { + const namespace = new FakeQueueNamespace() + return Effect.gen(function*() { + const queue = yield* PersistedQueue.make({ name: "orders", schema: Schema.String }) + yield* queue.offer("first", { id: "a" }) + yield* queue.offer("second", { id: "b" }) + yield* queue.offer("duplicate", { id: "a" }) + const taken: Array = [] + yield* queue.take((value) => Effect.sync(() => taken.push(value))) + yield* queue.take((value) => Effect.sync(() => taken.push(value))) + assert.deepStrictEqual(taken, ["first", "second"]) + // Completed rows are retained so custom-id dedup survives completion. + const store = namespace.stores.get(objectName("orders"))! + assert.strictEqual(store.sql.items.get("a")!.completed, 1) + assert.strictEqual(store.sql.items.get("b")!.completed, 1) + yield* queue.offer("again", { id: "a" }) + assert.strictEqual(store.sql.items.get("a")!.element, JSON.stringify("first")) + assert.strictEqual(store.sql.items.get("a")!.completed, 1) + }).pipe(Effect.provide(namespace.layer)) + }) + + it.effect("waits for an offer when the queue is empty", () => { + const namespace = new FakeQueueNamespace() + return Effect.gen(function*() { + const queue = yield* PersistedQueue.make({ name: "empty", schema: Schema.String }) + const fiber = yield* Effect.forkChild(queue.take((value) => Effect.succeed(value))) + yield* settle() + yield* queue.offer("wake", { id: "a" }) + assert.strictEqual(yield* Fiber.join(fiber), "wake") + }).pipe(Effect.provide(namespace.layer)) + }) + + it.effect("wakes concurrent waiting takers with distinct items", () => { + const namespace = new FakeQueueNamespace() + return Effect.gen(function*() { + const runtime = namespace.getByName(objectName("bulk")) + const first = runtime.take("taker-1", 10, 500) + const second = runtime.take("taker-2", 10, 500) + yield* settle() + yield* Effect.promise(() => Promise.all([runtime.offer("a", "\"1\""), runtime.offer("b", "\"2\"")])) + const items = yield* Effect.promise(() => Promise.all([first, second])) + assert.deepStrictEqual(items.map((item) => item.id).sort(), ["a", "b"]) + }) + }) + + it.effect("cancelling a waiting take keeps the next offer for live takers", () => { + const namespace = new FakeQueueNamespace() + return Effect.gen(function*() { + const queue = yield* PersistedQueue.make({ name: "cancelled", schema: Schema.String }) + const fiber = yield* Effect.forkChild(queue.take((value) => Effect.succeed(value))) + yield* settle() + yield* Fiber.interrupt(fiber) + yield* queue.offer("job", { id: "a" }) + // The interrupted taker's waiter is gone; the item is immediately + // available instead of leased to a dead taker for the lease period. + assert.strictEqual(yield* queue.take((value) => Effect.succeed(value)), "job") + }).pipe(Effect.provide(namespace.layer)) + }) + + it.effect("cancelling a taker that was already leased an item releases it", () => { + const namespace = new FakeQueueNamespace() + return Effect.gen(function*() { + const runtime = namespace.getByName(objectName("raced")) + const pending = runtime.take("taker-1", 10, 500) + yield* settle() + yield* Effect.promise(() => runtime.offer("a", "\"first\"")) + yield* Effect.promise(() => pending) + const store = namespace.stores.get(objectName("raced"))! + assert.isNotNull(store.sql.items.get("a")!.lease_until) + // The taker's fiber was interrupted after the item was leased but + // before it could register its finalizer; the cancel releases it. + yield* Effect.promise(() => runtime.cancelTake("taker-1")) + assert.isNull(store.sql.items.get("a")!.lease_until) + const replay = yield* Effect.promise(() => runtime.take("taker-2", 10, 500)) + assert.strictEqual(replay.id, "a") + }) + }) + + it.effect("retries a failed handler and counts the attempt", () => { + const namespace = new FakeQueueNamespace() + return Effect.gen(function*() { + const queue = yield* PersistedQueue.make({ name: "retries", schema: Schema.String }) + yield* queue.offer("job", { id: "a" }) + const failed = yield* Effect.flip(queue.take(() => Effect.fail("boom" as const))) + assert.strictEqual(failed, "boom") + const store = namespace.stores.get(objectName("retries"))! + assert.strictEqual(store.sql.items.get("a")!.attempts, 1) + assert.include(store.sql.items.get("a")!.last_failure, "boom") + const attempts = yield* queue.take((_, metadata) => Effect.succeed(metadata.attempts)) + assert.strictEqual(attempts, 1) + assert.strictEqual(store.sql.items.get("a")!.completed, 1) + }).pipe(Effect.provide(namespace.layer)) + }) + + it.effect("requeues a failed item behind later offers", () => { + const namespace = new FakeQueueNamespace() + return Effect.gen(function*() { + const runtime = namespace.getByName(objectName("ordered")) + yield* Effect.promise(() => runtime.offer("a", "\"first\"")) + yield* Effect.promise(() => runtime.offer("b", "\"second\"")) + const item = yield* Effect.promise(() => runtime.take("taker-1", 10, 500)) + assert.strictEqual(item.id, "a") + yield* Effect.promise(() => runtime.fail(item.id, "boom")) + // The retry sorts after the untouched item, so "a" cannot hot-loop. + const next = yield* Effect.promise(() => runtime.take("taker-2", 10, 500)) + assert.strictEqual(next.id, "b") + const retried = yield* Effect.promise(() => runtime.take("taker-3", 10, 500)) + assert.strictEqual(retried.id, "a") + }) + }) + + it.effect("stops delivering an item that exhausted its attempts", () => { + const namespace = new FakeQueueNamespace() + return Effect.gen(function*() { + const runtime = namespace.getByName(objectName("dead")) + yield* Effect.promise(() => runtime.offer("a", "\"poison\"")) + const item = yield* Effect.promise(() => runtime.take("taker-1", 1, 1000)) + yield* Effect.promise(() => runtime.fail(item.id, "boom")) + const outcome = yield* Effect.promise(() => + Promise.race([ + runtime.take("taker-2", 1, 1000).then(() => "delivered"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 20)) + ]) + ) + assert.strictEqual(outcome, "pending") + // The exhausted item stays behind as a dead letter. + const store = namespace.stores.get(objectName("dead"))! + assert.strictEqual(store.sql.items.get("a")!.attempts, 1) + }) + }) + + it.effect("releases an interrupted take without counting an attempt", () => { + const namespace = new FakeQueueNamespace() + return Effect.gen(function*() { + const queue = yield* PersistedQueue.make({ name: "interrupts", schema: Schema.String }) + yield* queue.offer("job", { id: "a" }) + const fiber = yield* Effect.forkChild(queue.take(() => Effect.never)) + yield* settle() + const store = namespace.stores.get(objectName("interrupts"))! + assert.isNotNull(store.sql.items.get("a")!.lease_until) + yield* Fiber.interrupt(fiber) + assert.isNull(store.sql.items.get("a")!.lease_until) + assert.strictEqual(store.sql.items.get("a")!.attempts, 0) + const attempts = yield* queue.take((_, metadata) => Effect.succeed(metadata.attempts)) + assert.strictEqual(attempts, 0) + }).pipe(Effect.provide(namespace.layer)) + }) + + it.effect("redelivers a leased item after a crash once the lease expires", () => { + const namespace = new FakeQueueNamespace() + const name = objectName("jobs") + return Effect.gen(function*() { + namespace.now = 1000 + const runtime = namespace.getByName(name) + yield* Effect.promise(() => runtime.offer("a", "\"first\"")) + const item = yield* Effect.promise(() => runtime.take("taker-1", 10, 500)) + assert.strictEqual(item.id, "a") + const store = namespace.stores.get(name)! + assert.strictEqual(store.alarm.current, 1500) + + // The worker died mid-processing and the object lost its alarm. + namespace.crash() + store.alarm.current = null + namespace.getByName(name) + yield* settle() + assert.strictEqual(store.alarm.current, 1500) + + namespace.now = 2000 + yield* Effect.promise(() => namespace.fireDueAlarms()) + const replay = yield* Effect.promise(() => namespace.getByName(name).take("taker-2", 10, 500)) + assert.strictEqual(replay.id, "a") + assert.strictEqual(replay.element, "\"first\"") + // Losing a lease is not a failed attempt. + assert.strictEqual(replay.attempts, 0) + }) + }) + + it.effect("an extended lease survives the original expiry", () => { + const namespace = new FakeQueueNamespace() + const name = objectName("extended") + return Effect.gen(function*() { + const runtime = namespace.getByName(name) + yield* Effect.promise(() => runtime.offer("a", "\"slow\"")) + const item = yield* Effect.promise(() => runtime.take("taker-1", 10, 500)) + const store = namespace.stores.get(name)! + assert.strictEqual(store.alarm.current, 500) + namespace.now = 400 + yield* Effect.promise(() => runtime.extend(item.id, 500)) + assert.strictEqual(store.sql.items.get("a")!.lease_until, 900) + + // The stale alarm fires at the original expiry, finds the lease still + // live, and re-arms the watchdog at the extended expiry. + namespace.now = 500 + yield* Effect.promise(() => namespace.fireDueAlarms()) + assert.isNotNull(store.sql.items.get("a")!.lease_until) + assert.strictEqual(store.alarm.current, 900) + + namespace.now = 1000 + yield* Effect.promise(() => namespace.fireDueAlarms()) + const replay = yield* Effect.promise(() => runtime.take("taker-2", 10, 500)) + assert.strictEqual(replay.id, "a") + }) + }) + + it.effect("wakes a waiting taker and re-arms the alarm for the next lease", () => { + const namespace = new FakeQueueNamespace() + const name = objectName("watchdog") + return Effect.gen(function*() { + namespace.now = 0 + const runtime = namespace.getByName(name) + yield* Effect.promise(() => runtime.offer("a", "\"first\"")) + yield* Effect.promise(() => runtime.offer("b", "\"second\"")) + yield* Effect.promise(() => runtime.take("taker-1", 10, 500)) + yield* Effect.promise(() => runtime.take("taker-2", 10, 5000)) + const store = namespace.stores.get(name)! + assert.strictEqual(store.alarm.current, 500) + + let woken: string | undefined + const waiting = runtime.take("taker-3", 10, 500).then((item) => { + woken = item.id + }) + yield* settle() + assert.isUndefined(woken) + + namespace.now = 600 + yield* Effect.promise(() => namespace.fireDueAlarms()) + yield* Effect.promise(() => waiting) + assert.strictEqual(woken, "a") + // Re-armed at the earliest remaining lease: the waiter's fresh lease. + assert.strictEqual(store.alarm.current, 1100) + }) + }) + + it.effect("routes each queue name to its own object", () => { + const namespace = new FakeQueueNamespace() + return Effect.gen(function*() { + const left = yield* PersistedQueue.make({ name: "left", schema: Schema.String }) + const right = yield* PersistedQueue.make({ name: "right", schema: Schema.String }) + yield* left.offer("from-left", { id: "a" }) + yield* right.offer("from-right", { id: "a" }) + assert.deepStrictEqual( + Array.from(namespace.stores.keys()).sort(), + [objectName("left"), objectName("right")].sort() + ) + assert.strictEqual(yield* left.take((value) => Effect.succeed(value)), "from-left") + assert.strictEqual(yield* right.take((value) => Effect.succeed(value)), "from-right") + // The same name resolves back to the same object. + const again = yield* PersistedQueue.make({ name: "left", schema: Schema.String }) + yield* again.offer("more", { id: "b" }) + assert.strictEqual(namespace.stores.size, 2) + assert.strictEqual(yield* left.take((value) => Effect.succeed(value)), "more") + }).pipe(Effect.provide(namespace.layer)) + }) +}) + +// The store-agnostic PersistedQueueStore contract suite the memory, Redis, +// and SQL stores also run against. +const contractNamespace = new FakeQueueNamespace() +PersistedQueueTest.suite( + "cloudflare", + Layer.succeed(PersistedQueue.PersistedQueueStore)( + CloudflarePersistedQueue.make({ queueNamespace: contractNamespace as never }) + ) +) diff --git a/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts new file mode 100644 index 00000000000..d5a01af2907 --- /dev/null +++ b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts @@ -0,0 +1,439 @@ +import type { SqlStorage } from "@cloudflare/workers-types" +import * as CloudflareWorkflowEngine from "@effect/platform-cloudflare/CloudflareWorkflowEngine" +import { encodeName } from "@effect/platform-cloudflare/internal/clusterName" +import type { EntityAlarm } from "@effect/platform-cloudflare/internal/entityStorage" +import { makeWorkflowRuntime, type WorkflowRuntime } from "@effect/platform-cloudflare/internal/workflowRuntime" +import { loadExecution } from "@effect/platform-cloudflare/internal/workflowStorage" +import { assert, describe, it } from "@effect/vitest" +import { Effect, Exit, Layer, Option, Schema } from "effect" +import { RpcTest } from "effect/unstable/rpc" +import { + Activity, + DurableClock, + DurableDeferred, + Workflow, + WorkflowEngine, + WorkflowProxy, + WorkflowProxyServer +} from "effect/unstable/workflow" + +class FakeSql { + execution: Record | undefined + readonly activities = new Map() + readonly deferreds = new Map() + readonly clocks = new Map() + + exec(query: string, ...bindings: Array) { + const rows = this.run(query, bindings) + return { toArray: () => rows } + } + + private run(query: string, bindings: Array): Array> { + if (query.startsWith("CREATE TABLE")) return [] + if (query.includes("INSERT OR IGNORE INTO workflow_execution")) { + this.execution ??= { + workflow_name: bindings[0], + execution_id: bindings[1], + payload: bindings[2], + parent_name: bindings[3], + parent_execution_id: bindings[4], + result: null, + resume_pending: 0 + } + return [] + } + if (query.includes("SET parent_name")) { + if (this.execution !== undefined && this.execution.parent_name === null) { + this.execution.parent_name = bindings[0] + this.execution.parent_execution_id = bindings[1] + } + return [] + } + if (query.includes("SET resume_pending")) { + if (this.execution !== undefined) this.execution.resume_pending = bindings[0] + return [] + } + if (query.includes("UPDATE workflow_execution")) { + if (this.execution !== undefined) this.execution.result = bindings[0] + return [] + } + if (query.includes("FROM workflow_execution")) { + return this.execution === undefined ? [] : [this.execution] + } + if (query.includes("INSERT OR IGNORE INTO workflow_activities")) { + if (!this.activities.has(String(bindings[0]))) { + this.activities.set(String(bindings[0]), String(bindings[1])) + } + return [] + } + if (query.includes("FROM workflow_activities")) { + const exit = this.activities.get(String(bindings[0])) + return exit === undefined ? [] : [{ exit }] + } + if (query.includes("INSERT INTO workflow_deferreds")) { + if (!this.deferreds.has(String(bindings[0]))) { + this.deferreds.set(String(bindings[0]), String(bindings[1])) + } + return [] + } + if (query.includes("FROM workflow_deferreds")) { + const exit = this.deferreds.get(String(bindings[0])) + return exit === undefined ? [] : [{ exit }] + } + if (query.includes("INSERT OR IGNORE INTO workflow_clocks")) { + if (!this.clocks.has(String(bindings[0]))) { + this.clocks.set(String(bindings[0]), { + deferredName: String(bindings[1]), + wakeUp: Number(bindings[2]), + fired: false + }) + } + return [] + } + if (query.includes("min(wake_up)")) { + const pending = Array.from(this.clocks.values()).filter((clock) => !clock.fired).map((clock) => clock.wakeUp) + return [{ wake_up: pending.length === 0 ? null : Math.min(...pending) }] + } + if (query.includes("wake_up <= ?")) { + return Array.from(this.clocks) + .filter(([, clock]) => !clock.fired && clock.wakeUp <= Number(bindings[0])) + .map(([name, clock]) => ({ name, deferred_name: clock.deferredName })) + } + if (query.includes("SET fired = 1")) { + const clock = this.clocks.get(String(bindings[0])) + if (clock !== undefined) clock.fired = true + return [] + } + throw new Error(`Unexpected SQL: ${query}`) + } + + get sql(): SqlStorage { + return this as unknown as SqlStorage + } +} + +class FakeAlarm { + current: number | null = null + + getAlarm() { + return Promise.resolve(this.current) + } + setAlarm(scheduledTime: number) { + this.current = scheduledTime + return Promise.resolve() + } + + get alarm(): EntityAlarm { + return this as unknown as EntityAlarm + } +} + +class FakeWorkflowNamespace { + readonly stores = new Map() + readonly runtimes = new Map() + now = 0 + + store(name: string) { + let store = this.stores.get(name) + if (store === undefined) { + store = { sql: new FakeSql(), alarm: new FakeAlarm() } + this.stores.set(name, store) + } + return store + } + + getByName(name: string): WorkflowRuntime { + let runtime = this.runtimes.get(name) + if (runtime === undefined) { + const store = this.store(name) + runtime = makeWorkflowRuntime({ + name, + sql: store.sql.sql, + alarm: store.alarm.alarm, + now: () => this.now, + waitUntil: (promise) => { + void promise + }, + getStub: (stubName) => this.getByName(stubName) + }) + this.runtimes.set(name, runtime) + } + return runtime + } + + /** Drops every in-memory runtime, as a crashed or hibernated isolate would. */ + crash() { + this.runtimes.clear() + } + + fireDueAlarms(): Promise { + const fired: Array> = [] + for (const [name, store] of this.stores) { + if (store.alarm.current !== null && store.alarm.current <= this.now) { + store.alarm.current = null + fired.push(this.getByName(name).runAlarm()) + } + } + return Promise.all(fired).then(() => undefined) + } + + get layer() { + return CloudflareWorkflowEngine.layer({ workflowNamespace: this as never }) + } +} + +const pollUntil = Effect.fnUntraced(function*< + W extends Workflow.Workflow +>(workflow: W, executionId: string, tag: "Complete" | "Suspended") { + while (true) { + const result = yield* workflow.poll(executionId) + if (Option.isSome(result) && result.value._tag === tag) return result.value + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 1))) + } +}) + +describe("CloudflareWorkflowEngine", () => { + it.effect("routes generated workflow proxy handlers through the encoded Durable Object name", () => { + const namespace = new FakeWorkflowNamespace() + const Proxied = Workflow.make("Proxied", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id + }) + const workflows = [Proxied] as const + const proxy = WorkflowProxy.toRpcGroup(workflows) + const workflowLayer = Proxied.toLayer(({ id }) => Effect.succeed(`done-${id}`)).pipe( + Layer.provideMerge(namespace.layer) + ) + + return Effect.gen(function*() { + const client = yield* RpcTest.makeClient(proxy) + const result = yield* client.Proxied({ id: "proxy:id" }) + const executionId = yield* Proxied.executionId({ id: "proxy:id" }) + + assert.strictEqual(result, "done-proxy:id") + assert.deepStrictEqual(Array.from(namespace.stores.keys()), [encodeName("Proxied", executionId)]) + }).pipe( + Effect.provide(WorkflowProxyServer.layerRpcHandlers(workflows)), + Effect.provide(workflowLayer) + ) + }) + + it.effect("persists a suspended execution and resumes it after an isolate loss", () => { + const namespace = new FakeWorkflowNamespace() + const Gate = DurableDeferred.make("Resumable/Gate", { success: Schema.String }) + let runs = 0 + let activityRuns = 0 + const Resumable = Workflow.make("Resumable", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id + }) + const layer = Resumable.toLayer(Effect.fnUntraced(function*({ id }) { + runs++ + const prefix = yield* Activity.make({ + name: "prefix", + success: Schema.String, + execute: Effect.sync(() => { + activityRuns++ + return "hello" + }) + }) + const value = yield* DurableDeferred.await(Gate) + return `${prefix}-${value}-${id}` + })).pipe(Layer.provideMerge(namespace.layer)) + + return Effect.gen(function*() { + const executionId = yield* Resumable.executionId({ id: "one" }) + yield* Resumable.execute({ id: "one" }, { discard: true }) + yield* pollUntil(Resumable, executionId, "Suspended") + assert.strictEqual(runs, 1) + assert.strictEqual(activityRuns, 1) + + namespace.crash() + const token = DurableDeferred.tokenFromExecutionId(Gate, { workflow: Resumable, executionId }) + yield* DurableDeferred.succeed(Gate, { token, value: "world" }) + const result = yield* pollUntil(Resumable, executionId, "Complete") + assert(result._tag === "Complete" && Exit.isSuccess(result.exit)) + assert.strictEqual(result.exit.value, "hello-world-one") + // The replay re-ran the workflow body but replayed the stored activity. + assert.strictEqual(runs, 2) + assert.strictEqual(activityRuns, 1) + + assert.strictEqual(yield* Resumable.execute({ id: "one" }), "hello-world-one") + assert.strictEqual(runs, 2) + }).pipe(Effect.provide(layer)) + }) + + it.effect("re-runs an activity when the object is lost mid-activity", () => { + const namespace = new FakeWorkflowNamespace() + let invocations = 0 + let release!: () => void + const started = new Promise((resolve) => { + release = resolve + }) + const Crashing = Workflow.make("Crashing", { + payload: { id: Schema.String }, + success: Schema.Number, + idempotencyKey: ({ id }) => id + }) + const layer = Crashing.toLayer(() => + Activity.make({ + name: "compute", + success: Schema.Number, + execute: Effect.suspend(() => { + invocations++ + if (invocations === 1) { + release() + return Effect.promise(() => new Promise(() => {})) + } + return Effect.succeed(42) + }) + }) + ).pipe(Layer.provideMerge(namespace.layer)) + + return Effect.gen(function*() { + const executionId = yield* Crashing.executionId({ id: "one" }) + yield* Crashing.execute({ id: "one" }, { discard: true }) + yield* Effect.promise(() => started) + + namespace.crash() + yield* Crashing.resume(executionId) + const result = yield* pollUntil(Crashing, executionId, "Complete") + assert(result._tag === "Complete" && Exit.isSuccess(result.exit)) + assert.strictEqual(result.exit.value, 42) + assert.strictEqual(invocations, 2) + + // The result is keyed `${name}/${attempt}` in the execution object. + const store = namespace.stores.get(encodeName("Crashing", executionId))! + assert.deepStrictEqual(Array.from(store.sql.activities.keys()), ["compute/1"]) + }).pipe(Effect.provide(layer)) + }) + + it.effect("schedules every DurableClock durably, including sub-minute sleeps", () => { + const namespace = new FakeWorkflowNamespace() + const Sleeper = Workflow.make("Sleeper", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id + }) + const layer = Sleeper.toLayer(() => + Effect.as(DurableClock.sleep({ name: "short", duration: "30 seconds" }), "woke") + ).pipe(Layer.provideMerge(namespace.layer)) + + return Effect.gen(function*() { + const executionId = yield* Sleeper.executionId({ id: "one" }) + yield* Sleeper.execute({ id: "one" }, { discard: true }) + yield* pollUntil(Sleeper, executionId, "Suspended") + + const store = namespace.stores.get(encodeName("Sleeper", executionId))! + assert.deepStrictEqual(Array.from(store.sql.clocks), [ + ["short", { deferredName: "DurableClock/short", wakeUp: 30_000, fired: false }] + ]) + assert.strictEqual(store.alarm.current, 30_000) + // An alarm wake has no `id.name`; the stored execution recovers it. + const stored = loadExecution(store.sql.sql) + assert.strictEqual(stored?.workflowName, "Sleeper") + assert.strictEqual(stored?.executionId, executionId) + + namespace.now = 30_000 + yield* Effect.promise(() => namespace.fireDueAlarms()) + const result = yield* pollUntil(Sleeper, executionId, "Complete") + assert(result._tag === "Complete" && Exit.isSuccess(result.exit)) + assert.strictEqual(result.exit.value, "woke") + assert.strictEqual(store.sql.clocks.get("short")?.fired, true) + }).pipe(Effect.provide(layer)) + }) + + it.effect("resumes a suspended parent when a child workflow completes", () => { + const namespace = new FakeWorkflowNamespace() + const Gate = DurableDeferred.make("NestedChild/Gate", { success: Schema.String }) + const Child = Workflow.make("NestedChild", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id + }) + const Parent = Workflow.make("NestedParent", { + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id + }) + const layer = Layer.merge( + Child.toLayer(() => DurableDeferred.await(Gate)), + Parent.toLayer(Effect.fnUntraced(function*({ id }) { + const value = yield* Child.execute({ id }) + return `parent-${value}` + })) + ).pipe(Layer.provideMerge(namespace.layer)) + + return Effect.gen(function*() { + const parentExecutionId = yield* Parent.executionId({ id: "one" }) + const childExecutionId = yield* Child.executionId({ id: "one" }) + yield* Parent.execute({ id: "one" }, { discard: true }) + yield* pollUntil(Parent, parentExecutionId, "Suspended") + yield* pollUntil(Child, childExecutionId, "Suspended") + + const token = DurableDeferred.tokenFromExecutionId(Gate, { workflow: Child, executionId: childExecutionId }) + yield* DurableDeferred.succeed(Gate, { token, value: "child" }) + const result = yield* pollUntil(Parent, parentExecutionId, "Complete") + assert(result._tag === "Complete" && Exit.isSuccess(result.exit)) + assert.strictEqual(result.exit.value, "parent-child") + }).pipe(Effect.provide(layer)) + }) + + it.effect("persists an interrupted completion when hard-interrupted mid-activity", () => { + const namespace = new FakeWorkflowNamespace() + let release!: () => void + const started = new Promise((resolve) => { + release = resolve + }) + const Hard = Workflow.make("HardInterrupt", { + payload: { id: Schema.String }, + idempotencyKey: ({ id }) => id + }) + const layer = Hard.toLayer(() => + Activity.make({ + name: "hang", + execute: Effect.andThen( + Effect.sync(() => release()), + Effect.promise(() => new Promise(() => {})) + ) + }) + ).pipe(Layer.provideMerge(namespace.layer)) + + return Effect.gen(function*() { + const engine = yield* WorkflowEngine.WorkflowEngine + const executionId = yield* Hard.executionId({ id: "one" }) + yield* Hard.execute({ id: "one" }, { discard: true }) + yield* Effect.promise(() => started) + + yield* engine.interruptUnsafe(Hard, executionId) + const result = yield* pollUntil(Hard, executionId, "Complete") + assert(result._tag === "Complete" && Exit.isFailure(result.exit)) + assert.isTrue(Exit.hasInterrupts(result.exit)) + }).pipe(Effect.provide(layer)) + }) + + it.effect("interrupts a suspended execution", () => { + const namespace = new FakeWorkflowNamespace() + const Gate = DurableDeferred.make("Interruptible/Gate") + const Interruptible = Workflow.make("Interruptible", { + payload: { id: Schema.String }, + idempotencyKey: ({ id }) => id + }) + const layer = Interruptible.toLayer(() => DurableDeferred.await(Gate)).pipe( + Layer.provideMerge(namespace.layer) + ) + + return Effect.gen(function*() { + const executionId = yield* Interruptible.executionId({ id: "one" }) + yield* Interruptible.execute({ id: "one" }, { discard: true }) + yield* pollUntil(Interruptible, executionId, "Suspended") + + yield* Interruptible.interrupt(executionId) + const result = yield* pollUntil(Interruptible, executionId, "Complete") + assert(result._tag === "Complete" && Exit.isFailure(result.exit)) + assert.isTrue(Exit.hasInterrupts(result.exit)) + }).pipe(Effect.provide(layer)) + }) +}) diff --git a/packages/platform/cloudflare/test/ClusterCron.test.ts b/packages/platform/cloudflare/test/ClusterCron.test.ts new file mode 100644 index 00000000000..02d38e9855c --- /dev/null +++ b/packages/platform/cloudflare/test/ClusterCron.test.ts @@ -0,0 +1,239 @@ +import type { DurableObjectStorage, SqlStorage } from "@cloudflare/workers-types" +import * as CloudflareCluster from "@effect/platform-cloudflare/CloudflareCluster" +import { completeTell, loadDue, persistRequest } from "@effect/platform-cloudflare/internal/entityMailbox" +import { getEntityRegistration } from "@effect/platform-cloudflare/internal/entityRegistry" +import { makeEntityRuntime } from "@effect/platform-cloudflare/internal/entityRuntime" +import { armAlarm, earliestDeliverAt } from "@effect/platform-cloudflare/internal/entityStorage" +import { decodeRequest } from "@effect/platform-cloudflare/internal/entityWire" +import { getSingletonRegistration } from "@effect/platform-cloudflare/internal/singletonRegistry" +import { assert, describe, it } from "@effect/vitest" +import { Cron, Effect, Layer, Option } from "effect" +import { TestClock } from "effect/testing" +import { ClusterCron } from "effect/unstable/cluster" + +interface MessageRow { + readonly requestId: string + readonly primaryKey: string | null + readonly envelope: string + readonly discard: boolean + readonly deliverAt: number | null + processed: boolean +} + +class FakeSql { + readonly messages = new Map() + + exec(query: string, ...bindings: Array) { + if (query.includes("FROM cluster_messages m") && query.includes("m.request_id = ?")) { + const requestId = String(bindings[0]) + const primaryKey = bindings[1] + const row = this.messages.get(requestId) ?? Array.from(this.messages.values()).find( + (row) => primaryKey !== null && row.primaryKey === primaryKey + ) + return this.rows( + row === undefined ? [] : [{ + request_id: row.requestId, + discard: row.discard ? 1 : 0, + processed: row.processed ? 1 : 0, + reply_to: null, + last_reply: null + }] + ) + } + if (query.includes("COUNT(*) AS count")) { + return this.rows([{ + count: Array.from(this.messages.values()).filter((row) => !row.processed).length + }]) + } + if (query.includes("COUNT(DISTINCT")) { + return this.rows([{ count: 0 }]) + } + if (query.includes("INSERT INTO cluster_messages")) { + const [requestId, primaryKey, envelope, discard, deliverAt] = bindings + this.messages.set(String(requestId), { + requestId: String(requestId), + primaryKey: primaryKey === null ? null : String(primaryKey), + envelope: String(envelope), + discard: Number(discard) === 1, + deliverAt: deliverAt === null ? null : Number(deliverAt), + processed: false + }) + return this.rows([]) + } + if (query.includes("m.deliver_at IS NOT NULL") && query.includes("m.deliver_at <= ?")) { + const now = Number(bindings[0]) + return this.rows( + Array.from(this.messages.values()) + .filter((row) => !row.processed && row.deliverAt !== null && row.deliverAt <= now) + .map((row) => ({ + envelope: row.envelope, + discard: row.discard ? 1 : 0, + deliver_at: row.deliverAt, + reply_to: null, + last_reply: null + })) + ) + } + if (query.includes("SET processed = 1") && query.includes("last_reply_id = NULL")) { + const row = this.messages.get(String(bindings[0])) + if (row !== undefined) row.processed = true + return this.rows([]) + } + if (query.includes("SELECT min(deliver_at)")) { + const pending = Array.from(this.messages.values()) + .filter((row) => !row.processed && row.deliverAt !== null) + .map((row) => row.deliverAt!) + return this.rows([{ deliver_at: pending.length === 0 ? null : Math.min(...pending) }]) + } + throw new Error(`Unexpected SQL: ${query}`) + } + + private rows(rows: Array>) { + return { toArray: () => rows } + } + + get sql(): SqlStorage { + return this as unknown as SqlStorage + } +} + +class FakeAlarm { + current: number | null = null + + getAlarm() { + return Promise.resolve(this.current) + } + + setAlarm(scheduledTime: number) { + this.current = scheduledTime + return Promise.resolve() + } + + deleteAlarm() { + this.current = null + return Promise.resolve() + } + + get storage(): Pick { + return this + } +} + +class FakeCronDestination { + readonly sql = new FakeSql() + readonly alarm = new FakeAlarm() + #nextReplyId = 0 + + constructor(readonly name: string) {} + + invoke( + envelope: string, + discard: boolean, + delivery?: { readonly deliverAt?: number; readonly primaryKey?: string | null } + ) { + const requestId = String(JSON.parse(envelope).requestId) + const persist = persistRequest( + this.sql.sql, + envelope, + delivery?.primaryKey ?? null, + discard, + delivery?.deliverAt ?? null + ) + const effect = delivery?.deliverAt === undefined + ? persist + : Effect.andThen(persist, armAlarm(this.alarm.storage, delivery.deliverAt)) + return Effect.runPromise( + Effect.as(effect, { _tag: "Success" as const, requestId, replies: [] as ReadonlyArray }) + ) + } + + acknowledge() { + return Promise.resolve([] as ReadonlyArray) + } + + fire(now: number) { + const { alarm, sql } = this + const nextReplyId = () => `reply-${this.#nextReplyId++}` + return Effect.gen(function*() { + alarm.current = null + for (const row of yield* loadDue(sql.sql, now)) { + const encoded = JSON.parse(row.envelope) + const registration = getEntityRegistration(encoded.address.entityType) + if (registration === undefined) return yield* Effect.die("Missing cron entity registration") + const request = yield* decodeRequest(registration, row.envelope) + const runtime = yield* makeEntityRuntime(registration, request.address, nextReplyId) + yield* runtime.run(request, Option.none(), row.discard, () => Effect.void) + yield* completeTell(sql.sql, String(request.requestId)) + } + const next = earliestDeliverAt(sql.sql) + if (next !== undefined) yield* armAlarm(alarm.storage, next) + }) + } +} + +class FakeEntityNamespace { + readonly destinations = new Map() + + getByName(name: string) { + let destination = this.destinations.get(name) + if (destination === undefined) { + destination = new FakeCronDestination(name) + this.destinations.set(name, destination) + } + return destination + } +} + +class FakeSingletonNamespace { + readonly names: Array = [] + + getByName(name: string) { + this.names.push(name) + return {} + } +} + +describe("ClusterCron", () => { + it.effect("seeds through Singleton and runs each fire on a delayed destination entity", () => + Effect.gen(function*() { + yield* TestClock.setTime(0) + const entityNamespace = new FakeEntityNamespace() + const singletonNamespace = new FakeSingletonNamespace() + let runs = 0 + const cron = ClusterCron.make({ + name: "hourly", + cron: Cron.parseUnsafe("* * * * * *", "UTC"), + execute: Effect.sync(() => runs++) + }) + const cluster = CloudflareCluster.layer({ + entities: [], + entityNamespace: entityNamespace as any, + workflowNamespace: { getByName: () => ({}) } as any, + queueNamespace: { getByName: () => ({}) } as any, + singletonNamespace: singletonNamespace as any + }) + yield* Layer.build(cron.pipe(Layer.provide(cluster))) + + const seed = getSingletonRegistration("ClusterCron/hourly") + assert.isDefined(seed) + yield* seed!.run.pipe(Effect.provideContext(seed!.context)) + + assert.deepStrictEqual(singletonNamespace.names, ["Singleton/ClusterCron/hourly"]) + const firstName = "18:ClusterCron/hourly1970-01-01T00:00:01.000Z" + const first = entityNamespace.destinations.get(firstName) + assert.isDefined(first) + const [persisted] = Array.from(first!.sql.messages.values()) + assert.strictEqual(persisted.deliverAt, 1_000) + assert.strictEqual(persisted.primaryKey, "ClusterCron/hourly/1970-01-01T00:00:01.000Z/run/") + assert.strictEqual(first!.alarm.current, 1_000) + assert.strictEqual(runs, 0) + + yield* TestClock.setTime(1_000) + yield* first!.fire(1_000) + + assert.strictEqual(runs, 1) + assert.isTrue(persisted.processed) + assert.strictEqual(first!.alarm.current, null) + assert.isTrue(entityNamespace.destinations.has("18:ClusterCron/hourly1970-01-01T00:00:02.000Z")) + })) +}) diff --git a/packages/platform/cloudflare/test/ClusterName.test.ts b/packages/platform/cloudflare/test/ClusterName.test.ts new file mode 100644 index 00000000000..3166fdd3566 --- /dev/null +++ b/packages/platform/cloudflare/test/ClusterName.test.ts @@ -0,0 +1,52 @@ +import * as CloudflareCluster from "@effect/platform-cloudflare/CloudflareCluster" +import { assert, describe, it } from "@effect/vitest" + +describe("ClusterName", () => { + describe("encodeName", () => { + it("length-prefixes the entity type", () => { + assert.strictEqual(CloudflareCluster.encodeName("User", "42"), "4:User42") + }) + + it("keeps separators in the id unambiguous", () => { + assert.strictEqual(CloudflareCluster.encodeName("Counter", "a:b"), "7:Countera:b") + }) + }) + + describe("decodeName", () => { + it("round-trips encoded names", () => { + const cases: ReadonlyArray = [ + ["User", "42"], + ["Counter", "a:b"], + ["A:B", "X"], + ["User", ""], + ["Workflow123", "9:already-prefixed"] + ] + for (const [type, id] of cases) { + assert.deepStrictEqual( + CloudflareCluster.decodeName(CloudflareCluster.encodeName(type, id)), + { type, id } + ) + } + }) + + it("rejects names without a length prefix", () => { + assert.isUndefined(CloudflareCluster.decodeName("")) + assert.isUndefined(CloudflareCluster.decodeName("User42")) + assert.isUndefined(CloudflareCluster.decodeName(":User")) + assert.isUndefined(CloudflareCluster.decodeName("4User42")) + }) + + it("rejects names whose declared length exceeds the payload", () => { + assert.isUndefined(CloudflareCluster.decodeName("10:User42")) + assert.isUndefined(CloudflareCluster.decodeName("5:User")) + }) + + it("rejects non-canonical length prefixes", () => { + assert.isUndefined(CloudflareCluster.decodeName("04:User42")) + }) + + it("rejects empty entity types", () => { + assert.isUndefined(CloudflareCluster.decodeName("0:whatever")) + }) + }) +}) diff --git a/packages/platform/cloudflare/test/EntityKeepAlive.test.ts b/packages/platform/cloudflare/test/EntityKeepAlive.test.ts new file mode 100644 index 00000000000..3c7d2996a37 --- /dev/null +++ b/packages/platform/cloudflare/test/EntityKeepAlive.test.ts @@ -0,0 +1,76 @@ +import { makeEntityKeepAlive } from "@effect/platform-cloudflare/internal/entityKeepAlive" +import { assert, describe, it } from "@effect/vitest" +import { Deferred, Effect, Fiber } from "effect" +import { TestClock } from "effect/testing" +import { Entity, EntityResource } from "effect/unstable/cluster" + +const makeFixture = Effect.gen(function*() { + const started = yield* Deferred.make() + let keepAlive!: ReturnType + keepAlive = makeEntityKeepAlive(() => { + Deferred.doneUnsafe(started, Effect.void) + return Effect.runPromise(keepAlive.await) + }) + return { keepAlive, started } +}) + +const provideKeepAlive = ( + effect: Effect.Effect, + keepAlive: ReturnType +) => Effect.provideService(effect, Entity.KeepAliveHandler, keepAlive.update) as Effect.Effect + +describe("EntityKeepAlive", () => { + it.effect("keeps the pin until the last holder releases", () => + Effect.gen(function*() { + const { keepAlive, started } = yield* makeFixture + yield* keepAlive.update(true) + yield* Deferred.await(started) + yield* keepAlive.update(true) + + assert.strictEqual(keepAlive.holderCount(), 2) + const waiter = yield* Effect.forkChild(keepAlive.await) + yield* keepAlive.update(false) + assert.strictEqual(keepAlive.holderCount(), 1) + + yield* keepAlive.update(false) + yield* Fiber.join(waiter) + assert.strictEqual(keepAlive.holderCount(), 0) + })) + + it.effect("releases an EntityResource pin after its idle TTL", () => + Effect.gen(function*() { + const { keepAlive, started } = yield* makeFixture + const resource = yield* provideKeepAlive( + EntityResource.make({ + acquire: Effect.succeed("resource"), + idleTimeToLive: "1 second" + }), + keepAlive + ) + + yield* Effect.scoped(resource.get) + yield* Deferred.await(started) + assert.strictEqual(keepAlive.holderCount(), 1) + + yield* TestClock.adjust("999 millis") + assert.strictEqual(keepAlive.holderCount(), 1) + yield* TestClock.adjust("1 millis") + assert.strictEqual(keepAlive.holderCount(), 0) + }).pipe(Effect.scoped)) + + it.effect("releases an EntityResource pin when it is closed", () => + Effect.gen(function*() { + const { keepAlive, started } = yield* makeFixture + const resource = yield* provideKeepAlive( + EntityResource.make({ acquire: Effect.succeed("resource") }), + keepAlive + ) + + yield* Effect.scoped(resource.get) + yield* Deferred.await(started) + assert.strictEqual(keepAlive.holderCount(), 1) + + yield* resource.close + assert.strictEqual(keepAlive.holderCount(), 0) + }).pipe(Effect.scoped)) +}) diff --git a/packages/platform/cloudflare/test/EntityMailbox.test.ts b/packages/platform/cloudflare/test/EntityMailbox.test.ts new file mode 100644 index 00000000000..e9fbbc091a7 --- /dev/null +++ b/packages/platform/cloudflare/test/EntityMailbox.test.ts @@ -0,0 +1,421 @@ +import type { SqlStorage } from "@cloudflare/workers-types" +import { + ackChunk, + clearReplies, + completeTell, + EncodedMessageTooLargeError, + loadDue, + loadNextReply, + loadUnprocessed, + MailboxFullError, + maximumEncodedSize, + persistRequest, + saveReply +} from "@effect/platform-cloudflare/internal/entityMailbox" +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" + +interface MessageRow { + readonly request_id: string + readonly message_id: string | null + readonly envelope: string + readonly discard: number + processed: number + last_reply_id: string | null + deliver_at?: number | null + reply_to?: string | null +} + +class FakeSql { + readonly messages = new Map() + readonly replies = new Map() + readonly acked = new Set() + + exec(query: string, ...bindings: Array) { + if (query.includes("COUNT(*) AS count")) { + return this.rows([{ + count: Array.from(this.messages.values()).filter((row) => row.processed === 0).length + }]) + } + if (query.includes("COUNT(DISTINCT")) { + return this.rows([{ + count: Array.from(this.messages.values()).filter((row) => + row.processed === 1 && Array.from(this.replies.entries()).some(([id, text]) => { + const reply = JSON.parse(text) + return reply.requestId === row.request_id && reply._tag === "Chunk" && !this.acked.has(id) + }) + ).length + }]) + } + if (query.includes("INSERT INTO cluster_messages")) { + const [requestId, messageId, envelope, discard, deliverAt, replyTo] = bindings as [ + string, + string | null, + string, + number, + number | null, + string | null + ] + this.messages.set(requestId, { + request_id: requestId, + message_id: messageId, + envelope, + discard, + processed: 0, + last_reply_id: null, + deliver_at: deliverAt, + reply_to: replyTo + }) + return this.rows([]) + } + if (query.includes("INTO cluster_replies")) { + const [replyId, requestId, reply] = bindings as [string, string, string] + this.replies.set(replyId, reply) + const row = this.messages.get(requestId) + if (row !== undefined) { + row.last_reply_id = replyId + if (JSON.parse(reply)._tag === "WithExit") row.processed = 1 + } + return this.rows([]) + } + if (query.includes("SET processed = 1") && query.includes("last_reply_id = NULL")) { + const row = this.messages.get(String(bindings[0])) + if (row !== undefined) { + row.processed = 1 + row.last_reply_id = null + } + return this.rows([]) + } + if (query.includes("UPDATE cluster_messages") && query.includes("last_reply_id")) { + return this.rows([]) + } + if (query.includes("SET reply_to = ?")) { + const row = this.messages.get(String(bindings[1])) + if (row !== undefined) row.reply_to = String(bindings[0]) + return this.rows([]) + } + if (query.includes("WHERE m.processed = 0")) { + const now = Number(bindings[0]) + const dueOnly = query.includes("m.deliver_at IS NOT NULL") + return this.rows( + Array.from(this.messages.values()) + .filter((row) => + row.processed === 0 && + (!dueOnly || row.deliver_at !== null && row.deliver_at !== undefined) && + (row.deliver_at === null || row.deliver_at === undefined || row.deliver_at <= now) + ) + .map((row) => ({ + request_id: row.request_id, + envelope: row.envelope, + last_reply: row.last_reply_id === null ? null : this.replies.get(row.last_reply_id), + discard: row.discard, + deliver_at: row.deliver_at, + reply_to: row.reply_to + })) + ) + } + if (query.includes("UPDATE cluster_replies") && query.includes("acked = 1")) { + this.acked.add(String(bindings[1])) + return this.rows([]) + } + if (query.includes("FROM cluster_replies") && query.includes("kind = 'Chunk'")) { + const request = String(bindings[0]) + const reply = Array.from(this.replies.entries()) + .map(([id, text]) => ({ id, value: JSON.parse(text) })) + .filter(({ id, value }) => value.requestId === request && value._tag === "Chunk" && !this.acked.has(id)) + .sort((left, right) => left.value.sequence - right.value.sequence)[0] + return this.rows(reply === undefined ? [] : [{ reply: this.replies.get(reply.id), kind: "Chunk" }]) + } + if (query.includes("FROM cluster_replies") && query.includes("kind = 'WithExit'")) { + const request = String(bindings[0]) + const reply = Array.from(this.replies.values()).find((text) => { + const value = JSON.parse(text) + return value.requestId === request && value._tag === "WithExit" + }) + return this.rows(reply === undefined ? [] : [{ reply, kind: "WithExit" }]) + } + if (query.includes("DELETE FROM cluster_replies")) { + const request = String(bindings[0]) + for (const [replyId, reply] of this.replies) { + if (JSON.parse(reply).requestId === request) this.replies.delete(replyId) + } + return this.rows([]) + } + if (query.includes("SET processed = 0") && query.includes("last_reply_id = NULL")) { + const row = this.messages.get(String(bindings[0])) + if (row !== undefined) { + row.processed = 0 + row.last_reply_id = null + } + return this.rows([]) + } + if (query.includes("FROM cluster_messages") && query.includes("request_id = ?")) { + const requestId = String(bindings[0]) + const primaryKey = bindings[1] + const row = this.messages.get(requestId) ?? Array.from(this.messages.values()).find( + (row) => primaryKey !== null && row.message_id === primaryKey + ) + return this.rows( + row === undefined ? [] : [{ + ...row, + last_reply: row.last_reply_id === null ? null : this.replies.get(row.last_reply_id) + }] + ) + } + throw new Error(`Unexpected SQL: ${query}`) + } + + private rows(rows: Array>) { + return { toArray: () => rows } + } + + get sql(): SqlStorage { + return this as unknown as SqlStorage + } +} + +const requestId = "0198bd72-6a80-72f1-8d87-5e9b5cf1e000" +const envelope = JSON.stringify({ + _tag: "Request", + requestId, + address: { + shardId: { group: "default", id: 1 }, + entityType: "Counter", + entityId: "one" + }, + tag: "Increment", + payload: { amount: 1 }, + headers: {} +}) + +const withRequestId = (id: string) => JSON.stringify({ ...JSON.parse(envelope), requestId: id }) + +describe("EntityMailbox", () => { + it.effect("persists a durable request before its handler can run", () => + Effect.gen(function*() { + const sql = new FakeSql() + const result = yield* persistRequest(sql.sql, envelope, null) + + assert.deepStrictEqual(result, { _tag: "Success" }) + assert.strictEqual(sql.messages.get(requestId)?.envelope, envelope) + assert.strictEqual(sql.messages.get(requestId)?.processed, 0) + })) + + it.effect("persists future delivery metadata and only loads the row when due", () => + Effect.gen(function*() { + const sql = new FakeSql() + yield* persistRequest(sql.sql, envelope, "scheduled", false, 2_000, "7:Callercaller") + + assert.strictEqual(sql.messages.get(requestId)?.deliver_at, 2_000) + assert.strictEqual(sql.messages.get(requestId)?.reply_to, JSON.stringify(["7:Callercaller"])) + assert.deepStrictEqual(yield* loadUnprocessed(sql.sql, 1_999), []) + assert.deepStrictEqual(yield* loadDue(sql.sql, 2_000), [{ + requestId, + envelope, + lastSentChunk: undefined, + discard: false, + deliverAt: 2_000, + replyTos: ["7:Callercaller"] + }]) + })) + + it.effect("preserves every reply target when a scheduled request is deduplicated", () => + Effect.gen(function*() { + const sql = new FakeSql() + const primaryKey = "Counter/one/Increment/scheduled" + yield* persistRequest(sql.sql, envelope, primaryKey, false, 2_000, "7:Callerfirst") + yield* persistRequest( + sql.sql, + withRequestId("0198bd72-6a81-72f1-8d87-5e9b5cf1e001"), + primaryKey, + false, + null, + "7:Callersecond" + ) + + assert.deepStrictEqual(yield* loadDue(sql.sql, 2_000), [{ + requestId, + envelope, + lastSentChunk: undefined, + discard: false, + deliverAt: 2_000, + replyTos: ["7:Callerfirst", "7:Callersecond"] + }]) + })) + + it.effect("maps a primary-key duplicate to the original request and last reply", () => + Effect.gen(function*() { + const sql = new FakeSql() + const primaryKey = "Counter/one/Increment/operation-1" + yield* persistRequest(sql.sql, envelope, primaryKey) + const reply = JSON.stringify({ + _tag: "WithExit", + requestId, + id: "reply-1", + exit: { _tag: "Success", value: 1 } + }) + yield* saveReply(sql.sql, reply) + + assert.deepStrictEqual( + yield* persistRequest(sql.sql, withRequestId("0198bd72-6a81-72f1-8d87-5e9b5cf1e001"), primaryKey), + { _tag: "Duplicate", originalId: requestId, processed: true } + ) + })) + + it.effect("replays an unprocessed row with its last sent chunk after a crash", () => + Effect.gen(function*() { + const sql = new FakeSql() + yield* persistRequest(sql.sql, envelope, null) + const chunk = JSON.stringify({ + _tag: "Chunk", + requestId, + id: "chunk-1", + sequence: 0, + values: [1] + }) + yield* saveReply(sql.sql, chunk) + + assert.deepStrictEqual(yield* loadUnprocessed(sql.sql), [{ + requestId, + envelope, + lastSentChunk: chunk, + discard: false + }]) + })) + + it.effect("marks a persisted tell complete without storing a user-visible reply", () => + Effect.gen(function*() { + const sql = new FakeSql() + yield* persistRequest(sql.sql, envelope, null) + yield* completeTell(sql.sql, requestId) + + assert.strictEqual(sql.messages.get(requestId)?.processed, 1) + assert.strictEqual(sql.replies.size, 0) + assert.deepStrictEqual(yield* loadUnprocessed(sql.sql), []) + })) + + it.effect("acknowledges stream chunks and clearReplies resumes the request", () => + Effect.gen(function*() { + const sql = new FakeSql() + yield* persistRequest(sql.sql, envelope, null) + const chunk = JSON.stringify({ + _tag: "Chunk", + requestId, + id: "chunk-1", + sequence: 0, + values: [1] + }) + yield* saveReply(sql.sql, chunk) + yield* ackChunk(sql.sql, requestId, "chunk-1") + assert.isTrue(sql.acked.has("chunk-1")) + + yield* clearReplies(sql.sql, requestId) + assert.deepStrictEqual(yield* loadUnprocessed(sql.sql), [{ + requestId, + envelope, + lastSentChunk: undefined, + discard: false + }]) + assert.strictEqual(sql.replies.size, 0) + })) + + it.effect("rejects the 4097th unprocessed request", () => + Effect.gen(function*() { + const sql = new FakeSql() + for (let index = 0; index < 4096; index++) { + sql.messages.set(String(index), { + request_id: String(index), + message_id: null, + envelope, + discard: 0, + processed: 0, + last_reply_id: null + }) + } + const error = yield* Effect.flip(persistRequest(sql.sql, envelope, null)) + assert.instanceOf(error, MailboxFullError) + })) + + it.effect("counts a completed stream with an unacknowledged chunk against capacity", () => + Effect.gen(function*() { + const sql = new FakeSql() + for (let index = 0; index < 4095; index++) { + sql.messages.set(String(index), { + request_id: String(index), + message_id: null, + envelope, + discard: 0, + processed: 0, + last_reply_id: null + }) + } + yield* persistRequest(sql.sql, envelope, null) + yield* saveReply(sql.sql, JSON.stringify({ _tag: "Chunk", requestId, id: "chunk", sequence: 0, values: [1] })) + yield* saveReply( + sql.sql, + JSON.stringify({ + _tag: "WithExit", + requestId, + id: "terminal", + exit: { _tag: "Success", value: null } + }) + ) + + const error = yield* Effect.flip( + persistRequest(sql.sql, withRequestId("0198bd72-6a83-72f1-8d87-5e9b5cf1e003"), null) + ) + assert.instanceOf(error, MailboxFullError) + })) + + it.effect("rejects encoded requests and chunks over 2 MB", () => + Effect.gen(function*() { + const sql = new FakeSql() + const largeRequest = JSON.stringify({ ...JSON.parse(envelope), payload: "x".repeat(maximumEncodedSize) }) + assert.instanceOf(yield* Effect.flip(persistRequest(sql.sql, largeRequest, null)), EncodedMessageTooLargeError) + + const largeChunk = JSON.stringify({ + _tag: "Chunk", + requestId, + id: "chunk-large", + sequence: 0, + values: ["x".repeat(maximumEncodedSize)] + }) + assert.instanceOf(yield* Effect.flip(saveReply(sql.sql, largeChunk)), EncodedMessageTooLargeError) + })) + + it.effect("releases persisted stream replies one chunk per acknowledgement", () => + Effect.gen(function*() { + const sql = new FakeSql() + yield* persistRequest(sql.sql, envelope, null) + const chunk0 = JSON.stringify({ _tag: "Chunk", requestId, id: "chunk-0", sequence: 0, values: [0] }) + const chunk1 = JSON.stringify({ _tag: "Chunk", requestId, id: "chunk-1", sequence: 1, values: [1] }) + const terminal = JSON.stringify({ + _tag: "WithExit", + requestId, + id: "terminal", + exit: { _tag: "Success", value: null } + }) + yield* saveReply(sql.sql, chunk0) + yield* saveReply(sql.sql, chunk1) + yield* saveReply(sql.sql, terminal) + + assert.deepStrictEqual(yield* loadNextReply(sql.sql, requestId), { reply: chunk0, kind: "Chunk" }) + yield* ackChunk(sql.sql, requestId, "chunk-0") + assert.deepStrictEqual(yield* loadNextReply(sql.sql, requestId), { reply: chunk1, kind: "Chunk" }) + yield* ackChunk(sql.sql, requestId, "chunk-1") + assert.deepStrictEqual(yield* loadNextReply(sql.sql, requestId), { reply: terminal, kind: "WithExit" }) + })) + + it.effect("retains tell discard mode for crash replay", () => + Effect.gen(function*() { + const sql = new FakeSql() + yield* persistRequest(sql.sql, envelope, null, true) + + assert.deepStrictEqual(yield* loadUnprocessed(sql.sql), [{ + requestId, + envelope, + lastSentChunk: undefined, + discard: true + }]) + })) +}) diff --git a/packages/platform/cloudflare/test/EntityManager.test.ts b/packages/platform/cloudflare/test/EntityManager.test.ts new file mode 100644 index 00000000000..1a9658fd09d --- /dev/null +++ b/packages/platform/cloudflare/test/EntityManager.test.ts @@ -0,0 +1,129 @@ +import type { DurableObjectStorage, SqlStorage } from "@cloudflare/workers-types" +import { makeEntityKeepAlive } from "@effect/platform-cloudflare/internal/entityKeepAlive" +import { loadNextReply } from "@effect/platform-cloudflare/internal/entityMailbox" +import { registerEntity, unregisterEntity } from "@effect/platform-cloudflare/internal/entityRegistry" +import type { EntityRegistration } from "@effect/platform-cloudflare/internal/entityRegistry" +import { makeEntityManager } from "@effect/platform-cloudflare/internal/entityRuntime" +import { ensureEntityStorage } from "@effect/platform-cloudflare/internal/entityStorage" +import { assert, describe, it } from "@effect/vitest" +import { Context, Effect, Schema, Stream } from "effect" +import { ClusterSchema, Entity, EntityAddress, EntityId, EntityType, ShardId } from "effect/unstable/cluster" +import { Rpc, RpcSchema } from "effect/unstable/rpc" +import { DatabaseSync, type SQLInputValue } from "node:sqlite" + +class SqliteStorage { + readonly sql: SqlStorage + + constructor(readonly database: DatabaseSync) { + this.sql = { + exec: (query: string, ...bindings: Array) => { + const rows = database.prepare(query).all(...bindings as Array) as Array> + return { toArray: () => rows } + } + } as SqlStorage + } + + transactionSync(f: () => A): A { + this.database.exec("BEGIN") + try { + const value = f() + this.database.exec("COMMIT") + return value + } catch (error) { + this.database.exec("ROLLBACK") + throw error + } + } +} + +const InterruptedStream = Entity.make("InterruptedStream", [ + Rpc.make("Watch", { + success: RpcSchema.Stream(Schema.Number, Schema.Never) + }).annotate(ClusterSchema.Persisted, true), + Rpc.make("Ping", { success: Schema.String }) +]) + +const address = EntityAddress.make({ + shardId: ShardId.make("default", 1), + entityType: EntityType.make(InterruptedStream.type), + entityId: EntityId.make("one") +}) + +const request = (requestId: string, tag: "Watch" | "Ping") => + JSON.stringify({ + _tag: "Request", + requestId, + address, + tag, + payload: null, + headers: {} + }) + +describe("EntityManager", () => { + it.effect("completes an interrupted persisted stream instead of replaying it", () => + Effect.gen(function*() { + const database = yield* Effect.acquireRelease( + Effect.sync(() => new DatabaseSync(":memory:")), + (database) => Effect.sync(() => database.close()) + ) + const storage = new SqliteStorage(database) + ensureEntityStorage(storage.sql) + + let streamRuns = 0 + const registration: EntityRegistration = { + entity: InterruptedStream, + build: Effect.succeed(InterruptedStream.of({ + Watch: () => { + streamRuns++ + return Stream.fromIterable([1, 2]).pipe(Stream.rechunk(1)) + }, + Ping: () => Effect.succeed("pong") + })), + options: undefined, + context: Context.empty() + } + assert.isTrue(registerEntity(InterruptedStream.type, registration)) + yield* Effect.addFinalizer(() => Effect.sync(() => unregisterEntity(InterruptedStream.type, registration))) + + const waitUntilFibers: Array<{ readonly pollUnsafe: () => unknown | undefined }> = [] + const manager = makeEntityManager({ + storage: storage as unknown as DurableObjectStorage, + address, + entityName: "17:InterruptedStreamone", + keepAlive: makeEntityKeepAlive(() => Promise.resolve()), + waitUntil: (effect) => { + waitUntilFibers.push(Effect.runFork(effect)) + }, + getNamespace: () => undefined + }) + const streamRequestId = "0198bd72-6a80-72f1-8d87-5e9b5cf1e000" + const first = yield* manager.invoke(request(streamRequestId, "Watch"), false) + assert.strictEqual(first._tag, "Success") + assert.strictEqual(first._tag === "Success" ? JSON.parse(first.replies[0])._tag : undefined, "Chunk") + + yield* manager.interrupt(streamRequestId) + const ping = yield* manager.invoke( + request("0198bd72-6a81-72f1-8d87-5e9b5cf1e001", "Ping"), + false + ) + yield* Effect.yieldNow + + assert.strictEqual(ping._tag, "Success") + assert.strictEqual(streamRuns, 1) + assert.isTrue(waitUntilFibers.every((fiber) => fiber.pollUnsafe() !== undefined)) + + const row = storage.sql.exec<{ readonly processed: number }>( + "SELECT processed FROM cluster_messages WHERE request_id = ?", + streamRequestId + ).toArray()[0] + assert.strictEqual(row.processed, 1) + assert.strictEqual((yield* loadNextReply(storage.sql, streamRequestId))?.kind, "WithExit") + assert.strictEqual( + storage.sql.exec<{ readonly count: number }>( + "SELECT COUNT(*) AS count FROM cluster_replies WHERE request_id = ? AND kind = 'Chunk' AND acked = 0", + streamRequestId + ).toArray()[0].count, + 0 + ) + })) +}) diff --git a/packages/platform/cloudflare/test/EntityRuntime.test.ts b/packages/platform/cloudflare/test/EntityRuntime.test.ts new file mode 100644 index 00000000000..0e72515ce2e --- /dev/null +++ b/packages/platform/cloudflare/test/EntityRuntime.test.ts @@ -0,0 +1,366 @@ +import type { EntityRegistration } from "@effect/platform-cloudflare/internal/entityRegistry" +import { makeEntityRuntime } from "@effect/platform-cloudflare/internal/entityRuntime" +import { assert, describe, it } from "@effect/vitest" +import { + Cause, + Context, + Deferred, + Effect, + Exit, + Fiber, + Metric, + Option, + Schedule, + Schema, + Scope, + Stream, + Tracer +} from "effect" +import { ClusterMetrics, Entity, EntityAddress, EntityId, EntityType, ShardId } from "effect/unstable/cluster" +import { Rpc, RpcSchema } from "effect/unstable/rpc" + +const User = Entity.make("User", [ + Rpc.make("Ping", { success: Schema.String }) +]) + +const address = new EntityAddress.EntityAddress({ + shardId: ShardId.make("default", 1), + entityType: EntityType.make("User"), + entityId: EntityId.make("42") +}) + +const request = { + _tag: "Request" as const, + requestId: "0198bd72-6a80-72f1-8d87-5e9b5cf1e000" as any, + address, + tag: "Ping" as const, + payload: undefined, + headers: {} +} + +describe("EntityRuntime", () => { + it.effect("records handler exits and tracks the cached entity metric", () => { + const Telemetry = Entity.make("Telemetry", [ + Rpc.make("Ping", { success: Schema.String }), + Rpc.make("Fail", { success: Schema.String }) + ]) + const telemetryAddress = new EntityAddress.EntityAddress({ + shardId: ShardId.make("default", 1), + entityType: EntityType.make("Telemetry"), + entityId: EntityId.make("observed") + }) + const spans: Array = [] + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options) + spans.push(span) + return span + } + }) + const context = Context.empty().pipe(Context.add(Tracer.Tracer, tracer)) + const metricContext = Context.merge( + context, + Metric.CurrentMetricAttributes.context({ type: Telemetry.type }) + ) + let activeDuringHandler = BigInt(0) + const registration: EntityRegistration = { + entity: Telemetry, + build: Effect.succeed(Telemetry.of({ + Ping: () => + Effect.sync(() => { + activeDuringHandler = ClusterMetrics.entities.valueUnsafe(metricContext).value + return "pong" + }), + Fail: () => Effect.die("boom") + })), + options: undefined, + context + } + + return Effect.gen(function*() { + const runtime = yield* makeEntityRuntime(registration, telemetryAddress, () => "reply") + yield* runtime.run({ ...request, address: telemetryAddress } as any, Option.none(), false, () => Effect.void) + + assert.strictEqual(activeDuringHandler, BigInt(1)) + assert.strictEqual(ClusterMetrics.entities.valueUnsafe(metricContext).value, BigInt(1)) + assert.strictEqual(spans[0].attributes.get("entityType"), "Telemetry") + assert.strictEqual(spans[0].attributes.get("entityId"), "observed") + assert.strictEqual(spans[0].attributes.get("rpc"), "Ping") + assert(spans[0].status._tag === "Ended") + assert.isTrue(Exit.isSuccess(spans[0].status.exit)) + + yield* runtime.run( + { ...request, address: telemetryAddress, tag: "Fail" } as any, + Option.none(), + false, + () => Effect.void + ) + + assert.deepStrictEqual(spans.map((span) => span.name), [ + "CloudflareCluster.handler", + "CloudflareCluster.handler" + ]) + assert(spans[1].status._tag === "Ended") + assert.isTrue(Exit.isFailure(spans[1].status.exit)) + assert.strictEqual(ClusterMetrics.entities.valueUnsafe(metricContext).value, BigInt(1)) + + yield* runtime.invalidate() + assert.strictEqual(ClusterMetrics.entities.valueUnsafe(metricContext).value, BigInt(0)) + }) + }) + + it.effect("builds handlers once per wake and returns terminal ask replies", () => + Effect.gen(function*() { + let builds = 0 + const registration: EntityRegistration = { + entity: User, + build: Effect.sync(() => { + builds++ + return User.of({ Ping: () => Effect.succeed("pong") }) + }), + options: undefined, + context: Context.empty() + } + let replyId = 0 + const runtime = yield* makeEntityRuntime(registration, address, () => `reply-${replyId++}`) + const replies: Array = [] + + yield* runtime.run(request as any, Option.none(), false, (reply) => + Effect.sync(() => { + replies.push(reply) + })) + yield* runtime.run( + { ...request, requestId: "0198bd72-6a81-72f1-8d87-5e9b5cf1e001" } as any, + Option.none(), + false, + (reply) => + Effect.sync(() => { + replies.push(reply) + }) + ) + + assert.strictEqual(builds, 1) + assert.strictEqual(replies.length, 2) + assert.isTrue(replies.every((reply) => reply._tag === "WithExit" && Exit.isSuccess(reply.exit))) + assert.deepStrictEqual(replies.map((reply) => reply.exit.value), ["pong", "pong"]) + })) + + it.effect("shares an asynchronous handler build between concurrent first requests", () => + Effect.gen(function*() { + const Concurrent = Entity.make("Concurrent", [ + Rpc.make("Ping", { success: Schema.String }) + ]) + const concurrentAddress = new EntityAddress.EntityAddress({ + shardId: ShardId.make("default", 1), + entityType: EntityType.make("Concurrent"), + entityId: EntityId.make("42") + }) + const context = Context.empty() + const metricContext = Context.merge( + context, + Metric.CurrentMetricAttributes.context({ type: Concurrent.type }) + ) + const releaseBuild = Deferred.makeUnsafe() + let builds = 0 + let finalizers = 0 + const registration: EntityRegistration = { + entity: Concurrent, + build: Effect.gen(function*() { + const scope = Option.getOrThrow(yield* Effect.serviceOption(Scope.Scope)) + builds++ + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + finalizers++ + }) + ) + yield* Deferred.await(releaseBuild) + return Concurrent.of({ Ping: () => Effect.succeed("pong") }) + }), + options: { concurrency: "unbounded" }, + context + } + const runtime = yield* makeEntityRuntime(registration, concurrentAddress, () => "reply") + const first = yield* Effect.forkChild( + runtime.run({ ...request, address: concurrentAddress } as any, Option.none(), false, () => Effect.void) + ) + const second = yield* Effect.forkChild( + runtime.run( + { + ...request, + requestId: "0198bd72-6a83-72f1-8d87-5e9b5cf1e003", + address: concurrentAddress + } as any, + Option.none(), + false, + () => Effect.void + ) + ) + + yield* Effect.yieldNow + yield* Deferred.succeed(releaseBuild, undefined) + yield* Fiber.join(first) + yield* Fiber.join(second) + + assert.strictEqual(builds, 1) + assert.strictEqual(ClusterMetrics.entities.valueUnsafe(metricContext).value, BigInt(1)) + assert.strictEqual(finalizers, 0) + + yield* runtime.invalidate() + assert.strictEqual(ClusterMetrics.entities.valueUnsafe(metricContext).value, BigInt(0)) + assert.strictEqual(finalizers, 1) + })) + + it.effect("resumes ask stream sequence from lastSentChunk and ends with WithExit", () => + Effect.gen(function*() { + const Streaming = Entity.make("User", [ + Rpc.make("Values", { success: RpcSchema.Stream(Schema.Number, Schema.Never) }) + ]) + const registration: EntityRegistration = { + entity: Streaming, + build: Effect.succeed(Streaming.of({ + Values: () => Stream.fromIterable([6, 7]).pipe(Stream.rechunk(1)) + })), + options: undefined, + context: Context.empty() + } + let replyId = 0 + const runtime = yield* makeEntityRuntime(registration, address, () => `reply-${replyId++}`) + const replies: Array = [] + const lastSentChunk = Option.some({ + _tag: "Chunk", + requestId: request.requestId, + id: "chunk-5", + sequence: 5, + values: [5] + } as any) + + yield* runtime.run( + { ...request, tag: "Values" } as any, + lastSentChunk, + false, + (reply) => Effect.sync(() => replies.push(reply)) + ) + + assert.deepStrictEqual(replies.map((reply) => reply._tag), ["Chunk", "Chunk", "WithExit"]) + assert.deepStrictEqual(replies.slice(0, 2).map((reply) => [reply.sequence, reply.values]), [ + [6, [6]], + [7, [7]] + ]) + assert.isTrue(Exit.isSuccess(replies[2].exit)) + })) + + it.effect("retries a defective stream from the last emitted chunk", () => + Effect.gen(function*() { + const Streaming = Entity.make("User", [ + Rpc.make("Values", { success: RpcSchema.Stream(Schema.Number, Schema.Never) }) + ]) + const seenLastChunks: Array = [] + const registration: EntityRegistration = { + entity: Streaming, + build: Effect.succeed(Streaming.of({ + Values: (request) => { + const last = Option.getOrUndefined(request.lastSentChunkValue) + seenLastChunks.push(last) + return last === undefined + ? Stream.concat(Stream.make(1), Stream.die("retry")) + : Stream.make(last + 1) + } + })), + options: { defectRetryPolicy: Schedule.recurs(1) }, + context: Context.empty() + } + let replyId = 0 + const runtime = yield* makeEntityRuntime(registration, address, () => `reply-${replyId++}`) + const replies: Array = [] + + yield* runtime.run( + { ...request, tag: "Values" } as any, + Option.none(), + false, + (reply) => Effect.sync(() => replies.push(reply)) + ) + + assert.deepStrictEqual(seenLastChunks, [undefined, 1]) + assert.deepStrictEqual( + replies.filter((reply) => reply._tag === "Chunk").map((reply) => [reply.sequence, reply.values]), + [[0, [1]], [1, [2]]] + ) + assert.isTrue(Exit.isSuccess(replies.at(-1).exit)) + })) + + it.effect("finishes tells without emitting a stored reply", () => + Effect.gen(function*() { + let handled = 0 + const registration: EntityRegistration = { + entity: User, + build: Effect.succeed(User.of({ + Ping: () => + Effect.sync(() => { + handled++ + return "pong" + }) + })), + options: undefined, + context: Context.empty() + } + const runtime = yield* makeEntityRuntime(registration, address, () => "reply") + const replies: Array = [] + + yield* runtime.run(request as any, Option.none(), true, (reply) => + Effect.sync(() => { + replies.push(reply) + })) + + assert.strictEqual(handled, 1) + assert.deepStrictEqual(replies, []) + })) + + it.effect("stores a terminal defect after retry exhaustion and rebuilds handlers in-wake", () => + Effect.gen(function*() { + let builds = 0 + let attempts = 0 + const registration: EntityRegistration = { + entity: User, + build: Effect.sync(() => { + const build = ++builds + return User.of({ + Ping: () => + build === 1 + ? Effect.sync(() => { + attempts++ + throw new Error("boom") + }) + : Effect.succeed("recovered") + }) + }), + options: { defectRetryPolicy: Schedule.recurs(2) }, + context: Context.empty() + } + const runtime = yield* makeEntityRuntime(registration, address, () => `reply-${builds}-${attempts}`) + const replies: Array = [] + + yield* runtime.run(request as any, Option.none(), false, (reply) => + Effect.sync(() => { + replies.push(reply) + })) + + assert.strictEqual(attempts, 3) + assert.strictEqual(builds, 2) + assert.strictEqual(replies.length, 1) + assert.isTrue(Exit.isFailure(replies[0].exit)) + assert.isTrue(Cause.hasDies(replies[0].exit.cause)) + + yield* runtime.run( + { ...request, requestId: "0198bd72-6a82-72f1-8d87-5e9b5cf1e002" } as any, + Option.none(), + false, + (reply) => + Effect.sync(() => { + replies.push(reply) + }) + ) + assert.isTrue(Exit.isSuccess(replies[1].exit)) + assert.strictEqual(replies[1].exit.value, "recovered") + })) +}) diff --git a/packages/platform/cloudflare/test/EntityStorage.test.ts b/packages/platform/cloudflare/test/EntityStorage.test.ts new file mode 100644 index 00000000000..e0b8db581fe --- /dev/null +++ b/packages/platform/cloudflare/test/EntityStorage.test.ts @@ -0,0 +1,96 @@ +import type { SqlStorage } from "@cloudflare/workers-types" +import type { EntityAlarm } from "@effect/platform-cloudflare/internal/entityStorage" +import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "@effect/platform-cloudflare/internal/entityStorage" +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" + +class FakeSql { + readonly statements: Array = [] + earliestDeliverAt: number | null = null + + exec(query: string, ..._bindings: Array) { + this.statements.push(query) + const rows: Array> = query.includes("min(deliver_at)") + ? [{ deliver_at: this.earliestDeliverAt }] + : [] + return { toArray: () => rows } + } + + get sql(): SqlStorage { + return this as unknown as SqlStorage + } +} + +class FakeAlarm { + readonly setCalls: Array = [] + current: number | null = null + + getAlarm() { + return Promise.resolve(this.current) + } + setAlarm(scheduledTime: number) { + this.setCalls.push(scheduledTime) + this.current = scheduledTime + return Promise.resolve() + } + + get alarm(): EntityAlarm { + return this as unknown as EntityAlarm + } +} + +describe("EntityStorage", () => { + describe("ensureEntityStorage", () => { + it("creates the mailbox tables idempotently", () => { + const sql = new FakeSql() + ensureEntityStorage(sql.sql) + const first = [...sql.statements] + assert.isAtLeast(first.length, 1) + for (const statement of first) { + assert.match(statement, /CREATE (TABLE|INDEX) IF NOT EXISTS/) + } + assert.isTrue(first.some((statement) => statement.includes("cluster_messages"))) + assert.isTrue(first.some((statement) => statement.includes("cluster_replies"))) + + ensureEntityStorage(sql.sql) + assert.deepStrictEqual(sql.statements, [...first, ...first]) + }) + }) + + describe("earliestDeliverAt", () => { + it("returns undefined without pending deliver_at rows", () => { + assert.isUndefined(earliestDeliverAt(new FakeSql().sql)) + }) + + it("returns the earliest pending deliver_at", () => { + const sql = new FakeSql() + sql.earliestDeliverAt = 1000 + assert.strictEqual(earliestDeliverAt(sql.sql), 1000) + }) + }) + + describe("armAlarm", () => { + it.effect("arms an unset alarm", () => + Effect.gen(function*() { + const alarm = new FakeAlarm() + yield* armAlarm(alarm.alarm, 1000) + assert.deepStrictEqual(alarm.setCalls, [1000]) + })) + + it.effect("keeps an already earlier alarm", () => + Effect.gen(function*() { + const alarm = new FakeAlarm() + alarm.current = 500 + yield* armAlarm(alarm.alarm, 1000) + assert.deepStrictEqual(alarm.setCalls, []) + })) + + it.effect("moves a later alarm forward", () => + Effect.gen(function*() { + const alarm = new FakeAlarm() + alarm.current = 2000 + yield* armAlarm(alarm.alarm, 1000) + assert.deepStrictEqual(alarm.setCalls, [1000]) + })) + }) +}) diff --git a/packages/platform/cloudflare/test/Singleton.test.ts b/packages/platform/cloudflare/test/Singleton.test.ts new file mode 100644 index 00000000000..8412bbd9b06 --- /dev/null +++ b/packages/platform/cloudflare/test/Singleton.test.ts @@ -0,0 +1,160 @@ +import type { DurableObjectStorage, SqlStorage } from "@cloudflare/workers-types" +import type { EntityAlarm } from "@effect/platform-cloudflare/internal/entityStorage" +import { armAlarm } from "@effect/platform-cloudflare/internal/entityStorage" +import { makeSingletonRuntime } from "@effect/platform-cloudflare/internal/singletonRuntime" +import { + beginSingletonWake, + ensureSingletonStorage, + loadSingletonState, + rememberSingletonName +} from "@effect/platform-cloudflare/internal/singletonStorage" +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" + +class FakeSql { + readonly statements: Array = [] + name: string | undefined + wakeAt: number | null = null + + exec(query: string, ...bindings: Array) { + this.statements.push(query) + if (query.startsWith("CREATE")) return this.rows([]) + if (query.startsWith("SELECT name, wake_at")) { + return this.rows( + this.name === undefined && this.wakeAt === null + ? [] + : [{ name: this.name ?? null, wake_at: this.wakeAt }] + ) + } + if (query.includes("INSERT INTO singleton_state (id, name)")) { + this.name = String(bindings[0]) + return this.rows([]) + } + if (query.includes("INSERT INTO singleton_state (id, wake_at)")) { + this.wakeAt = Number(bindings[0]) + return this.rows([]) + } + if (query.startsWith("UPDATE singleton_state SET wake_at = NULL")) { + this.wakeAt = null + return this.rows([]) + } + throw new Error(`Unexpected SQL: ${query}`) + } + + private rows(rows: Array>) { + return { toArray: () => rows } + } + + get sql(): SqlStorage { + return this as unknown as SqlStorage + } +} + +class FakeAlarm { + readonly setCalls: Array = [] + deleteCalls = 0 + current: number | null = null + + getAlarm() { + return Promise.resolve(this.current) + } + + setAlarm(scheduledTime: number) { + this.setCalls.push(scheduledTime) + this.current = scheduledTime + return Promise.resolve() + } + + deleteAlarm() { + this.deleteCalls++ + this.current = null + return Promise.resolve() + } + + get alarm(): Pick { + return this as unknown as Pick + } +} + +describe("Singleton", () => { + it.effect("runs one wake to completion, coalesces a duplicate, then accepts the next fire", () => { + const sql = new FakeSql() + const alarm = new FakeAlarm() + let runs = 0 + let release!: () => void + let started!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const running = new Promise((resolve) => { + started = resolve + }) + const runtime = makeSingletonRuntime({ + sql: sql.sql, + alarm: alarm.alarm, + now: () => 100, + run: Effect.sync(() => { + runs++ + started() + }).pipe(Effect.andThen(Effect.promise(() => gate))) + }) + + return Effect.gen(function*() { + const first = runtime.wake() + yield* Effect.promise(() => running) + assert.strictEqual(runs, 1) + + // This returns immediately instead of extending the current wake. + yield* Effect.promise(() => runtime.wake()) + assert.strictEqual(runs, 1) + + release() + yield* Effect.promise(() => first) + assert.deepStrictEqual(loadSingletonState(sql.sql), { name: undefined, wakeAt: undefined }) + assert.strictEqual(alarm.current, null) + + // A later Cron Trigger is a new intended fire. + yield* Effect.promise(() => runtime.wake()) + assert.strictEqual(runs, 2) + assert.strictEqual(alarm.deleteCalls, 2) + }) + }) + + it.effect("re-arms and completes a wake left pending by isolate loss", () => { + const sql = new FakeSql() + const alarm = new FakeAlarm() + ensureSingletonStorage(sql.sql) + rememberSingletonName(sql.sql, "Singleton/recovery") + assert.isTrue(beginSingletonWake(sql.sql, 50)) + + return Effect.gen(function*() { + const pending = loadSingletonState(sql.sql) + assert.deepStrictEqual(pending, { name: "Singleton/recovery", wakeAt: 50 }) + yield* armAlarm(alarm as unknown as EntityAlarm, pending.wakeAt!) + + let runs = 0 + const runtime = makeSingletonRuntime({ + sql: sql.sql, + alarm: alarm.alarm, + now: () => 100, + run: Effect.sync(() => runs++) + }) + yield* Effect.promise(() => runtime.runAlarm()) + + assert.strictEqual(runs, 1) + assert.deepStrictEqual(alarm.setCalls, [50]) + assert.strictEqual(alarm.current, null) + assert.deepStrictEqual(loadSingletonState(sql.sql), { name: "Singleton/recovery", wakeAt: undefined }) + }) + }) + + it("ensures its SQLite table idempotently", () => { + const sql = new FakeSql() + ensureSingletonStorage(sql.sql) + ensureSingletonStorage(sql.sql) + assert.strictEqual(sql.statements.filter((statement) => statement.startsWith("CREATE")).length, 2) + for (const statement of sql.statements) { + assert.match(statement, /CREATE TABLE IF NOT EXISTS singleton_state/) + } + }) +}) diff --git a/packages/platform/cloudflare/test/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts new file mode 100644 index 00000000000..3d9e20f679f --- /dev/null +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -0,0 +1,367 @@ +export { + ClusterDurableQueue, + ClusterSingleton, + ClusterWorkflow +} from "@effect/platform-cloudflare/CloudflareDurableObjects" +import { ClusterEntity as BaseClusterEntity } from "@effect/platform-cloudflare/CloudflareDurableObjects" +import { encodeName } from "@effect/platform-cloudflare/internal/clusterName" +import { registerEntity } from "@effect/platform-cloudflare/internal/entityRegistry" +import { Context, Deferred, Effect, Schema, Stream } from "effect" +import { ClusterSchema, Entity } from "effect/unstable/cluster" +import { Rpc, RpcSchema } from "effect/unstable/rpc" + +const Add = Rpc.make("Add", { + payload: { operationId: Schema.String }, + primaryKey: ({ operationId }) => operationId +}).annotate(ClusterSchema.Persisted, true) +const AddVolatile = Rpc.make("AddVolatile", { + payload: { operationId: Schema.String } +}) +const Get = Rpc.make("Get", { success: Schema.Number }) +const Watch = Rpc.make("Watch", { + success: RpcSchema.Stream(Schema.Number, Schema.Never) +}).annotate(ClusterSchema.Persisted, true) +const Mailbox = Entity.make("Mailbox", [Add, AddVolatile, Get, Watch]) +const values = new Map() +type TestDurableObjectState = ConstructorParameters[0] +type ScheduledRow = { + readonly request_id: string + readonly message_id: string | null + readonly processed: number + readonly deliver_at: number | null + readonly reply_to: string | null +} + +export class ClusterEntity extends BaseClusterEntity { + readonly #testState: TestDurableObjectState + + constructor(ctx: TestDurableObjectState, env: unknown) { + super(ctx, env) + this.#testState = ctx + entityEnv = env as Record + } + + seedPoison(envelope: string): void { + const requestId = JSON.parse(envelope).requestId + this.#testState.storage.sql.exec( + `INSERT INTO cluster_messages (request_id, message_id, envelope, discard, processed, last_reply_id) + VALUES (?, NULL, ?, 0, 0, NULL)`, + requestId, + envelope + ) + } + + scheduledRows(): Array { + return this.#testState.storage.sql.exec( + `SELECT request_id, message_id, processed, deliver_at, reply_to + FROM cluster_messages + ORDER BY rowid ASC` + ).toArray() + } + + getAlarm(): Promise { + return this.#testState.storage.getAlarm() + } + + override async deliverReply(requestId: string, reply: string): Promise { + await this.#testState.storage.put("test-delayed-reply", { requestId, reply }) + return super.deliverReply(requestId, reply) + } + + delayedReply(): Promise<{ readonly requestId: string; readonly reply: string } | undefined> { + return this.#testState.storage.get("test-delayed-reply") + } +} + +// The Durable Object env, captured on construction so entity handlers can +// invoke other entities through the CLUSTER_ENTITY namespace. +let entityEnv: Record | undefined + +const requestEnvelope = (entityType: string, entityId: string, tag: string) => + JSON.stringify({ + _tag: "Request", + requestId: crypto.randomUUID(), + address: { + shardId: { group: "default", id: 1 }, + entityType, + entityId + }, + tag, + payload: null, + headers: {} + }) + +const invokeValue = ( + namespace: { getByName: (name: string) => any }, + entityType: string, + entityId: string, + tag: string +): Promise => + namespace + .getByName(encodeName(entityType, entityId)) + .invoke(requestEnvelope(entityType, entityId, tag), false) + .then((result: { readonly replies: ReadonlyArray }) => String(JSON.parse(result.replies[0]).exit.value)) + +const askEntity = (entityType: string, entityId: string, tag: string) => + Effect.promise(() => invokeValue(entityEnv!.CLUSTER_ENTITY, entityType, entityId, tag)) + +const gates = new Map>() +const gateKey = (address: { readonly entityType: string; readonly entityId: string }) => + `${address.entityType}/${address.entityId}` +const registerGateEntity = ( + type: string, + options: { readonly concurrency?: number | "unbounded" } | undefined +) => { + const entity = Entity.make(type, [ + Rpc.make("WaitTurn", { success: Schema.String }), + Rpc.make("Open", { success: Schema.String }) + ]) + registerEntity(type, { + entity, + build: Effect.succeed(entity.of({ + WaitTurn: (request) => + Effect.gen(function*() { + const gate = Deferred.makeUnsafe() + gates.set(gateKey(request.address), gate) + return yield* Effect.race( + Effect.as(Deferred.await(gate), "opened"), + Effect.as(Effect.sleep(1000), "timeout") + ).pipe(Effect.ensuring(Effect.sync(() => gates.delete(gateKey(request.address))))) + }), + Open: (request) => + Effect.sync(() => { + const gate = gates.get(gateKey(request.address)) + if (gate === undefined) return "no-waiter" + Deferred.doneUnsafe(gate, Effect.void) + return "opened" + }) + })), + options, + context: Context.empty() + }) +} +registerGateEntity("GateSerial", undefined) +registerGateEntity("GateConcurrent", { concurrency: 2 }) +registerGateEntity("GateUnbounded", { concurrency: "unbounded" }) + +const CycleA = Entity.make("CycleA", [ + Rpc.make("Start", { success: Schema.String }), + Rpc.make("Answer", { success: Schema.String }) +]) +const CycleB = Entity.make("CycleB", [ + Rpc.make("Forward", { success: Schema.String }) +]) +registerEntity("CycleA", { + entity: CycleA, + build: Effect.succeed(CycleA.of({ + Start: (request) => + Effect.map( + askEntity("CycleB", request.address.entityId, "Forward"), + (value) => `cycle:${value}` + ), + Answer: () => Effect.succeed("pong") + })), + options: { concurrency: 2 }, + context: Context.empty() +}) +registerEntity("CycleB", { + entity: CycleB, + build: Effect.succeed(CycleB.of({ + Forward: (request) => askEntity("CycleA", request.address.entityId, "Answer") + })), + options: undefined, + context: Context.empty() +}) + +registerEntity("Mailbox", { + entity: Mailbox, + build: Effect.succeed(Mailbox.of({ + Add: (request) => + Effect.sync(() => { + values.set(request.address.entityId, (values.get(request.address.entityId) ?? 0) + 1) + }), + AddVolatile: (request) => + Effect.sync(() => { + values.set(request.address.entityId, (values.get(request.address.entityId) ?? 0) + 1) + }), + Get: (request) => Effect.sync(() => values.get(request.address.entityId) ?? 0), + Watch: (request) => + Stream.fromIterable([1, 2]).pipe( + Stream.rechunk(1), + Stream.tap((value) => + Effect.sync(() => { + values.set(request.address.entityId, value) + }) + ) + ) + })), + options: undefined, + context: Context.empty() +}) + +export default { + async fetch(request: Request, env: Record): Promise { + const url = new URL(request.url) + if (url.pathname === "/scheduled-rows") { + const id = url.searchParams.get("id") ?? "scheduled" + const stub = env.CLUSTER_ENTITY.getByName(`7:Mailbox${id}`) + return Response.json({ rows: await stub.scheduledRows(), alarm: await stub.getAlarm() }) + } + if (url.pathname === "/delayed-reply") { + const id = url.searchParams.get("id") ?? "caller" + const stub = env.CLUSTER_ENTITY.getByName(`7:Mailbox${id}`) + return Response.json({ reply: await stub.delayedReply() }) + } + if (url.pathname === "/delayed") { + const id = url.searchParams.get("id") ?? "scheduled" + const operationId = url.searchParams.get("operationId") ?? "operation" + const discard = url.searchParams.get("discard") === "true" + const deliverAt = Number(url.searchParams.get("deliverAt")) + const requestId = crypto.randomUUID() + const stub = env.CLUSTER_ENTITY.getByName(`7:Mailbox${id}`) + const result = await stub.invoke( + JSON.stringify({ + _tag: "Request", + requestId, + address: { + shardId: { group: "default", id: 1 }, + entityType: "Mailbox", + entityId: id + }, + tag: "Add", + payload: { operationId }, + headers: {} + }), + discard, + { + deliverAt, + primaryKey: `Mailbox/${id}/Add/${operationId}`, + ...(url.searchParams.get("replyTo") === null + ? undefined + : { replyTo: url.searchParams.get("replyTo") }) + } + ) + return Response.json(result) + } + if (url.pathname === "/interrupt-delayed") { + const id = url.searchParams.get("id") ?? "interrupted" + const deliverAt = Date.now() + 60_000 + const firstRequestId = crypto.randomUUID() + const secondRequestId = crypto.randomUUID() + const stub = env.CLUSTER_ENTITY.getByName(`7:Mailbox${id}`) + const invoke = (requestId: string) => + stub.invoke( + JSON.stringify({ + _tag: "Request", + requestId, + address: { + shardId: { group: "default", id: 1 }, + entityType: "Mailbox", + entityId: id + }, + tag: "Add", + payload: { operationId: "same" }, + headers: {} + }), + false, + { deliverAt, primaryKey: `Mailbox/${id}/Add/same` } + ) + const first = invoke(firstRequestId) + const second = invoke(secondRequestId) + await new Promise((resolve) => setTimeout(resolve, 50)) + await stub.interrupt(firstRequestId, secondRequestId) + const secondStatus = await Promise.race([ + second.then(() => "resolved", () => "rejected"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 250)) + ]) + const firstStatus = await Promise.race([ + first.then(() => "settled", () => "settled"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 50)) + ]) + await stub.interrupt(firstRequestId, firstRequestId) + await Promise.allSettled([first, second]) + return Response.json({ firstStatus, secondStatus }) + } + if (url.pathname === "/gate") { + const type = url.searchParams.get("type") ?? "GateSerial" + const id = url.searchParams.get("id") ?? "gate" + const wait = invokeValue(env.CLUSTER_ENTITY, type, id, "WaitTurn") + await new Promise((resolve) => setTimeout(resolve, 100)) + const open = await invokeValue(env.CLUSTER_ENTITY, type, id, "Open") + return Response.json({ wait: await wait, open }) + } + if (url.pathname === "/cycle") { + const id = url.searchParams.get("id") ?? "cycle" + return Response.json({ value: await invokeValue(env.CLUSTER_ENTITY, "CycleA", id, "Start") }) + } + if (url.pathname === "/seed-poison") { + const stub = env.CLUSTER_ENTITY.getByName("7:Mailboxcounter") + await stub.seedPoison(JSON.stringify({ + _tag: "Request", + requestId: crypto.randomUUID(), + address: { + shardId: { group: "default", id: 1 }, + entityType: "Mailbox", + entityId: "counter" + }, + tag: "Add", + payload: { operationId: 123 }, + headers: {} + })) + return new Response("seeded") + } + if (url.pathname === "/ack") { + const stub = env.CLUSTER_ENTITY.getByName("7:Mailboxcounter") + return Response.json( + await stub.acknowledge( + url.searchParams.get("requestId"), + url.searchParams.get("replyId") + ) + ) + } + if (url.pathname === "/mailbox") { + const id = url.searchParams.get("id") ?? "counter" + const stub = env.CLUSTER_ENTITY.getByName(`7:Mailbox${id}`) + const tag = url.searchParams.get("tag") ?? "Get" + const operationId = url.searchParams.get("operationId") ?? "operation" + const requestId = crypto.randomUUID() + const discardParam = url.searchParams.get("discard") + const discard = discardParam === null ? tag === "Add" || tag === "AddVolatile" : discardParam === "true" + try { + const result = await stub.invoke( + JSON.stringify({ + _tag: "Request", + requestId, + address: { + shardId: { group: "default", id: 1 }, + entityType: "Mailbox", + entityId: id + }, + tag, + payload: tag === "Get" || tag === "Watch" ? null : { operationId }, + headers: {} + }), + discard + ) + return Response.json(result) + } catch (error) { + return new Response(error instanceof Error ? `${error.stack}\n${String(error.cause)}` : String(error), { + status: 599 + }) + } + } + const binding = url.pathname.slice(1) + const namespace = env[binding] + if (namespace === undefined) { + return new Response(`unknown binding: ${binding}`, { status: 404 }) + } + const defaultName = binding === "CLUSTER_SINGLETON" ? "Singleton/test" : "4:User42" + const stub = namespace.getByName(url.searchParams.get("name") ?? defaultName) + try { + await stub.fetch(request) + return new Response("expected the object to reject direct fetch", { status: 500 }) + } catch (error) { + return new Response(String(error), { status: 200 }) + } + } +} diff --git a/packages/platform/cloudflare/tsconfig.json b/packages/platform/cloudflare/tsconfig.json new file mode 100644 index 00000000000..4cfa8204e59 --- /dev/null +++ b/packages/platform/cloudflare/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../../effect" } + ], + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 693cec06eb2..6faf39508c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,6 +436,22 @@ importers: specifier: workspace:^ version: link:../../effect + packages/platform/cloudflare: + dependencies: + '@cloudflare/workers-types': + specifier: ^5.20260816.1 + version: 5.20260816.1 + devDependencies: + effect: + specifier: workspace:^ + version: link:../../effect + esbuild: + specifier: ^0.25.12 + version: 0.25.12 + miniflare: + specifier: ^4.20260730.0 + version: 4.20260730.0 + packages/platform/deno: dependencies: '@db/redis': @@ -1847,156 +1863,312 @@ packages: '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -4258,6 +4430,11 @@ packages: es6-promise@3.3.1: resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -7782,81 +7959,159 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.28.2': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.28.2': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.28.2': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.28.2': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.28.2': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.28.2': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.28.2': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.28.2': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.28.2': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.28.2': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.28.2': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.28.2': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.28.2': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.28.2': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.28.2': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.28.2': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.28.2': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.28.2': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.28.2': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.28.2': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.28.2': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.28.2': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.28.2': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.28.2': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.28.2': optional: true @@ -9979,6 +10234,35 @@ snapshots: es6-promise@3.3.1: {} + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 diff --git a/tsconfig.packages.json b/tsconfig.packages.json index fb54b67c7a8..52634701bea 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -15,6 +15,7 @@ { "path": "packages/opentelemetry" }, { "path": "packages/platform/browser" }, { "path": "packages/platform/bun" }, + { "path": "packages/platform/cloudflare" }, { "path": "packages/platform/deno" }, { "path": "packages/platform/node" }, { "path": "packages/platform/node-shared" }, diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 943e0af02e4..91e1bad3bec 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -47,6 +47,8 @@ "@effect/platform-browser/*": ["./packages/platform/browser/src/*.ts"], "@effect/platform-bun": ["./packages/platform/bun/src/index.ts"], "@effect/platform-bun/*": ["./packages/platform/bun/src/*.ts"], + "@effect/platform-cloudflare": ["./packages/platform/cloudflare/src/index.ts"], + "@effect/platform-cloudflare/*": ["./packages/platform/cloudflare/src/*.ts"], "@effect/platform-node": ["./packages/platform/node/src/index.ts"], "@effect/platform-node/*": ["./packages/platform/node/src/*.ts"], "@effect/platform-node-shared": ["./packages/platform/node-shared/src/index.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index 66aa54298ff..97e39af2657 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -132,6 +132,7 @@ export default defineConfig({ } }), ...project("@effect/platform-bun", "packages/platform/bun", isBun), + ...project("@effect/platform-cloudflare", "packages/platform/cloudflare", !isDeno), ...project("@effect/platform-deno", "packages/platform/deno", isDeno), ...project("@effect/platform-node", "packages/platform/node", isNode), ...project(