From 42bc6d8f8afdf4b2d61796cd49f5b9d4a73a6b9a Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 04:34:17 +0000 Subject: [PATCH 01/37] feat(platform-cloudflare): scaffold package with cluster name codec Adds the @effect/platform-cloudflare package with the length-prefixed Durable Object name encoding shared by entity, workflow, queue, and singleton addresses. Co-Authored-By: Claude Fable 5 --- packages/platform/cloudflare/LICENSE | 21 ++ packages/platform/cloudflare/package.json | 74 +++++ .../cloudflare/src/CloudflareCluster.ts | 55 ++++ packages/platform/cloudflare/src/index.ts | 10 + .../cloudflare/src/internal/clusterName.ts | 23 ++ .../cloudflare/test/ClusterName.test.ts | 48 +++ packages/platform/cloudflare/tsconfig.json | 8 + pnpm-lock.yaml | 283 ++++++++++++++++++ tsconfig.packages.json | 1 + tsconfig.tests.json | 2 + vitest.config.ts | 1 + 11 files changed, 526 insertions(+) create mode 100644 packages/platform/cloudflare/LICENSE create mode 100644 packages/platform/cloudflare/package.json create mode 100644 packages/platform/cloudflare/src/CloudflareCluster.ts create mode 100644 packages/platform/cloudflare/src/index.ts create mode 100644 packages/platform/cloudflare/src/internal/clusterName.ts create mode 100644 packages/platform/cloudflare/test/ClusterName.test.ts create mode 100644 packages/platform/cloudflare/tsconfig.json 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/package.json b/packages/platform/cloudflare/package.json new file mode 100644 index 00000000000..f7c23449c91 --- /dev/null +++ b/packages/platform/cloudflare/package.json @@ -0,0 +1,74 @@ +{ + "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:^" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260816.1", + "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..3b6d06b51bf --- /dev/null +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -0,0 +1,55 @@ +/** + * 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 * as Internal from "./internal/clusterName.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 encoding + * @since 4.0.0 + */ +export const decodeName: (name: string) => ClusterName | undefined = Internal.decodeName diff --git a/packages/platform/cloudflare/src/index.ts b/packages/platform/cloudflare/src/index.ts new file mode 100644 index 00000000000..d0ef284547e --- /dev/null +++ b/packages/platform/cloudflare/src/index.ts @@ -0,0 +1,10 @@ +/** + * @since 4.0.0 + */ + +// @barrel: Auto-generated exports. Do not edit manually. + +/** + * @since 4.0.0 + */ +export * as CloudflareCluster from "./CloudflareCluster.ts" diff --git a/packages/platform/cloudflare/src/internal/clusterName.ts b/packages/platform/cloudflare/src/internal/clusterName.ts new file mode 100644 index 00000000000..4d48104e63f --- /dev/null +++ b/packages/platform/cloudflare/src/internal/clusterName.ts @@ -0,0 +1,23 @@ +/** @internal */ +export interface ClusterName { + readonly type: string + readonly id: string +} + +/** @internal */ +export const encodeName = (type: string, id: string): string => `${type.length}:${type}${id}` + +const lengthPrefix = /^(0|[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/test/ClusterName.test.ts b/packages/platform/cloudflare/test/ClusterName.test.ts new file mode 100644 index 00000000000..3bdbc0f86b5 --- /dev/null +++ b/packages/platform/cloudflare/test/ClusterName.test.ts @@ -0,0 +1,48 @@ +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")) + }) + }) +}) diff --git a/packages/platform/cloudflare/tsconfig.json b/packages/platform/cloudflare/tsconfig.json new file mode 100644 index 00000000000..e2a8ca19a0d --- /dev/null +++ b/packages/platform/cloudflare/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../../effect" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 693cec06eb2..01afe42bb3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,6 +436,21 @@ importers: specifier: workspace:^ version: link:../../effect + packages/platform/cloudflare: + devDependencies: + '@cloudflare/workers-types': + specifier: ^5.20260816.1 + version: 5.20260816.1 + 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 +1862,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 +4429,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 +7958,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 +10233,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( From dbad9ad3bf8a375d52d558e17a7e982315d44bb8 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 04:38:48 +0000 Subject: [PATCH 02/37] feat(platform-cloudflare): Durable Object classes with cheap entity constructor Adds the four SQLite-backed Durable Object classes the cluster binds: the shared entity class plus workflow, durable queue, and singleton placeholders. The entity constructor opens SQLite, ensures the mailbox tables, and re-arms the single alarm; user handlers are never built in the constructor. Includes a Miniflare smoke test for the bindings. Co-Authored-By: Claude Fable 5 --- .../src/CloudflareDurableObjects.ts | 75 +++++++++++++++++ packages/platform/cloudflare/src/index.ts | 5 ++ .../cloudflare/src/internal/entityStorage.ts | 68 +++++++++++++++ .../test/CloudflareDurableObjects.test.ts | 48 +++++++++++ .../cloudflare/test/EntityStorage.test.ts | 83 +++++++++++++++++++ .../cloudflare/test/fixtures/worker.ts | 24 ++++++ packages/platform/cloudflare/tsconfig.json | 5 +- 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 packages/platform/cloudflare/src/CloudflareDurableObjects.ts create mode 100644 packages/platform/cloudflare/src/internal/entityStorage.ts create mode 100644 packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts create mode 100644 packages/platform/cloudflare/test/EntityStorage.test.ts create mode 100644 packages/platform/cloudflare/test/fixtures/worker.ts diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts new file mode 100644 index 00000000000..b1c324699ba --- /dev/null +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -0,0 +1,75 @@ +/** + * 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 { ensureEntityStorage, rearmAlarm } from "./internal/entityStorage.ts" + +const notExposed = (className: string) => () => { + throw new Error( + `@effect/platform-cloudflare: ${className} is not exposed over fetch, use the same-Worker namespace binding` + ) +} + +/** + * 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 { + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env) + ensureEntityStorage(ctx.storage.sql) + void ctx.blockConcurrencyWhile(() => rearmAlarm(ctx.storage, ctx.storage.sql)) + } + + override fetch: () => never = notExposed("ClusterEntity") +} + +/** + * The workflow execution class. Placeholder for the Cloudflare workflow + * engine; it only reserves the binding for now. + * + * @category durable objects + * @since 4.0.0 + */ +export class ClusterWorkflow extends DurableObject { + override fetch: () => never = notExposed("ClusterWorkflow") +} + +/** + * The durable queue class. Placeholder for `DurableQueue`; one object per + * queue name. It only reserves the binding for now. + * + * @category durable objects + * @since 4.0.0 + */ +export class ClusterDurableQueue extends DurableObject { + override fetch: () => never = notExposed("ClusterDurableQueue") +} + +/** + * The singleton class. Placeholder for `Singleton`; one object per singleton + * name, woken by a Worker Cron Trigger. It only reserves the binding for now. + * + * @category durable objects + * @since 4.0.0 + */ +export class ClusterSingleton extends DurableObject { + override fetch: () => never = notExposed("ClusterSingleton") +} diff --git a/packages/platform/cloudflare/src/index.ts b/packages/platform/cloudflare/src/index.ts index d0ef284547e..9e093723c1c 100644 --- a/packages/platform/cloudflare/src/index.ts +++ b/packages/platform/cloudflare/src/index.ts @@ -8,3 +8,8 @@ * @since 4.0.0 */ export * as CloudflareCluster from "./CloudflareCluster.ts" + +/** + * @since 4.0.0 + */ +export * as CloudflareDurableObjects from "./CloudflareDurableObjects.ts" diff --git a/packages/platform/cloudflare/src/internal/entityStorage.ts b/packages/platform/cloudflare/src/internal/entityStorage.ts new file mode 100644 index 00000000000..4d321f35613 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityStorage.ts @@ -0,0 +1,68 @@ +/** + * 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 + */ + +/** @internal */ +export interface EntitySql { + exec(query: string, ...bindings: Array): { + toArray(): Array> + } +} + +/** @internal */ +export interface EntityAlarm { + getAlarm(): Promise + setAlarm(scheduledTime: number): Promise +} + +const ddl = [ + `CREATE TABLE IF NOT EXISTS cluster_messages ( + request_id TEXT PRIMARY KEY, + message_id TEXT UNIQUE, + tag TEXT NOT NULL, + payload TEXT, + headers TEXT, + trace_id TEXT, + span_id TEXT, + sampled INTEGER, + processed INTEGER NOT NULL DEFAULT 0, + last_reply_id TEXT, + deliver_at INTEGER + )`, + `CREATE TABLE IF NOT EXISTS cluster_replies ( + reply_id TEXT PRIMARY KEY, + request_id TEXT NOT NULL, + kind INTEGER NOT NULL, + payload TEXT NOT NULL, + sequence INTEGER, + acked INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE INDEX IF NOT EXISTS cluster_messages_deliver_at_idx + ON cluster_messages (processed, deliver_at)`, + `CREATE INDEX IF NOT EXISTS cluster_replies_request_id_idx + ON cluster_replies (request_id)` +] + +/** @internal */ +export const ensureEntityStorage = (sql: EntitySql): void => { + for (const statement of ddl) { + sql.exec(statement) + } +} + +/** @internal */ +export const rearmAlarm = async (alarm: EntityAlarm, sql: EntitySql): Promise => { + const rows = sql.exec( + "SELECT min(deliver_at) AS deliver_at FROM cluster_messages WHERE processed = 0 AND deliver_at IS NOT NULL" + ).toArray() + const deliverAt = rows[0]?.deliver_at + if (typeof deliverAt !== "number") return + const current = await alarm.getAlarm() + if (current === null || current > deliverAt) { + await alarm.setAlarm(deliverAt) + } +} diff --git a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts new file mode 100644 index 00000000000..d39ceff84bf --- /dev/null +++ b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts @@ -0,0 +1,48 @@ +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) +}) diff --git a/packages/platform/cloudflare/test/EntityStorage.test.ts b/packages/platform/cloudflare/test/EntityStorage.test.ts new file mode 100644 index 00000000000..96c8090028d --- /dev/null +++ b/packages/platform/cloudflare/test/EntityStorage.test.ts @@ -0,0 +1,83 @@ +import { ensureEntityStorage, rearmAlarm } from "@effect/platform-cloudflare/internal/entityStorage" +import { assert, describe, it } from "@effect/vitest" + +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 } + } +} + +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() + } +} + +describe("EntityStorage", () => { + describe("ensureEntityStorage", () => { + it("creates the mailbox tables idempotently", () => { + const sql = new FakeSql() + ensureEntityStorage(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) + assert.deepStrictEqual(sql.statements, [...first, ...first]) + }) + }) + + describe("rearmAlarm", () => { + it("does nothing without pending deliver_at rows", async () => { + const sql = new FakeSql() + const alarm = new FakeAlarm() + await rearmAlarm(alarm, sql) + assert.deepStrictEqual(alarm.setCalls, []) + }) + + it("arms the alarm at the earliest deliver_at", async () => { + const sql = new FakeSql() + sql.earliestDeliverAt = 1000 + const alarm = new FakeAlarm() + await rearmAlarm(alarm, sql) + assert.deepStrictEqual(alarm.setCalls, [1000]) + }) + + it("keeps an already earlier alarm", async () => { + const sql = new FakeSql() + sql.earliestDeliverAt = 1000 + const alarm = new FakeAlarm() + alarm.current = 500 + await rearmAlarm(alarm, sql) + assert.deepStrictEqual(alarm.setCalls, []) + }) + + it("moves a later alarm forward", async () => { + const sql = new FakeSql() + sql.earliestDeliverAt = 1000 + const alarm = new FakeAlarm() + alarm.current = 2000 + await rearmAlarm(alarm, sql) + assert.deepStrictEqual(alarm.setCalls, [1000]) + }) + }) +}) diff --git a/packages/platform/cloudflare/test/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts new file mode 100644 index 00000000000..086b3389b03 --- /dev/null +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -0,0 +1,24 @@ +export { + ClusterDurableQueue, + ClusterEntity, + ClusterSingleton, + ClusterWorkflow +} from "@effect/platform-cloudflare/CloudflareDurableObjects" + +export default { + async fetch(request: Request, env: Record): Promise { + const url = new URL(request.url) + const binding = url.pathname.slice(1) + const namespace = env[binding] + if (namespace === undefined) { + return new Response(`unknown binding: ${binding}`, { status: 404 }) + } + const stub = namespace.getByName(url.searchParams.get("name") ?? "4:User42") + 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 index e2a8ca19a0d..4cfa8204e59 100644 --- a/packages/platform/cloudflare/tsconfig.json +++ b/packages/platform/cloudflare/tsconfig.json @@ -4,5 +4,8 @@ "include": ["src"], "references": [ { "path": "../../effect" } - ] + ], + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + } } From 3ba563a345d4b5787fbc948fdeb4276543fc9113 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 04:42:11 +0000 Subject: [PATCH 03/37] feat(platform-cloudflare): CloudflareCluster.layer with Worker-side Sharding Provides the cluster Sharding service from the four Durable Object namespace bindings. Entity clients resolve their object with the length-prefixed name and getByName; unknown entity types fail at the Worker before contacting a Durable Object. Entity handlers register per EntityType at Worker init. Messaging paths land with the mailbox work. Co-Authored-By: Claude Fable 5 --- .../cloudflare/src/CloudflareCluster.ts | 148 ++++++++++++++++++ .../cloudflare/test/CloudflareCluster.test.ts | 100 ++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 packages/platform/cloudflare/test/CloudflareCluster.test.ts diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index 3b6d06b51bf..3273f820cf1 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -10,6 +10,15 @@ * * @since 4.0.0 */ +import type * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Stream from "effect/Stream" +import type * as Entity from "effect/unstable/cluster/Entity" +import * as RunnerAddress from "effect/unstable/cluster/RunnerAddress" +import * as ShardId from "effect/unstable/cluster/ShardId" +import { Sharding } from "effect/unstable/cluster/Sharding" +import * as Snowflake from "effect/unstable/cluster/Snowflake" import * as Internal from "./internal/clusterName.ts" /** @@ -53,3 +62,142 @@ export const encodeName: (type: string, id: string) => string = Internal.encodeN * @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 +} + +/** + * The synthetic runner address for a Durable Object, derived from its name. + * + * **Details** + * + * There is no runner fleet and no peer dialing on the Cloudflare path; the + * address only gives logs, metrics, and `Entity.CurrentRunnerAddress` a stable + * identity, with the port fixed to `0`. + * + * @category models + * @since 4.0.0 + */ +export const makeRunnerAddress = (objectName: string): RunnerAddress.RunnerAddress => RunnerAddress.make(objectName, 0) + +interface EntityRegistration { + readonly entity: Entity.Entity + readonly build: Effect.Effect + readonly options: Record | undefined + readonly context: Context.Context +} + +const notImplemented = (method: string) => + Effect.die( + new Error(`CloudflareCluster: ${method} is not implemented yet on the Cloudflare Durable Object path`) + ) + +const make = Effect.fnUntraced(function*(options: LayerOptions) { + const entities = new Map>() + for (const entity of options.entities) { + entities.set(entity.type, entity) + } + const registrations = new Map() + const snowflakeGen = yield* Snowflake.makeGenerator + + 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 makeStubClient = (entity: Entity.Entity, entityId: string) => { + options.entityNamespace.getByName(Internal.encodeName(entity.type, entityId)) + const target: Record = {} + return new Proxy(target, { + has: (_, tag) => entity.protocol.requests.has(tag as string), + get(target, tag) { + if (Object.hasOwn(target, tag)) { + return target[tag] + } else if (!entity.protocol.requests.has(tag as string)) { + return undefined + } + const method = () => notImplemented("entity messaging") + target[tag] = method + return method + } + }) + } + + const makeClient = (entity: Entity.Entity) => + entities.has(entity.type) + ? Effect.sync(() => (entityId: string) => makeStubClient(entity, entityId)) + : unknownEntity(entity) + + const registerEntity = ( + entity: Entity.Entity, + build: Effect.Effect, + buildOptions?: Record + ) => + Effect.contextWith((context: Context.Context) => { + if (!entities.has(entity.type)) { + return unknownEntity(entity) + } else if (registrations.has(entity.type)) { + return Effect.die( + new Error(`CloudflareCluster: handlers for entity type "${entity.type}" are already registered`) + ) + } + registrations.set(entity.type, { entity, build, options: buildOptions, context }) + return Effect.void + }) + + return Sharding.of({ + getRegistrationEvents: Stream.never, + getShardId: (_entityId, group) => ShardId.make(group, 1), + hasShardId: () => true, + getSnowflake: Effect.sync(() => snowflakeGen.nextUnsafe()), + isShutdown: Effect.succeed(false), + makeClient: makeClient as Sharding["Service"]["makeClient"], + registerEntity: registerEntity as Sharding["Service"]["registerEntity"], + registerSingleton: () => notImplemented("Sharding.registerSingleton"), + send: () => notImplemented("Sharding.send"), + sendOutgoing: () => notImplemented("Sharding.sendOutgoing"), + notify: () => notImplemented("Sharding.notify"), + reset: () => notImplemented("Sharding.reset"), + 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. `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. + * + * @category layers + * @since 4.0.0 + */ +export const layer = (options: LayerOptions): Layer.Layer => Layer.effect(Sharding)(make(options)) diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts new file mode 100644 index 00000000000..1257799814b --- /dev/null +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -0,0 +1,100 @@ +import * as CloudflareCluster from "@effect/platform-cloudflare/CloudflareCluster" +import { assert, describe, it } from "@effect/vitest" +import { Effect, Exit, Layer, Schema } from "effect" +import { Entity } from "effect/unstable/cluster" +import { Rpc } from "effect/unstable/rpc" + +const User = Entity.make("User", [ + Rpc.make("Ping", { success: Schema.String }) +]) + +const Counter = Entity.make("Counter", [ + Rpc.make("Increment") +]) + +class FakeNamespace { + readonly names: Array = [] + + getByName(name: string) { + this.names.push(name) + return { name } + } +} + +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("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("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("fails on duplicate handler registration", () => + Effect.gen(function*() { + const { options } = makeOptions() + const handlers = User.toLayer({ Ping: () => Effect.succeed("pong") }) + const exit = yield* Layer.build( + Layer.merge(handlers, User.toLayer({ Ping: () => Effect.succeed("pong2") })).pipe( + Layer.provide(CloudflareCluster.layer(options)) + ) + ).pipe(Effect.exit) + assert.isTrue(Exit.isFailure(exit)) + })) + + 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)) + })) + }) + + describe("makeRunnerAddress", () => { + it("derives a synthetic runner address from the object name", () => { + const address = CloudflareCluster.makeRunnerAddress("4:User42") + assert.strictEqual(address.host, "4:User42") + assert.strictEqual(address.port, 0) + }) + }) +}) From 953aaf59fcb97ec649713ea1b3dd27253223778b Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 04:43:17 +0000 Subject: [PATCH 04/37] docs(platform-cloudflare): wrangler example and changeset Co-Authored-By: Claude Fable 5 --- .changeset/cloudflare-cluster-scaffold.md | 9 +++ .changeset/config.json | 1 + packages/platform/cloudflare/README.md | 84 +++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 .changeset/cloudflare-cluster-scaffold.md create mode 100644 packages/platform/cloudflare/README.md diff --git a/.changeset/cloudflare-cluster-scaffold.md b/.changeset/cloudflare-cluster-scaffold.md new file mode 100644 index 00000000000..e36d3c7ab95 --- /dev/null +++ b/.changeset/cloudflare-cluster-scaffold.md @@ -0,0 +1,9 @@ +--- +"@effect/platform-cloudflare": minor +--- + +Add the `@effect/platform-cloudflare` package with the Worker and Durable +Object glue for running Effect Cluster on Cloudflare: the four SQLite-backed +Durable Object classes, the length-prefixed entity name encoding, and +`CloudflareCluster.layer` providing the cluster `Sharding` service from the +same-Worker namespace bindings. 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/packages/platform/cloudflare/README.md b/packages/platform/cloudflare/README.md new file mode 100644 index 00000000000..b50d5449a2d --- /dev/null +++ b/packages/platform/cloudflare/README.md @@ -0,0 +1,84 @@ +# @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", + ], + }, + ], +} +``` + +```ts +// src/worker.ts +import { CloudflareCluster } from "@effect/platform-cloudflare" +import { Effect, Layer, Schema } from "effect" +import { Entity } 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 clusterLayer = (env: Env) => + CounterLayer.pipe( + Layer.provideMerge(CloudflareCluster.layer({ + entities: [Counter], + entityNamespace: env.CLUSTER_ENTITY, + workflowNamespace: env.CLUSTER_WORKFLOW, + queueNamespace: env.CLUSTER_QUEUE, + singletonNamespace: env.CLUSTER_SINGLETON + })) + ) +``` + +`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. + +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 is user code on the Worker. + +## Documentation + +- [Effect website](https://effect.website) From 701c88eac52dbe30fd70ba54d2450da3fefb29cf Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 04:58:33 +0000 Subject: [PATCH 05/37] fix(platform-cloudflare): apply review findings - Exclude the package from deno check (workers-types globals are not visible to Deno) and ship @cloudflare/workers-types as a runtime dependency since the public types reference it. - Match core Sharding registerEntity semantics: duplicates are a no-op and registrations are removed when the registering scope closes. - Handle alarm() on ClusterEntity so an armed alarm cannot fire into a missing handler, and only block object construction on alarm work when a pending deliver_at row exists. - Replace async/await storage glue with Effect-based helpers typed against the official workers-types signatures. - Reject empty entity types in decodeName, single-source the ClusterName type, simplify the stub client to a plain record, align reply-table uniqueness with the cluster reply protocol, and fix JSDoc categories. Co-Authored-By: Claude Fable 5 --- deno.json | 1 + packages/platform/cloudflare/package.json | 4 +- .../cloudflare/src/CloudflareCluster.ts | 54 +++++++------- .../src/CloudflareDurableObjects.ts | 15 +++- .../cloudflare/src/internal/clusterName.ts | 8 +-- .../cloudflare/src/internal/entityStorage.ts | 42 ++++++----- .../cloudflare/test/CloudflareCluster.test.ts | 7 +- .../cloudflare/test/ClusterName.test.ts | 4 ++ .../cloudflare/test/EntityStorage.test.ts | 71 +++++++++++-------- pnpm-lock.yaml | 3 +- 10 files changed, 114 insertions(+), 95 deletions(-) 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/platform/cloudflare/package.json b/packages/platform/cloudflare/package.json index f7c23449c91..f9af75b6823 100644 --- a/packages/platform/cloudflare/package.json +++ b/packages/platform/cloudflare/package.json @@ -65,8 +65,10 @@ "peerDependencies": { "effect": "workspace:^" }, + "dependencies": { + "@cloudflare/workers-types": "^5.20260816.1" + }, "devDependencies": { - "@cloudflare/workers-types": "^5.20260816.1", "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 index 3273f820cf1..2d0874e2540 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -58,7 +58,7 @@ export const encodeName: (type: string, id: string) => string = Internal.encodeN * including non-canonical length prefixes. A Durable Object uses this to * recover its own address from `ctx.id.name`. * - * @category encoding + * @category decoding * @since 4.0.0 */ export const decodeName: (name: string) => ClusterName | undefined = Internal.decodeName @@ -94,7 +94,7 @@ export interface LayerOptions { * address only gives logs, metrics, and `Entity.CurrentRunnerAddress` a stable * identity, with the port fixed to `0`. * - * @category models + * @category constructors * @since 4.0.0 */ export const makeRunnerAddress = (objectName: string): RunnerAddress.RunnerAddress => RunnerAddress.make(objectName, 0) @@ -117,6 +117,8 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { entities.set(entity.type, entity) } const registrations = new Map() + // Snowflakes are isolate-local here (random machine id, no coordination). + // Persisted request ids on this path use uuidv7, not these snowflakes. const snowflakeGen = yield* Snowflake.makeGenerator const unknownEntity = (entity: Entity.Entity) => @@ -126,22 +128,14 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { ) ) + const stubMethod = () => notImplemented("entity messaging") const makeStubClient = (entity: Entity.Entity, entityId: string) => { options.entityNamespace.getByName(Internal.encodeName(entity.type, entityId)) - const target: Record = {} - return new Proxy(target, { - has: (_, tag) => entity.protocol.requests.has(tag as string), - get(target, tag) { - if (Object.hasOwn(target, tag)) { - return target[tag] - } else if (!entity.protocol.requests.has(tag as string)) { - return undefined - } - const method = () => notImplemented("entity messaging") - target[tag] = method - return method - } - }) + const client: Record = {} + for (const tag of entity.protocol.requests.keys()) { + client[tag] = stubMethod + } + return client } const makeClient = (entity: Entity.Entity) => @@ -149,22 +143,24 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { ? Effect.sync(() => (entityId: string) => makeStubClient(entity, entityId)) : unknownEntity(entity) - const registerEntity = ( + const registerEntity = Effect.fnUntraced(function*( entity: Entity.Entity, build: Effect.Effect, buildOptions?: Record - ) => - Effect.contextWith((context: Context.Context) => { - if (!entities.has(entity.type)) { - return unknownEntity(entity) - } else if (registrations.has(entity.type)) { - return Effect.die( - new Error(`CloudflareCluster: handlers for entity type "${entity.type}" are already registered`) - ) - } - registrations.set(entity.type, { entity, build, options: buildOptions, context }) - return Effect.void - }) + ) { + if (!entities.has(entity.type)) { + return yield* unknownEntity(entity) + } else if (registrations.has(entity.type)) { + return + } + const context = yield* Effect.context() + registrations.set(entity.type, { entity, build, options: buildOptions, context }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + registrations.delete(entity.type) + }) + ) + }) return Sharding.of({ getRegistrationEvents: Stream.never, diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index b1c324699ba..c88c87628a4 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -10,7 +10,8 @@ * @since 4.0.0 */ import { DurableObject } from "cloudflare:workers" -import { ensureEntityStorage, rearmAlarm } from "./internal/entityStorage.ts" +import * as Effect from "effect/Effect" +import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/entityStorage.ts" const notExposed = (className: string) => () => { throw new Error( @@ -34,10 +35,18 @@ const notExposed = (className: string) => () => { export class ClusterEntity extends DurableObject { constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env) - ensureEntityStorage(ctx.storage.sql) - void ctx.blockConcurrencyWhile(() => rearmAlarm(ctx.storage, ctx.storage.sql)) + const sql = ctx.storage.sql + ensureEntityStorage(sql) + const deliverAt = earliestDeliverAt(sql) + if (deliverAt !== undefined) { + void ctx.blockConcurrencyWhile(() => Effect.runPromise(armAlarm(ctx.storage, deliverAt))) + } } + // Scheduled rows cannot exist until the mailbox lands; handling the alarm + // here keeps an armed alarm from firing into a missing handler. + override alarm(): void {} + override fetch: () => never = notExposed("ClusterEntity") } diff --git a/packages/platform/cloudflare/src/internal/clusterName.ts b/packages/platform/cloudflare/src/internal/clusterName.ts index 4d48104e63f..20634c5f60f 100644 --- a/packages/platform/cloudflare/src/internal/clusterName.ts +++ b/packages/platform/cloudflare/src/internal/clusterName.ts @@ -1,13 +1,9 @@ -/** @internal */ -export interface ClusterName { - readonly type: string - readonly id: string -} +import type { ClusterName } from "../CloudflareCluster.ts" /** @internal */ export const encodeName = (type: string, id: string): string => `${type.length}:${type}${id}` -const lengthPrefix = /^(0|[1-9]\d*):/ +const lengthPrefix = /^([1-9]\d*):/ /** @internal */ export const decodeName = (name: string): ClusterName | undefined => { diff --git a/packages/platform/cloudflare/src/internal/entityStorage.ts b/packages/platform/cloudflare/src/internal/entityStorage.ts index 4d321f35613..2f08e5016fa 100644 --- a/packages/platform/cloudflare/src/internal/entityStorage.ts +++ b/packages/platform/cloudflare/src/internal/entityStorage.ts @@ -5,19 +5,11 @@ * * @internal */ +import type { DurableObjectStorage, SqlStorage } from "@cloudflare/workers-types" +import * as Effect from "effect/Effect" /** @internal */ -export interface EntitySql { - exec(query: string, ...bindings: Array): { - toArray(): Array> - } -} - -/** @internal */ -export interface EntityAlarm { - getAlarm(): Promise - setAlarm(scheduledTime: number): Promise -} +export type EntityAlarm = Pick const ddl = [ `CREATE TABLE IF NOT EXISTS cluster_messages ( @@ -39,30 +31,36 @@ const ddl = [ kind INTEGER NOT NULL, payload TEXT NOT NULL, sequence INTEGER, - acked INTEGER NOT NULL DEFAULT 0 + acked INTEGER NOT NULL DEFAULT 0, + UNIQUE (request_id, kind), + 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_request_id_idx - ON cluster_replies (request_id)` + ON cluster_messages (processed, deliver_at)` ] /** @internal */ -export const ensureEntityStorage = (sql: EntitySql): void => { +export const ensureEntityStorage = (sql: SqlStorage): void => { for (const statement of ddl) { sql.exec(statement) } } /** @internal */ -export const rearmAlarm = async (alarm: EntityAlarm, sql: EntitySql): Promise => { +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() const deliverAt = rows[0]?.deliver_at - if (typeof deliverAt !== "number") return - const current = await alarm.getAlarm() - if (current === null || current > deliverAt) { - await alarm.setAlarm(deliverAt) - } + return typeof deliverAt === "number" ? deliverAt : 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/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index 1257799814b..d75551dd133 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -66,16 +66,15 @@ describe("CloudflareCluster", () => { ) })) - it.effect("fails on duplicate handler registration", () => + it.effect("ignores duplicate handler registration", () => Effect.gen(function*() { const { options } = makeOptions() const handlers = User.toLayer({ Ping: () => Effect.succeed("pong") }) - const exit = yield* Layer.build( + yield* Layer.build( Layer.merge(handlers, User.toLayer({ Ping: () => Effect.succeed("pong2") })).pipe( Layer.provide(CloudflareCluster.layer(options)) ) - ).pipe(Effect.exit) - assert.isTrue(Exit.isFailure(exit)) + ) })) it.effect("fails when registering an entity type not bound at Worker init", () => diff --git a/packages/platform/cloudflare/test/ClusterName.test.ts b/packages/platform/cloudflare/test/ClusterName.test.ts index 3bdbc0f86b5..3166fdd3566 100644 --- a/packages/platform/cloudflare/test/ClusterName.test.ts +++ b/packages/platform/cloudflare/test/ClusterName.test.ts @@ -44,5 +44,9 @@ describe("ClusterName", () => { 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/EntityStorage.test.ts b/packages/platform/cloudflare/test/EntityStorage.test.ts index 96c8090028d..e0b8db581fe 100644 --- a/packages/platform/cloudflare/test/EntityStorage.test.ts +++ b/packages/platform/cloudflare/test/EntityStorage.test.ts @@ -1,5 +1,8 @@ -import { ensureEntityStorage, rearmAlarm } from "@effect/platform-cloudflare/internal/entityStorage" +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 = [] @@ -12,6 +15,10 @@ class FakeSql { : [] return { toArray: () => rows } } + + get sql(): SqlStorage { + return this as unknown as SqlStorage + } } class FakeAlarm { @@ -26,13 +33,17 @@ class FakeAlarm { 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) + ensureEntityStorage(sql.sql) const first = [...sql.statements] assert.isAtLeast(first.length, 1) for (const statement of first) { @@ -41,43 +52,45 @@ describe("EntityStorage", () => { assert.isTrue(first.some((statement) => statement.includes("cluster_messages"))) assert.isTrue(first.some((statement) => statement.includes("cluster_replies"))) - ensureEntityStorage(sql) + ensureEntityStorage(sql.sql) assert.deepStrictEqual(sql.statements, [...first, ...first]) }) }) - describe("rearmAlarm", () => { - it("does nothing without pending deliver_at rows", async () => { - const sql = new FakeSql() - const alarm = new FakeAlarm() - await rearmAlarm(alarm, sql) - assert.deepStrictEqual(alarm.setCalls, []) + describe("earliestDeliverAt", () => { + it("returns undefined without pending deliver_at rows", () => { + assert.isUndefined(earliestDeliverAt(new FakeSql().sql)) }) - it("arms the alarm at the earliest deliver_at", async () => { + it("returns the earliest pending deliver_at", () => { const sql = new FakeSql() sql.earliestDeliverAt = 1000 - const alarm = new FakeAlarm() - await rearmAlarm(alarm, sql) - assert.deepStrictEqual(alarm.setCalls, [1000]) + assert.strictEqual(earliestDeliverAt(sql.sql), 1000) }) + }) - it("keeps an already earlier alarm", async () => { - const sql = new FakeSql() - sql.earliestDeliverAt = 1000 - const alarm = new FakeAlarm() - alarm.current = 500 - await rearmAlarm(alarm, sql) - assert.deepStrictEqual(alarm.setCalls, []) - }) + 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("moves a later alarm forward", async () => { - const sql = new FakeSql() - sql.earliestDeliverAt = 1000 - const alarm = new FakeAlarm() - alarm.current = 2000 - await rearmAlarm(alarm, sql) - 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/pnpm-lock.yaml b/pnpm-lock.yaml index 01afe42bb3a..6faf39508c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -437,10 +437,11 @@ importers: version: link:../../effect packages/platform/cloudflare: - devDependencies: + dependencies: '@cloudflare/workers-types': specifier: ^5.20260816.1 version: 5.20260816.1 + devDependencies: effect: specifier: workspace:^ version: link:../../effect From 5841eb17b3a9ce8640fcc6d2ee7d4abf18872aad Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 05:40:18 +0000 Subject: [PATCH 06/37] feat(platform-cloudflare): add durable entity mailbox --- .../cloudflare/src/CloudflareCluster.ts | 222 ++++++++++-- .../src/CloudflareDurableObjects.ts | 199 +++++++++++ .../cloudflare/src/internal/entityMailbox.ts | 222 ++++++++++++ .../cloudflare/src/internal/entityRegistry.ts | 33 ++ .../cloudflare/src/internal/entityRuntime.ts | 129 +++++++ .../cloudflare/src/internal/entityStorage.ts | 13 +- .../cloudflare/src/internal/entityWire.ts | 112 ++++++ .../cloudflare/test/CloudflareCluster.test.ts | 88 ++++- .../test/CloudflareDurableObjects.test.ts | 25 ++ .../cloudflare/test/EntityMailbox.test.ts | 321 ++++++++++++++++++ .../cloudflare/test/EntityRuntime.test.ts | 177 ++++++++++ .../cloudflare/test/fixtures/worker.ts | 60 ++++ 12 files changed, 1558 insertions(+), 43 deletions(-) create mode 100644 packages/platform/cloudflare/src/internal/entityMailbox.ts create mode 100644 packages/platform/cloudflare/src/internal/entityRegistry.ts create mode 100644 packages/platform/cloudflare/src/internal/entityRuntime.ts create mode 100644 packages/platform/cloudflare/src/internal/entityWire.ts create mode 100644 packages/platform/cloudflare/test/EntityMailbox.test.ts create mode 100644 packages/platform/cloudflare/test/EntityRuntime.test.ts diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index 2d0874e2540..a28f0b916ac 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -10,16 +10,24 @@ * * @since 4.0.0 */ -import type * as Context from "effect/Context" +import { Clock } from "effect/Clock" +import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" +import * as Schema from "effect/Schema" import * as Stream from "effect/Stream" +import { MailboxFull, PersistenceError } from "effect/unstable/cluster/ClusterError" 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 RunnerAddress from "effect/unstable/cluster/RunnerAddress" import * as ShardId from "effect/unstable/cluster/ShardId" import { Sharding } from "effect/unstable/cluster/Sharding" -import * as Snowflake from "effect/unstable/cluster/Snowflake" +import type * as Rpc from "effect/unstable/rpc/Rpc" +import * as RpcClient from "effect/unstable/rpc/RpcClient" import * as Internal from "./internal/clusterName.ts" +import { registerEntity as registerEntityHandler, unregisterEntity } from "./internal/entityRegistry.ts" +import { decodeReplyFor } from "./internal/entityWire.ts" /** * A Durable Object name decoded back into its entity address parts. @@ -99,27 +107,53 @@ export interface LayerOptions { */ export const makeRunnerAddress = (objectName: string): RunnerAddress.RunnerAddress => RunnerAddress.make(objectName, 0) -interface EntityRegistration { - readonly entity: Entity.Entity - readonly build: Effect.Effect - readonly options: Record | undefined - readonly context: Context.Context -} - 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) => Promise<{ + readonly requestId: string + readonly replies: ReadonlyArray + readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined + }> + readonly acknowledge: (requestId: string, replyId: string) => Promise> + readonly reset?: (requestId: string) => void +} + +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 make = Effect.fnUntraced(function*(options: LayerOptions) { const entities = new Map>() for (const entity of options.entities) { entities.set(entity.type, entity) } - const registrations = new Map() - // Snowflakes are isolate-local here (random machine id, no coordination). - // Persisted request ids on this path use uuidv7, not these snowflakes. - const snowflakeGen = yield* Snowflake.makeGenerator + const clock = yield* Clock + const requestTargets = new Map() const unknownEntity = (entity: Entity.Entity) => Effect.die( @@ -128,20 +162,140 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { ) ) - const stubMethod = () => notImplemented("entity messaging") - const makeStubClient = (entity: Entity.Entity, entityId: string) => { - options.entityNamespace.getByName(Internal.encodeName(entity.type, entityId)) - const client: Record = {} - for (const tag of entity.protocol.requests.keys()) { - client[tag] = stubMethod + 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 } - return client - } + const entries = new Map() + let client!: Effect.Success>> + + 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: entry.clientRequestId as any, + values: reply.values + }) + } + entries.delete(entry.clientRequestId) + return client.write({ + _tag: "Exit", + clientId: 0, + requestId: entry.clientRequestId as any, + exit: reply.exit + }) + }), + { discard: true } + ) + + client = yield* RpcClient.makeNoSerialization(entity.protocol, { + spanPrefix: `${entity.type}.client`, + supportsAck: true, + generateRequestId: () => uuidV7(clock.currentTimeMillisUnsafe()) as any, + onFromClient({ context, discard, message }): 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 = JSON.stringify({ + _tag: "Request", + requestId: clientRequestId, + address: { + shardId: target.address.shardId, + entityType: target.address.entityType, + entityId: target.address.entityId + }, + 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) + return Effect.promise(() => target.stub.invoke(envelope, discard)).pipe( + Effect.flatMap((result) => { + if (result.error === "MailboxFull") { + return Effect.fail(new MailboxFull({ address: target.address }) as MailboxFull | PersistenceError) + } else if (result.error === "EncodedMessageTooLarge") { + return Effect.fail( + new PersistenceError({ cause: new Error("Encoded entity message exceeds 2 MB") }) as + | MailboxFull + | PersistenceError + ) + } + entry.storageRequestId = result.requestId + requestTargets.set(clientRequestId, { stub: target.stub, storageRequestId: result.requestId }) + return discard ? Effect.void : deliverReplies(entry, result.replies) + }) + ) + }) + } + 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": { + entries.delete(String(message.requestId)) + return Effect.void + } + default: + return Effect.void + } + } + }) - const makeClient = (entity: Entity.Entity) => - entities.has(entity.type) - ? Effect.sync(() => (entityId: string) => makeStubClient(entity, entityId)) - : unknownEntity(entity) + 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()) { + result[tag] = (payload: unknown, methodOptions?: { readonly context?: Context.Context }) => + (client.client as any)[tag](payload, { + ...methodOptions, + context: methodOptions?.context === undefined + ? target + : Context.merge(methodOptions.context, target) + }) + } + return result as any + } + }) const registerEntity = Effect.fnUntraced(function*( entity: Entity.Entity, @@ -150,14 +304,13 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { ) { if (!entities.has(entity.type)) { return yield* unknownEntity(entity) - } else if (registrations.has(entity.type)) { - return } const context = yield* Effect.context() - registrations.set(entity.type, { entity, build, options: buildOptions, context }) + const registration = { entity, build: build as any, options: buildOptions, context } + if (!registerEntityHandler(entity.type, registration)) return yield* Effect.addFinalizer(() => Effect.sync(() => { - registrations.delete(entity.type) + unregisterEntity(entity.type, registration) }) ) }) @@ -166,7 +319,7 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { getRegistrationEvents: Stream.never, getShardId: (_entityId, group) => ShardId.make(group, 1), hasShardId: () => true, - getSnowflake: Effect.sync(() => snowflakeGen.nextUnsafe()), + getSnowflake: Effect.sync(() => uuidV7(clock.currentTimeMillisUnsafe()) as any), isShutdown: Effect.succeed(false), makeClient: makeClient as Sharding["Service"]["makeClient"], registerEntity: registerEntity as Sharding["Service"]["registerEntity"], @@ -174,7 +327,14 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { send: () => notImplemented("Sharding.send"), sendOutgoing: () => notImplemented("Sharding.sendOutgoing"), notify: () => notImplemented("Sharding.notify"), - reset: () => notImplemented("Sharding.reset"), + reset: (requestId) => { + const target = requestTargets.get(String(requestId)) + if (target === undefined || target.stub.reset === undefined) return Effect.succeed(false) + return Effect.sync(() => { + target.stub.reset!(target.storageRequestId) + return true + }) + }, pollStorage: notImplemented("Sharding.pollStorage"), activeEntityCount: Effect.succeed(0) }) diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index c88c87628a4..86f839dd0c5 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -10,8 +10,34 @@ * @since 4.0.0 */ import { DurableObject } from "cloudflare:workers" +import * as Context from "effect/Context" import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import { Persisted } from "effect/unstable/cluster/ClusterSchema" +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 type * as Reply from "effect/unstable/cluster/Reply" +import * as ShardId from "effect/unstable/cluster/ShardId" +import type * as Rpc from "effect/unstable/rpc/Rpc" +import { decodeName } from "./internal/clusterName.ts" +import { + ackChunk, + completeTell, + EncodedMessageTooLargeError, + loadMessage, + loadNextReply, + loadUnprocessed, + MailboxFullError, + persistRequest, + resetMessage, + saveReply +} from "./internal/entityMailbox.ts" +import { type EntityRegistration, getEntityRegistration } from "./internal/entityRegistry.ts" +import { makeEntityRuntime } from "./internal/entityRuntime.ts" import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/entityStorage.ts" +import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts" const notExposed = (className: string) => () => { throw new Error( @@ -19,6 +45,8 @@ const notExposed = (className: string) => () => { ) } +type EntityRuntime = Effect.Success> + /** * The shared entity class. One instance holds one entity address; the handlers * for every `EntityType` are registered at Worker init. @@ -33,8 +61,21 @@ const notExposed = (className: string) => () => { * @since 4.0.0 */ export class ClusterEntity extends DurableObject { + readonly #state: DurableObjectState + readonly #address: EntityAddress.EntityAddress + #runtime: EntityRuntime | undefined + #serial: Promise = Promise.resolve() + constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env) + this.#state = ctx + const name = decodeName(ctx.id.name ?? "") + if (name === undefined) throw new Error("ClusterEntity requires a canonical entity Durable Object name") + this.#address = EntityAddress.make({ + shardId: ShardId.make("default", 1), + entityType: EntityType.make(name.type), + entityId: EntityId.make(name.id) + }) const sql = ctx.storage.sql ensureEntityStorage(sql) const deliverAt = earliestDeliverAt(sql) @@ -47,6 +88,164 @@ export class ClusterEntity extends DurableObject { // here keeps an armed alarm from firing into a missing handler. override alarm(): void {} + /** @internal Same-Worker RPC transport used by `CloudflareCluster.layer`. */ + invoke(envelopeText: string, discard: boolean): Promise<{ + readonly requestId: string + readonly replies: ReadonlyArray + readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined + }> { + const operation = this.#serial.then(() => Effect.runPromise(this.#invoke(envelopeText, discard))) + this.#serial = operation.then(() => void 0, () => void 0) + return operation + } + + #invoke(envelopeText: string, discard: boolean) { + const registration = getEntityRegistration(this.#address.entityType) + if (registration === undefined) { + return Effect.die(`No handlers registered for entity type: ${this.#address.entityType}`) + } + const storage = this.#state.storage + const getRuntime = this.#getRuntime.bind(this) + const run = this.#run.bind(this) + const runStored = this.#runStored.bind(this) + return Effect.gen(function*() { + const runtime = yield* getRuntime(registration) + yield* Effect.forEach( + loadUnprocessed(storage.sql), + (row) => runStored(registration, runtime, row.envelope, row.lastSentChunk, row.discard), + { discard: true } + ) + + 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 replies = yield* run(registration, runtime, envelope, undefined, discard, false) + return { requestId: String(envelope.requestId), replies } + } + + const result = yield* Effect.result(Effect.catchDefect( + Effect.sync(() => + storage.transactionSync(() => + persistRequest( + storage.sql, + envelopeText, + Envelope.primaryKey(envelope), + discard + ) + ) + ), + (error) => Effect.fail(error) + )) + if (result._tag === "Failure") { + if (result.failure instanceof MailboxFullError) { + return { requestId: String(envelope.requestId), replies: [], error: "MailboxFull" as const } + } else if (result.failure instanceof EncodedMessageTooLargeError) { + return { requestId: String(envelope.requestId), replies: [], error: "EncodedMessageTooLarge" as const } + } + return yield* Effect.die(result.failure) + } + const persistedResult = result.success + if (persistedResult._tag === "Duplicate") { + const nextReply = loadNextReply(storage.sql, persistedResult.originalId) + if (nextReply !== undefined) { + return { requestId: persistedResult.originalId, replies: [nextReply] } + } + if (persistedResult.processed) { + return { requestId: persistedResult.originalId, replies: [] } + } + const original = loadMessage(storage.sql, persistedResult.originalId) + if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") + const replies = yield* runStored( + registration, + runtime, + original.envelope, + original.lastSentChunk, + original.discard + ) + return { requestId: persistedResult.originalId, replies } + } + const replies = yield* run(registration, runtime, envelope, undefined, discard, true) + return { requestId: String(envelope.requestId), replies } + }) + } + + /** @internal Acknowledges a streamed chunk. */ + acknowledge(requestId: string, replyId: string): Promise> { + this.#state.storage.transactionSync(() => ackChunk(this.#state.storage.sql, requestId, replyId)) + const nextReply = loadNextReply(this.#state.storage.sql, requestId) + return Promise.resolve(nextReply === undefined ? [] : [nextReply]) + } + + /** @internal */ + clearReplies(requestId: string): void { + this.#state.storage.transactionSync(() => resetMessage(this.#state.storage.sql, requestId)) + } + + /** @internal */ + reset(requestId: string): void { + this.#state.storage.transactionSync(() => resetMessage(this.#state.storage.sql, requestId)) + } + + #getRuntime(registration: EntityRegistration) { + if (this.#runtime !== undefined) return Effect.succeed(this.#runtime) + return Effect.map(makeEntityRuntime(registration, this.#address, () => crypto.randomUUID()), (runtime) => { + this.#runtime = runtime + return runtime + }) + } + + #runStored( + registration: EntityRegistration, + runtime: EntityRuntime, + envelopeText: string, + lastSentChunk: string | undefined, + discard: boolean + ) { + return Effect.flatMap( + decodeRequest(registration, envelopeText), + (envelope) => this.#run(registration, runtime, envelope, lastSentChunk, discard, true) + ) + } + + #run( + registration: EntityRegistration, + runtime: EntityRuntime, + envelope: Envelope.Request.Any, + lastSentChunkText: string | undefined, + discard: boolean, + persisted: boolean + ): Effect.Effect> { + const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps + const storage = this.#state.storage + return Effect.gen(function*() { + const lastSentChunk = lastSentChunkText === undefined + ? Option.none() + : Option.filter( + Option.some(yield* decodeReplyFor(rpc, registration.context, lastSentChunkText)), + (reply): reply is Reply.Chunk => reply._tag === "Chunk" + ) + const replies: Array = [] + yield* runtime.run( + envelope, + lastSentChunk as any, + discard, + (reply) => + Effect.gen(function*() { + const encoded = yield* encodeReplyFor(registration, rpc, reply) + if (persisted) { + storage.transactionSync(() => saveReply(storage.sql, encoded)) + } + replies.push(encoded) + }) + ) + if (discard && persisted) completeTell(storage.sql, String(envelope.requestId)) + if (discard || !persisted) return replies + const nextReply = loadNextReply(storage.sql, String(envelope.requestId)) + return nextReply === undefined ? [] : [nextReply] + }) + } + override fetch: () => never = notExposed("ClusterEntity") } diff --git a/packages/platform/cloudflare/src/internal/entityMailbox.ts b/packages/platform/cloudflare/src/internal/entityMailbox.ts new file mode 100644 index 00000000000..e9c9d903d6b --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityMailbox.ts @@ -0,0 +1,222 @@ +/** + * SQLite mailbox primitives for a single entity Durable Object. + * + * @internal + */ +import type { SqlStorage } from "@cloudflare/workers-types" + +/** @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 lastReceivedReply: string | undefined + readonly processed: boolean +} + +const encodedSize = (text: string): number => new TextEncoder().encode(text).byteLength + +/** @internal */ +export const persistRequest = ( + sql: SqlStorage, + envelopeText: string, + primaryKey: string | null, + discard = false +): PersistResult => { + if (encodedSize(envelopeText) > maximumEncodedSize) { + throw 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.processed, 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 = ? OR (? IS NOT NULL AND m.message_id = ?) + LIMIT 1`, + envelope.requestId, + primaryKey, + primaryKey + ).toArray()[0] + if (existing !== undefined) { + return { + _tag: "Duplicate", + originalId: String(existing.request_id), + lastReceivedReply: typeof existing.last_reply === "string" ? existing.last_reply : undefined, + processed: Number(existing.processed) === 1 + } + } + + const count = sql.exec( + `SELECT COUNT(*) AS count + FROM cluster_messages m + WHERE m.processed = 0 OR EXISTS ( + SELECT 1 FROM cluster_replies r + WHERE r.request_id = m.request_id AND r.kind = 'Chunk' AND r.acked = 0 + )` + ).toArray()[0]?.count + if (Number(count) >= mailboxCapacity) { + throw 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) + VALUES (?, ?, ?, ?, 0, NULL)`, + envelope.requestId, + primaryKey, + envelopeText, + discard ? 1 : 0 + ) + return { _tag: "Success" } +} + +/** @internal */ +export const saveReply = (sql: SqlStorage, replyText: string): void => { + if (encodedSize(replyText) > maximumEncodedSize) { + throw 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 + ) +} + +/** @internal */ +export const loadUnprocessed = (sql: SqlStorage): Array<{ + readonly envelope: string + readonly lastSentChunk: string | undefined + readonly discard: boolean +}> => + sql.exec( + `SELECT m.envelope, m.discard, 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 + ORDER BY m.rowid ASC` + ).toArray().map((row) => ({ + envelope: String(row.envelope), + lastSentChunk: typeof row.last_reply === "string" ? row.last_reply : undefined, + discard: Number(row.discard) === 1 + })) + +/** @internal */ +export const loadMessage = (sql: SqlStorage, requestId: string): { + readonly envelope: string + readonly lastSentChunk: string | undefined + readonly discard: boolean +} | undefined => { + const row = sql.exec( + `SELECT m.envelope, m.discard, 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 : { + envelope: String(row.envelope), + lastSentChunk: typeof row.last_reply === "string" ? row.last_reply : undefined, + discard: Number(row.discard) === 1 + } +} + +/** @internal */ +export const loadNextReply = (sql: SqlStorage, requestId: string): string | undefined => { + const row = sql.exec( + `SELECT reply + 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 + FROM cluster_replies + WHERE request_id = ? AND kind = 'WithExit' + LIMIT 1`, + requestId + ).toArray()[0] + return typeof row?.reply === "string" ? row.reply : undefined +} + +/** @internal */ +export const completeTell = (sql: SqlStorage, requestId: string): void => { + 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): void => { + 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): void => { + 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 + ) +} + +/** @internal */ +export const resetMessage = clearReplies diff --git a/packages/platform/cloudflare/src/internal/entityRegistry.ts b/packages/platform/cloudflare/src/internal/entityRegistry.ts new file mode 100644 index 00000000000..6560f458c80 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityRegistry.ts @@ -0,0 +1,33 @@ +/** @internal */ +import type * as Context from "effect/Context" +import type * as Effect from "effect/Effect" +import type * as Entity from "effect/unstable/cluster/Entity" + +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 registrations = new Map() + +/** @internal */ +export const getEntityRegistration = (type: string): EntityRegistration | undefined => registrations.get(type) + +/** @internal */ +export const registerEntity = (type: string, registration: EntityRegistration): boolean => { + if (registrations.has(type)) return false + registrations.set(type, registration) + return true +} + +/** @internal */ +export const unregisterEntity = (type: string, registration: EntityRegistration): void => { + if (registrations.get(type) === registration) registrations.delete(type) +} diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts new file mode 100644 index 00000000000..c55d2461c8f --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -0,0 +1,129 @@ +/** @internal */ +import * as Cause from "effect/Cause" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Option from "effect/Option" +import type * as Schedule from "effect/Schedule" +import * as Scope from "effect/Scope" +import * as Stream from "effect/Stream" +import { CurrentAddress, CurrentRunnerAddress, Request } from "effect/unstable/cluster/Entity" +import type * as EntityAddress from "effect/unstable/cluster/EntityAddress" +import type * 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 type { EntityRegistration } from "./entityRegistry.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 +) { + let cached: CachedHandlers | undefined + + const invalidate = Effect.fnUntraced(function*() { + if (cached === undefined) return + const scope = cached.scope + cached = undefined + yield* Scope.close(scope, Exit.void) + }) + + const getHandlers = Effect.fnUntraced(function*() { + if (cached !== undefined) return cached + const scope = yield* Scope.make() + const context = registration.context.pipe( + Context.add(CurrentAddress, address), + Context.add(CurrentRunnerAddress, RunnerAddress.make(`${address.entityType}/${address.entityId}`, 0)), + Context.add(Scope.Scope, scope) + ) + const handlers = yield* Effect.provideContext(registration.build, context) + return cached = { handlers, context, scope } + }) + + const runWithDefectRetry = (effect: Effect.Effect) => + Effect.flatMap(Effect.exit(effect), (first) => { + if ( + Exit.isSuccess(first) || !Cause.hasDies(first.cause) || registration.options?.defectRetryPolicy === undefined + ) { + return Effect.succeed(first) + } + return Effect.exit( + Effect.retry(effect, registration.options.defectRetryPolicy as Schedule.Schedule) + ) + }) + + 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 request = new Request({ ...envelope, lastSentChunk }) + const result = handler(request) + const unwrapped = Rpc.isWrapper(result as object) ? result.value : result + const streamSchemas = RpcSchema.getStreamSchemas(rpc.successSchema) + let sequence = Option.match(lastSentChunk, { + onNone: () => 0, + onSome: (chunk) => chunk.sequence + 1 + }) + + const execute = Option.isSome(streamSchemas) + ? 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 respond(reply) + }) + : unwrapped as Effect.Effect + + const exit = yield* Effect.provideContext(runWithDefectRetry(execute), entry.context) + if (!discard) { + yield* respond( + new Reply.WithExit({ + requestId: envelope.requestId, + id: nextId() as any, + exit: Option.isSome(streamSchemas) && Exit.isSuccess(exit) ? Exit.void : exit as any + }) + ) + } + if (Exit.isFailure(exit) && Cause.hasDies(exit.cause)) { + yield* rebuildAfterDefect + } + }) + + return { run, invalidate } as const +}) diff --git a/packages/platform/cloudflare/src/internal/entityStorage.ts b/packages/platform/cloudflare/src/internal/entityStorage.ts index 2f08e5016fa..f93407043ba 100644 --- a/packages/platform/cloudflare/src/internal/entityStorage.ts +++ b/packages/platform/cloudflare/src/internal/entityStorage.ts @@ -15,12 +15,8 @@ const ddl = [ `CREATE TABLE IF NOT EXISTS cluster_messages ( request_id TEXT PRIMARY KEY, message_id TEXT UNIQUE, - tag TEXT NOT NULL, - payload TEXT, - headers TEXT, - trace_id TEXT, - span_id TEXT, - sampled INTEGER, + envelope TEXT NOT NULL, + discard INTEGER NOT NULL DEFAULT 0, processed INTEGER NOT NULL DEFAULT 0, last_reply_id TEXT, deliver_at INTEGER @@ -28,11 +24,10 @@ const ddl = [ `CREATE TABLE IF NOT EXISTS cluster_replies ( reply_id TEXT PRIMARY KEY, request_id TEXT NOT NULL, - kind INTEGER NOT NULL, - payload TEXT NOT NULL, + reply TEXT NOT NULL, + kind TEXT NOT NULL, sequence INTEGER, acked INTEGER NOT NULL DEFAULT 0, - UNIQUE (request_id, kind), UNIQUE (request_id, sequence) )`, `CREATE INDEX IF NOT EXISTS cluster_messages_deliver_at_idx diff --git a/packages/platform/cloudflare/src/internal/entityWire.ts b/packages/platform/cloudflare/src/internal/entityWire.ts new file mode 100644 index 00000000000..1a7a31ccd1a --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityWire.ts @@ -0,0 +1,112 @@ +/** @internal */ +import type * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +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 + +/** @internal */ +export const decodeRequest = ( + registration: EntityRegistration, + envelopeText: string +): Effect.Effect => + 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 + }).pipe(Effect.provideContext(registration.context as any), Effect.orDie) as Effect.Effect + +/** @internal */ +export const encodeReplyFor = ( + registration: EntityRegistration, + rpc: Rpc.AnyWithProps, + reply: Reply.Reply +): Effect.Effect => { + if (reply._tag === "WithExit") { + return 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 }) + ).pipe( + Effect.provideContext(registration.context as any), + Effect.orDie + ) as Effect.Effect + } + return Option.match(RpcSchema.getStreamSchemas(rpc.successSchema), { + onNone: () => Effect.die(`Expected a stream RPC: ${rpc._tag}`), + onSome: (schemas) => + Effect.map( + Schema.encodeUnknownEffect(Schema.toCodecJson(Schema.NonEmptyArray(schemas.success)))(reply.values), + (values) => + JSON.stringify({ + _tag: "Chunk" as const, + requestId: String(reply.requestId), + id: String(reply.id), + sequence: reply.sequence, + values + }) + ) + }).pipe( + Effect.provideContext(registration.context as any), + Effect.orDie + ) as Effect.Effect +} + +/** @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 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 }) + ).pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect> + } + return Option.match(RpcSchema.getStreamSchemas(rpc.successSchema), { + onNone: () => Effect.die(`Expected a stream RPC: ${rpc._tag}`), + onSome: (schemas) => + Effect.map( + Schema.decodeUnknownEffect(Schema.toCodecJson(Schema.NonEmptyArray(schemas.success)))(encoded.values), + (values) => + new Reply.Chunk({ + requestId: encoded.requestId as any, + id: encoded.id as any, + sequence: encoded.sequence, + values + }) + ) + }).pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect> +} diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index d75551dd133..275ac4f133e 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -1,8 +1,8 @@ import * as CloudflareCluster from "@effect/platform-cloudflare/CloudflareCluster" import { assert, describe, it } from "@effect/vitest" -import { Effect, Exit, Layer, Schema } from "effect" +import { Effect, Exit, Layer, Schema, Stream } from "effect" import { Entity } from "effect/unstable/cluster" -import { Rpc } from "effect/unstable/rpc" +import { Rpc, RpcSchema } from "effect/unstable/rpc" const User = Entity.make("User", [ Rpc.make("Ping", { success: Schema.String }) @@ -12,12 +12,17 @@ const Counter = Entity.make("Counter", [ Rpc.make("Increment") ]) +const Events = Entity.make("Events", [ + Rpc.make("Numbers", { success: RpcSchema.Stream(Schema.Number, Schema.Never) }) +]) + class FakeNamespace { readonly names: Array = [] + constructor(readonly stub: object = {}) {} getByName(name: string) { this.names.push(name) - return { name } + return this.stub } } @@ -45,6 +50,83 @@ describe("CloudflareCluster", () => { assert.deepStrictEqual(entityNamespace.names, ["4:User42"]) })) + 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({ + 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({ + 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("fails for an entity type not bound at Worker init", () => Effect.gen(function*() { const { entityNamespace, options } = makeOptions() diff --git a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts index d39ceff84bf..fb8c4d543de 100644 --- a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts +++ b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts @@ -45,4 +45,29 @@ describe("CloudflareDurableObjects", () => { 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) }) diff --git a/packages/platform/cloudflare/test/EntityMailbox.test.ts b/packages/platform/cloudflare/test/EntityMailbox.test.ts new file mode 100644 index 00000000000..d06024092ca --- /dev/null +++ b/packages/platform/cloudflare/test/EntityMailbox.test.ts @@ -0,0 +1,321 @@ +import type { SqlStorage } from "@cloudflare/workers-types" +import { + ackChunk, + clearReplies, + completeTell, + EncodedMessageTooLargeError, + loadNextReply, + loadUnprocessed, + MailboxFullError, + maximumEncodedSize, + persistRequest, + saveReply +} from "@effect/platform-cloudflare/internal/entityMailbox" +import { assert, describe, it } from "@effect/vitest" + +interface MessageRow { + readonly request_id: string + readonly message_id: string | null + readonly envelope: string + readonly discard: number + processed: number + last_reply_id: 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 || 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] = bindings as [string, string | null, string, number] + this.messages.set(requestId, { + request_id: requestId, + message_id: messageId, + envelope, + discard, + processed: 0, + last_reply_id: null + }) + 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("WHERE m.processed = 0")) { + return this.rows( + Array.from(this.messages.values()) + .filter((row) => row.processed === 0) + .map((row) => ({ + envelope: row.envelope, + last_reply: row.last_reply_id === null ? null : this.replies.get(row.last_reply_id), + discard: row.discard + })) + ) + } + 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) }]) + } + 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 }]) + } + 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("persists a durable request before its handler can run", () => { + const sql = new FakeSql() + const result = 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("maps a primary-key duplicate to the original request and last reply", () => { + const sql = new FakeSql() + const primaryKey = "Counter/one/Increment/operation-1" + persistRequest(sql.sql, envelope, primaryKey) + const reply = JSON.stringify({ + _tag: "WithExit", + requestId, + id: "reply-1", + exit: { _tag: "Success", value: 1 } + }) + saveReply(sql.sql, reply) + + assert.deepStrictEqual( + persistRequest(sql.sql, withRequestId("0198bd72-6a81-72f1-8d87-5e9b5cf1e001"), primaryKey), + { _tag: "Duplicate", originalId: requestId, lastReceivedReply: reply, processed: true } + ) + }) + + it("replays an unprocessed row with its last sent chunk after a crash", () => { + const sql = new FakeSql() + persistRequest(sql.sql, envelope, null) + const chunk = JSON.stringify({ + _tag: "Chunk", + requestId, + id: "chunk-1", + sequence: 0, + values: [1] + }) + saveReply(sql.sql, chunk) + + assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ envelope, lastSentChunk: chunk, discard: false }]) + }) + + it("marks a persisted tell complete without storing a user-visible reply", () => { + const sql = new FakeSql() + persistRequest(sql.sql, envelope, null) + completeTell(sql.sql, requestId) + + assert.strictEqual(sql.messages.get(requestId)?.processed, 1) + assert.strictEqual(sql.replies.size, 0) + assert.deepStrictEqual(loadUnprocessed(sql.sql), []) + }) + + it("acknowledges stream chunks and clearReplies resumes the request", () => { + const sql = new FakeSql() + persistRequest(sql.sql, envelope, null) + const chunk = JSON.stringify({ + _tag: "Chunk", + requestId, + id: "chunk-1", + sequence: 0, + values: [1] + }) + saveReply(sql.sql, chunk) + ackChunk(sql.sql, requestId, "chunk-1") + assert.isTrue(sql.acked.has("chunk-1")) + + clearReplies(sql.sql, requestId) + assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ envelope, lastSentChunk: undefined, discard: false }]) + assert.strictEqual(sql.replies.size, 0) + }) + + it("rejects the 4097th unprocessed request", () => { + 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 + }) + } + assert.throws(() => persistRequest(sql.sql, envelope, null), MailboxFullError) + }) + + it("counts a completed stream with an unacknowledged chunk against capacity", () => { + 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 + }) + } + persistRequest(sql.sql, envelope, null) + saveReply(sql.sql, JSON.stringify({ _tag: "Chunk", requestId, id: "chunk", sequence: 0, values: [1] })) + saveReply( + sql.sql, + JSON.stringify({ + _tag: "WithExit", + requestId, + id: "terminal", + exit: { _tag: "Success", value: null } + }) + ) + + assert.throws( + () => persistRequest(sql.sql, withRequestId("0198bd72-6a83-72f1-8d87-5e9b5cf1e003"), null), + MailboxFullError + ) + }) + + it("rejects encoded requests and chunks over 2 MB", () => { + const sql = new FakeSql() + const largeRequest = JSON.stringify({ ...JSON.parse(envelope), payload: "x".repeat(maximumEncodedSize) }) + assert.throws(() => persistRequest(sql.sql, largeRequest, null), EncodedMessageTooLargeError) + + const largeChunk = JSON.stringify({ + _tag: "Chunk", + requestId, + id: "chunk-large", + sequence: 0, + values: ["x".repeat(maximumEncodedSize)] + }) + assert.throws(() => saveReply(sql.sql, largeChunk), EncodedMessageTooLargeError) + }) + + it("releases persisted stream replies one chunk per acknowledgement", () => { + const sql = new FakeSql() + 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 } + }) + saveReply(sql.sql, chunk0) + saveReply(sql.sql, chunk1) + saveReply(sql.sql, terminal) + + assert.strictEqual(loadNextReply(sql.sql, requestId), chunk0) + ackChunk(sql.sql, requestId, "chunk-0") + assert.strictEqual(loadNextReply(sql.sql, requestId), chunk1) + ackChunk(sql.sql, requestId, "chunk-1") + assert.strictEqual(loadNextReply(sql.sql, requestId), terminal) + }) + + it("retains tell discard mode for crash replay", () => { + const sql = new FakeSql() + persistRequest(sql.sql, envelope, null, true) + + assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ envelope, lastSentChunk: undefined, discard: true }]) + }) +}) diff --git a/packages/platform/cloudflare/test/EntityRuntime.test.ts b/packages/platform/cloudflare/test/EntityRuntime.test.ts new file mode 100644 index 00000000000..c62c0b484d3 --- /dev/null +++ b/packages/platform/cloudflare/test/EntityRuntime.test.ts @@ -0,0 +1,177 @@ +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, Effect, Exit, Option, Schedule, Schema, Stream } from "effect" +import { 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("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("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("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(1) }, + 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, 2) + 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/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts index 086b3389b03..13d0d3495ea 100644 --- a/packages/platform/cloudflare/test/fixtures/worker.ts +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -4,10 +4,70 @@ export { ClusterSingleton, ClusterWorkflow } from "@effect/platform-cloudflare/CloudflareDurableObjects" +import { registerEntity } from "@effect/platform-cloudflare/internal/entityRegistry" +import { Context, Effect, Schema } from "effect" +import { ClusterSchema, Entity } from "effect/unstable/cluster" +import { Rpc } 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 Mailbox = Entity.make("Mailbox", [Add, AddVolatile, Get]) +const values = new Map() + +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) + })), + options: undefined, + context: Context.empty() +}) export default { async fetch(request: Request, env: Record): Promise { const url = new URL(request.url) + if (url.pathname === "/mailbox") { + const stub = env.CLUSTER_ENTITY.getByName("7:Mailboxcounter") + const tag = url.searchParams.get("tag") ?? "Get" + const operationId = url.searchParams.get("operationId") ?? "operation" + const requestId = crypto.randomUUID() + try { + const result = await stub.invoke( + JSON.stringify({ + _tag: "Request", + requestId, + address: { + shardId: { group: "default", id: 1 }, + entityType: "Mailbox", + entityId: "counter" + }, + tag, + payload: tag === "Get" ? null : { operationId }, + headers: {} + }), + tag !== "Get" + ) + 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) { From 7d904b2a6154a576a7778977d18fccc1c4019b49 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 05:48:31 +0000 Subject: [PATCH 07/37] fix(platform-cloudflare): use public stream schema API --- .../cloudflare/src/internal/entityRuntime.ts | 6 +- .../cloudflare/src/internal/entityWire.ts | 57 +++++++++---------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index c55d2461c8f..da7228e0e30 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -91,13 +91,13 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( const request = new Request({ ...envelope, lastSentChunk }) const result = handler(request) const unwrapped = Rpc.isWrapper(result as object) ? result.value : result - const streamSchemas = RpcSchema.getStreamSchemas(rpc.successSchema) + const streamSchema = RpcSchema.isStreamSchema(rpc.successSchema) ? rpc.successSchema : undefined let sequence = Option.match(lastSentChunk, { onNone: () => 0, onSome: (chunk) => chunk.sequence + 1 }) - const execute = Option.isSome(streamSchemas) + const execute = streamSchema !== undefined ? Stream.runForEachArray(unwrapped as Stream.Stream, (values) => { if (discard) return Effect.void const reply = new Reply.Chunk({ @@ -116,7 +116,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( new Reply.WithExit({ requestId: envelope.requestId, id: nextId() as any, - exit: Option.isSome(streamSchemas) && Exit.isSuccess(exit) ? Exit.void : exit as any + exit: streamSchema !== undefined && Exit.isSuccess(exit) ? Exit.void : exit as any }) ) } diff --git a/packages/platform/cloudflare/src/internal/entityWire.ts b/packages/platform/cloudflare/src/internal/entityWire.ts index 1a7a31ccd1a..82c111ecd63 100644 --- a/packages/platform/cloudflare/src/internal/entityWire.ts +++ b/packages/platform/cloudflare/src/internal/entityWire.ts @@ -1,7 +1,6 @@ /** @internal */ import type * as Context from "effect/Context" import * as Effect from "effect/Effect" -import * as Option from "effect/Option" import * as Schema from "effect/Schema" import * as EntityAddress from "effect/unstable/cluster/EntityAddress" import * as EntityId from "effect/unstable/cluster/EntityId" @@ -62,21 +61,20 @@ export const encodeReplyFor = ( Effect.orDie ) as Effect.Effect } - return Option.match(RpcSchema.getStreamSchemas(rpc.successSchema), { - onNone: () => Effect.die(`Expected a stream RPC: ${rpc._tag}`), - onSome: (schemas) => - Effect.map( - Schema.encodeUnknownEffect(Schema.toCodecJson(Schema.NonEmptyArray(schemas.success)))(reply.values), - (values) => - JSON.stringify({ - _tag: "Chunk" as const, - requestId: String(reply.requestId), - id: String(reply.id), - sequence: reply.sequence, - values - }) - ) - }).pipe( + if (!RpcSchema.isStreamSchema(rpc.successSchema)) { + return Effect.die(`Expected a stream RPC: ${rpc._tag}`) + } + return Effect.map( + Schema.encodeUnknownEffect(Schema.toCodecJson(Schema.NonEmptyArray(rpc.successSchema.success)))(reply.values), + (values) => + JSON.stringify({ + _tag: "Chunk" as const, + requestId: String(reply.requestId), + id: String(reply.id), + sequence: reply.sequence, + values + }) + ).pipe( Effect.provideContext(registration.context as any), Effect.orDie ) as Effect.Effect @@ -95,18 +93,17 @@ export const decodeReplyFor = ( (exit) => new Reply.WithExit({ requestId: encoded.requestId as any, id: encoded.id as any, exit: exit as any }) ).pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect> } - return Option.match(RpcSchema.getStreamSchemas(rpc.successSchema), { - onNone: () => Effect.die(`Expected a stream RPC: ${rpc._tag}`), - onSome: (schemas) => - Effect.map( - Schema.decodeUnknownEffect(Schema.toCodecJson(Schema.NonEmptyArray(schemas.success)))(encoded.values), - (values) => - new Reply.Chunk({ - requestId: encoded.requestId as any, - id: encoded.id as any, - sequence: encoded.sequence, - values - }) - ) - }).pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect> + if (!RpcSchema.isStreamSchema(rpc.successSchema)) { + return Effect.die(`Expected a stream RPC: ${rpc._tag}`) + } + return Effect.map( + Schema.decodeUnknownEffect(Schema.toCodecJson(Schema.NonEmptyArray(rpc.successSchema.success)))(encoded.values), + (values) => + new Reply.Chunk({ + requestId: encoded.requestId as any, + id: encoded.id as any, + sequence: encoded.sequence, + values + }) + ).pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect> } From 85800fe8179b04dcff0a53d1484a219a9209995c Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 06:49:25 +0000 Subject: [PATCH 08/37] refactor(platform-cloudflare): simplify entity mailbox internals Co-Authored-By: Claude Fable 5 --- .../src/CloudflareDurableObjects.ts | 81 +++++----- .../cloudflare/src/internal/entityMailbox.ts | 38 ++--- .../cloudflare/src/internal/entityWire.ts | 145 ++++++++++-------- 3 files changed, 137 insertions(+), 127 deletions(-) diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 86f839dd0c5..4aff6040420 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -24,6 +24,7 @@ import type * as Rpc from "effect/unstable/rpc/Rpc" import { decodeName } from "./internal/clusterName.ts" import { ackChunk, + clearReplies, completeTell, EncodedMessageTooLargeError, loadMessage, @@ -31,7 +32,7 @@ import { loadUnprocessed, MailboxFullError, persistRequest, - resetMessage, + type PersistResult, saveReply } from "./internal/entityMailbox.ts" import { type EntityRegistration, getEntityRegistration } from "./internal/entityRegistry.ts" @@ -104,15 +105,12 @@ export class ClusterEntity extends DurableObject { if (registration === undefined) { return Effect.die(`No handlers registered for entity type: ${this.#address.entityType}`) } - const storage = this.#state.storage - const getRuntime = this.#getRuntime.bind(this) - const run = this.#run.bind(this) - const runStored = this.#runStored.bind(this) - return Effect.gen(function*() { - const runtime = yield* getRuntime(registration) + return Effect.gen({ self: this }, function*() { + const storage = this.#state.storage + const runtime = yield* this.#getRuntime(registration) yield* Effect.forEach( loadUnprocessed(storage.sql), - (row) => runStored(registration, runtime, row.envelope, row.lastSentChunk, row.discard), + (row) => this.#runStored(registration, runtime, row.envelope, row.lastSentChunk, row.discard), { discard: true } ) @@ -120,52 +118,48 @@ export class ClusterEntity extends DurableObject { const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps const isPersisted = Context.get(rpc.annotations, Persisted) if (!isPersisted) { - const replies = yield* run(registration, runtime, envelope, undefined, discard, false) + const replies = yield* this.#run(registration, runtime, envelope, undefined, discard, false) return { requestId: String(envelope.requestId), replies } } - const result = yield* Effect.result(Effect.catchDefect( - Effect.sync(() => - storage.transactionSync(() => - persistRequest( - storage.sql, - envelopeText, - Envelope.primaryKey(envelope), - discard - ) + let persisted: PersistResult + try { + persisted = storage.transactionSync(() => + persistRequest( + storage.sql, + envelopeText, + Envelope.primaryKey(envelope), + discard ) - ), - (error) => Effect.fail(error) - )) - if (result._tag === "Failure") { - if (result.failure instanceof MailboxFullError) { + ) + } catch (error) { + if (error instanceof MailboxFullError) { return { requestId: String(envelope.requestId), replies: [], error: "MailboxFull" as const } - } else if (result.failure instanceof EncodedMessageTooLargeError) { + } else if (error instanceof EncodedMessageTooLargeError) { return { requestId: String(envelope.requestId), replies: [], error: "EncodedMessageTooLarge" as const } } - return yield* Effect.die(result.failure) + return yield* Effect.die(error) } - const persistedResult = result.success - if (persistedResult._tag === "Duplicate") { - const nextReply = loadNextReply(storage.sql, persistedResult.originalId) + if (persisted._tag === "Duplicate") { + const nextReply = loadNextReply(storage.sql, persisted.originalId) if (nextReply !== undefined) { - return { requestId: persistedResult.originalId, replies: [nextReply] } + return { requestId: persisted.originalId, replies: [nextReply] } } - if (persistedResult.processed) { - return { requestId: persistedResult.originalId, replies: [] } + if (persisted.processed) { + return { requestId: persisted.originalId, replies: [] } } - const original = loadMessage(storage.sql, persistedResult.originalId) + const original = loadMessage(storage.sql, persisted.originalId) if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") - const replies = yield* runStored( + const replies = yield* this.#runStored( registration, runtime, original.envelope, original.lastSentChunk, original.discard ) - return { requestId: persistedResult.originalId, replies } + return { requestId: persisted.originalId, replies } } - const replies = yield* run(registration, runtime, envelope, undefined, discard, true) + const replies = yield* this.#run(registration, runtime, envelope, undefined, discard, true) return { requestId: String(envelope.requestId), replies } }) } @@ -179,12 +173,12 @@ export class ClusterEntity extends DurableObject { /** @internal */ clearReplies(requestId: string): void { - this.#state.storage.transactionSync(() => resetMessage(this.#state.storage.sql, requestId)) + this.#state.storage.transactionSync(() => clearReplies(this.#state.storage.sql, requestId)) } /** @internal */ reset(requestId: string): void { - this.#state.storage.transactionSync(() => resetMessage(this.#state.storage.sql, requestId)) + this.clearReplies(requestId) } #getRuntime(registration: EntityRegistration) { @@ -219,16 +213,15 @@ export class ClusterEntity extends DurableObject { const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps const storage = this.#state.storage return Effect.gen(function*() { - const lastSentChunk = lastSentChunkText === undefined - ? Option.none() - : Option.filter( - Option.some(yield* decodeReplyFor(rpc, registration.context, lastSentChunkText)), - (reply): reply is Reply.Chunk => reply._tag === "Chunk" - ) + let lastSentChunk = Option.none>() + if (lastSentChunkText !== undefined) { + const reply = yield* decodeReplyFor(rpc, registration.context, lastSentChunkText) + if (reply._tag === "Chunk") lastSentChunk = Option.some(reply) + } const replies: Array = [] yield* runtime.run( envelope, - lastSentChunk as any, + lastSentChunk, discard, (reply) => Effect.gen(function*() { diff --git a/packages/platform/cloudflare/src/internal/entityMailbox.ts b/packages/platform/cloudflare/src/internal/entityMailbox.ts index e9c9d903d6b..10a9531d4e4 100644 --- a/packages/platform/cloudflare/src/internal/entityMailbox.ts +++ b/packages/platform/cloudflare/src/internal/entityMailbox.ts @@ -31,7 +31,9 @@ export type PersistResult = { readonly processed: boolean } -const encodedSize = (text: string): number => new TextEncoder().encode(text).byteLength +const textEncoder = new TextEncoder() + +const encodedSize = (text: string): number => textEncoder.encode(text).byteLength /** @internal */ export const persistRequest = ( @@ -129,29 +131,30 @@ export const saveReply = (sql: SqlStorage, replyText: string): void => { } /** @internal */ -export const loadUnprocessed = (sql: SqlStorage): Array<{ +export interface StoredMessage { readonly envelope: string readonly lastSentChunk: string | undefined readonly discard: boolean -}> => +} + +const rowToMessage = (row: Record): StoredMessage => ({ + envelope: String(row.envelope), + lastSentChunk: typeof row.last_reply === "string" ? row.last_reply : undefined, + discard: Number(row.discard) === 1 +}) + +/** @internal */ +export const loadUnprocessed = (sql: SqlStorage): Array => sql.exec( `SELECT m.envelope, m.discard, 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 ORDER BY m.rowid ASC` - ).toArray().map((row) => ({ - envelope: String(row.envelope), - lastSentChunk: typeof row.last_reply === "string" ? row.last_reply : undefined, - discard: Number(row.discard) === 1 - })) + ).toArray().map(rowToMessage) /** @internal */ -export const loadMessage = (sql: SqlStorage, requestId: string): { - readonly envelope: string - readonly lastSentChunk: string | undefined - readonly discard: boolean -} | undefined => { +export const loadMessage = (sql: SqlStorage, requestId: string): StoredMessage | undefined => { const row = sql.exec( `SELECT m.envelope, m.discard, r.reply AS last_reply FROM cluster_messages m @@ -160,11 +163,7 @@ export const loadMessage = (sql: SqlStorage, requestId: string): { LIMIT 1`, requestId ).toArray()[0] - return row === undefined ? undefined : { - envelope: String(row.envelope), - lastSentChunk: typeof row.last_reply === "string" ? row.last_reply : undefined, - discard: Number(row.discard) === 1 - } + return row === undefined ? undefined : rowToMessage(row) } /** @internal */ @@ -217,6 +216,3 @@ export const clearReplies = (sql: SqlStorage, requestId: string): void => { requestId ) } - -/** @internal */ -export const resetMessage = clearReplies diff --git a/packages/platform/cloudflare/src/internal/entityWire.ts b/packages/platform/cloudflare/src/internal/entityWire.ts index 82c111ecd63..3dcb383c980 100644 --- a/packages/platform/cloudflare/src/internal/entityWire.ts +++ b/packages/platform/cloudflare/src/internal/entityWire.ts @@ -15,36 +15,49 @@ import type { EntityRegistration } from "./entityRegistry.ts" type EncodedRequest = Extract +const runWith = ( + effect: Effect.Effect, + context: Context.Context +): Effect.Effect => effect.pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect + +const chunkValuesCodec = (rpc: Rpc.AnyWithProps) => + RpcSchema.isStreamSchema(rpc.successSchema) + ? Schema.toCodecJson(Schema.NonEmptyArray(rpc.successSchema.success)) + : undefined + /** @internal */ export const decodeRequest = ( registration: EntityRegistration, envelopeText: string ): Effect.Effect => - 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 - }).pipe(Effect.provideContext(registration.context as any), Effect.orDie) as 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 = ( @@ -53,31 +66,32 @@ export const encodeReplyFor = ( reply: Reply.Reply ): Effect.Effect => { if (reply._tag === "WithExit") { - return 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 }) - ).pipe( - Effect.provideContext(registration.context as any), - Effect.orDie - ) as Effect.Effect + 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 + ) } - if (!RpcSchema.isStreamSchema(rpc.successSchema)) { + const codec = chunkValuesCodec(rpc) + if (codec === undefined) { return Effect.die(`Expected a stream RPC: ${rpc._tag}`) } - return Effect.map( - Schema.encodeUnknownEffect(Schema.toCodecJson(Schema.NonEmptyArray(rpc.successSchema.success)))(reply.values), - (values) => - JSON.stringify({ - _tag: "Chunk" as const, - requestId: String(reply.requestId), - id: String(reply.id), - sequence: reply.sequence, - values - }) - ).pipe( - Effect.provideContext(registration.context as any), - Effect.orDie - ) as Effect.Effect + 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 */ @@ -88,22 +102,29 @@ export const decodeReplyFor = ( ): Effect.Effect> => { const encoded = JSON.parse(replyText) as Reply.Encoded if (encoded._tag === "WithExit") { - return 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 }) - ).pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect> + 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 + ) } - if (!RpcSchema.isStreamSchema(rpc.successSchema)) { + const codec = chunkValuesCodec(rpc) + if (codec === undefined) { return Effect.die(`Expected a stream RPC: ${rpc._tag}`) } - return Effect.map( - Schema.decodeUnknownEffect(Schema.toCodecJson(Schema.NonEmptyArray(rpc.successSchema.success)))(encoded.values), - (values) => - new Reply.Chunk({ - requestId: encoded.requestId as any, - id: encoded.id as any, - sequence: encoded.sequence, - values - }) - ).pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect> + 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 + ) } From 06b4c892a41f9886f5153640e640233bbd9cba1e Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 07:11:22 +0000 Subject: [PATCH 09/37] fix(platform-cloudflare): address mailbox audit findings --- .../cloudflare/src/CloudflareCluster.ts | 36 +++- .../src/CloudflareDurableObjects.ts | 196 ++++++++++++++++-- .../cloudflare/src/internal/entityRuntime.ts | 19 +- .../cloudflare/test/CloudflareCluster.test.ts | 138 +++++++++++- .../test/CloudflareDurableObjects.test.ts | 38 ++++ .../cloudflare/test/EntityRuntime.test.ts | 39 ++++ .../cloudflare/test/fixtures/worker.ts | 54 ++++- 7 files changed, 484 insertions(+), 36 deletions(-) diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index a28f0b916ac..edfbc4cd7c8 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -17,6 +17,7 @@ import * as Layer from "effect/Layer" import * as Schema from "effect/Schema" import * as Stream from "effect/Stream" import { MailboxFull, PersistenceError } from "effect/unstable/cluster/ClusterError" +import { Persisted } from "effect/unstable/cluster/ClusterSchema" 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" @@ -119,7 +120,8 @@ interface EntityStub { readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined }> readonly acknowledge: (requestId: string, replyId: string) => Promise> - readonly reset?: (requestId: string) => void + readonly interrupt?: (requestId: string) => Promise + readonly reset?: (requestId: string) => Promise } interface ClientTargetValue { @@ -147,6 +149,8 @@ const uuidV7 = (timestamp: number): string => { .join("-") } +const requestTargetCapacity = 4096 + const make = Effect.fnUntraced(function*(options: LayerOptions) { const entities = new Map>() for (const entity of options.entities) { @@ -154,6 +158,16 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { } const clock = yield* Clock const requestTargets = new Map() + const rememberRequestTarget = ( + requestId: string, + target: { readonly stub: EntityStub; storageRequestId: string } + ): void => { + requestTargets.delete(requestId) + requestTargets.set(requestId, target) + if (requestTargets.size <= requestTargetCapacity) return + const oldest = requestTargets.keys().next().value + if (oldest !== undefined) requestTargets.delete(oldest) + } const unknownEntity = (entity: Entity.Entity) => Effect.die( @@ -250,7 +264,12 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { ) } entry.storageRequestId = result.requestId - requestTargets.set(clientRequestId, { stub: target.stub, storageRequestId: result.requestId }) + if (!discard && Context.get(rpc.annotations, Persisted)) { + rememberRequestTarget(clientRequestId, { + stub: target.stub, + storageRequestId: result.requestId + }) + } return discard ? Effect.void : deliverReplies(entry, result.replies) }) ) @@ -264,8 +283,12 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { ) } case "Interrupt": { - entries.delete(String(message.requestId)) - return Effect.void + const clientRequestId = String(message.requestId) + const entry = entries.get(clientRequestId) + entries.delete(clientRequestId) + requestTargets.delete(clientRequestId) + if (entry === undefined || target.stub.interrupt === undefined) return Effect.void + return Effect.promise(() => target.stub.interrupt!(entry.storageRequestId)) } default: return Effect.void @@ -330,10 +353,7 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { reset: (requestId) => { const target = requestTargets.get(String(requestId)) if (target === undefined || target.stub.reset === undefined) return Effect.succeed(false) - return Effect.sync(() => { - target.stub.reset!(target.storageRequestId) - return true - }) + return Effect.as(Effect.promise(() => target.stub.reset!(target.storageRequestId)), true) }, pollStorage: notImplemented("Sharding.pollStorage"), activeEntityCount: Effect.succeed(0) diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 4aff6040420..86b6ea5fbe7 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -10,15 +10,18 @@ * @since 4.0.0 */ import { DurableObject } from "cloudflare:workers" +import * as Cause from "effect/Cause" import * as Context from "effect/Context" import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Fiber from "effect/Fiber" import * as Option from "effect/Option" import { Persisted } from "effect/unstable/cluster/ClusterSchema" 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 type * as Reply from "effect/unstable/cluster/Reply" +import * as Reply from "effect/unstable/cluster/Reply" import * as ShardId from "effect/unstable/cluster/ShardId" import type * as Rpc from "effect/unstable/rpc/Rpc" import { decodeName } from "./internal/clusterName.ts" @@ -48,6 +51,28 @@ const notExposed = (className: string) => () => { type EntityRuntime = Effect.Success> +interface ReplySession { + readonly replies: Array + readonly takers: Array<{ + readonly resolve: (reply: string | undefined) => void + readonly reject: (error: unknown) => void + }> + done: boolean + failed: boolean + failure: unknown + ack: { + readonly replyId: string + readonly resolve: () => void + } | undefined + interrupt: (() => Promise) | undefined +} + +interface ReplayMessage { + readonly envelope: string + readonly lastSentChunk: string | undefined + readonly discard: boolean +} + /** * The shared entity class. One instance holds one entity address; the handlers * for every `EntityType` are registered at Worker init. @@ -66,6 +91,7 @@ export class ClusterEntity extends DurableObject { readonly #address: EntityAddress.EntityAddress #runtime: EntityRuntime | undefined #serial: Promise = Promise.resolve() + readonly #sessions = new Map() constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env) @@ -110,7 +136,10 @@ export class ClusterEntity extends DurableObject { const runtime = yield* this.#getRuntime(registration) yield* Effect.forEach( loadUnprocessed(storage.sql), - (row) => this.#runStored(registration, runtime, row.envelope, row.lastSentChunk, row.discard), + (row) => + this.#runStored(registration, runtime, row.envelope, row.lastSentChunk, row.discard).pipe( + Effect.catchCause((cause) => this.#completeReplayFailure(registration, row, cause)) + ), { discard: true } ) @@ -143,6 +172,7 @@ export class ClusterEntity extends DurableObject { if (persisted._tag === "Duplicate") { const nextReply = loadNextReply(storage.sql, persisted.originalId) if (nextReply !== undefined) { + this.#releaseTerminalSession(persisted.originalId, nextReply) return { requestId: persisted.originalId, replies: [nextReply] } } if (persisted.processed) { @@ -167,18 +197,41 @@ export class ClusterEntity extends DurableObject { /** @internal Acknowledges a streamed chunk. */ acknowledge(requestId: string, replyId: string): Promise> { this.#state.storage.transactionSync(() => ackChunk(this.#state.storage.sql, requestId, replyId)) + const session = this.#sessions.get(requestId) + if (session !== undefined) { + if (session.ack?.replyId === replyId) { + session.ack.resolve() + session.ack = undefined + return this.#takeReply(requestId, session) + } + const nextReply = loadNextReply(this.#state.storage.sql, requestId) + return Promise.resolve(nextReply === undefined ? [] : [nextReply]) + } const nextReply = loadNextReply(this.#state.storage.sql, requestId) return Promise.resolve(nextReply === undefined ? [] : [nextReply]) } + /** @internal Interrupts an in-memory handler execution. Persisted rows remain replayable. */ + interrupt(requestId: string): Promise { + const session = this.#sessions.get(requestId) + if (session === undefined) return Promise.resolve() + this.#sessions.delete(requestId) + session.ack?.resolve() + session.ack = undefined + session.done = true + for (const take of session.takers.splice(0)) take.resolve(undefined) + return session.interrupt?.() ?? Promise.resolve() + } + /** @internal */ clearReplies(requestId: string): void { this.#state.storage.transactionSync(() => clearReplies(this.#state.storage.sql, requestId)) } /** @internal */ - reset(requestId: string): void { + reset(requestId: string): Promise { this.clearReplies(requestId) + return Promise.resolve() } #getRuntime(registration: EntityRegistration) { @@ -202,6 +255,45 @@ export class ClusterEntity extends DurableObject { ) } + #completeReplayFailure( + registration: EntityRegistration, + row: ReplayMessage, + cause: Cause.Cause + ): Effect.Effect { + const storage = this.#state.storage + return Effect.suspend(() => { + const encoded = JSON.parse(row.envelope) as { readonly requestId?: unknown; readonly tag?: unknown } + if (typeof encoded.requestId !== "string") return Effect.void + if (row.discard || typeof encoded.tag !== "string") { + completeTell(storage.sql, encoded.requestId) + return Effect.void + } + const rpc = registration.entity.protocol.requests.get(encoded.tag) as Rpc.AnyWithProps | undefined + if (rpc === undefined) { + completeTell(storage.sql, encoded.requestId) + return Effect.void + } + return Effect.flatMap( + encodeReplyFor( + registration, + rpc, + new Reply.WithExit({ + requestId: encoded.requestId as any, + id: crypto.randomUUID() as any, + exit: Exit.failCause(cause) + }) + ), + (reply) => Effect.sync(() => storage.transactionSync(() => saveReply(storage.sql, reply))) + ).pipe( + Effect.catchCause(() => + Effect.sync(() => { + completeTell(storage.sql, encoded.requestId as string) + }) + ) + ) + }) + } + #run( registration: EntityRegistration, runtime: EntityRuntime, @@ -212,33 +304,113 @@ export class ClusterEntity extends DurableObject { ): Effect.Effect> { const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps const storage = this.#state.storage - return Effect.gen(function*() { + return Effect.gen({ self: this }, function*() { let lastSentChunk = Option.none>() if (lastSentChunkText !== undefined) { const reply = yield* decodeReplyFor(rpc, registration.context, lastSentChunkText) if (reply._tag === "Chunk") lastSentChunk = Option.some(reply) } - const replies: Array = [] - yield* runtime.run( + const requestId = String(envelope.requestId) + if (!discard) { + const active = this.#sessions.get(requestId) + if (active !== undefined) { + const nextReply = persisted ? loadNextReply(storage.sql, requestId) : undefined + return nextReply === undefined ? [] : [nextReply] + } + } + + const session = discard ? undefined : this.#makeSession(requestId) + const execution = runtime.run( envelope, lastSentChunk, discard, (reply) => - Effect.gen(function*() { + Effect.gen({ self: this }, function*() { const encoded = yield* encodeReplyFor(registration, rpc, reply) if (persisted) { storage.transactionSync(() => saveReply(storage.sql, encoded)) } - replies.push(encoded) + if (session !== undefined) { + yield* Effect.promise(() => this.#offerReply(session, encoded)) + } }) ) - if (discard && persisted) completeTell(storage.sql, String(envelope.requestId)) - if (discard || !persisted) return replies - const nextReply = loadNextReply(storage.sql, String(envelope.requestId)) - return nextReply === undefined ? [] : [nextReply] + if (discard) { + yield* execution + if (persisted) completeTell(storage.sql, requestId) + return [] + } + + const fiber = Effect.runFork(execution) + session!.interrupt = () => Effect.runPromise(Fiber.interrupt(fiber)) + const completion = Effect.runPromise(Fiber.await(fiber)).then((exit) => { + this.#finishSession(requestId, session!, exit) + }) + this.#state.waitUntil(completion) + return yield* Effect.promise(() => this.#takeReply(requestId, session!)) }) } + #makeSession(requestId: string): ReplySession { + const session: ReplySession = { + replies: [], + takers: [], + done: false, + failed: false, + failure: undefined, + ack: undefined, + interrupt: undefined + } + this.#sessions.set(requestId, session) + return session + } + + #offerReply(session: ReplySession, reply: string): Promise { + const encoded = JSON.parse(reply) as { readonly _tag?: unknown; readonly id?: unknown } + let acknowledged = Promise.resolve() + if (encoded._tag === "Chunk" && typeof encoded.id === "string") { + let resolve!: () => void + acknowledged = new Promise((resume) => { + resolve = resume + }) + session.ack = { replyId: encoded.id, resolve } + } + const take = session.takers.shift() + if (take === undefined) session.replies.push(reply) + else take.resolve(reply) + return acknowledged + } + + async #takeReply(requestId: string, session: ReplySession): Promise> { + const reply = session.replies.shift() ?? await (session.done + ? session.failed ? Promise.reject(session.failure) : Promise.resolve(undefined) + : new Promise((resolve, reject) => session.takers.push({ resolve, reject }))) + if (reply === undefined) return [] + this.#releaseTerminalSession(requestId, reply) + return [reply] + } + + #finishSession(requestId: string, session: ReplySession, exit: Exit.Exit): void { + session.done = true + if (Exit.isFailure(exit)) { + session.failed = true + session.failure = Cause.squash(exit.cause) + } + for (const take of session.takers.splice(0)) { + if (session.failed) take.reject(session.failure) + else take.resolve(undefined) + } + if (session.replies.length === 0 && session.ack === undefined) { + this.#sessions.delete(requestId) + } + } + + #releaseTerminalSession(requestId: string, reply: string): void { + if ((JSON.parse(reply) as { readonly _tag?: unknown })._tag === "WithExit") { + this.#sessions.delete(requestId) + } + } + override fetch: () => never = notExposed("ClusterEntity") } diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index da7228e0e30..5ba8e23d774 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -88,17 +88,19 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( return } - const request = new Request({ ...envelope, lastSentChunk }) - const result = handler(request) - const unwrapped = Rpc.isWrapper(result as object) ? result.value : result 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 = streamSchema !== undefined - ? Stream.runForEachArray(unwrapped as Stream.Stream, (values) => { + 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, @@ -106,9 +108,12 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( sequence: sequence++, values: values as any }) - return respond(reply) + return Effect.tap(respond(reply), () => + Effect.sync(() => { + currentLastSentChunk = Option.some(reply) + })) }) - : unwrapped as Effect.Effect + }) const exit = yield* Effect.provideContext(runWithDefectRetry(execute), entry.context) if (!discard) { diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index 275ac4f133e..d6fc370cf74 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -1,13 +1,17 @@ import * as CloudflareCluster from "@effect/platform-cloudflare/CloudflareCluster" import { assert, describe, it } from "@effect/vitest" -import { Effect, Exit, Layer, Schema, Stream } from "effect" -import { Entity } from "effect/unstable/cluster" +import { Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" +import { ClusterSchema, Entity, Sharding } from "effect/unstable/cluster" import { Rpc, RpcSchema } from "effect/unstable/rpc" const User = Entity.make("User", [ Rpc.make("Ping", { success: Schema.String }) ]) +const PersistedUser = Entity.make("PersistedUser", [ + Rpc.make("Ping", { success: Schema.String }).annotate(ClusterSchema.Persisted, true) +]) + const Counter = Entity.make("Counter", [ Rpc.make("Increment") ]) @@ -127,6 +131,136 @@ describe("CloudflareCluster", () => { }).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(interruptedRequestId: string) { + interruptions.push(interruptedRequestId) + 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]) + }).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({ + 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({ + 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() diff --git a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts index fb8c4d543de..9b8bc1618a4 100644 --- a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts +++ b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts @@ -70,4 +70,42 @@ describe("CloudflareDurableObjects", () => { assert.strictEqual(terminal._tag, "WithExit") assert.deepStrictEqual(terminal.exit, { _tag: "Success", value: 3 }) }), 60_000) + + it.effect( + "returns the first persisted stream chunk without waiting for the stream to end", + () => + Effect.gen(function*() { + const miniflare = yield* makeMiniflare + const result = yield* Effect.promise(() => + Promise.race([ + miniflare.dispatchFetch("http://placeholder/mailbox?tag=Watch").then(async (response) => + await response.json() as { readonly replies: ReadonlyArray } + ), + new Promise((_, reject) => setTimeout(() => reject(new Error("stream did not yield")), 2_000)) + ]) + ) + const first = JSON.parse(result.replies[0]) + + assert.strictEqual(first._tag, "Chunk") + assert.deepStrictEqual(first.values, [1]) + }), + 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) }) diff --git a/packages/platform/cloudflare/test/EntityRuntime.test.ts b/packages/platform/cloudflare/test/EntityRuntime.test.ts index c62c0b484d3..fa26620be11 100644 --- a/packages/platform/cloudflare/test/EntityRuntime.test.ts +++ b/packages/platform/cloudflare/test/EntityRuntime.test.ts @@ -100,6 +100,45 @@ describe("EntityRuntime", () => { 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 diff --git a/packages/platform/cloudflare/test/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts index 13d0d3495ea..9e9ce73df35 100644 --- a/packages/platform/cloudflare/test/fixtures/worker.ts +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -1,13 +1,13 @@ export { ClusterDurableQueue, - ClusterEntity, ClusterSingleton, ClusterWorkflow } from "@effect/platform-cloudflare/CloudflareDurableObjects" +import { ClusterEntity as BaseClusterEntity } from "@effect/platform-cloudflare/CloudflareDurableObjects" import { registerEntity } from "@effect/platform-cloudflare/internal/entityRegistry" -import { Context, Effect, Schema } from "effect" +import { Context, Effect, Schema, Stream } from "effect" import { ClusterSchema, Entity } from "effect/unstable/cluster" -import { Rpc } from "effect/unstable/rpc" +import { Rpc, RpcSchema } from "effect/unstable/rpc" const Add = Rpc.make("Add", { payload: { operationId: Schema.String }, @@ -17,8 +17,31 @@ const AddVolatile = Rpc.make("AddVolatile", { payload: { operationId: Schema.String } }) const Get = Rpc.make("Get", { success: Schema.Number }) -const Mailbox = Entity.make("Mailbox", [Add, AddVolatile, Get]) +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] + +export class ClusterEntity extends BaseClusterEntity { + readonly #testState: TestDurableObjectState + + constructor(ctx: TestDurableObjectState, env: unknown) { + super(ctx, env) + this.#testState = ctx + } + + 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 + ) + } +} registerEntity("Mailbox", { entity: Mailbox, @@ -31,7 +54,8 @@ registerEntity("Mailbox", { Effect.sync(() => { values.set(request.address.entityId, (values.get(request.address.entityId) ?? 0) + 1) }), - Get: (request) => Effect.sync(() => values.get(request.address.entityId) ?? 0) + Get: (request) => Effect.sync(() => values.get(request.address.entityId) ?? 0), + Watch: () => Stream.concat(Stream.make(1), Stream.never) })), options: undefined, context: Context.empty() @@ -40,6 +64,22 @@ registerEntity("Mailbox", { export default { async fetch(request: Request, env: Record): Promise { const url = new URL(request.url) + 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 === "/mailbox") { const stub = env.CLUSTER_ENTITY.getByName("7:Mailboxcounter") const tag = url.searchParams.get("tag") ?? "Get" @@ -56,10 +96,10 @@ export default { entityId: "counter" }, tag, - payload: tag === "Get" ? null : { operationId }, + payload: tag === "Get" || tag === "Watch" ? null : { operationId }, headers: {} }), - tag !== "Get" + tag === "Add" || tag === "AddVolatile" ) return Response.json(result) } catch (error) { From 4e2aa2d360a716bad55da977a7cbe97b586c3e6d Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 07:37:07 +0000 Subject: [PATCH 10/37] test(platform-cloudflare): cover stream acknowledgements --- .../test/CloudflareDurableObjects.test.ts | 57 +++++++++++++------ .../cloudflare/test/fixtures/worker.ts | 19 ++++++- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts index 9b8bc1618a4..914766b8005 100644 --- a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts +++ b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts @@ -71,26 +71,51 @@ describe("CloudflareDurableObjects", () => { assert.deepStrictEqual(terminal.exit, { _tag: "Success", value: 3 }) }), 60_000) - it.effect( - "returns the first persisted stream chunk without waiting for the stream to end", - () => - Effect.gen(function*() { - const miniflare = yield* makeMiniflare - const result = yield* Effect.promise(() => + 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/mailbox?tag=Watch").then(async (response) => - await response.json() as { readonly replies: ReadonlyArray } - ), - new Promise((_, reject) => setTimeout(() => reject(new Error("stream did not yield")), 2_000)) + 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 first = JSON.parse(result.replies[0]) - assert.strictEqual(first._tag, "Chunk") - assert.deepStrictEqual(first.values, [1]) - }), - 60_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*() { diff --git a/packages/platform/cloudflare/test/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts index 9e9ce73df35..76b0fb18aac 100644 --- a/packages/platform/cloudflare/test/fixtures/worker.ts +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -55,7 +55,15 @@ registerEntity("Mailbox", { values.set(request.address.entityId, (values.get(request.address.entityId) ?? 0) + 1) }), Get: (request) => Effect.sync(() => values.get(request.address.entityId) ?? 0), - Watch: () => Stream.concat(Stream.make(1), Stream.never) + 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() @@ -80,6 +88,15 @@ export default { })) 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 stub = env.CLUSTER_ENTITY.getByName("7:Mailboxcounter") const tag = url.searchParams.get("tag") ?? "Get" From bdeb29d07fa17891499eb2561d1232e316204412 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 08:05:33 +0000 Subject: [PATCH 11/37] feat(platform-cloudflare): add delayed entity delivery --- .../cloudflare/src/CloudflareCluster.ts | 114 ++++++++- .../src/CloudflareDurableObjects.ts | 218 +++++++++++++++--- .../cloudflare/src/internal/entityMailbox.ts | 59 +++-- .../cloudflare/src/internal/entityReply.ts | 31 +++ .../cloudflare/src/internal/entityRuntime.ts | 5 +- .../cloudflare/src/internal/entityStorage.ts | 3 +- .../cloudflare/test/CloudflareCluster.test.ts | 204 +++++++++++++++- .../test/CloudflareDurableObjects.test.ts | 99 ++++++++ .../cloudflare/test/EntityMailbox.test.ts | 49 +++- .../cloudflare/test/fixtures/worker.ts | 67 +++++- 10 files changed, 781 insertions(+), 68 deletions(-) create mode 100644 packages/platform/cloudflare/src/internal/entityReply.ts diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index edfbc4cd7c8..207ade88578 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -18,16 +18,20 @@ import * as Schema from "effect/Schema" import * as Stream from "effect/Stream" import { MailboxFull, PersistenceError } from "effect/unstable/cluster/ClusterError" import { Persisted } 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 RunnerAddress from "effect/unstable/cluster/RunnerAddress" import * as ShardId from "effect/unstable/cluster/ShardId" import { Sharding } from "effect/unstable/cluster/Sharding" import type * as Rpc from "effect/unstable/rpc/Rpc" import * as RpcClient from "effect/unstable/rpc/RpcClient" +import * as RpcSchema from "effect/unstable/rpc/RpcSchema" import * as Internal from "./internal/clusterName.ts" import { registerEntity as registerEntityHandler, unregisterEntity } from "./internal/entityRegistry.ts" +import { CurrentEntityName, registerReplyHandler, unregisterReplyHandler } from "./internal/entityReply.ts" import { decodeReplyFor } from "./internal/entityWire.ts" /** @@ -114,7 +118,11 @@ const notImplemented = (method: string) => ) interface EntityStub { - readonly invoke: (envelope: string, discard: boolean) => Promise<{ + readonly invoke: (envelope: string, discard: boolean, delivery?: { + readonly deliverAt: number + readonly primaryKey: string | null + readonly replyTo?: string | undefined + }) => Promise<{ readonly requestId: string readonly replies: ReadonlyArray readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined @@ -252,7 +260,49 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { storageRequestId: clientRequestId } if (!discard) entries.set(clientRequestId, entry) - return Effect.promise(() => target.stub.invoke(envelope, discard)).pipe( + 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 : Context.get(context, CurrentEntityName) + 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 }) } + : undefined + let replyHandler: ((reply: string) => Promise) | undefined + if (delivery?.replyTo !== undefined) { + replyHandler = async (reply) => { + unregisterReplyHandler(clientRequestId) + unregisterReplyHandler(entry.storageRequestId) + await Effect.runPromise(deliverReplies(entry, [reply])) + } + registerReplyHandler(clientRequestId, replyHandler) + } + return Effect.promise(() => target.stub.invoke(envelope, discard, delivery)).pipe( Effect.flatMap((result) => { if (result.error === "MailboxFull") { return Effect.fail(new MailboxFull({ address: target.address }) as MailboxFull | PersistenceError) @@ -264,14 +314,31 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { ) } entry.storageRequestId = result.requestId + if (replyHandler !== undefined && result.requestId !== clientRequestId) { + registerReplyHandler(result.requestId, replyHandler) + } if (!discard && Context.get(rpc.annotations, Persisted)) { rememberRequestTarget(clientRequestId, { stub: target.stub, storageRequestId: result.requestId }) } - return discard ? Effect.void : deliverReplies(entry, result.replies) - }) + const deliver = discard ? Effect.void : deliverReplies(entry, result.replies) + if (replyHandler === undefined || result.replies.length === 0) return deliver + return Effect.ensuring( + deliver, + Effect.sync(() => { + unregisterReplyHandler(clientRequestId) + unregisterReplyHandler(entry.storageRequestId) + }) + ) + }), + Effect.tapCause(() => + Effect.sync(() => { + unregisterReplyHandler(clientRequestId) + unregisterReplyHandler(entry.storageRequestId) + }) + ) ) }) } @@ -287,6 +354,8 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { const entry = entries.get(clientRequestId) entries.delete(clientRequestId) requestTargets.delete(clientRequestId) + unregisterReplyHandler(clientRequestId) + if (entry !== undefined) unregisterReplyHandler(entry.storageRequestId) if (entry === undefined || target.stub.interrupt === undefined) return Effect.void return Effect.promise(() => target.stub.interrupt!(entry.storageRequestId)) } @@ -308,13 +377,36 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { }) const result: Record = {} for (const tag of entity.protocol.requests.keys()) { - result[tag] = (payload: unknown, methodOptions?: { readonly context?: Context.Context }) => - (client.client as any)[tag](payload, { - ...methodOptions, - context: methodOptions?.context === undefined - ? target - : Context.merge(methodOptions.context, target) - }) + const rpc = entity.protocol.requests.get(tag)! as Rpc.AnyWithProps + const contextFor = ( + ambient: Context.Context, + methodOptions?: { readonly context?: Context.Context } + ) => { + const currentEntityName = Context.get(ambient, CurrentEntityName) + let requestContext = currentEntityName === undefined + ? target + : Context.add(target, CurrentEntityName, currentEntityName) + 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 } diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 86b6ea5fbe7..232a3b9854a 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -30,6 +30,7 @@ import { clearReplies, completeTell, EncodedMessageTooLargeError, + loadDue, loadMessage, loadNextReply, loadUnprocessed, @@ -39,6 +40,7 @@ import { saveReply } from "./internal/entityMailbox.ts" import { type EntityRegistration, getEntityRegistration } from "./internal/entityRegistry.ts" +import { deliverReply as deliverEntityReply } from "./internal/entityReply.ts" import { makeEntityRuntime } from "./internal/entityRuntime.ts" import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/entityStorage.ts" import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts" @@ -71,6 +73,31 @@ interface ReplayMessage { readonly envelope: string readonly lastSentChunk: string | undefined readonly discard: boolean + readonly deliverAt?: number | undefined + readonly replyTo?: string | undefined +} + +interface InvokeResult { + readonly requestId: string + readonly replies: ReadonlyArray + readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined +} + +interface InvokeOutcome { + readonly result: InvokeResult + readonly deferred?: Promise | undefined +} + +interface DeliveryOptions { + readonly deliverAt: number + readonly primaryKey: string | null + readonly replyTo?: string | undefined +} + +interface WorkerWaiter { + readonly clientRequestId: string + readonly resolve: (result: InvokeResult) => void + readonly reject: (error: unknown) => void } /** @@ -89,14 +116,18 @@ interface ReplayMessage { export class ClusterEntity extends DurableObject { readonly #state: DurableObjectState readonly #address: EntityAddress.EntityAddress + readonly #name: string #runtime: EntityRuntime | undefined #serial: Promise = Promise.resolve() readonly #sessions = new Map() + readonly #workerWaiters = new Map>() + readonly #workerWaiterTargets = new Map() constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env) this.#state = ctx - const name = decodeName(ctx.id.name ?? "") + this.#name = ctx.id.name ?? "" + const name = decodeName(this.#name) if (name === undefined) throw new Error("ClusterEntity requires a canonical entity Durable Object name") this.#address = EntityAddress.make({ shardId: ShardId.make("default", 1), @@ -111,22 +142,20 @@ export class ClusterEntity extends DurableObject { } } - // Scheduled rows cannot exist until the mailbox lands; handling the alarm - // here keeps an armed alarm from firing into a missing handler. - override alarm(): void {} + override alarm(): Promise { + const operation = this.#serial.then(() => Effect.runPromise(this.#runAlarm())) + this.#serial = operation.then(() => void 0, () => void 0) + return operation + } /** @internal Same-Worker RPC transport used by `CloudflareCluster.layer`. */ - invoke(envelopeText: string, discard: boolean): Promise<{ - readonly requestId: string - readonly replies: ReadonlyArray - readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined - }> { - const operation = this.#serial.then(() => Effect.runPromise(this.#invoke(envelopeText, discard))) + invoke(envelopeText: string, discard: boolean, delivery?: DeliveryOptions): Promise { + const operation = this.#serial.then(() => Effect.runPromise(this.#invoke(envelopeText, discard, delivery))) this.#serial = operation.then(() => void 0, () => void 0) - return operation + return operation.then((outcome) => outcome.deferred ?? outcome.result) } - #invoke(envelopeText: string, discard: boolean) { + #invoke(envelopeText: string, discard: boolean, delivery?: DeliveryOptions): Effect.Effect { const registration = getEntityRegistration(this.#address.entityType) if (registration === undefined) { return Effect.die(`No handlers registered for entity type: ${this.#address.entityType}`) @@ -137,7 +166,19 @@ export class ClusterEntity extends DurableObject { yield* Effect.forEach( loadUnprocessed(storage.sql), (row) => - this.#runStored(registration, runtime, row.envelope, row.lastSentChunk, row.discard).pipe( + this.#runStored( + registration, + runtime, + row.envelope, + row.lastSentChunk, + row.discard, + row.deliverAt === undefined + ? undefined + : { + scheduled: true, + ...(row.replyTo === undefined ? undefined : { replyTo: row.replyTo }) + } + ).pipe( Effect.catchCause((cause) => this.#completeReplayFailure(registration, row, cause)) ), { discard: true } @@ -148,7 +189,7 @@ export class ClusterEntity extends DurableObject { const isPersisted = Context.get(rpc.annotations, Persisted) if (!isPersisted) { const replies = yield* this.#run(registration, runtime, envelope, undefined, discard, false) - return { requestId: String(envelope.requestId), replies } + return { result: { requestId: String(envelope.requestId), replies } } } let persisted: PersistResult @@ -157,15 +198,19 @@ export class ClusterEntity extends DurableObject { persistRequest( storage.sql, envelopeText, - Envelope.primaryKey(envelope), - discard + delivery?.primaryKey ?? Envelope.primaryKey(envelope), + discard, + delivery?.deliverAt, + delivery?.replyTo ) ) } catch (error) { if (error instanceof MailboxFullError) { - return { requestId: String(envelope.requestId), replies: [], error: "MailboxFull" as const } + return { result: { requestId: String(envelope.requestId), replies: [], error: "MailboxFull" as const } } } else if (error instanceof EncodedMessageTooLargeError) { - return { requestId: String(envelope.requestId), replies: [], error: "EncodedMessageTooLarge" as const } + return { + result: { requestId: String(envelope.requestId), replies: [], error: "EncodedMessageTooLarge" as const } + } } return yield* Effect.die(error) } @@ -173,10 +218,19 @@ export class ClusterEntity extends DurableObject { const nextReply = loadNextReply(storage.sql, persisted.originalId) if (nextReply !== undefined) { this.#releaseTerminalSession(persisted.originalId, nextReply) - return { requestId: persisted.originalId, replies: [nextReply] } + return { result: { requestId: persisted.originalId, replies: [nextReply] } } } if (persisted.processed) { - return { requestId: persisted.originalId, replies: [] } + return { result: { requestId: persisted.originalId, replies: [] } } + } + if (delivery !== undefined) { + yield* this.#armEarliestAlarm() + return this.#delayedOutcome( + persisted.originalId, + discard, + delivery.replyTo, + String(envelope.requestId) + ) } const original = loadMessage(storage.sql, persisted.originalId) if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") @@ -187,13 +241,34 @@ export class ClusterEntity extends DurableObject { original.lastSentChunk, original.discard ) - return { requestId: persisted.originalId, replies } + return { result: { requestId: persisted.originalId, replies } } + } + if (delivery !== undefined) { + yield* this.#armEarliestAlarm() + return this.#delayedOutcome(String(envelope.requestId), discard, delivery.replyTo) } const replies = yield* this.#run(registration, runtime, envelope, undefined, discard, true) - return { requestId: String(envelope.requestId), replies } + return { result: { requestId: String(envelope.requestId), replies } } }) } + #delayedOutcome( + requestId: string, + discard: boolean, + replyTo: string | undefined, + clientRequestId = requestId + ): InvokeOutcome { + const result = { requestId, replies: [] } + if (discard || replyTo !== undefined) return { result } + const deferred = new Promise((resolve, reject) => { + const waiters = this.#workerWaiters.get(requestId) ?? [] + waiters.push({ clientRequestId, resolve, reject }) + this.#workerWaiters.set(requestId, waiters) + this.#workerWaiterTargets.set(clientRequestId, requestId) + }) + return { result, deferred } + } + /** @internal Acknowledges a streamed chunk. */ acknowledge(requestId: string, replyId: string): Promise> { this.#state.storage.transactionSync(() => ackChunk(this.#state.storage.sql, requestId, replyId)) @@ -213,6 +288,18 @@ export class ClusterEntity extends DurableObject { /** @internal Interrupts an in-memory handler execution. Persisted rows remain replayable. */ interrupt(requestId: string): Promise { + const storageRequestId = this.#workerWaiterTargets.get(requestId) ?? requestId + const waiters = this.#workerWaiters.get(storageRequestId) + if (waiters !== undefined) { + const remaining = waiters.filter((waiter) => { + if (waiter.clientRequestId !== requestId) return true + waiter.reject(new Error("Delayed entity request interrupted")) + return false + }) + this.#workerWaiterTargets.delete(requestId) + if (remaining.length === 0) this.#workerWaiters.delete(storageRequestId) + else this.#workerWaiters.set(storageRequestId, remaining) + } const session = this.#sessions.get(requestId) if (session === undefined) return Promise.resolve() this.#sessions.delete(requestId) @@ -236,10 +323,13 @@ export class ClusterEntity extends DurableObject { #getRuntime(registration: EntityRegistration) { if (this.#runtime !== undefined) return Effect.succeed(this.#runtime) - return Effect.map(makeEntityRuntime(registration, this.#address, () => crypto.randomUUID()), (runtime) => { - this.#runtime = runtime - return runtime - }) + return Effect.map( + makeEntityRuntime(registration, this.#address, () => crypto.randomUUID(), this.#name), + (runtime) => { + this.#runtime = runtime + return runtime + } + ) } #runStored( @@ -247,14 +337,42 @@ export class ClusterEntity extends DurableObject { runtime: EntityRuntime, envelopeText: string, lastSentChunk: string | undefined, - discard: boolean + discard: boolean, + options?: { readonly scheduled?: boolean; readonly replyTo?: string | undefined } ) { return Effect.flatMap( decodeRequest(registration, envelopeText), - (envelope) => this.#run(registration, runtime, envelope, lastSentChunk, discard, true) + (envelope) => this.#run(registration, runtime, envelope, lastSentChunk, discard, true, options) ) } + #runAlarm() { + const registration = getEntityRegistration(this.#address.entityType) + if (registration === undefined) { + return Effect.die(`No handlers registered for entity type: ${this.#address.entityType}`) + } + return Effect.gen({ self: this }, function*() { + const runtime = yield* this.#getRuntime(registration) + yield* Effect.forEach( + loadDue(this.#state.storage.sql), + (row) => + this.#runStored(registration, runtime, row.envelope, row.lastSentChunk, row.discard, { + scheduled: true, + ...(row.replyTo === undefined ? undefined : { replyTo: row.replyTo }) + }).pipe( + Effect.catchCause((cause) => this.#completeReplayFailure(registration, row, cause)) + ), + { discard: true } + ) + yield* this.#armEarliestAlarm() + }) + } + + #armEarliestAlarm(): Effect.Effect { + const deliverAt = earliestDeliverAt(this.#state.storage.sql) + return deliverAt === undefined ? Effect.void : armAlarm(this.#state.storage, deliverAt) + } + #completeReplayFailure( registration: EntityRegistration, row: ReplayMessage, @@ -283,7 +401,10 @@ export class ClusterEntity extends DurableObject { exit: Exit.failCause(cause) }) ), - (reply) => Effect.sync(() => storage.transactionSync(() => saveReply(storage.sql, reply))) + (reply) => + Effect.sync(() => storage.transactionSync(() => saveReply(storage.sql, reply))).pipe( + Effect.andThen(this.#deliverScheduledReply(encoded.requestId as string, reply, row.replyTo)) + ) ).pipe( Effect.catchCause(() => Effect.sync(() => { @@ -300,7 +421,8 @@ export class ClusterEntity extends DurableObject { envelope: Envelope.Request.Any, lastSentChunkText: string | undefined, discard: boolean, - persisted: boolean + persisted: boolean, + options?: { readonly scheduled?: boolean; readonly replyTo?: string | undefined } ): Effect.Effect> { const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps const storage = this.#state.storage @@ -319,7 +441,8 @@ export class ClusterEntity extends DurableObject { } } - const session = discard ? undefined : this.#makeSession(requestId) + const scheduled = options?.scheduled === true + const session = discard || scheduled ? undefined : this.#makeSession(requestId) const execution = runtime.run( envelope, lastSentChunk, @@ -333,11 +456,14 @@ export class ClusterEntity extends DurableObject { if (session !== undefined) { yield* Effect.promise(() => this.#offerReply(session, encoded)) } + if (scheduled && reply._tag === "WithExit") { + yield* this.#deliverScheduledReply(requestId, encoded, options?.replyTo) + } }) ) - if (discard) { + if (discard || scheduled) { yield* execution - if (persisted) completeTell(storage.sql, requestId) + if (discard && persisted) completeTell(storage.sql, requestId) return [] } @@ -351,6 +477,32 @@ export class ClusterEntity extends DurableObject { }) } + #deliverScheduledReply(requestId: string, reply: string, replyTo: string | undefined): Effect.Effect { + const waiters = this.#workerWaiters.get(requestId) + if (waiters !== undefined) { + this.#workerWaiters.delete(requestId) + for (const waiter of waiters) { + this.#workerWaiterTargets.delete(waiter.clientRequestId) + waiter.resolve({ requestId, replies: [reply] }) + } + } + if (replyTo === undefined) return Effect.void + const namespace = (this.#state.exports as Record).ClusterEntity as + | { + readonly getByName: ( + name: string + ) => { readonly deliverReply: (requestId: string, reply: string) => Promise } + } + | undefined + if (namespace === undefined) return Effect.void + return Effect.promise(() => namespace.getByName(replyTo).deliverReply(requestId, reply)).pipe(Effect.ignore) + } + + /** @internal Completes an in-memory delayed ask owned by this entity object. */ + deliverReply(requestId: string, reply: string): Promise { + return deliverEntityReply(requestId, reply) + } + #makeSession(requestId: string): ReplySession { const session: ReplySession = { replies: [], diff --git a/packages/platform/cloudflare/src/internal/entityMailbox.ts b/packages/platform/cloudflare/src/internal/entityMailbox.ts index 10a9531d4e4..f7bbb09dd7f 100644 --- a/packages/platform/cloudflare/src/internal/entityMailbox.ts +++ b/packages/platform/cloudflare/src/internal/entityMailbox.ts @@ -40,7 +40,9 @@ export const persistRequest = ( sql: SqlStorage, envelopeText: string, primaryKey: string | null, - discard = false + discard = false, + deliverAt: number | null = null, + replyTo: string | null = null ): PersistResult => { if (encodedSize(envelopeText) > maximumEncodedSize) { throw new EncodedMessageTooLargeError("Encoded entity request exceeds 2 MB") @@ -61,6 +63,13 @@ export const persistRequest = ( primaryKey ).toArray()[0] if (existing !== undefined) { + if (replyTo !== null && Number(existing.processed) === 0) { + sql.exec( + "UPDATE cluster_messages SET reply_to = ? WHERE request_id = ?", + replyTo, + String(existing.request_id) + ) + } return { _tag: "Duplicate", originalId: String(existing.request_id), @@ -82,12 +91,15 @@ export const persistRequest = ( } sql.exec( - `INSERT INTO cluster_messages (request_id, message_id, envelope, discard, processed, last_reply_id) - VALUES (?, ?, ?, ?, 0, NULL)`, + `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 + discard ? 1 : 0, + deliverAt, + replyTo ) return { _tag: "Success" } } @@ -135,28 +147,47 @@ export interface StoredMessage { readonly envelope: string readonly lastSentChunk: string | undefined readonly discard: boolean + readonly deliverAt?: number | undefined + readonly replyTo?: string | undefined } -const rowToMessage = (row: Record): StoredMessage => ({ - envelope: String(row.envelope), - lastSentChunk: typeof row.last_reply === "string" ? row.last_reply : undefined, - discard: Number(row.discard) === 1 -}) +const rowToMessage = (row: Record): StoredMessage => { + const message: StoredMessage = { + envelope: String(row.envelope), + lastSentChunk: typeof row.last_reply === "string" ? row.last_reply : undefined, + discard: Number(row.discard) === 1, + ...(typeof row.deliver_at === "number" ? { deliverAt: row.deliver_at } : undefined), + ...(typeof row.reply_to === "string" ? { replyTo: row.reply_to } : undefined) + } + return message +} + +/** @internal */ +export const loadUnprocessed = (sql: SqlStorage, now = Date.now()): Array => + sql.exec( + `SELECT 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 + ).toArray().map(rowToMessage) /** @internal */ -export const loadUnprocessed = (sql: SqlStorage): Array => +export const loadDue = (sql: SqlStorage, now = Date.now()): Array => sql.exec( - `SELECT m.envelope, m.discard, r.reply AS last_reply + `SELECT 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 - ORDER BY m.rowid ASC` + WHERE m.processed = 0 AND m.deliver_at IS NOT NULL AND m.deliver_at <= ? + ORDER BY m.rowid ASC`, + now ).toArray().map(rowToMessage) /** @internal */ export const loadMessage = (sql: SqlStorage, requestId: string): StoredMessage | undefined => { const row = sql.exec( - `SELECT m.envelope, m.discard, r.reply AS last_reply + `SELECT 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 = ? diff --git a/packages/platform/cloudflare/src/internal/entityReply.ts b/packages/platform/cloudflare/src/internal/entityReply.ts new file mode 100644 index 00000000000..5bdb919eb57 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityReply.ts @@ -0,0 +1,31 @@ +/** @internal */ +import * as Context from "effect/Context" + +/** @internal */ +export const CurrentEntityName = Context.Reference( + "@effect/platform-cloudflare/CurrentEntityName", + { defaultValue: () => undefined } +) + +type ReplyHandler = (reply: string) => Promise + +const handlers = new Map() + +/** @internal */ +export const registerReplyHandler = (requestId: string, handler: ReplyHandler): void => { + handlers.set(requestId, handler) +} + +/** @internal */ +export const unregisterReplyHandler = (requestId: string): void => { + handlers.delete(requestId) +} + +/** @internal */ +export const deliverReply = async (requestId: string, reply: string): Promise => { + const handler = handlers.get(requestId) + if (handler === undefined) return false + handlers.delete(requestId) + await handler(reply) + return true +} diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index 5ba8e23d774..b66d29f3124 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -15,6 +15,7 @@ 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 type { EntityRegistration } from "./entityRegistry.ts" +import { CurrentEntityName } from "./entityReply.ts" interface CachedHandlers { readonly handlers: Record any> @@ -26,7 +27,8 @@ interface CachedHandlers { export const makeEntityRuntime = Effect.fnUntraced(function*( registration: EntityRegistration, address: EntityAddress.EntityAddress, - nextId: () => string + nextId: () => string, + entityName = `${String(address.entityType).length}:${address.entityType}${address.entityId}` ) { let cached: CachedHandlers | undefined @@ -43,6 +45,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( const 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) ) const handlers = yield* Effect.provideContext(registration.build, context) diff --git a/packages/platform/cloudflare/src/internal/entityStorage.ts b/packages/platform/cloudflare/src/internal/entityStorage.ts index f93407043ba..e42b10f1a5c 100644 --- a/packages/platform/cloudflare/src/internal/entityStorage.ts +++ b/packages/platform/cloudflare/src/internal/entityStorage.ts @@ -19,7 +19,8 @@ const ddl = [ discard INTEGER NOT NULL DEFAULT 0, processed INTEGER NOT NULL DEFAULT 0, last_reply_id TEXT, - deliver_at INTEGER + deliver_at INTEGER, + reply_to TEXT )`, `CREATE TABLE IF NOT EXISTS cluster_replies ( reply_id TEXT PRIMARY KEY, diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index d6fc370cf74..49a74909c76 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -1,7 +1,8 @@ import * as CloudflareCluster from "@effect/platform-cloudflare/CloudflareCluster" +import { CurrentEntityName, deliverReply } from "@effect/platform-cloudflare/internal/entityReply" import { assert, describe, it } from "@effect/vitest" -import { Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" -import { ClusterSchema, Entity, Sharding } from "effect/unstable/cluster" +import { DateTime, Effect, Exit, Fiber, Layer, PrimaryKey, Schema, Stream } from "effect" +import { ClusterSchema, DeliverAt, Entity, Sharding } from "effect/unstable/cluster" import { Rpc, RpcSchema } from "effect/unstable/rpc" const User = Entity.make("User", [ @@ -20,6 +21,40 @@ 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 = {}) {} @@ -170,6 +205,171 @@ describe("CloudflareCluster", () => { }).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({ + 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({ + 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 stub = { + invoke(envelopeText: string, _discard: boolean, delivery: { readonly replyTo?: string }) { + const envelope = JSON.parse(envelopeText) + assert.strictEqual(delivery.replyTo, "6:Callerone") + queueMicrotask(() => { + void deliverReply( + envelope.requestId, + JSON.stringify({ + _tag: "WithExit", + requestId: envelope.requestId, + id: "terminal", + exit: { _tag: "Success", value: "callback" } + }) + ) + }) + return Promise.resolve({ 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") + ) + assert.strictEqual(result, "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({ 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("does not retain reset targets for volatile requests", () => { let requestId = "" let resets = 0 diff --git a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts index 914766b8005..b7daffd3dcd 100644 --- a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts +++ b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts @@ -133,4 +133,103 @@ describe("CloudflareDurableObjects", () => { 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("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/EntityMailbox.test.ts b/packages/platform/cloudflare/test/EntityMailbox.test.ts index d06024092ca..5afed1cb42a 100644 --- a/packages/platform/cloudflare/test/EntityMailbox.test.ts +++ b/packages/platform/cloudflare/test/EntityMailbox.test.ts @@ -4,6 +4,7 @@ import { clearReplies, completeTell, EncodedMessageTooLargeError, + loadDue, loadNextReply, loadUnprocessed, MailboxFullError, @@ -20,6 +21,8 @@ interface MessageRow { readonly discard: number processed: number last_reply_id: string | null + deliver_at?: number | null + reply_to?: string | null } class FakeSql { @@ -39,14 +42,23 @@ class FakeSql { }]) } if (query.includes("INSERT INTO cluster_messages")) { - const [requestId, messageId, envelope, discard] = bindings as [string, string | null, string, number] + 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 + last_reply_id: null, + deliver_at: deliverAt, + reply_to: replyTo }) return this.rows([]) } @@ -71,14 +83,27 @@ class FakeSql { 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) + .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) => ({ envelope: row.envelope, last_reply: row.last_reply_id === null ? null : this.replies.get(row.last_reply_id), - discard: row.discard + discard: row.discard, + deliver_at: row.deliver_at, + reply_to: row.reply_to })) ) } @@ -168,6 +193,22 @@ describe("EntityMailbox", () => { assert.strictEqual(sql.messages.get(requestId)?.processed, 0) }) + it("persists future delivery metadata and only loads the row when due", () => { + const sql = new FakeSql() + 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, "7:Callercaller") + assert.deepStrictEqual(loadUnprocessed(sql.sql, 1_999), []) + assert.deepStrictEqual(loadDue(sql.sql, 2_000), [{ + envelope, + lastSentChunk: undefined, + discard: false, + deliverAt: 2_000, + replyTo: "7:Callercaller" + }]) + }) + it("maps a primary-key duplicate to the original request and last reply", () => { const sql = new FakeSql() const primaryKey = "Counter/one/Increment/operation-1" diff --git a/packages/platform/cloudflare/test/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts index 76b0fb18aac..5c992c746ca 100644 --- a/packages/platform/cloudflare/test/fixtures/worker.ts +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -41,6 +41,27 @@ export class ClusterEntity extends BaseClusterEntity { 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") + } } registerEntity("Mailbox", { @@ -72,6 +93,47 @@ registerEntity("Mailbox", { 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 === "/seed-poison") { const stub = env.CLUSTER_ENTITY.getByName("7:Mailboxcounter") await stub.seedPoison(JSON.stringify({ @@ -98,7 +160,8 @@ export default { ) } if (url.pathname === "/mailbox") { - const stub = env.CLUSTER_ENTITY.getByName("7:Mailboxcounter") + 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() @@ -110,7 +173,7 @@ export default { address: { shardId: { group: "default", id: 1 }, entityType: "Mailbox", - entityId: "counter" + entityId: id }, tag, payload: tag === "Get" || tag === "Watch" ? null : { operationId }, From 2180bde5eb2440f42998b9ce0d075b30c8d8b312 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 08:47:49 +0000 Subject: [PATCH 12/37] fix(platform-cloudflare): preserve delayed request waiters --- .../cloudflare/src/CloudflareCluster.ts | 32 ++++---- .../src/CloudflareDurableObjects.ts | 76 +++++++++++++------ .../cloudflare/src/internal/entityMailbox.ts | 24 ++++-- .../cloudflare/src/internal/entityReply.ts | 23 ++++-- .../cloudflare/test/CloudflareCluster.test.ts | 68 ++++++++++++++++- .../test/CloudflareDurableObjects.test.ts | 35 +++++++++ .../cloudflare/test/EntityMailbox.test.ts | 26 ++++++- .../cloudflare/test/fixtures/worker.ts | 39 ++++++++++ 8 files changed, 268 insertions(+), 55 deletions(-) diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index 207ade88578..29b2c2718a6 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -119,8 +119,8 @@ const notImplemented = (method: string) => interface EntityStub { readonly invoke: (envelope: string, discard: boolean, delivery?: { - readonly deliverAt: number - readonly primaryKey: string | null + readonly deliverAt?: number | undefined + readonly primaryKey?: string | null | undefined readonly replyTo?: string | undefined }) => Promise<{ readonly requestId: string @@ -128,7 +128,7 @@ interface EntityStub { readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined }> readonly acknowledge: (requestId: string, replyId: string) => Promise> - readonly interrupt?: (requestId: string) => Promise + readonly interrupt?: (storageRequestId: string, clientRequestId?: string) => Promise readonly reset?: (requestId: string) => Promise } @@ -192,6 +192,7 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { readonly clientRequestId: string storageRequestId: string lastChunkId?: string + replyHandler?: (reply: string) => Promise } const entries = new Map() let client!: Effect.Success>> @@ -292,14 +293,17 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { } const delivery = delayed ? { deliverAt: deliverAt!, primaryKey, ...(replyTo === undefined ? undefined : { replyTo }) } - : undefined + : replyTo === undefined + ? undefined + : { replyTo } let replyHandler: ((reply: string) => Promise) | undefined if (delivery?.replyTo !== undefined) { replyHandler = async (reply) => { - unregisterReplyHandler(clientRequestId) - unregisterReplyHandler(entry.storageRequestId) + unregisterReplyHandler(clientRequestId, replyHandler) + unregisterReplyHandler(entry.storageRequestId, replyHandler) await Effect.runPromise(deliverReplies(entry, [reply])) } + entry.replyHandler = replyHandler registerReplyHandler(clientRequestId, replyHandler) } return Effect.promise(() => target.stub.invoke(envelope, discard, delivery)).pipe( @@ -328,15 +332,15 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { return Effect.ensuring( deliver, Effect.sync(() => { - unregisterReplyHandler(clientRequestId) - unregisterReplyHandler(entry.storageRequestId) + unregisterReplyHandler(clientRequestId, replyHandler) + unregisterReplyHandler(entry.storageRequestId, replyHandler) }) ) }), Effect.tapCause(() => Effect.sync(() => { - unregisterReplyHandler(clientRequestId) - unregisterReplyHandler(entry.storageRequestId) + unregisterReplyHandler(clientRequestId, replyHandler) + unregisterReplyHandler(entry.storageRequestId, replyHandler) }) ) ) @@ -354,10 +358,12 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { const entry = entries.get(clientRequestId) entries.delete(clientRequestId) requestTargets.delete(clientRequestId) - unregisterReplyHandler(clientRequestId) - if (entry !== undefined) unregisterReplyHandler(entry.storageRequestId) + unregisterReplyHandler(clientRequestId, entry?.replyHandler) + if (entry?.replyHandler !== undefined) { + unregisterReplyHandler(entry.storageRequestId, entry.replyHandler) + } if (entry === undefined || target.stub.interrupt === undefined) return Effect.void - return Effect.promise(() => target.stub.interrupt!(entry.storageRequestId)) + return Effect.promise(() => target.stub.interrupt!(entry.storageRequestId, clientRequestId)) } default: return Effect.void diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 232a3b9854a..f4417584120 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -74,7 +74,7 @@ interface ReplayMessage { readonly lastSentChunk: string | undefined readonly discard: boolean readonly deliverAt?: number | undefined - readonly replyTo?: string | undefined + readonly replyTos?: ReadonlyArray | undefined } interface InvokeResult { @@ -89,8 +89,8 @@ interface InvokeOutcome { } interface DeliveryOptions { - readonly deliverAt: number - readonly primaryKey: string | null + readonly deliverAt?: number | undefined + readonly primaryKey?: string | null | undefined readonly replyTo?: string | undefined } @@ -121,7 +121,6 @@ export class ClusterEntity extends DurableObject { #serial: Promise = Promise.resolve() readonly #sessions = new Map() readonly #workerWaiters = new Map>() - readonly #workerWaiterTargets = new Map() constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env) @@ -176,7 +175,7 @@ export class ClusterEntity extends DurableObject { ? undefined : { scheduled: true, - ...(row.replyTo === undefined ? undefined : { replyTo: row.replyTo }) + ...(row.replyTos === undefined ? undefined : { replyTos: row.replyTos }) } ).pipe( Effect.catchCause((cause) => this.#completeReplayFailure(registration, row, cause)) @@ -223,7 +222,7 @@ export class ClusterEntity extends DurableObject { if (persisted.processed) { return { result: { requestId: persisted.originalId, replies: [] } } } - if (delivery !== undefined) { + if (delivery?.deliverAt !== undefined) { yield* this.#armEarliestAlarm() return this.#delayedOutcome( persisted.originalId, @@ -234,6 +233,15 @@ export class ClusterEntity extends DurableObject { } const original = loadMessage(storage.sql, persisted.originalId) if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") + if (original.deliverAt !== undefined && original.deliverAt > Date.now()) { + yield* this.#armEarliestAlarm() + return this.#delayedOutcome( + persisted.originalId, + discard, + delivery?.replyTo, + String(envelope.requestId) + ) + } const replies = yield* this.#runStored( registration, runtime, @@ -243,7 +251,7 @@ export class ClusterEntity extends DurableObject { ) return { result: { requestId: persisted.originalId, replies } } } - if (delivery !== undefined) { + if (delivery?.deliverAt !== undefined) { yield* this.#armEarliestAlarm() return this.#delayedOutcome(String(envelope.requestId), discard, delivery.replyTo) } @@ -264,7 +272,6 @@ export class ClusterEntity extends DurableObject { const waiters = this.#workerWaiters.get(requestId) ?? [] waiters.push({ clientRequestId, resolve, reject }) this.#workerWaiters.set(requestId, waiters) - this.#workerWaiterTargets.set(clientRequestId, requestId) }) return { result, deferred } } @@ -287,22 +294,20 @@ export class ClusterEntity extends DurableObject { } /** @internal Interrupts an in-memory handler execution. Persisted rows remain replayable. */ - interrupt(requestId: string): Promise { - const storageRequestId = this.#workerWaiterTargets.get(requestId) ?? requestId + interrupt(storageRequestId: string, clientRequestId = storageRequestId): Promise { const waiters = this.#workerWaiters.get(storageRequestId) if (waiters !== undefined) { const remaining = waiters.filter((waiter) => { - if (waiter.clientRequestId !== requestId) return true + if (waiter.clientRequestId !== clientRequestId) return true waiter.reject(new Error("Delayed entity request interrupted")) return false }) - this.#workerWaiterTargets.delete(requestId) if (remaining.length === 0) this.#workerWaiters.delete(storageRequestId) else this.#workerWaiters.set(storageRequestId, remaining) } - const session = this.#sessions.get(requestId) + const session = this.#sessions.get(storageRequestId) if (session === undefined) return Promise.resolve() - this.#sessions.delete(requestId) + this.#sessions.delete(storageRequestId) session.ack?.resolve() session.ack = undefined session.done = true @@ -338,7 +343,7 @@ export class ClusterEntity extends DurableObject { envelopeText: string, lastSentChunk: string | undefined, discard: boolean, - options?: { readonly scheduled?: boolean; readonly replyTo?: string | undefined } + options?: { readonly scheduled?: boolean; readonly replyTos?: ReadonlyArray | undefined } ) { return Effect.flatMap( decodeRequest(registration, envelopeText), @@ -358,7 +363,7 @@ export class ClusterEntity extends DurableObject { (row) => this.#runStored(registration, runtime, row.envelope, row.lastSentChunk, row.discard, { scheduled: true, - ...(row.replyTo === undefined ? undefined : { replyTo: row.replyTo }) + ...(row.replyTos === undefined ? undefined : { replyTos: row.replyTos }) }).pipe( Effect.catchCause((cause) => this.#completeReplayFailure(registration, row, cause)) ), @@ -403,7 +408,7 @@ export class ClusterEntity extends DurableObject { ), (reply) => Effect.sync(() => storage.transactionSync(() => saveReply(storage.sql, reply))).pipe( - Effect.andThen(this.#deliverScheduledReply(encoded.requestId as string, reply, row.replyTo)) + Effect.andThen(this.#deliverScheduledReply(encoded.requestId as string, reply, row.replyTos)) ) ).pipe( Effect.catchCause(() => @@ -422,7 +427,7 @@ export class ClusterEntity extends DurableObject { lastSentChunkText: string | undefined, discard: boolean, persisted: boolean, - options?: { readonly scheduled?: boolean; readonly replyTo?: string | undefined } + options?: { readonly scheduled?: boolean; readonly replyTos?: ReadonlyArray | undefined } ): Effect.Effect> { const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps const storage = this.#state.storage @@ -457,7 +462,7 @@ export class ClusterEntity extends DurableObject { yield* Effect.promise(() => this.#offerReply(session, encoded)) } if (scheduled && reply._tag === "WithExit") { - yield* this.#deliverScheduledReply(requestId, encoded, options?.replyTo) + yield* this.#deliverScheduledReply(requestId, encoded, options?.replyTos) } }) ) @@ -477,16 +482,19 @@ export class ClusterEntity extends DurableObject { }) } - #deliverScheduledReply(requestId: string, reply: string, replyTo: string | undefined): Effect.Effect { + #deliverScheduledReply( + requestId: string, + reply: string, + replyTos: ReadonlyArray | undefined + ): Effect.Effect { const waiters = this.#workerWaiters.get(requestId) if (waiters !== undefined) { this.#workerWaiters.delete(requestId) for (const waiter of waiters) { - this.#workerWaiterTargets.delete(waiter.clientRequestId) waiter.resolve({ requestId, replies: [reply] }) } } - if (replyTo === undefined) return Effect.void + if (replyTos === undefined) return Effect.void const namespace = (this.#state.exports as Record).ClusterEntity as | { readonly getByName: ( @@ -494,8 +502,28 @@ export class ClusterEntity extends DurableObject { ) => { readonly deliverReply: (requestId: string, reply: string) => Promise } } | undefined - if (namespace === undefined) return Effect.void - return Effect.promise(() => namespace.getByName(replyTo).deliverReply(requestId, reply)).pipe(Effect.ignore) + 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)) + ), + { discard: true } + ) } /** @internal Completes an in-memory delayed ask owned by this entity object. */ diff --git a/packages/platform/cloudflare/src/internal/entityMailbox.ts b/packages/platform/cloudflare/src/internal/entityMailbox.ts index f7bbb09dd7f..0c2c6bb8b3c 100644 --- a/packages/platform/cloudflare/src/internal/entityMailbox.ts +++ b/packages/platform/cloudflare/src/internal/entityMailbox.ts @@ -53,7 +53,7 @@ export const persistRequest = ( } const existing = sql.exec( - `SELECT m.request_id, m.processed, r.reply AS last_reply + `SELECT m.request_id, m.processed, 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 = ? OR (? IS NOT NULL AND m.message_id = ?) @@ -64,9 +64,11 @@ export const persistRequest = ( ).toArray()[0] if (existing !== undefined) { if (replyTo !== null && Number(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 = ?", - replyTo, + JSON.stringify(replyTos), String(existing.request_id) ) } @@ -99,7 +101,7 @@ export const persistRequest = ( envelopeText, discard ? 1 : 0, deliverAt, - replyTo + replyTo === null ? null : JSON.stringify([replyTo]) ) return { _tag: "Success" } } @@ -148,16 +150,28 @@ export interface StoredMessage { readonly lastSentChunk: string | undefined readonly discard: boolean readonly deliverAt?: number | undefined - readonly replyTo?: string | undefined + readonly replyTos?: ReadonlyArray | undefined +} + +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 { + // Rows written before reply targets became a collection contain one plain name. + } + return [value] } const rowToMessage = (row: Record): StoredMessage => { + const replyTos = decodeReplyTargets(row.reply_to) const message: StoredMessage = { envelope: String(row.envelope), lastSentChunk: typeof row.last_reply === "string" ? row.last_reply : undefined, discard: Number(row.discard) === 1, ...(typeof row.deliver_at === "number" ? { deliverAt: row.deliver_at } : undefined), - ...(typeof row.reply_to === "string" ? { replyTo: row.reply_to } : undefined) + ...(replyTos.length === 0 ? undefined : { replyTos }) } return message } diff --git a/packages/platform/cloudflare/src/internal/entityReply.ts b/packages/platform/cloudflare/src/internal/entityReply.ts index 5bdb919eb57..2981c457ebe 100644 --- a/packages/platform/cloudflare/src/internal/entityReply.ts +++ b/packages/platform/cloudflare/src/internal/entityReply.ts @@ -9,23 +9,32 @@ export const CurrentEntityName = Context.Reference( type ReplyHandler = (reply: string) => Promise -const handlers = new Map() +const handlers = new Map>() /** @internal */ export const registerReplyHandler = (requestId: string, handler: ReplyHandler): void => { - handlers.set(requestId, handler) + const registered = handlers.get(requestId) ?? new Set() + registered.add(handler) + handlers.set(requestId, registered) } /** @internal */ -export const unregisterReplyHandler = (requestId: string): void => { - handlers.delete(requestId) +export const unregisterReplyHandler = (requestId: string, handler?: ReplyHandler): void => { + if (handler === undefined) { + handlers.delete(requestId) + return + } + const registered = handlers.get(requestId) + if (registered === undefined) return + registered.delete(handler) + if (registered.size === 0) handlers.delete(requestId) } /** @internal */ export const deliverReply = async (requestId: string, reply: string): Promise => { - const handler = handlers.get(requestId) - if (handler === undefined) return false + const registered = handlers.get(requestId) + if (registered === undefined) return false handlers.delete(requestId) - await handler(reply) + await Promise.all(Array.from(registered, (handler) => handler(reply))) return true } diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index 49a74909c76..0006f8c0d71 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -172,7 +172,7 @@ describe("CloudflareCluster", () => { const invoked = new Promise((resolve) => { resumeInvoked = resolve }) - const interruptions: Array = [] + const interruptions: Array> = [] const stub = { invoke(envelopeText: string) { requestId = JSON.parse(envelopeText).requestId @@ -182,8 +182,8 @@ describe("CloudflareCluster", () => { acknowledge() { return Promise.resolve([]) }, - interrupt(interruptedRequestId: string) { - interruptions.push(interruptedRequestId) + interrupt(storageRequestId: string, clientRequestId?: string) { + interruptions.push([storageRequestId, clientRequestId]) return Promise.resolve() } } @@ -201,7 +201,7 @@ describe("CloudflareCluster", () => { yield* Effect.promise(() => invoked) yield* Fiber.interrupt(fiber) - assert.deepStrictEqual(interruptions, [requestId]) + assert.deepStrictEqual(interruptions, [[requestId, requestId]]) }).pipe(Effect.provide(CloudflareCluster.layer(options))) }) @@ -339,6 +339,66 @@ describe("CloudflareCluster", () => { }).pipe(Effect.provide(CloudflareCluster.layer(options))) }) + it.effect("delivers a deduplicated delayed reply to every pinned caller", () => { + 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 first = yield* Effect.forkChild( + client.Ask(request).pipe(Effect.provideService(CurrentEntityName, "6:Callerone")) + ) + const second = yield* Effect.forkChild( + client.Ask(request).pipe(Effect.provideService(CurrentEntityName, "6:Callerone")) + ) + yield* Effect.promise(() => invoked) + for (const resolve of pending) resolve({ requestId: storageRequestId, replies: [] }) + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))) + const delivered = yield* Effect.promise(() => + deliverReply( + 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 = { diff --git a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts index b7daffd3dcd..33c7865a3fb 100644 --- a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts +++ b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts @@ -205,6 +205,41 @@ describe("CloudflareDurableObjects", () => { 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("does not run a future row when an immediate request deduplicates to it", () => + 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-dedup" + yield* fetchJson( + `/delayed?id=${id}&operationId=same&discard=true&deliverAt=${Date.now() + 60_000}` + ) + yield* fetchJson(`/mailbox?id=${id}&tag=Add&operationId=same`) + + 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("delivers a scheduled ask reply to the caller Durable Object", () => Effect.gen(function*() { const miniflare = yield* makeMiniflare diff --git a/packages/platform/cloudflare/test/EntityMailbox.test.ts b/packages/platform/cloudflare/test/EntityMailbox.test.ts index 5afed1cb42a..6f3f104217e 100644 --- a/packages/platform/cloudflare/test/EntityMailbox.test.ts +++ b/packages/platform/cloudflare/test/EntityMailbox.test.ts @@ -198,14 +198,36 @@ describe("EntityMailbox", () => { 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, "7:Callercaller") + assert.strictEqual(sql.messages.get(requestId)?.reply_to, JSON.stringify(["7:Callercaller"])) assert.deepStrictEqual(loadUnprocessed(sql.sql, 1_999), []) assert.deepStrictEqual(loadDue(sql.sql, 2_000), [{ envelope, lastSentChunk: undefined, discard: false, deliverAt: 2_000, - replyTo: "7:Callercaller" + replyTos: ["7:Callercaller"] + }]) + }) + + it("preserves every reply target when a scheduled request is deduplicated", () => { + const sql = new FakeSql() + const primaryKey = "Counter/one/Increment/scheduled" + persistRequest(sql.sql, envelope, primaryKey, false, 2_000, "7:Callerfirst") + persistRequest( + sql.sql, + withRequestId("0198bd72-6a81-72f1-8d87-5e9b5cf1e001"), + primaryKey, + false, + null, + "7:Callersecond" + ) + + assert.deepStrictEqual(loadDue(sql.sql, 2_000), [{ + envelope, + lastSentChunk: undefined, + discard: false, + deliverAt: 2_000, + replyTos: ["7:Callerfirst", "7:Callersecond"] }]) }) diff --git a/packages/platform/cloudflare/test/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts index 5c992c746ca..785cce6f129 100644 --- a/packages/platform/cloudflare/test/fixtures/worker.ts +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -134,6 +134,45 @@ export default { ) 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 === "/seed-poison") { const stub = env.CLUSTER_ENTITY.getByName("7:Mailboxcounter") await stub.seedPoison(JSON.stringify({ From 42db16dbdc24d6280bcbb88136c53da84ca9f0a5 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 09:02:51 +0000 Subject: [PATCH 13/37] fix(platform-cloudflare): reject ask deduplication to tells --- .../cloudflare/src/CloudflareCluster.ts | 8 ++- .../src/CloudflareDurableObjects.ts | 15 ++++-- .../cloudflare/src/internal/entityMailbox.ts | 4 +- .../cloudflare/test/CloudflareCluster.test.ts | 32 +++++++++++ .../test/CloudflareDurableObjects.test.ts | 53 ++++++++++++++----- .../cloudflare/test/fixtures/worker.ts | 4 +- 6 files changed, 97 insertions(+), 19 deletions(-) diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index 29b2c2718a6..27ccf6df931 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -125,7 +125,7 @@ interface EntityStub { }) => Promise<{ readonly requestId: string readonly replies: ReadonlyArray - readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined + readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | "AskDeduplicatedToTell" | undefined }> readonly acknowledge: (requestId: string, replyId: string) => Promise> readonly interrupt?: (storageRequestId: string, clientRequestId?: string) => Promise @@ -316,6 +316,12 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { | MailboxFull | PersistenceError ) + } else if (result.error === "AskDeduplicatedToTell") { + return Effect.fail( + new PersistenceError({ + cause: new Error("Cannot deduplicate an ask onto a tell with the same PrimaryKey") + }) as MailboxFull | PersistenceError + ) } entry.storageRequestId = result.requestId if (replyHandler !== undefined && result.requestId !== clientRequestId) { diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index f4417584120..4ef2f40513b 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -80,7 +80,7 @@ interface ReplayMessage { interface InvokeResult { readonly requestId: string readonly replies: ReadonlyArray - readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | undefined + readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | "AskDeduplicatedToTell" | undefined } interface InvokeOutcome { @@ -214,6 +214,17 @@ export class ClusterEntity extends DurableObject { return yield* Effect.die(error) } if (persisted._tag === "Duplicate") { + const original = loadMessage(storage.sql, persisted.originalId) + if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") + if (original.discard && !discard) { + return { + result: { + requestId: persisted.originalId, + replies: [], + error: "AskDeduplicatedToTell" as const + } + } + } const nextReply = loadNextReply(storage.sql, persisted.originalId) if (nextReply !== undefined) { this.#releaseTerminalSession(persisted.originalId, nextReply) @@ -231,8 +242,6 @@ export class ClusterEntity extends DurableObject { String(envelope.requestId) ) } - const original = loadMessage(storage.sql, persisted.originalId) - if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") if (original.deliverAt !== undefined && original.deliverAt > Date.now()) { yield* this.#armEarliestAlarm() return this.#delayedOutcome( diff --git a/packages/platform/cloudflare/src/internal/entityMailbox.ts b/packages/platform/cloudflare/src/internal/entityMailbox.ts index 0c2c6bb8b3c..1d9327e19fb 100644 --- a/packages/platform/cloudflare/src/internal/entityMailbox.ts +++ b/packages/platform/cloudflare/src/internal/entityMailbox.ts @@ -53,7 +53,7 @@ export const persistRequest = ( } const existing = sql.exec( - `SELECT m.request_id, m.processed, m.reply_to, r.reply AS last_reply + `SELECT m.request_id, m.discard, m.processed, 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 = ? OR (? IS NOT NULL AND m.message_id = ?) @@ -63,7 +63,7 @@ export const persistRequest = ( primaryKey ).toArray()[0] if (existing !== undefined) { - if (replyTo !== null && Number(existing.processed) === 0) { + if (replyTo !== null && Number(existing.discard) === 0 && Number(existing.processed) === 0) { const replyTos = decodeReplyTargets(existing.reply_to) if (!replyTos.includes(replyTo)) replyTos.push(replyTo) sql.exec( diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index 0006f8c0d71..54dc132591d 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -430,6 +430,38 @@ describe("CloudflareCluster", () => { }).pipe(Effect.provide(CloudflareCluster.layer(options))) }) + it.effect("surfaces ask-to-tell deduplication as a persistence failure", () => { + const stub = { + invoke() { + return Promise.resolve({ + requestId: "original-tell", + replies: [], + error: "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.exit + ) + assert.isTrue(Exit.isFailure(exit)) + assert.isFalse(yield* Effect.promise(() => deliverReply("original-tell", "unused"))) + }).pipe(Effect.provide(CloudflareCluster.layer(options))) + }) + it.effect("does not retain reset targets for volatile requests", () => { let requestId = "" let resets = 0 diff --git a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts index 33c7865a3fb..3376ea5050d 100644 --- a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts +++ b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts @@ -219,25 +219,54 @@ describe("CloudflareDurableObjects", () => { assert.deepStrictEqual(result, { firstStatus: "pending", secondStatus: "rejected" }) }), 60_000) - it.effect("does not run a future row when an immediate request deduplicates to it", () => + 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.error, "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(() => - miniflare.dispatchFetch(`http://placeholder${path}`).then(async (response) => { - const body = await response.text() - assert.strictEqual(response.status, 200, body) - return JSON.parse(body) - }) + 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 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`) - const result = yield* fetchJson(`/mailbox?id=${id}&tag=Get`) - assert.deepStrictEqual(JSON.parse(result.replies[0]).exit, { _tag: "Success", value: 0 }) + assert.strictEqual(duplicate.error, "AskDeduplicatedToTell") }), 60_000) it.effect("delivers a scheduled ask reply to the caller Durable Object", () => diff --git a/packages/platform/cloudflare/test/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts index 785cce6f129..1a68fc1a541 100644 --- a/packages/platform/cloudflare/test/fixtures/worker.ts +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -204,6 +204,8 @@ export default { 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({ @@ -218,7 +220,7 @@ export default { payload: tag === "Get" || tag === "Watch" ? null : { operationId }, headers: {} }), - tag === "Add" || tag === "AddVolatile" + discard ) return Response.json(result) } catch (error) { From a8eff96d5d22a13de1af6f431e2d116588713edb Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 09:28:11 +0000 Subject: [PATCH 14/37] feat(platform-cloudflare): pin entity resource holders --- .../effect/src/unstable/cluster/Entity.ts | 11 +++ .../src/CloudflareDurableObjects.ts | 26 ++++++- .../src/internal/entityKeepAlive.ts | 43 +++++++++++ .../cloudflare/src/internal/entityRuntime.ts | 10 ++- .../cloudflare/test/EntityKeepAlive.test.ts | 76 +++++++++++++++++++ 5 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 packages/platform/cloudflare/src/internal/entityKeepAlive.ts create mode 100644 packages/platform/cloudflare/test/EntityKeepAlive.test.ts diff --git a/packages/effect/src/unstable/cluster/Entity.ts b/packages/effect/src/unstable/cluster/Entity.ts index 0e4847dcd07..fc30a350e4b 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,9 @@ export const KeepAliveRpc = Rpc.make("Cluster/Entity/keepAlive") export class KeepAliveLatch extends Context.Service()( "effect/cluster/Entity/KeepAliveLatch" ) {} + +/** @internal */ +export class KeepAliveHandler extends Context.Service< + KeepAliveHandler, + (enabled: boolean) => Effect.Effect +>()("effect/cluster/Entity/KeepAliveHandler") {} diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 4ef2f40513b..6689e9f8827 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -25,6 +25,7 @@ import * as Reply from "effect/unstable/cluster/Reply" import * as ShardId from "effect/unstable/cluster/ShardId" import type * as Rpc from "effect/unstable/rpc/Rpc" import { decodeName } from "./internal/clusterName.ts" +import { makeEntityKeepAlive } from "./internal/entityKeepAlive.ts" import { ackChunk, clearReplies, @@ -121,6 +122,7 @@ export class ClusterEntity extends DurableObject { #serial: Promise = Promise.resolve() readonly #sessions = new Map() readonly #workerWaiters = new Map>() + readonly #keepAlive constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env) @@ -133,6 +135,17 @@ export class ClusterEntity extends DurableObject { entityType: EntityType.make(name.type), entityId: EntityId.make(name.id) }) + this.#keepAlive = makeEntityKeepAlive(() => { + const namespace = (this.#state.exports as Record).ClusterEntity as + | { readonly getByName: (name: string) => { readonly hold: () => Promise } } + | undefined + if (namespace === undefined) { + return Promise.reject( + new Error("CloudflareCluster: ClusterEntity export is unavailable for keep-alive") + ) + } + return namespace.getByName(this.#name).hold() + }) const sql = ctx.storage.sql ensureEntityStorage(sql) const deliverAt = earliestDeliverAt(sql) @@ -147,6 +160,11 @@ export class ClusterEntity extends DurableObject { return operation } + /** @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 { const operation = this.#serial.then(() => Effect.runPromise(this.#invoke(envelopeText, discard, delivery))) @@ -338,7 +356,13 @@ export class ClusterEntity extends DurableObject { #getRuntime(registration: EntityRegistration) { if (this.#runtime !== undefined) return Effect.succeed(this.#runtime) return Effect.map( - makeEntityRuntime(registration, this.#address, () => crypto.randomUUID(), this.#name), + makeEntityRuntime( + registration, + this.#address, + () => crypto.randomUUID(), + this.#name, + this.#keepAlive.update + ), (runtime) => { this.#runtime = runtime return runtime diff --git a/packages/platform/cloudflare/src/internal/entityKeepAlive.ts b/packages/platform/cloudflare/src/internal/entityKeepAlive.ts new file mode 100644 index 00000000000..02dbf9b0eee --- /dev/null +++ b/packages/platform/cloudflare/src/internal/entityKeepAlive.ts @@ -0,0 +1,43 @@ +/** @internal */ +import * as Effect from "effect/Effect" +import * as Latch from "effect/Latch" + +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/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index b66d29f3124..13aa5aa4dd5 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -7,7 +7,7 @@ import * as Option from "effect/Option" import type * as Schedule from "effect/Schedule" import * as Scope from "effect/Scope" import * as Stream from "effect/Stream" -import { CurrentAddress, CurrentRunnerAddress, Request } from "effect/unstable/cluster/Entity" +import { CurrentAddress, CurrentRunnerAddress, KeepAliveHandler, Request } from "effect/unstable/cluster/Entity" import type * as EntityAddress from "effect/unstable/cluster/EntityAddress" import type * as Envelope from "effect/unstable/cluster/Envelope" import * as Reply from "effect/unstable/cluster/Reply" @@ -28,7 +28,8 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( registration: EntityRegistration, address: EntityAddress.EntityAddress, nextId: () => string, - entityName = `${String(address.entityType).length}:${address.entityType}${address.entityId}` + entityName = `${String(address.entityType).length}:${address.entityType}${address.entityId}`, + keepAlive?: (enabled: boolean) => Effect.Effect ) { let cached: CachedHandlers | undefined @@ -42,12 +43,15 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( const getHandlers = Effect.fnUntraced(function*() { if (cached !== undefined) return cached const scope = yield* Scope.make() - const context = registration.context.pipe( + 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) + } const handlers = yield* Effect.provideContext(registration.build, context) return cached = { handlers, context, scope } }) 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)) +}) From 82e9063bb9f1eae8f1ef8d8f9648955eb2d5e105 Mon Sep 17 00:00:00 2001 From: Claude Engineer Date: Tue, 18 Aug 2026 09:56:06 +0000 Subject: [PATCH 15/37] feat(platform-cloudflare): add CloudflareWorkflowEngine with durable clocks Co-Authored-By: Claude Fable 5 --- .changeset/cloudflare-workflow-engine.md | 16 + .../src/unstable/workflow/DurableClock.ts | 22 +- .../cloudflare/src/CloudflareCluster.ts | 22 +- .../src/CloudflareDurableObjects.ts | 94 ++++- .../src/CloudflareWorkflowEngine.ts | 177 +++++++++ packages/platform/cloudflare/src/index.ts | 5 + .../src/internal/workflowRegistry.ts | 84 +++++ .../src/internal/workflowRuntime.ts | 238 ++++++++++++ .../src/internal/workflowStorage.ts | 147 ++++++++ .../cloudflare/src/internal/workflowWire.ts | 105 ++++++ .../test/CloudflareWorkflowEngine.test.ts | 356 ++++++++++++++++++ 11 files changed, 1254 insertions(+), 12 deletions(-) create mode 100644 .changeset/cloudflare-workflow-engine.md create mode 100644 packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts create mode 100644 packages/platform/cloudflare/src/internal/workflowRegistry.ts create mode 100644 packages/platform/cloudflare/src/internal/workflowRuntime.ts create mode 100644 packages/platform/cloudflare/src/internal/workflowStorage.ts create mode 100644 packages/platform/cloudflare/src/internal/workflowWire.ts create mode 100644 packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts diff --git a/.changeset/cloudflare-workflow-engine.md b/.changeset/cloudflare-workflow-engine.md new file mode 100644 index 00000000000..3df6655cc2e --- /dev/null +++ b/.changeset/cloudflare-workflow-engine.md @@ -0,0 +1,16 @@ +--- +"@effect/platform-cloudflare": minor +"effect": minor +--- + +Add `CloudflareWorkflowEngine`, running durable workflows on the dedicated +workflow Durable Object class. One workflow execution is one Durable Object: +run state, activity results keyed `${name}/${attempt}`, durable deferred +exits, and the clock due table live on the object's SQLite storage behind its +single alarm. `CloudflareCluster.layer` now also provides the +`WorkflowEngine` service. + +Every `DurableClock` is durable on this engine: `DurableClock.sleep` reads its +default in-memory threshold from the new `DurableClock.InMemoryThreshold` +reference, which the Cloudflare engine sets to zero so even sub-minute sleeps +persist a due row and arm the alarm. diff --git a/packages/effect/src/unstable/workflow/DurableClock.ts b/packages/effect/src/unstable/workflow/DurableClock.ts index 7029c863c3d..ccb550f509b 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. @@ -95,7 +113,7 @@ export const sleep: ( const inMemoryThreshold = options.inMemoryThreshold ? 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/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index 27ccf6df931..99b2b4ad95b 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -29,6 +29,8 @@ import { Sharding } from "effect/unstable/cluster/Sharding" import type * as Rpc from "effect/unstable/rpc/Rpc" import * as RpcClient from "effect/unstable/rpc/RpcClient" import * as RpcSchema from "effect/unstable/rpc/RpcSchema" +import type { WorkflowEngine } from "effect/unstable/workflow/WorkflowEngine" +import * as CloudflareWorkflowEngine from "./CloudflareWorkflowEngine.ts" import * as Internal from "./internal/clusterName.ts" import { registerEntity as registerEntityHandler, unregisterEntity } from "./internal/entityRegistry.ts" import { CurrentEntityName, registerReplyHandler, unregisterReplyHandler } from "./internal/entityReply.ts" @@ -470,14 +472,20 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { * **Details** * * Provides the cluster `Sharding` service on top of the four same-Worker - * Durable Object namespace bindings. `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. + * Durable Object namespace bindings, plus the `WorkflowEngine` backed by the + * workflow class. `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.effect(Sharding)(make(options)) +export const layer = (options: LayerOptions): Layer.Layer => + Layer.merge( + Layer.effect(Sharding)(make(options)), + CloudflareWorkflowEngine.layer({ workflowNamespace: options.workflowNamespace }) + ) diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 6689e9f8827..dd161eb2529 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -45,6 +45,9 @@ import { deliverReply as deliverEntityReply } from "./internal/entityReply.ts" import { makeEntityRuntime } from "./internal/entityRuntime.ts" import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/entityStorage.ts" import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts" +import type { WorkflowRunOptions, WorkflowStub } from "./internal/workflowRegistry.ts" +import { makeWorkflowRuntime } from "./internal/workflowRuntime.ts" +import { earliestClockWakeUp, ensureWorkflowStorage } from "./internal/workflowStorage.ts" const notExposed = (className: string) => () => { throw new Error( @@ -54,6 +57,8 @@ const notExposed = (className: string) => () => { type EntityRuntime = Effect.Success> +type WorkflowRuntime = ReturnType + interface ReplySession { readonly replies: Array readonly takers: Array<{ @@ -628,13 +633,98 @@ export class ClusterEntity extends DurableObject { } /** - * The workflow execution class. Placeholder for the Cloudflare workflow - * engine; it only reserves the binding for now. + * 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 + #runtime: WorkflowRuntime | undefined + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env) + this.#state = ctx + if (decodeName(ctx.id.name ?? "") === undefined) { + throw new Error("ClusterWorkflow requires a canonical workflow Durable Object name") + } + ensureWorkflowStorage(ctx.storage.sql) + const wakeUp = earliestClockWakeUp(ctx.storage.sql) + if (wakeUp !== undefined) { + void ctx.blockConcurrencyWhile(() => Effect.runPromise(armAlarm(ctx.storage, wakeUp))) + } + } + + #getRuntime(): WorkflowRuntime { + if (this.#runtime === undefined) { + this.#runtime = makeWorkflowRuntime({ + name: this.#state.id.name ?? "", + sql: this.#state.storage.sql, + alarm: this.#state.storage, + now: () => Date.now(), + waitUntil: (promise) => this.#state.waitUntil(promise), + getStub: (name) => { + const namespace = (this.#state.exports as Record).ClusterWorkflow as + | { readonly getByName: (name: string) => unknown } + | undefined + if (namespace === undefined) { + throw new Error("CloudflareCluster: ClusterWorkflow export is unavailable for workflow delivery") + } + return namespace.getByName(name) as WorkflowStub + } + }) + } + 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 { + return this.#getRuntime().runAlarm() + } + override fetch: () => never = notExposed("ClusterWorkflow") } diff --git a/packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts b/packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts new file mode 100644 index 00000000000..d0dc6969248 --- /dev/null +++ b/packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts @@ -0,0 +1,177 @@ +/** + * 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 { + deferredState, + getExecution, + 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 + + const stubFor = (workflowName: string, executionId: string): WorkflowStub => + getExecution(executionId) ?? + (options.workflowNamespace.getByName(encodeName(workflowName, executionId)) as unknown as WorkflowStub) + + const localHandle = Effect.fnUntraced(function*(operation: string) { + const instance = yield* WorkflowEngine.WorkflowInstance + const handle = getExecution(instance.executionId) + if (handle === undefined) { + return yield* Effect.die( + `CloudflareWorkflowEngine: ${operation} is only available inside a workflow Durable Object execution` + ) + } + return { instance, handle } 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 = 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 === undefined ? undefined : { 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 text = yield* Effect.promise(() => stubFor(workflow._tag, executionId).poll()) + if (text === undefined) return Option.none() + return Option.some(yield* decodeResult(workflow, text, context)) + }), + + interrupt: (workflow, executionId) => Effect.promise(() => stubFor(workflow._tag, executionId).interrupt()), + + interruptUnsafe: (workflow, executionId) => + Effect.promise(() => stubFor(workflow._tag, executionId).interruptUnsafe()), + + resume: (workflow, executionId) => Effect.promise(() => stubFor(workflow._tag, executionId).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()) + yield* Effect.promise(() => stubFor(workflowName, executionId).deferredDone(deferredName, text)) + }), + + scheduleClock: (workflow, opts) => + Effect.suspend(() => { + const wakeUp = clock.currentTimeMillisUnsafe() + Math.ceil(Duration.toMillis(opts.clock.duration)) + return Effect.promise(() => + stubFor(workflow._tag, opts.executionId).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 index 9e093723c1c..1079f4e0dee 100644 --- a/packages/platform/cloudflare/src/index.ts +++ b/packages/platform/cloudflare/src/index.ts @@ -13,3 +13,8 @@ export * as CloudflareCluster from "./CloudflareCluster.ts" * @since 4.0.0 */ export * as CloudflareDurableObjects from "./CloudflareDurableObjects.ts" + +/** + * @since 4.0.0 + */ +export * as CloudflareWorkflowEngine from "./CloudflareWorkflowEngine.ts" diff --git a/packages/platform/cloudflare/src/internal/workflowRegistry.ts b/packages/platform/cloudflare/src/internal/workflowRegistry.ts new file mode 100644 index 00000000000..c9bf684cd41 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/workflowRegistry.ts @@ -0,0 +1,84 @@ +/** + * 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; execution handles are recorded per workflow Durable Object so + * engine operations run against local SQLite instead of a self-RPC. + * + * @internal + */ +import type * 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" + +/** @internal */ +export interface WorkflowRegistration { + readonly workflow: Workflow.Any + readonly execute: ( + payload: object, + executionId: string + ) => Effect.Effect + readonly context: Context.Context +} + +const registrations = new Map() + +/** @internal */ +export const getWorkflowRegistration = (name: string): WorkflowRegistration | undefined => registrations.get(name) + +/** @internal */ +export const registerWorkflow = (name: string, registration: WorkflowRegistration): boolean => { + if (registrations.has(name)) return false + registrations.set(name, registration) + return true +} + +/** @internal */ +export const unregisterWorkflow = (name: string, registration: WorkflowRegistration): void => { + if (registrations.get(name) === registration) registrations.delete(name) +} + +/** @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 loadActivity: (key: string) => string | undefined + readonly saveActivity: (key: string, exit: string) => void + readonly loadDeferred: (name: string) => string | undefined +} + +const executions = new Map() + +/** @internal */ +export const getExecution = (executionId: string): WorkflowExecutionHandle | undefined => executions.get(executionId) + +/** @internal */ +export const registerExecution = (executionId: string, handle: WorkflowExecutionHandle): () => void => { + executions.set(executionId, handle) + return () => { + if (executions.get(executionId) === handle) executions.delete(executionId) + } +} + +/** @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..9248806ec19 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/workflowRuntime.ts @@ -0,0 +1,238 @@ +/** + * 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 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 { + deferredState, + getExecution, + getWorkflowRegistration, + registerExecution, + 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" + +/** @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 loadActivity: (key: string) => string | undefined + readonly saveActivity: (key: string, exit: string) => void + readonly loadDeferred: (name: string) => string | undefined + readonly runAlarm: () => Promise + /** Drops the module-level execution handle, as isolate loss would. */ + readonly dispose: () => void +} + +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) + const initialWakeUp = WorkflowStorage.earliestClockWakeUp(sql) + if (initialWakeUp !== undefined) { + options.waitUntil(Effect.runPromise(armAlarm(options.alarm, initialWakeUp))) + } + + let inflight: Inflight | undefined + let resumeRequested = false + + const isComplete = (result: string | undefined): boolean => + result !== undefined && (JSON.parse(result) as { readonly _tag?: unknown })._tag === "Complete" + + const parentStub = (parent: { readonly workflowName: string; readonly executionId: string }): WorkflowStub => + getExecution(parent.executionId) ?? options.getStub(encodeName(parent.workflowName, parent.executionId)) + + 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) + ) + const fiber = Effect.runFork(execute) + const promise = Effect.runPromise(Fiber.await(fiber)).then((exit) => { + inflight = undefined + if (Exit.isFailure(exit)) { + resumeRequested = false + return Effect.runPromise(Effect.logError("Workflow execution failed", exit.cause)).then(() => + Promise.reject(Cause.squash(exit.cause)) + ) + } + const { result, text } = exit.value + WorkflowStorage.saveResult(sql, text) + if (result._tag === "Complete") { + resumeRequested = false + if (row.parent !== undefined) { + options.waitUntil(parentStub(row.parent).resume().then(() => undefined, () => undefined)) + } + } else if (resumeRequested) { + resumeRequested = false + options.waitUntil(startAttempt(row).then(() => undefined, () => undefined)) + } + return text + }) + 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, payload, opts.parent) + row = WorkflowStorage.loadExecution(sql)! + } + if (inflight !== undefined) { + if (!opts.discard) return inflight.promise + } else if (row.result === undefined) { + const attempt = startAttempt(row) + if (!opts.discard) return attempt + options.waitUntil(attempt.then(() => undefined, () => undefined)) + } else if (!opts.discard) { + return Promise.resolve(row.result) + } + return Promise.resolve("") + } + + 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() + } + options.waitUntil(startAttempt(row).then(() => undefined, () => undefined)) + return Promise.resolve() + } + + const deferredDone = (deferredName: string, exitText: string): Promise => { + if (!WorkflowStorage.saveDeferred(sql, deferredName, exitText)) return Promise.resolve() + return Effect.runPromise( + Effect.flatMap( + decodeExit(exitText, Context.empty()), + (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 Effect.runPromise(encodeExit(Exit.void, Context.empty())).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 => + Effect.runPromise(encodeExit(Exit.void, Context.empty())).then((voidExit) => { + let chain: Promise = Promise.resolve() + for (const clock of WorkflowStorage.dueClocks(sql, options.now())) { + WorkflowStorage.markClockFired(sql, clock.name) + chain = chain.then(() => deferredDone(clock.deferredName, voidExit)) + } + return chain.then(() => { + const earliest = WorkflowStorage.earliestClockWakeUp(sql) + return earliest === undefined ? undefined : Effect.runPromise(armAlarm(options.alarm, earliest)) + }) + }).then(() => undefined) + + let dispose!: () => void + const runtime: WorkflowRuntime = { + 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), + dispose: () => dispose() + } + dispose = registerExecution(executionId, runtime) + 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..b05be377552 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/workflowStorage.ts @@ -0,0 +1,147 @@ +/** + * 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, + payload TEXT NOT NULL, + parent_name TEXT, + parent_execution_id TEXT, + result TEXT + )`, + `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) + } +} + +/** @internal */ +export interface ExecutionRow { + readonly workflowName: string + readonly payload: string + readonly parent: { readonly workflowName: string; readonly executionId: string } | undefined + readonly result: string | undefined +} + +/** @internal */ +export const loadExecution = (sql: SqlStorage): ExecutionRow | undefined => { + const row = sql.exec( + "SELECT workflow_name, payload, parent_name, parent_execution_id, result FROM workflow_execution WHERE id = 0" + ).toArray()[0] + if (row === undefined) return undefined + return { + workflowName: String(row.workflow_name), + payload: String(row.payload), + parent: typeof row.parent_name === "string" && typeof row.parent_execution_id === "string" + ? { workflowName: row.parent_name, executionId: row.parent_execution_id } + : undefined, + result: typeof row.result === "string" ? row.result : undefined + } +} + +/** @internal */ +export const createExecution = ( + sql: SqlStorage, + workflowName: string, + payload: string, + parent: { readonly workflowName: string; readonly executionId: string } | undefined +): void => { + sql.exec( + `INSERT OR IGNORE INTO workflow_execution (id, workflow_name, payload, parent_name, parent_execution_id, result) + VALUES (0, ?, ?, ?, ?, NULL)`, + workflowName, + payload, + parent?.workflowName ?? null, + parent?.executionId ?? null + ) +} + +/** @internal */ +export const saveResult = (sql: SqlStorage, result: string): void => { + sql.exec("UPDATE workflow_execution SET result = ? WHERE id = 0", result) +} + +/** @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 === undefined ? undefined : String(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 === undefined ? undefined : String(row.exit) +} + +/** @internal */ +export const saveDeferred = (sql: SqlStorage, name: string, exit: string): boolean => { + if (loadDeferred(sql, name) !== undefined) return false + sql.exec("INSERT OR IGNORE 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] + const wakeUp = row?.wake_up + return typeof wakeUp === "number" ? wakeUp : 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: String(row.name), + deferredName: String(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..0c3576a009c --- /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" + +const runWith = ( + effect: Effect.Effect, + context: Context.Context +): Effect.Effect => effect.pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect + +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 resultCodecs = new WeakMap() + +const resultCodec = (workflow: Workflow.Any): Schema.Top => { + let codec = resultCodecs.get(workflow) + if (codec === undefined) { + codec = Schema.toCodecJson(Workflow.Result({ + success: workflow.successSchema as any, + error: workflow.errorSchema as any + })) + resultCodecs.set(workflow, codec) + } + return codec +} + +/** @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(Schema.toCodecJson(workflow.payloadSchema))(payload), + (encoded) => JSON.stringify(encoded) + ), + context + ) + +/** @internal */ +export const decodePayload = ( + workflow: Workflow.Any, + text: string, + context: Context.Context +): Effect.Effect => + runWith( + Schema.decodeUnknownEffect(Schema.toCodecJson(workflow.payloadSchema))(JSON.parse(text)) as Effect.Effect< + object, + any + >, + context + ) diff --git a/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts new file mode 100644 index 00000000000..407c3dc0053 --- /dev/null +++ b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts @@ -0,0 +1,356 @@ +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 { assert, describe, it } from "@effect/vitest" +import { Effect, Exit, Layer, Option, Schema } from "effect" +import { Activity, DurableClock, DurableDeferred, Workflow } 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], + payload: bindings[1], + parent_name: bindings[2], + parent_execution_id: bindings[3], + result: null + } + 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 OR IGNORE 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() { + for (const runtime of this.runtimes.values()) { + runtime.dispose() + } + 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("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) + + 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("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)) + }) +}) From 69931e765040d24059647d56259cd2a2d37201d2 Mon Sep 17 00:00:00 2001 From: Claude Engineer Date: Tue, 18 Aug 2026 10:09:45 +0000 Subject: [PATCH 16/37] fix(platform-cloudflare): apply workflow engine review findings - Recover the Durable Object name from the stored execution on alarm wakes, where ctx.id.name is undefined, so due clocks fire after eviction - Persist a resume_pending marker with each deferred exit and replay on wake, so a resume lost with the isolate cannot strand a suspended execution - Route in-run engine operations through a context-provided execution handle instead of a module-level map that could outlive its Durable Object - Link a late-arriving parent to an existing child execution - Batch due-clock completions into a single replay per alarm - Honor an explicit inMemoryThreshold of zero in DurableClock.sleep Co-Authored-By: Claude Fable 5 --- .../src/unstable/workflow/DurableClock.ts | 2 +- .../src/CloudflareDurableObjects.ts | 26 +++++-- .../src/CloudflareWorkflowEngine.ts | 55 ++++++------- .../cloudflare/src/internal/entityWire.ts | 3 +- .../src/internal/workflowRegistry.ts | 29 ++++--- .../src/internal/workflowRuntime.ts | 77 ++++++++++++------- .../src/internal/workflowStorage.ts | 49 ++++++++++-- .../cloudflare/src/internal/workflowWire.ts | 24 +++--- .../test/CloudflareWorkflowEngine.test.ts | 27 +++++-- 9 files changed, 194 insertions(+), 98 deletions(-) diff --git a/packages/effect/src/unstable/workflow/DurableClock.ts b/packages/effect/src/unstable/workflow/DurableClock.ts index ccb550f509b..6256c8e1941 100644 --- a/packages/effect/src/unstable/workflow/DurableClock.ts +++ b/packages/effect/src/unstable/workflow/DurableClock.ts @@ -111,7 +111,7 @@ export const sleep: ( return } - const inMemoryThreshold = options.inMemoryThreshold + const inMemoryThreshold = options.inMemoryThreshold !== undefined ? Duration.fromInputUnsafe(options.inMemoryThreshold) : yield* InMemoryThreshold diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index dd161eb2529..534089ac27a 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -24,7 +24,7 @@ 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 type * as Rpc from "effect/unstable/rpc/Rpc" -import { decodeName } from "./internal/clusterName.ts" +import { decodeName, encodeName } from "./internal/clusterName.ts" import { makeEntityKeepAlive } from "./internal/entityKeepAlive.ts" import { ackChunk, @@ -47,7 +47,7 @@ import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/ent import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts" import type { WorkflowRunOptions, WorkflowStub } from "./internal/workflowRegistry.ts" import { makeWorkflowRuntime } from "./internal/workflowRuntime.ts" -import { earliestClockWakeUp, ensureWorkflowStorage } from "./internal/workflowStorage.ts" +import { earliestClockWakeUp, ensureWorkflowStorage, loadExecutionName } from "./internal/workflowStorage.ts" const notExposed = (className: string) => () => { throw new Error( @@ -649,15 +649,24 @@ export class ClusterEntity extends DurableObject { */ 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 - if (decodeName(ctx.id.name ?? "") === undefined) { - throw new Error("ClusterWorkflow requires a canonical workflow Durable Object name") - } 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 = loadExecutionName(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))) @@ -666,8 +675,11 @@ export class ClusterWorkflow extends DurableObject { #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.#state.id.name ?? "", + name: this.#name, sql: this.#state.storage.sql, alarm: this.#state.storage, now: () => Date.now(), @@ -722,6 +734,8 @@ export class ClusterWorkflow extends DurableObject { } 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() } diff --git a/packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts b/packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts index d0dc6969248..18cb9892494 100644 --- a/packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts +++ b/packages/platform/cloudflare/src/CloudflareWorkflowEngine.ts @@ -24,8 +24,8 @@ 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, - getExecution, registerWorkflow, unregisterWorkflow, type WorkflowRegistration, @@ -60,19 +60,25 @@ export interface LayerOptions { export const make = Effect.fnUntraced(function*(options: LayerOptions) { const clock = yield* Clock - const stubFor = (workflowName: string, executionId: string): WorkflowStub => - getExecution(executionId) ?? - (options.workflowNamespace.getByName(encodeName(workflowName, executionId)) as unknown as WorkflowStub) + // 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 = getExecution(instance.executionId) - if (handle === undefined) { + 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 } as const + return { instance, handle: handle.value } as const }) return WorkflowEngine.makeUnsafe({ @@ -90,33 +96,31 @@ export const make = Effect.fnUntraced(function*(options: LayerOptions) { execute: Effect.fnUntraced(function*(workflow, opts) { const context = yield* Effect.context() const payload = yield* encodePayload(workflow, opts.payload, context) - const stub = stubFor(workflow._tag, opts.executionId) + 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 === undefined ? undefined : { parent }) - }) - ) + 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 text = yield* Effect.promise(() => stubFor(workflow._tag, executionId).poll()) + 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.promise(() => stubFor(workflow._tag, executionId).interrupt()), + interrupt: (workflow, executionId) => + Effect.flatMap(stubFor(workflow._tag, executionId), (stub) => Effect.promise(() => stub.interrupt())), interruptUnsafe: (workflow, executionId) => - Effect.promise(() => stubFor(workflow._tag, executionId).interruptUnsafe()), + Effect.flatMap(stubFor(workflow._tag, executionId), (stub) => Effect.promise(() => stub.interruptUnsafe())), - resume: (workflow, executionId) => Effect.promise(() => stubFor(workflow._tag, executionId).resume()), + 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") @@ -148,16 +152,15 @@ export const make = Effect.fnUntraced(function*(options: LayerOptions) { deferredDone: Effect.fnUntraced(function*({ deferredName, executionId, exit, workflowName }) { const text = yield* encodeExit(exit, Context.empty()) - yield* Effect.promise(() => stubFor(workflowName, executionId).deferredDone(deferredName, text)) + const stub = yield* stubFor(workflowName, executionId) + yield* Effect.promise(() => stub.deferredDone(deferredName, text)) }), - scheduleClock: (workflow, opts) => - Effect.suspend(() => { - const wakeUp = clock.currentTimeMillisUnsafe() + Math.ceil(Duration.toMillis(opts.clock.duration)) - return Effect.promise(() => - stubFor(workflow._tag, opts.executionId).scheduleClock(opts.clock.name, opts.clock.deferred.name, wakeUp) - ) - }) + 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)) + }) }) }) diff --git a/packages/platform/cloudflare/src/internal/entityWire.ts b/packages/platform/cloudflare/src/internal/entityWire.ts index 3dcb383c980..aea6d45c25b 100644 --- a/packages/platform/cloudflare/src/internal/entityWire.ts +++ b/packages/platform/cloudflare/src/internal/entityWire.ts @@ -15,7 +15,8 @@ import type { EntityRegistration } from "./entityRegistry.ts" type EncodedRequest = Extract -const runWith = ( +/** @internal */ +export const runWith = ( effect: Effect.Effect, context: Context.Context ): Effect.Effect => effect.pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect diff --git a/packages/platform/cloudflare/src/internal/workflowRegistry.ts b/packages/platform/cloudflare/src/internal/workflowRegistry.ts index c9bf684cd41..49bb775995b 100644 --- a/packages/platform/cloudflare/src/internal/workflowRegistry.ts +++ b/packages/platform/cloudflare/src/internal/workflowRegistry.ts @@ -1,12 +1,13 @@ /** * 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; execution handles are recorded per workflow Durable Object so - * engine operations run against local SQLite instead of a self-RPC. + * 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 type * as Context from "effect/Context" +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" @@ -62,23 +63,21 @@ export interface WorkflowStub { /** @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 } -const executions = new Map() - -/** @internal */ -export const getExecution = (executionId: string): WorkflowExecutionHandle | undefined => executions.get(executionId) - -/** @internal */ -export const registerExecution = (executionId: string, handle: WorkflowExecutionHandle): () => void => { - executions.set(executionId, handle) - return () => { - if (executions.get(executionId) === handle) executions.delete(executionId) - } -} +/** + * 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 index 9248806ec19..40eaab8248a 100644 --- a/packages/platform/cloudflare/src/internal/workflowRuntime.ts +++ b/packages/platform/cloudflare/src/internal/workflowRuntime.ts @@ -21,10 +21,9 @@ import * as WorkflowEngine from "effect/unstable/workflow/WorkflowEngine" import { decodeName, encodeName } from "./clusterName.ts" import { armAlarm, type EntityAlarm } from "./entityStorage.ts" import { + CurrentExecutionHandle, deferredState, - getExecution, getWorkflowRegistration, - registerExecution, type WorkflowRunOptions, type WorkflowStub } from "./workflowRegistry.ts" @@ -34,6 +33,11 @@ import { decodeExit, decodePayload, encodeExit, encodeResult } from "./workflowW /** @internal */ export const InterruptSignalName = "Workflow/InterruptSignal" +let voidExitCache: Promise | undefined +const voidExitText = (): Promise< + string +> => (voidExitCache ??= Effect.runPromise(encodeExit(Exit.void, Context.empty()))) + /** @internal */ export interface WorkflowRuntimeOptions { readonly name: string @@ -46,12 +50,11 @@ export interface WorkflowRuntimeOptions { /** @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 - /** Drops the module-level execution handle, as isolate loss would. */ - readonly dispose: () => void } interface Inflight { @@ -74,10 +77,6 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu const sql = options.sql WorkflowStorage.ensureWorkflowStorage(sql) - const initialWakeUp = WorkflowStorage.earliestClockWakeUp(sql) - if (initialWakeUp !== undefined) { - options.waitUntil(Effect.runPromise(armAlarm(options.alarm, initialWakeUp))) - } let inflight: Inflight | undefined let resumeRequested = false @@ -86,7 +85,7 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu result !== undefined && (JSON.parse(result) as { readonly _tag?: unknown })._tag === "Complete" const parentStub = (parent: { readonly workflowName: string; readonly executionId: string }): WorkflowStub => - getExecution(parent.executionId) ?? options.getStub(encodeName(parent.workflowName, parent.executionId)) + options.getStub(encodeName(parent.workflowName, parent.executionId)) const startAttempt = (row: WorkflowStorage.ExecutionRow): Promise => { const registration = getWorkflowRegistration(workflowName) @@ -114,12 +113,15 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu Effect.flatMap((result) => Effect.map(encodeResult(workflow, result, registration.context), (text) => ({ result, text })) ), - Effect.provideService(DurableClock.InMemoryThreshold, Duration.zero) + Effect.provideService(DurableClock.InMemoryThreshold, Duration.zero), + Effect.provideService(CurrentExecutionHandle, runtime) ) const fiber = Effect.runFork(execute) const promise = Effect.runPromise(Fiber.await(fiber)).then((exit) => { inflight = undefined if (Exit.isFailure(exit)) { + // No auto-replay after a defect: it would hot-loop a defecting + // workflow. The next external contact replays from storage. resumeRequested = false return Effect.runPromise(Effect.logError("Workflow execution failed", exit.cause)).then(() => Promise.reject(Cause.squash(exit.cause)) @@ -128,13 +130,19 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu const { result, text } = exit.value WorkflowStorage.saveResult(sql, text) if (result._tag === "Complete") { + WorkflowStorage.setResumePending(sql, false) resumeRequested = false - if (row.parent !== undefined) { - options.waitUntil(parentStub(row.parent).resume().then(() => undefined, () => undefined)) + const parent = WorkflowStorage.loadExecution(sql)?.parent + if (parent !== undefined) { + options.waitUntil(parentStub(parent).resume().then(() => undefined, () => undefined)) } } else if (resumeRequested) { resumeRequested = false options.waitUntil(startAttempt(row).then(() => undefined, () => undefined)) + } 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 }) @@ -145,8 +153,13 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu const run = (payload: string, opts: WorkflowRunOptions): Promise => { let row = WorkflowStorage.loadExecution(sql) if (row === undefined) { - WorkflowStorage.createExecution(sql, workflowName, payload, opts.parent) - row = WorkflowStorage.loadExecution(sql)! + WorkflowStorage.createExecution(sql, workflowName, executionId, payload, opts.parent) + row = { workflowName, 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 (inflight !== undefined) { if (!opts.discard) return inflight.promise @@ -173,6 +186,9 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu 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 replays the execution. + WorkflowStorage.setResumePending(sql, true) return Effect.runPromise( Effect.flatMap( decodeExit(exitText, Context.empty()), @@ -184,9 +200,7 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu const interrupt = (): Promise => { const row = WorkflowStorage.loadExecution(sql) if (row === undefined || isComplete(row.result)) return Promise.resolve() - return Effect.runPromise(encodeExit(Exit.void, Context.empty())).then((exitText) => - deferredDone(InterruptSignalName, exitText) - ) + return voidExitText().then((exitText) => deferredDone(InterruptSignalName, exitText)) } const interruptUnsafe = (): Promise => { @@ -206,20 +220,23 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu } const runAlarm = (): Promise => - Effect.runPromise(encodeExit(Exit.void, Context.empty())).then((voidExit) => { - let chain: Promise = Promise.resolve() - for (const clock of WorkflowStorage.dueClocks(sql, options.now())) { + voidExitText().then((voidExit) => { + const completed = WorkflowStorage.dueClocks(sql, options.now()).filter((clock) => { WorkflowStorage.markClockFired(sql, clock.name) - chain = chain.then(() => deferredDone(clock.deferredName, voidExit)) - } - return chain.then(() => { + return WorkflowStorage.saveDeferred(sql, clock.deferredName, voidExit) + }) + return Effect.runPromise(Effect.forEach( + completed, + (clock) => deferredState.deferredDone(executionId, clock.deferredName, Exit.void), + { discard: true } + )).then(() => completed.length === 0 ? undefined : resume()).then(() => { const earliest = WorkflowStorage.earliestClockWakeUp(sql) return earliest === undefined ? undefined : Effect.runPromise(armAlarm(options.alarm, earliest)) }) }).then(() => undefined) - let dispose!: () => void const runtime: WorkflowRuntime = { + executionId, run, poll: () => Promise.resolve(WorkflowStorage.loadExecution(sql)?.result), resume, @@ -230,9 +247,15 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu runAlarm, loadActivity: (key) => WorkflowStorage.loadActivity(sql, key), saveActivity: (key, exit) => WorkflowStorage.saveActivity(sql, key, exit), - loadDeferred: (deferredName) => WorkflowStorage.loadDeferred(sql, deferredName), - dispose: () => dispose() + 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. + const stored = WorkflowStorage.loadExecution(sql) + if (stored !== undefined && stored.resumePending && !isComplete(stored.result)) { + options.waitUntil(startAttempt(stored).then(() => undefined, () => undefined)) } - dispose = registerExecution(executionId, runtime) + return runtime } diff --git a/packages/platform/cloudflare/src/internal/workflowStorage.ts b/packages/platform/cloudflare/src/internal/workflowStorage.ts index b05be377552..171c50bc1c3 100644 --- a/packages/platform/cloudflare/src/internal/workflowStorage.ts +++ b/packages/platform/cloudflare/src/internal/workflowStorage.ts @@ -11,10 +11,12 @@ 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 + result TEXT, + resume_pending INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS workflow_activities ( key TEXT PRIMARY KEY, @@ -45,12 +47,14 @@ export interface ExecutionRow { readonly payload: string readonly parent: { readonly workflowName: string; readonly executionId: string } | undefined readonly result: string | undefined + readonly resumePending: boolean } /** @internal */ export const loadExecution = (sql: SqlStorage): ExecutionRow | undefined => { const row = sql.exec( - "SELECT workflow_name, payload, parent_name, parent_execution_id, result FROM workflow_execution WHERE id = 0" + `SELECT workflow_name, payload, parent_name, parent_execution_id, result, resume_pending + FROM workflow_execution WHERE id = 0` ).toArray()[0] if (row === undefined) return undefined return { @@ -59,32 +63,67 @@ export const loadExecution = (sql: SqlStorage): ExecutionRow | undefined => { parent: typeof row.parent_name === "string" && typeof row.parent_execution_id === "string" ? { workflowName: row.parent_name, executionId: row.parent_execution_id } : undefined, - result: typeof row.result === "string" ? row.result : undefined + result: typeof row.result === "string" ? row.result : undefined, + resumePending: row.resume_pending === 1 } } +/** + * The stored `(workflowName, executionId)` of this object's execution, used to + * recover the object name on an alarm wake where `ctx.id.name` is undefined. + * + * @internal + */ +export const loadExecutionName = ( + sql: SqlStorage +): { readonly workflowName: string; readonly executionId: string } | undefined => { + const row = sql.exec("SELECT workflow_name, execution_id FROM workflow_execution WHERE id = 0").toArray()[0] + if (row === undefined) return undefined + return { workflowName: String(row.workflow_name), executionId: String(row.execution_id) } +} + /** @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, payload, parent_name, parent_execution_id, result) - VALUES (0, ?, ?, ?, ?, NULL)`, + `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] diff --git a/packages/platform/cloudflare/src/internal/workflowWire.ts b/packages/platform/cloudflare/src/internal/workflowWire.ts index 0c3576a009c..a41b817a762 100644 --- a/packages/platform/cloudflare/src/internal/workflowWire.ts +++ b/packages/platform/cloudflare/src/internal/workflowWire.ts @@ -4,11 +4,7 @@ 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" - -const runWith = ( - effect: Effect.Effect, - context: Context.Context -): Effect.Effect => effect.pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect +import { runWith } from "./entityWire.ts" const AnyOrVoid = Schema.Union([Schema.Undefined, Schema.Any]) @@ -48,6 +44,17 @@ const resultCodec = (workflow: Workflow.Any): Schema.Top => { return codec } +const payloadCodecs = new WeakMap() + +const payloadCodec = (workflow: Workflow.Any): Schema.Top => { + let codec = payloadCodecs.get(workflow) + if (codec === undefined) { + codec = Schema.toCodecJson(workflow.payloadSchema) + payloadCodecs.set(workflow, codec) + } + return codec +} + /** @internal */ export const encodeResult = ( workflow: Workflow.Any, @@ -84,7 +91,7 @@ export const encodePayload = ( ): Effect.Effect => runWith( Effect.map( - Schema.encodeUnknownEffect(Schema.toCodecJson(workflow.payloadSchema))(payload), + Schema.encodeUnknownEffect(payloadCodec(workflow))(payload), (encoded) => JSON.stringify(encoded) ), context @@ -97,9 +104,6 @@ export const decodePayload = ( context: Context.Context ): Effect.Effect => runWith( - Schema.decodeUnknownEffect(Schema.toCodecJson(workflow.payloadSchema))(JSON.parse(text)) as Effect.Effect< - object, - any - >, + Schema.decodeUnknownEffect(payloadCodec(workflow))(JSON.parse(text)) as Effect.Effect, context ) diff --git a/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts index 407c3dc0053..e9a0c0e5f3c 100644 --- a/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts +++ b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts @@ -3,6 +3,7 @@ import * as CloudflareWorkflowEngine from "@effect/platform-cloudflare/Cloudflar 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 { loadExecutionName } from "@effect/platform-cloudflare/internal/workflowStorage" import { assert, describe, it } from "@effect/vitest" import { Effect, Exit, Layer, Option, Schema } from "effect" import { Activity, DurableClock, DurableDeferred, Workflow } from "effect/unstable/workflow" @@ -23,13 +24,26 @@ class FakeSql { if (query.includes("INSERT OR IGNORE INTO workflow_execution")) { this.execution ??= { workflow_name: bindings[0], - payload: bindings[1], - parent_name: bindings[2], - parent_execution_id: bindings[3], - result: null + 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 [] @@ -140,9 +154,6 @@ class FakeWorkflowNamespace { /** Drops every in-memory runtime, as a crashed or hibernated isolate would. */ crash() { - for (const runtime of this.runtimes.values()) { - runtime.dispose() - } this.runtimes.clear() } @@ -285,6 +296,8 @@ describe("CloudflareWorkflowEngine", () => { ["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. + assert.deepStrictEqual(loadExecutionName(store.sql.sql), { workflowName: "Sleeper", executionId }) namespace.now = 30_000 yield* Effect.promise(() => namespace.fireDueAlarms()) From afdba944e9db75121b01521f213eaac4f0c4bd6e Mon Sep 17 00:00:00 2001 From: Claude Engineer Date: Tue, 18 Aug 2026 10:22:58 +0000 Subject: [PATCH 17/37] fix(platform-cloudflare): harden workflow engine wake and interrupt paths - Persist the interrupted completion when a hard interrupt kills the run fiber before it can encode its result, instead of rejecting callers - Set resume_pending for alarm-completed clocks and keep a guard alarm armed while a resume is pending, so a replay lost with the isolate is retried - Record an in-flight resume request synchronously with the deferred write, closing the window where a settling attempt could clear it unserviced - Retry and log parent resume instead of a single swallowed RPC, and resume the parent on a defect exit like the cluster engine Co-Authored-By: Claude Fable 5 --- .../src/internal/workflowRuntime.ts | 91 ++++++++++++++----- .../test/CloudflareWorkflowEngine.test.ts | 35 ++++++- 2 files changed, 101 insertions(+), 25 deletions(-) diff --git a/packages/platform/cloudflare/src/internal/workflowRuntime.ts b/packages/platform/cloudflare/src/internal/workflowRuntime.ts index 40eaab8248a..9e2024baa0c 100644 --- a/packages/platform/cloudflare/src/internal/workflowRuntime.ts +++ b/packages/platform/cloudflare/src/internal/workflowRuntime.ts @@ -15,6 +15,7 @@ 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" @@ -33,6 +34,8 @@ import { decodeExit, decodePayload, encodeExit, encodeResult } from "./workflowW /** @internal */ export const InterruptSignalName = "Workflow/InterruptSignal" +const resumeGuardMillis = 60_000 + let voidExitCache: Promise | undefined const voidExitText = (): Promise< string @@ -84,8 +87,16 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu const isComplete = (result: string | undefined): boolean => result !== undefined && (JSON.parse(result) as { readonly _tag?: unknown })._tag === "Complete" - const parentStub = (parent: { readonly workflowName: string; readonly executionId: string }): WorkflowStub => - options.getStub(encodeName(parent.workflowName, parent.executionId)) + // 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) @@ -116,26 +127,16 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu Effect.provideService(DurableClock.InMemoryThreshold, Duration.zero), Effect.provideService(CurrentExecutionHandle, runtime) ) - const fiber = Effect.runFork(execute) - const promise = Effect.runPromise(Fiber.await(fiber)).then((exit) => { - inflight = undefined - if (Exit.isFailure(exit)) { - // No auto-replay after a defect: it would hot-loop a defecting - // workflow. The next external contact replays from storage. - resumeRequested = false - return Effect.runPromise(Effect.logError("Workflow execution failed", exit.cause)).then(() => - Promise.reject(Cause.squash(exit.cause)) - ) - } - const { result, text } = exit.value + 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(parentStub(parent).resume().then(() => undefined, () => undefined)) - } + if (parent !== undefined) options.waitUntil(resumeParent(parent)) } else if (resumeRequested) { resumeRequested = false options.waitUntil(startAttempt(row).then(() => undefined, () => undefined)) @@ -145,6 +146,29 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu 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 @@ -187,12 +211,15 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu 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 replays the execution. + // 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( - Effect.flatMap( - decodeExit(exitText, Context.empty()), - (exit) => deferredState.deferredDone(executionId, deferredName, exit) + armAlarm(options.alarm, options.now() + resumeGuardMillis).pipe( + Effect.andThen(decodeExit(exitText, Context.empty())), + Effect.flatMap((exit) => deferredState.deferredDone(executionId, deferredName, exit)) ) ).then(() => resume()) } @@ -225,13 +252,29 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu 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(() => completed.length === 0 ? undefined : resume()).then(() => { - const earliest = WorkflowStorage.earliestClockWakeUp(sql) - return earliest === undefined ? undefined : Effect.runPromise(armAlarm(options.alarm, earliest)) + )).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 earliest = WorkflowStorage.earliestClockWakeUp(sql) + const guard = pending ? options.now() + resumeGuardMillis : undefined + const target = earliest === undefined + ? guard + : guard === undefined + ? earliest + : Math.min(earliest, guard) + return target === undefined ? undefined : Effect.runPromise(armAlarm(options.alarm, target)) + }) }) }).then(() => undefined) diff --git a/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts index e9a0c0e5f3c..cb176c8072c 100644 --- a/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts +++ b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts @@ -6,7 +6,7 @@ import { makeWorkflowRuntime, type WorkflowRuntime } from "@effect/platform-clou import { loadExecutionName } from "@effect/platform-cloudflare/internal/workflowStorage" import { assert, describe, it } from "@effect/vitest" import { Effect, Exit, Layer, Option, Schema } from "effect" -import { Activity, DurableClock, DurableDeferred, Workflow } from "effect/unstable/workflow" +import { Activity, DurableClock, DurableDeferred, Workflow, WorkflowEngine } from "effect/unstable/workflow" class FakeSql { execution: Record | undefined @@ -344,6 +344,39 @@ describe("CloudflareWorkflowEngine", () => { }).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") From 050bc5739c965c20b4d3e0b60fb06782744555c1 Mon Sep 17 00:00:00 2001 From: Claude Engineer Date: Tue, 18 Aug 2026 10:27:26 +0000 Subject: [PATCH 18/37] refactor(platform-cloudflare): simplify workflow engine internals - Fold loadExecutionName into loadExecution and share one detach helper for fire-and-forget promises - Flatten run()'s discard branching and route the wake self-heal through resume() - Deduplicate the per-workflow codec caches and drop the redundant conflict clause on the single-threaded deferred insert Co-Authored-By: Claude Fable 5 --- .../src/CloudflareDurableObjects.ts | 4 +- .../src/internal/workflowRuntime.ts | 44 +++++++++---------- .../src/internal/workflowStorage.ts | 34 +++++++------- .../cloudflare/src/internal/workflowWire.ts | 36 +++++++-------- .../test/CloudflareWorkflowEngine.test.ts | 8 ++-- 5 files changed, 60 insertions(+), 66 deletions(-) diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 534089ac27a..235dbd20691 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -47,7 +47,7 @@ import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/ent import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts" import type { WorkflowRunOptions, WorkflowStub } from "./internal/workflowRegistry.ts" import { makeWorkflowRuntime } from "./internal/workflowRuntime.ts" -import { earliestClockWakeUp, ensureWorkflowStorage, loadExecutionName } from "./internal/workflowStorage.ts" +import { earliestClockWakeUp, ensureWorkflowStorage, loadExecution } from "./internal/workflowStorage.ts" const notExposed = (className: string) => () => { throw new Error( @@ -664,7 +664,7 @@ export class ClusterWorkflow extends DurableObject { } else { // An alarm wake carries no `id.name`; recover it from the stored // execution so due clocks still fire after eviction. - const stored = loadExecutionName(ctx.storage.sql) + const stored = loadExecution(ctx.storage.sql) this.#name = stored === undefined ? undefined : encodeName(stored.workflowName, stored.executionId) } const wakeUp = earliestClockWakeUp(ctx.storage.sql) diff --git a/packages/platform/cloudflare/src/internal/workflowRuntime.ts b/packages/platform/cloudflare/src/internal/workflowRuntime.ts index 9e2024baa0c..e9d64ecbc9e 100644 --- a/packages/platform/cloudflare/src/internal/workflowRuntime.ts +++ b/packages/platform/cloudflare/src/internal/workflowRuntime.ts @@ -84,6 +84,10 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu 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" @@ -139,7 +143,7 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu if (parent !== undefined) options.waitUntil(resumeParent(parent)) } else if (resumeRequested) { resumeRequested = false - options.waitUntil(startAttempt(row).then(() => undefined, () => undefined)) + detach(startAttempt(row)) } else { // This attempt observed every persisted deferred and still suspended, // so the pending resume (if any) has been serviced. @@ -178,23 +182,20 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu let row = WorkflowStorage.loadExecution(sql) if (row === undefined) { WorkflowStorage.createExecution(sql, workflowName, executionId, payload, opts.parent) - row = { workflowName, payload, parent: opts.parent, result: undefined, resumePending: false } + 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 (inflight !== undefined) { - if (!opts.discard) return inflight.promise - } else if (row.result === undefined) { - const attempt = startAttempt(row) - if (!opts.discard) return attempt - options.waitUntil(attempt.then(() => undefined, () => undefined)) - } else if (!opts.discard) { - return Promise.resolve(row.result) + if (opts.discard) { + if (inflight === undefined && row.result === undefined) detach(startAttempt(row)) + return Promise.resolve("") } - return Promise.resolve("") + if (inflight !== undefined) return inflight.promise + if (row.result !== undefined) return Promise.resolve(row.result) + return startAttempt(row) } const resume = (): Promise => { @@ -204,7 +205,7 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu resumeRequested = true return Promise.resolve() } - options.waitUntil(startAttempt(row).then(() => undefined, () => undefined)) + detach(startAttempt(row)) return Promise.resolve() } @@ -266,14 +267,12 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu 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 earliest = WorkflowStorage.earliestClockWakeUp(sql) - const guard = pending ? options.now() + resumeGuardMillis : undefined - const target = earliest === undefined - ? guard - : guard === undefined - ? earliest - : Math.min(earliest, guard) - return target === undefined ? undefined : Effect.runPromise(armAlarm(options.alarm, target)) + 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) @@ -295,9 +294,8 @@ export const makeWorkflowRuntime = (options: WorkflowRuntimeOptions): WorkflowRu // Self-heal on wake: a resume recorded by deferredDone but lost with the // previous isolate replays now instead of waiting for external contact. - const stored = WorkflowStorage.loadExecution(sql) - if (stored !== undefined && stored.resumePending && !isComplete(stored.result)) { - options.waitUntil(startAttempt(stored).then(() => undefined, () => undefined)) + 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 index 171c50bc1c3..34c9749c773 100644 --- a/packages/platform/cloudflare/src/internal/workflowStorage.ts +++ b/packages/platform/cloudflare/src/internal/workflowStorage.ts @@ -41,9 +41,15 @@ export const ensureWorkflowStorage = (sql: SqlStorage): void => { } } -/** @internal */ +/** + * 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 @@ -53,12 +59,13 @@ export interface ExecutionRow { /** @internal */ export const loadExecution = (sql: SqlStorage): ExecutionRow | undefined => { const row = sql.exec( - `SELECT workflow_name, payload, parent_name, parent_execution_id, result, resume_pending + `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: String(row.workflow_name), + executionId: String(row.execution_id), payload: String(row.payload), parent: typeof row.parent_name === "string" && typeof row.parent_execution_id === "string" ? { workflowName: row.parent_name, executionId: row.parent_execution_id } @@ -68,20 +75,6 @@ export const loadExecution = (sql: SqlStorage): ExecutionRow | undefined => { } } -/** - * The stored `(workflowName, executionId)` of this object's execution, used to - * recover the object name on an alarm wake where `ctx.id.name` is undefined. - * - * @internal - */ -export const loadExecutionName = ( - sql: SqlStorage -): { readonly workflowName: string; readonly executionId: string } | undefined => { - const row = sql.exec("SELECT workflow_name, execution_id FROM workflow_execution WHERE id = 0").toArray()[0] - if (row === undefined) return undefined - return { workflowName: String(row.workflow_name), executionId: String(row.execution_id) } -} - /** @internal */ export const createExecution = ( sql: SqlStorage, @@ -141,10 +134,15 @@ export const loadDeferred = (sql: SqlStorage, name: string): string | undefined return row === undefined ? undefined : String(row.exit) } -/** @internal */ +/** + * 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 OR IGNORE INTO workflow_deferreds (name, exit) VALUES (?, ?)", name, exit) + sql.exec("INSERT INTO workflow_deferreds (name, exit) VALUES (?, ?)", name, exit) return true } diff --git a/packages/platform/cloudflare/src/internal/workflowWire.ts b/packages/platform/cloudflare/src/internal/workflowWire.ts index a41b817a762..a3510b5ea1d 100644 --- a/packages/platform/cloudflare/src/internal/workflowWire.ts +++ b/packages/platform/cloudflare/src/internal/workflowWire.ts @@ -30,30 +30,26 @@ export const decodeExit = ( context ) -const resultCodecs = new WeakMap() - -const resultCodec = (workflow: Workflow.Any): Schema.Top => { - let codec = resultCodecs.get(workflow) - if (codec === undefined) { - codec = Schema.toCodecJson(Workflow.Result({ - success: workflow.successSchema as any, - error: workflow.errorSchema as any - })) - resultCodecs.set(workflow, codec) +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 } - return codec } -const payloadCodecs = new WeakMap() +const resultCodec = cachedCodec((workflow) => + Schema.toCodecJson(Workflow.Result({ + success: workflow.successSchema as any, + error: workflow.errorSchema as any + })) +) -const payloadCodec = (workflow: Workflow.Any): Schema.Top => { - let codec = payloadCodecs.get(workflow) - if (codec === undefined) { - codec = Schema.toCodecJson(workflow.payloadSchema) - payloadCodecs.set(workflow, codec) - } - return codec -} +const payloadCodec = cachedCodec((workflow) => Schema.toCodecJson(workflow.payloadSchema)) /** @internal */ export const encodeResult = ( diff --git a/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts index cb176c8072c..149615e2a39 100644 --- a/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts +++ b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts @@ -3,7 +3,7 @@ import * as CloudflareWorkflowEngine from "@effect/platform-cloudflare/Cloudflar 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 { loadExecutionName } from "@effect/platform-cloudflare/internal/workflowStorage" +import { loadExecution } from "@effect/platform-cloudflare/internal/workflowStorage" import { assert, describe, it } from "@effect/vitest" import { Effect, Exit, Layer, Option, Schema } from "effect" import { Activity, DurableClock, DurableDeferred, Workflow, WorkflowEngine } from "effect/unstable/workflow" @@ -61,7 +61,7 @@ class FakeSql { const exit = this.activities.get(String(bindings[0])) return exit === undefined ? [] : [{ exit }] } - if (query.includes("INSERT OR IGNORE INTO workflow_deferreds")) { + if (query.includes("INSERT INTO workflow_deferreds")) { if (!this.deferreds.has(String(bindings[0]))) { this.deferreds.set(String(bindings[0]), String(bindings[1])) } @@ -297,7 +297,9 @@ describe("CloudflareWorkflowEngine", () => { ]) assert.strictEqual(store.alarm.current, 30_000) // An alarm wake has no `id.name`; the stored execution recovers it. - assert.deepStrictEqual(loadExecutionName(store.sql.sql), { workflowName: "Sleeper", executionId }) + 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()) From 540e7b5c90e5048e0c921d02a35cf15d4b7dbc18 Mon Sep 17 00:00:00 2001 From: Claude Engineer Date: Tue, 18 Aug 2026 11:13:39 +0000 Subject: [PATCH 19/37] feat(platform-cloudflare): add DurableQueue Durable Object One queue name is one Durable Object: items, attempt counts, and in-flight leases live on the object's SQLite storage behind its single alarm, which acts as a watchdog redelivering items whose worker died. CloudflareCluster now also provides PersistedQueueFactory, so the DurableQueue user API works on the Cloudflare path out of the box. Co-Authored-By: Claude Fable 5 --- .changeset/cloudflare-persisted-queue.md | 11 + .../cloudflare/src/CloudflareCluster.ts | 13 +- .../src/CloudflareDurableObjects.ts | 87 +++- .../src/CloudflarePersistedQueue.ts | 145 ++++++ packages/platform/cloudflare/src/index.ts | 5 + .../cloudflare/src/internal/queueRuntime.ts | 164 +++++++ .../cloudflare/src/internal/queueStorage.ts | 113 +++++ .../test/CloudflarePersistedQueue.test.ts | 454 ++++++++++++++++++ 8 files changed, 986 insertions(+), 6 deletions(-) create mode 100644 .changeset/cloudflare-persisted-queue.md create mode 100644 packages/platform/cloudflare/src/CloudflarePersistedQueue.ts create mode 100644 packages/platform/cloudflare/src/internal/queueRuntime.ts create mode 100644 packages/platform/cloudflare/src/internal/queueStorage.ts create mode 100644 packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts diff --git a/.changeset/cloudflare-persisted-queue.md b/.changeset/cloudflare-persisted-queue.md new file mode 100644 index 00000000000..4f354039ddd --- /dev/null +++ b/.changeset/cloudflare-persisted-queue.md @@ -0,0 +1,11 @@ +--- +"@effect/platform-cloudflare": minor +--- + +Add `CloudflarePersistedQueue`, running persisted queues on the dedicated +queue Durable Object class. One queue name is one Durable Object: items, +attempt counts, and in-flight leases live on the object's SQLite storage +behind its single alarm, which acts as a watchdog redelivering items whose +worker died before completing them. `CloudflareCluster.layer` now also +provides the `PersistedQueueFactory` service, so the `DurableQueue` user API +works on the Cloudflare path out of the box. diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index 99b2b4ad95b..bc342c0a54d 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -26,10 +26,12 @@ import * as Envelope from "effect/unstable/cluster/Envelope" import * as RunnerAddress from "effect/unstable/cluster/RunnerAddress" 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 * 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 * as Internal from "./internal/clusterName.ts" import { registerEntity as registerEntityHandler, unregisterEntity } from "./internal/entityRegistry.ts" @@ -473,7 +475,9 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { * * Provides the cluster `Sharding` service on top of the four same-Worker * Durable Object namespace bindings, plus the `WorkflowEngine` backed by the - * workflow class. `Entity.client` resolves an entity to its Durable Object by + * 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 @@ -484,8 +488,9 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { * @category layers * @since 4.0.0 */ -export const layer = (options: LayerOptions): Layer.Layer => - Layer.merge( +export const layer = (options: LayerOptions): Layer.Layer => + Layer.mergeAll( Layer.effect(Sharding)(make(options)), - CloudflareWorkflowEngine.layer({ workflowNamespace: options.workflowNamespace }) + 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 index 235dbd20691..2598cd50c31 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -45,6 +45,8 @@ import { deliverReply as deliverEntityReply } from "./internal/entityReply.ts" import { makeEntityRuntime } from "./internal/entityRuntime.ts" import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/entityStorage.ts" import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts" +import { makeQueueRuntime } from "./internal/queueRuntime.ts" +import { earliestLeaseExpiry, ensureQueueStorage } from "./internal/queueStorage.ts" import type { WorkflowRunOptions, WorkflowStub } from "./internal/workflowRegistry.ts" import { makeWorkflowRuntime } from "./internal/workflowRuntime.ts" import { earliestClockWakeUp, ensureWorkflowStorage, loadExecution } from "./internal/workflowStorage.ts" @@ -59,6 +61,14 @@ type EntityRuntime = Effect.Success> type WorkflowRuntime = ReturnType +type QueueRuntime = ReturnType + +interface QueueItem { + readonly id: string + readonly element: string + readonly attempts: number +} + interface ReplySession { readonly replies: Array readonly takers: Array<{ @@ -743,13 +753,86 @@ export class ClusterWorkflow extends DurableObject { } /** - * The durable queue class. Placeholder for `DurableQueue`; one object per - * queue name. It only reserves the binding for now. + * 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 #state: DurableObjectState + #runtime: QueueRuntime | undefined + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env) + this.#state = ctx + if (ctx.id.name !== undefined && decodeName(ctx.id.name) === undefined) { + throw new Error("ClusterDurableQueue requires a canonical queue Durable Object name") + } + ensureQueueStorage(ctx.storage.sql) + const expiry = earliestLeaseExpiry(ctx.storage.sql) + if (expiry !== undefined) { + void ctx.blockConcurrencyWhile(() => Effect.runPromise(armAlarm(ctx.storage, expiry))) + } + } + + #getRuntime(): QueueRuntime { + if (this.#runtime === undefined) { + this.#runtime = makeQueueRuntime({ + sql: this.#state.storage.sql, + alarm: this.#state.storage, + now: () => Date.now() + }) + } + return this.#runtime + } + + /** @internal Same-Worker RPC transport used by `CloudflarePersistedQueue.layer`. */ + offer(id: string, element: string): Promise { + return this.#getRuntime().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.#getRuntime().take(takerId, maxAttempts, leaseMillis) + } + + /** @internal Cancels a waiting take, releasing an item already leased to it. */ + cancelTake(takerId: string): Promise { + return this.#getRuntime().cancelTake(takerId) + } + + /** @internal */ + complete(id: string): Promise { + return this.#getRuntime().complete(id) + } + + /** @internal Records a failed attempt and requeues the item. */ + fail(id: string, lastFailure: string): Promise { + return this.#getRuntime().fail(id, lastFailure) + } + + /** @internal Requeues the item without counting an attempt. */ + release(id: string): Promise { + return this.#getRuntime().release(id) + } + + /** @internal Extends the lease of an item still being processed. */ + extend(id: string, leaseMillis: number): Promise { + return this.#getRuntime().extend(id, leaseMillis) + } + + override alarm(): Promise { + return this.#getRuntime().runAlarm() + } + override fetch: () => never = notExposed("ClusterDurableQueue") } diff --git a/packages/platform/cloudflare/src/CloudflarePersistedQueue.ts b/packages/platform/cloudflare/src/CloudflarePersistedQueue.ts new file mode 100644 index 00000000000..abdcfd312d8 --- /dev/null +++ b/packages/platform/cloudflare/src/CloudflarePersistedQueue.ts @@ -0,0 +1,145 @@ +/** + * 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 finalize = (run: () => Promise): Effect.Effect => + Effect.promise(run).pipe( + Effect.sandbox, + Effect.retry({ times: 5, schedule: Schedule.exponential(100, 1.5) }), + Effect.orDie + ) + +/** + * 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.promise(() => stubFor(name).cancelTake(takerId)).pipe( + Effect.sandbox, + Effect.retry({ times: 5, schedule: Schedule.exponential(100, 1.5) }), + Effect.ignore + ) + ) + ) + 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/index.ts b/packages/platform/cloudflare/src/index.ts index 1079f4e0dee..0a2a6ebee87 100644 --- a/packages/platform/cloudflare/src/index.ts +++ b/packages/platform/cloudflare/src/index.ts @@ -14,6 +14,11 @@ export * as CloudflareCluster from "./CloudflareCluster.ts" */ export * as CloudflareDurableObjects from "./CloudflareDurableObjects.ts" +/** + * @since 4.0.0 + */ +export * as CloudflarePersistedQueue from "./CloudflarePersistedQueue.ts" + /** * @since 4.0.0 */ diff --git a/packages/platform/cloudflare/src/internal/queueRuntime.ts b/packages/platform/cloudflare/src/internal/queueRuntime.ts new file mode 100644 index 00000000000..eb1f2aebbf1 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/queueRuntime.ts @@ -0,0 +1,164 @@ +/** + * 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 { 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() + + const rememberDelivered = (takerId: string, itemId: string): void => { + delivered.set(takerId, itemId) + if (delivered.size <= deliveredCapacity) return + const oldest = delivered.keys().next().value + if (oldest !== undefined) delivered.delete(oldest) + } + + const lease = ( + takerId: string, + maxAttempts: number, + leaseMillis: number + ): Effect.Effect => + Effect.suspend(() => { + const now = options.now() + const item = leaseNextItem(sql, now, now + leaseMillis, maxAttempts) + if (item === undefined) return Effect.succeed(undefined) + rememberDelivered(takerId, item.id) + return Effect.as(armAlarm(options.alarm, now + leaseMillis), item) + }) + + // 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) + rememberDelivered(waiter.takerId, item.id) + 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.andThen(Effect.sync(mutate), wakeWaiters)) + + return { + offer: (id, element) => + mutateAndWake(() => { + offerItem(sql, id, element) + }), + + take: (takerId, maxAttempts, leaseMillis) => + Effect.runPromise(lease(takerId, maxAttempts, leaseMillis)).then((item) => + item !== undefined ? item : new Promise((resolve) => { + waiters.push({ takerId, maxAttempts, leaseMillis, resolve }) + }) + ), + + 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..214a3a460f4 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/queueStorage.ts @@ -0,0 +1,113 @@ +/** + * 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)` +] + +/** @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 +} + +/** @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 { id: String(row.id), element: String(row.element), attempts: Number(row.attempts) } +} + +/** @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] + const leaseUntil = row?.lease_until + return typeof leaseUntil === "number" ? leaseUntil : undefined +} diff --git a/packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts b/packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts new file mode 100644 index 00000000000..97dd5c248d0 --- /dev/null +++ b/packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts @@ -0,0 +1,454 @@ +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, ensureQueueStorage } 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: ensure the table and + // re-arm the single alarm from the earliest pending lease expiry. + ensureQueueStorage(store.sql.sql) + const expiry = earliestLeaseExpiry(store.sql.sql) + if (expiry !== undefined) { + void Effect.runPromise(armAlarm(store.alarm.alarm, expiry)) + } + runtime = makeQueueRuntime({ + sql: store.sql.sql, + alarm: store.alarm.alarm, + now: () => this.now + }) + 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 }) + ) +) From 8e9806847373c96b3a382f8375c971831a792be4 Mon Sep 17 00:00:00 2001 From: Claude Engineer Date: Tue, 18 Aug 2026 11:18:24 +0000 Subject: [PATCH 20/37] refactor(platform-cloudflare): simplify durable queue internals Single leasing code path through the waiter wake pass, an eagerly built queue runtime without the lazy indirection, a derived RPC item type instead of a duplicated interface, and one shared stub retry policy. Behavior and test coverage unchanged. Co-Authored-By: Claude Fable 5 --- .../src/CloudflareDurableObjects.ts | 45 +++++++------------ .../src/CloudflarePersistedQueue.ts | 15 +++---- .../cloudflare/src/internal/queueRuntime.ts | 27 ++++------- .../test/CloudflarePersistedQueue.test.ts | 13 +++--- 4 files changed, 35 insertions(+), 65 deletions(-) diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 2598cd50c31..e79250d5d6d 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -46,7 +46,7 @@ import { makeEntityRuntime } from "./internal/entityRuntime.ts" import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/entityStorage.ts" import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts" import { makeQueueRuntime } from "./internal/queueRuntime.ts" -import { earliestLeaseExpiry, ensureQueueStorage } from "./internal/queueStorage.ts" +import { earliestLeaseExpiry } from "./internal/queueStorage.ts" import type { WorkflowRunOptions, WorkflowStub } from "./internal/workflowRegistry.ts" import { makeWorkflowRuntime } from "./internal/workflowRuntime.ts" import { earliestClockWakeUp, ensureWorkflowStorage, loadExecution } from "./internal/workflowStorage.ts" @@ -63,11 +63,7 @@ type WorkflowRuntime = ReturnType type QueueRuntime = ReturnType -interface QueueItem { - readonly id: string - readonly element: string - readonly attempts: number -} +type QueueItem = Awaited> interface ReplySession { readonly replies: Array @@ -767,70 +763,61 @@ export class ClusterWorkflow extends DurableObject { * @since 4.0.0 */ export class ClusterDurableQueue extends DurableObject { - readonly #state: DurableObjectState - #runtime: QueueRuntime | undefined + readonly #runtime: QueueRuntime constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env) - this.#state = ctx if (ctx.id.name !== undefined && decodeName(ctx.id.name) === undefined) { throw new Error("ClusterDurableQueue requires a canonical queue Durable Object name") } - ensureQueueStorage(ctx.storage.sql) + 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))) } } - #getRuntime(): QueueRuntime { - if (this.#runtime === undefined) { - this.#runtime = makeQueueRuntime({ - sql: this.#state.storage.sql, - alarm: this.#state.storage, - now: () => Date.now() - }) - } - return this.#runtime - } - /** @internal Same-Worker RPC transport used by `CloudflarePersistedQueue.layer`. */ offer(id: string, element: string): Promise { - return this.#getRuntime().offer(id, element) + 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.#getRuntime().take(takerId, maxAttempts, leaseMillis) + 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.#getRuntime().cancelTake(takerId) + return this.#runtime.cancelTake(takerId) } /** @internal */ complete(id: string): Promise { - return this.#getRuntime().complete(id) + return this.#runtime.complete(id) } /** @internal Records a failed attempt and requeues the item. */ fail(id: string, lastFailure: string): Promise { - return this.#getRuntime().fail(id, lastFailure) + return this.#runtime.fail(id, lastFailure) } /** @internal Requeues the item without counting an attempt. */ release(id: string): Promise { - return this.#getRuntime().release(id) + return this.#runtime.release(id) } /** @internal Extends the lease of an item still being processed. */ extend(id: string, leaseMillis: number): Promise { - return this.#getRuntime().extend(id, leaseMillis) + return this.#runtime.extend(id, leaseMillis) } override alarm(): Promise { - return this.#getRuntime().runAlarm() + return this.#runtime.runAlarm() } override fetch: () => never = notExposed("ClusterDurableQueue") diff --git a/packages/platform/cloudflare/src/CloudflarePersistedQueue.ts b/packages/platform/cloudflare/src/CloudflarePersistedQueue.ts index abdcfd312d8..b9a8d75839e 100644 --- a/packages/platform/cloudflare/src/CloudflarePersistedQueue.ts +++ b/packages/platform/cloudflare/src/CloudflarePersistedQueue.ts @@ -49,13 +49,14 @@ interface QueueStub { const leaseMillis = 120_000 const leaseRefreshMillis = 30_000 -const finalize = (run: () => Promise): Effect.Effect => +const attempt = (run: () => Promise) => Effect.promise(run).pipe( Effect.sandbox, - Effect.retry({ times: 5, schedule: Schedule.exponential(100, 1.5) }), - Effect.orDie + 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. @@ -95,13 +96,7 @@ export const make = (options: LayerOptions): PersistedQueue.PersistedQueueStore[ Effect.orDie ) ).pipe( - Effect.onInterrupt(() => - Effect.promise(() => stubFor(name).cancelTake(takerId)).pipe( - Effect.sandbox, - Effect.retry({ times: 5, schedule: Schedule.exponential(100, 1.5) }), - Effect.ignore - ) - ) + Effect.onInterrupt(() => Effect.ignore(attempt(() => stubFor(name).cancelTake(takerId)))) ) yield* Effect.addFinalizer(Exit.match({ onFailure: (cause) => diff --git a/packages/platform/cloudflare/src/internal/queueRuntime.ts b/packages/platform/cloudflare/src/internal/queueRuntime.ts index eb1f2aebbf1..6ed2649b262 100644 --- a/packages/platform/cloudflare/src/internal/queueRuntime.ts +++ b/packages/platform/cloudflare/src/internal/queueRuntime.ts @@ -70,19 +70,6 @@ export const makeQueueRuntime = (options: QueueRuntimeOptions): QueueRuntime => if (oldest !== undefined) delivered.delete(oldest) } - const lease = ( - takerId: string, - maxAttempts: number, - leaseMillis: number - ): Effect.Effect => - Effect.suspend(() => { - const now = options.now() - const item = leaseNextItem(sql, now, now + leaseMillis, maxAttempts) - if (item === undefined) return Effect.succeed(undefined) - rememberDelivered(takerId, item.id) - return Effect.as(armAlarm(options.alarm, now + leaseMillis), item) - }) - // 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 @@ -116,12 +103,14 @@ export const makeQueueRuntime = (options: QueueRuntimeOptions): QueueRuntime => offerItem(sql, id, element) }), - take: (takerId, maxAttempts, leaseMillis) => - Effect.runPromise(lease(takerId, maxAttempts, leaseMillis)).then((item) => - item !== undefined ? item : new Promise((resolve) => { - waiters.push({ takerId, maxAttempts, leaseMillis, resolve }) - }) - ), + // 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) diff --git a/packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts b/packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts index 97dd5c248d0..89dcecf3652 100644 --- a/packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts +++ b/packages/platform/cloudflare/test/CloudflarePersistedQueue.test.ts @@ -3,7 +3,7 @@ import * as CloudflarePersistedQueue from "@effect/platform-cloudflare/Cloudflar 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, ensureQueueStorage } from "@effect/platform-cloudflare/internal/queueStorage" +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" @@ -145,18 +145,17 @@ class FakeQueueNamespace { let runtime = this.runtimes.get(name) if (runtime === undefined) { const store = this.store(name) - // Mirrors the ClusterDurableQueue constructor: ensure the table and + // Mirrors the ClusterDurableQueue constructor: build the runtime and // re-arm the single alarm from the earliest pending lease expiry. - ensureQueueStorage(store.sql.sql) - const expiry = earliestLeaseExpiry(store.sql.sql) - if (expiry !== undefined) { - void Effect.runPromise(armAlarm(store.alarm.alarm, 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 From e1fa410c49931c518ead0ff131ff42f424382f21 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 11:25:21 +0000 Subject: [PATCH 21/37] feat(platform-cloudflare): add singleton Durable Object wake --- .changeset/cloudflare-singleton.md | 8 + packages/platform/cloudflare/README.md | 25 ++- .../cloudflare/src/CloudflareCluster.ts | 23 ++- .../src/CloudflareDurableObjects.ts | 71 +++++++- .../src/internal/singletonRegistry.ts | 26 +++ .../src/internal/singletonRuntime.ts | 73 ++++++++ .../src/internal/singletonStorage.ts | 59 +++++++ .../cloudflare/test/CloudflareCluster.test.ts | 22 ++- .../cloudflare/test/Singleton.test.ts | 160 ++++++++++++++++++ 9 files changed, 461 insertions(+), 6 deletions(-) create mode 100644 .changeset/cloudflare-singleton.md create mode 100644 packages/platform/cloudflare/src/internal/singletonRegistry.ts create mode 100644 packages/platform/cloudflare/src/internal/singletonRuntime.ts create mode 100644 packages/platform/cloudflare/src/internal/singletonStorage.ts create mode 100644 packages/platform/cloudflare/test/Singleton.test.ts diff --git a/.changeset/cloudflare-singleton.md b/.changeset/cloudflare-singleton.md new file mode 100644 index 00000000000..c579d498b18 --- /dev/null +++ b/.changeset/cloudflare-singleton.md @@ -0,0 +1,8 @@ +--- +"@effect/platform-cloudflare": minor +--- + +Run cluster singletons on named `Singleton/` Durable Objects. Worker +Cron Triggers can call the object's `wake()` RPC to run the registered effect +once and then allow hibernation; concurrent duplicate wakes are coalesced and +an interrupted wake is recovered through the object's SQLite-backed alarm. diff --git a/packages/platform/cloudflare/README.md b/packages/platform/cloudflare/README.md index b50d5449a2d..f36d0efdd10 100644 --- a/packages/platform/cloudflare/README.md +++ b/packages/platform/cloudflare/README.md @@ -37,6 +37,9 @@ The package ships four Durable Object classes. Re-export them from your Worker e ], }, ], + "triggers": { + "crons": ["0 * * * *"], + }, } ``` @@ -44,7 +47,7 @@ The package ships four Durable Object classes. Re-export them from your Worker e // src/worker.ts import { CloudflareCluster } from "@effect/platform-cloudflare" import { Effect, Layer, Schema } from "effect" -import { Entity } from "effect/unstable/cluster" +import { Entity, Singleton } from "effect/unstable/cluster" import { Rpc } from "effect/unstable/rpc" export { @@ -63,8 +66,13 @@ const CounterLayer = Counter.toLayer({ Increment: () => Effect.succeed(1) }) +const MaintenanceLayer = Singleton.make( + "hourly-maintenance", + Effect.logInfo("Running hourly maintenance") +) + const clusterLayer = (env: Env) => - CounterLayer.pipe( + Layer.merge(CounterLayer, MaintenanceLayer).pipe( Layer.provideMerge(CloudflareCluster.layer({ entities: [Counter], entityNamespace: env.CLUSTER_ENTITY, @@ -75,6 +83,19 @@ const clusterLayer = (env: Env) => ) ``` +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. 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 is user code on the Worker. diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index bc342c0a54d..138cbd9333b 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -37,6 +37,7 @@ import * as Internal from "./internal/clusterName.ts" import { registerEntity as registerEntityHandler, unregisterEntity } from "./internal/entityRegistry.ts" import { CurrentEntityName, registerReplyHandler, unregisterReplyHandler } from "./internal/entityReply.ts" import { decodeReplyFor } from "./internal/entityWire.ts" +import { registerSingleton as registerSingletonHandler, unregisterSingleton } from "./internal/singletonRegistry.ts" /** * A Durable Object name decoded back into its entity address parts. @@ -446,6 +447,26 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { ) }) + const registerSingleton = Effect.fnUntraced(function*( + name: string, + run: Effect.Effect + ) { + 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), @@ -454,7 +475,7 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { isShutdown: Effect.succeed(false), makeClient: makeClient as Sharding["Service"]["makeClient"], registerEntity: registerEntity as Sharding["Service"]["registerEntity"], - registerSingleton: () => notImplemented("Sharding.registerSingleton"), + registerSingleton: registerSingleton as Sharding["Service"]["registerSingleton"], send: () => notImplemented("Sharding.send"), sendOutgoing: () => notImplemented("Sharding.sendOutgoing"), notify: () => notImplemented("Sharding.notify"), diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index e79250d5d6d..61f432ca2fb 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -47,6 +47,9 @@ import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/ent import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts" import { makeQueueRuntime } from "./internal/queueRuntime.ts" import { earliestLeaseExpiry } 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" @@ -65,6 +68,8 @@ type QueueRuntime = ReturnType type QueueItem = Awaited> +type SingletonRuntime = ReturnType + interface ReplySession { readonly replies: Array readonly takers: Array<{ @@ -824,12 +829,74 @@ export class ClusterDurableQueue extends DurableObject { } /** - * The singleton class. Placeholder for `Singleton`; one object per singleton - * name, woken by a Worker Cron Trigger. It only reserves the binding for now. + * 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}"`) + } + this.#runtime = makeSingletonRuntime({ + sql: this.#state.storage.sql, + alarm: this.#state.storage, + now: () => Date.now(), + run: registration.run.pipe( + Effect.scoped, + Effect.provideContext(registration.context), + Effect.orDie + ) + }) + 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/internal/singletonRegistry.ts b/packages/platform/cloudflare/src/internal/singletonRegistry.ts new file mode 100644 index 00000000000..e617e301002 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/singletonRegistry.ts @@ -0,0 +1,26 @@ +/** @internal */ +import type * as Context from "effect/Context" +import type * as Effect from "effect/Effect" + +/** @internal */ +export interface SingletonRegistration { + readonly run: Effect.Effect + readonly context: Context.Context +} + +const registrations = new Map() + +/** @internal */ +export const getSingletonRegistration = (name: string): SingletonRegistration | undefined => registrations.get(name) + +/** @internal */ +export const registerSingleton = (name: string, registration: SingletonRegistration): boolean => { + if (registrations.has(name)) return false + registrations.set(name, registration) + return true +} + +/** @internal */ +export const unregisterSingleton = (name: string, registration: SingletonRegistration): void => { + if (registrations.get(name) === registration) registrations.delete(name) +} 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..23088931f45 --- /dev/null +++ b/packages/platform/cloudflare/src/internal/singletonStorage.ts @@ -0,0 +1,59 @@ +/** + * 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 +} + +/** @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: typeof row?.name === "string" ? row.name : undefined, + wakeAt: typeof row?.wake_at === "number" ? 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/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index 54dc132591d..90617769543 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -2,7 +2,7 @@ import * as CloudflareCluster from "@effect/platform-cloudflare/CloudflareCluste import { CurrentEntityName, deliverReply } 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, Sharding } from "effect/unstable/cluster" +import { ClusterSchema, DeliverAt, Entity, Sharding, Singleton } from "effect/unstable/cluster" import { Rpc, RpcSchema } from "effect/unstable/rpc" const User = Entity.make("User", [ @@ -79,6 +79,26 @@ const makeOptions = () => { 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() 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/) + } + }) +}) From d10fd2dbd24e6dac8eaa3c633b1161562caa4e28 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 11:38:44 +0000 Subject: [PATCH 22/37] feat(platform-cloudflare): run ClusterCron on Durable Objects --- .changeset/cloudflare-cluster-cron.md | 5 + .../cloudflare/src/CloudflareCluster.ts | 17 +- .../cloudflare/test/ClusterCron.test.ts | 234 ++++++++++++++++++ .../cloudflare/test/fixtures/worker.ts | 3 +- 4 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 .changeset/cloudflare-cluster-cron.md create mode 100644 packages/platform/cloudflare/test/ClusterCron.test.ts diff --git a/.changeset/cloudflare-cluster-cron.md b/.changeset/cloudflare-cluster-cron.md new file mode 100644 index 00000000000..c192a25a66b --- /dev/null +++ b/.changeset/cloudflare-cluster-cron.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-cloudflare": patch +--- + +Run `ClusterCron` through Cloudflare singleton wakes and per-fire entity alarms. diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index 138cbd9333b..6bf1b859533 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -17,7 +17,7 @@ import * as Layer from "effect/Layer" import * as Schema from "effect/Schema" import * as Stream from "effect/Stream" import { MailboxFull, PersistenceError } from "effect/unstable/cluster/ClusterError" -import { Persisted } from "effect/unstable/cluster/ClusterSchema" +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" @@ -164,6 +164,16 @@ const uuidV7 = (timestamp: number): string => { 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) { @@ -434,15 +444,18 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { build: Effect.Effect, buildOptions?: Record ) { - if (!entities.has(entity.type)) { + 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) }) ) }) diff --git a/packages/platform/cloudflare/test/ClusterCron.test.ts b/packages/platform/cloudflare/test/ClusterCron.test.ts new file mode 100644 index 00000000000..b8633bbe7ea --- /dev/null +++ b/packages/platform/cloudflare/test/ClusterCron.test.ts @@ -0,0 +1,234 @@ +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("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) + persistRequest( + this.sql.sql, + envelope, + delivery?.primaryKey ?? null, + discard, + delivery?.deliverAt ?? null + ) + const effect = delivery?.deliverAt === undefined + ? Effect.void + : armAlarm(this.alarm.storage, delivery.deliverAt) + return Effect.runPromise(Effect.as(effect, { 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 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) + 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/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts index 1a68fc1a541..915032971e5 100644 --- a/packages/platform/cloudflare/test/fixtures/worker.ts +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -234,7 +234,8 @@ export default { if (namespace === undefined) { return new Response(`unknown binding: ${binding}`, { status: 404 }) } - const stub = namespace.getByName(url.searchParams.get("name") ?? "4:User42") + 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 }) From 8e90d2a6cb426712e4fc4cf40ed0c69323bded3d Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 11:51:34 +0000 Subject: [PATCH 23/37] feat(platform-cloudflare): add proxy telemetry and guidance --- .../cloudflare-cluster-observability.md | 6 ++ packages/platform/cloudflare/README.md | 64 +++++++++++++++++- .../src/CloudflareDurableObjects.ts | 65 ++++++++++++++----- .../cloudflare/src/internal/entityRuntime.ts | 23 ++++++- .../cloudflare/test/CloudflareCluster.test.ts | 52 ++++++++++++++- .../test/CloudflareWorkflowEngine.test.ts | 37 ++++++++++- .../cloudflare/test/EntityRuntime.test.ts | 51 ++++++++++++++- 7 files changed, 274 insertions(+), 24 deletions(-) create mode 100644 .changeset/cloudflare-cluster-observability.md diff --git a/.changeset/cloudflare-cluster-observability.md b/.changeset/cloudflare-cluster-observability.md new file mode 100644 index 00000000000..b0e2519b6aa --- /dev/null +++ b/.changeset/cloudflare-cluster-observability.md @@ -0,0 +1,6 @@ +--- +"@effect/platform-cloudflare": patch +--- + +Add entity mailbox spans, active entity and singleton metrics, Worker proxy +coverage, and the v1 compatibility and lifecycle guidance. diff --git a/packages/platform/cloudflare/README.md b/packages/platform/cloudflare/README.md index f36d0efdd10..693f4546f2f 100644 --- a/packages/platform/cloudflare/README.md +++ b/packages/platform/cloudflare/README.md @@ -98,7 +98,69 @@ export default { `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. -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 is user code on the Worker. +## 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. + +## 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.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 diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 61f432ca2fb..959b5cad22f 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -16,6 +16,8 @@ import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" import * as Fiber from "effect/Fiber" import * as Option from "effect/Option" +import * as Result from "effect/Result" +import * as ClusterMetrics from "effect/unstable/cluster/ClusterMetrics" import { Persisted } from "effect/unstable/cluster/ClusterSchema" import * as EntityAddress from "effect/unstable/cluster/EntityAddress" import * as EntityId from "effect/unstable/cluster/EntityId" @@ -225,19 +227,32 @@ export class ClusterEntity extends DurableObject { return { result: { requestId: String(envelope.requestId), replies } } } - let persisted: PersistResult - try { - persisted = storage.transactionSync(() => - persistRequest( - storage.sql, - envelopeText, - delivery?.primaryKey ?? Envelope.primaryKey(envelope), - discard, - delivery?.deliverAt, - delivery?.replyTo + const persistedResult = yield* Effect.result( + Effect.try(() => + storage.transactionSync(() => + persistRequest( + storage.sql, + envelopeText, + delivery?.primaryKey ?? Envelope.primaryKey(envelope), + discard, + delivery?.deliverAt, + delivery?.replyTo + ) ) + ).pipe( + Effect.withSpan("CloudflareCluster.persist", { + attributes: { + entityType: registration.entity.type, + entityId: String(this.#address.entityId), + rpc: envelope.tag + } + }, { captureStackTrace: false }), + Effect.provideContext(registration.context) ) - } catch (error) { + ) + if (Result.isFailure(persistedResult)) { + const cause = persistedResult.failure.cause + const error = cause instanceof Error ? cause : new Error(String(cause)) if (error instanceof MailboxFullError) { return { result: { requestId: String(envelope.requestId), replies: [], error: "MailboxFull" as const } } } else if (error instanceof EncodedMessageTooLargeError) { @@ -247,6 +262,7 @@ export class ClusterEntity extends DurableObject { } return yield* Effect.die(error) } + const persisted: PersistResult = persistedResult.success if (persisted._tag === "Duplicate") { const original = loadMessage(storage.sql, persisted.originalId) if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") @@ -419,7 +435,15 @@ export class ClusterEntity extends DurableObject { { discard: true } ) yield* this.#armEarliestAlarm() - }) + }).pipe( + Effect.withSpan("CloudflareCluster.alarm", { + attributes: { + entityType: registration.entity.type, + entityId: String(this.#address.entityId) + } + }, { captureStackTrace: false }), + Effect.provideContext(registration.context) + ) } #armEarliestAlarm(): Effect.Effect { @@ -875,15 +899,22 @@ export class ClusterSingleton extends DurableObject { 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.ensuring(Effect.sync(() => { + ClusterMetrics.singletons.modifyUnsafe(BigInt(-1), registration.context) + })), + Effect.scoped, + Effect.provideContext(registration.context), + Effect.orDie + ) this.#runtime = makeSingletonRuntime({ sql: this.#state.storage.sql, alarm: this.#state.storage, now: () => Date.now(), - run: registration.run.pipe( - Effect.scoped, - Effect.provideContext(registration.context), - Effect.orDie - ) + run }) return this.#runtime } diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index 13aa5aa4dd5..4b57f2ef935 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -3,10 +3,12 @@ import * as Cause from "effect/Cause" import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" +import * as Metric from "effect/Metric" import * as Option from "effect/Option" import type * as Schedule from "effect/Schedule" import * as Scope from "effect/Scope" import * as Stream from "effect/Stream" +import * as ClusterMetrics from "effect/unstable/cluster/ClusterMetrics" import { CurrentAddress, CurrentRunnerAddress, KeepAliveHandler, Request } from "effect/unstable/cluster/Entity" import type * as EntityAddress from "effect/unstable/cluster/EntityAddress" import type * as Envelope from "effect/unstable/cluster/Envelope" @@ -122,7 +124,26 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( }) }) - const exit = yield* Effect.provideContext(runWithDefectRetry(execute), entry.context) + const metricContext = Context.merge( + entry.context, + Metric.CurrentMetricAttributes.context({ type: registration.entity.type }) + ) + const handlerEffect = Effect.sync(() => { + ClusterMetrics.entities.modifyUnsafe(BigInt(1), metricContext) + }).pipe( + Effect.andThen(runWithDefectRetry(execute)), + Effect.ensuring(Effect.sync(() => { + ClusterMetrics.entities.modifyUnsafe(BigInt(-1), metricContext) + })), + Effect.withSpan("CloudflareCluster.handler", { + attributes: { + entityType: registration.entity.type, + entityId: String(address.entityId), + rpc: envelope.tag + } + }, { captureStackTrace: false }) + ) + const exit = yield* Effect.provideContext(handlerEffect, entry.context) if (!discard) { yield* respond( new Reply.WithExit({ diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index 90617769543..7fb0ff0d3b6 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -2,8 +2,16 @@ import * as CloudflareCluster from "@effect/platform-cloudflare/CloudflareCluste import { CurrentEntityName, deliverReply } 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, Sharding, Singleton } from "effect/unstable/cluster" -import { Rpc, RpcSchema } from "effect/unstable/rpc" +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 }) @@ -109,6 +117,46 @@ describe("CloudflareCluster", () => { 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({ + 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 = { diff --git a/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts index 149615e2a39..d5a01af2907 100644 --- a/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts +++ b/packages/platform/cloudflare/test/CloudflareWorkflowEngine.test.ts @@ -6,7 +6,16 @@ import { makeWorkflowRuntime, type WorkflowRuntime } from "@effect/platform-clou import { loadExecution } from "@effect/platform-cloudflare/internal/workflowStorage" import { assert, describe, it } from "@effect/vitest" import { Effect, Exit, Layer, Option, Schema } from "effect" -import { Activity, DurableClock, DurableDeferred, Workflow, WorkflowEngine } from "effect/unstable/workflow" +import { RpcTest } from "effect/unstable/rpc" +import { + Activity, + DurableClock, + DurableDeferred, + Workflow, + WorkflowEngine, + WorkflowProxy, + WorkflowProxyServer +} from "effect/unstable/workflow" class FakeSql { execution: Record | undefined @@ -184,6 +193,32 @@ const pollUntil = Effect.fnUntraced(function*< }) 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 }) diff --git a/packages/platform/cloudflare/test/EntityRuntime.test.ts b/packages/platform/cloudflare/test/EntityRuntime.test.ts index fa26620be11..288b80d5bba 100644 --- a/packages/platform/cloudflare/test/EntityRuntime.test.ts +++ b/packages/platform/cloudflare/test/EntityRuntime.test.ts @@ -1,8 +1,8 @@ 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, Effect, Exit, Option, Schedule, Schema, Stream } from "effect" -import { Entity, EntityAddress, EntityId, EntityType, ShardId } from "effect/unstable/cluster" +import { Cause, Context, Effect, Exit, Metric, Option, Schedule, Schema, 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", [ @@ -25,6 +25,53 @@ const request = { } describe("EntityRuntime", () => { + it.effect("records a handler span and tracks the active entity metric", () => { + const Telemetry = Entity.make("Telemetry", [Rpc.make("Ping", { 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 active = BigInt(0) + const registration: EntityRegistration = { + entity: Telemetry, + build: Effect.succeed(Telemetry.of({ + Ping: () => + Effect.sync(() => { + active = ClusterMetrics.entities.valueUnsafe(metricContext).value + return "pong" + }) + })), + 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(active, BigInt(1)) + assert.strictEqual(ClusterMetrics.entities.valueUnsafe(metricContext).value, BigInt(0)) + assert.deepStrictEqual(spans.map((span) => span.name), ["CloudflareCluster.handler"]) + assert.strictEqual(spans[0].attributes.get("entityType"), "Telemetry") + assert.strictEqual(spans[0].attributes.get("entityId"), "observed") + assert.strictEqual(spans[0].attributes.get("rpc"), "Ping") + }) + }) + it.effect("builds handlers once per wake and returns terminal ask replies", () => Effect.gen(function*() { let builds = 0 From 11a7cafae1f3a575fa62731ee8480c60856539d5 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 12:04:04 +0000 Subject: [PATCH 24/37] fix(platform-cloudflare): correct telemetry lifecycles --- .../src/CloudflareDurableObjects.ts | 33 +++++++++------- .../cloudflare/src/internal/entityRuntime.ts | 25 ++++++------ .../cloudflare/test/EntityRuntime.test.ts | 39 +++++++++++++++---- 3 files changed, 60 insertions(+), 37 deletions(-) diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 959b5cad22f..ad7d6edba11 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -228,18 +228,22 @@ export class ClusterEntity extends DurableObject { } const persistedResult = yield* Effect.result( - Effect.try(() => - storage.transactionSync(() => - persistRequest( - storage.sql, - envelopeText, - delivery?.primaryKey ?? Envelope.primaryKey(envelope), - discard, - delivery?.deliverAt, - delivery?.replyTo - ) - ) - ).pipe( + // Preserve unknown thrown values so the fallback defect is unchanged. + // @effect-diagnostics-next-line unknownInEffectCatch:off + Effect.try({ + try: () => + storage.transactionSync(() => + persistRequest( + storage.sql, + envelopeText, + delivery?.primaryKey ?? Envelope.primaryKey(envelope), + discard, + delivery?.deliverAt, + delivery?.replyTo + ) + ), + catch: (error) => error + }).pipe( Effect.withSpan("CloudflareCluster.persist", { attributes: { entityType: registration.entity.type, @@ -251,8 +255,7 @@ export class ClusterEntity extends DurableObject { ) ) if (Result.isFailure(persistedResult)) { - const cause = persistedResult.failure.cause - const error = cause instanceof Error ? cause : new Error(String(cause)) + const error = persistedResult.failure if (error instanceof MailboxFullError) { return { result: { requestId: String(envelope.requestId), replies: [], error: "MailboxFull" as const } } } else if (error instanceof EncodedMessageTooLargeError) { @@ -903,10 +906,10 @@ export class ClusterSingleton extends DurableObject { 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.scoped, Effect.provideContext(registration.context), Effect.orDie ) diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index 4b57f2ef935..5a0c4a01877 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -34,12 +34,20 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( keepAlive?: (enabled: boolean) => Effect.Effect ) { let cached: CachedHandlers | undefined + const metricContext = Context.merge( + registration.context, + Metric.CurrentMetricAttributes.context({ type: registration.entity.type }) + ) const invalidate = Effect.fnUntraced(function*() { if (cached === undefined) return const scope = cached.scope cached = undefined - yield* Scope.close(scope, Exit.void) + yield* Scope.close(scope, Exit.void).pipe( + Effect.ensuring(Effect.sync(() => { + ClusterMetrics.entities.modifyUnsafe(BigInt(-1), metricContext) + })) + ) }) const getHandlers = Effect.fnUntraced(function*() { @@ -55,6 +63,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( context = Context.add(context, KeepAliveHandler, keepAlive) } const handlers = yield* Effect.provideContext(registration.build, context) + ClusterMetrics.entities.modifyUnsafe(BigInt(1), metricContext) return cached = { handlers, context, scope } }) @@ -122,19 +131,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( currentLastSentChunk = Option.some(reply) })) }) - }) - - const metricContext = Context.merge( - entry.context, - Metric.CurrentMetricAttributes.context({ type: registration.entity.type }) - ) - const handlerEffect = Effect.sync(() => { - ClusterMetrics.entities.modifyUnsafe(BigInt(1), metricContext) }).pipe( - Effect.andThen(runWithDefectRetry(execute)), - Effect.ensuring(Effect.sync(() => { - ClusterMetrics.entities.modifyUnsafe(BigInt(-1), metricContext) - })), Effect.withSpan("CloudflareCluster.handler", { attributes: { entityType: registration.entity.type, @@ -143,7 +140,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( } }, { captureStackTrace: false }) ) - const exit = yield* Effect.provideContext(handlerEffect, entry.context) + const exit = yield* Effect.provideContext(runWithDefectRetry(execute), entry.context) if (!discard) { yield* respond( new Reply.WithExit({ diff --git a/packages/platform/cloudflare/test/EntityRuntime.test.ts b/packages/platform/cloudflare/test/EntityRuntime.test.ts index 288b80d5bba..0f9c4dfacf7 100644 --- a/packages/platform/cloudflare/test/EntityRuntime.test.ts +++ b/packages/platform/cloudflare/test/EntityRuntime.test.ts @@ -25,8 +25,11 @@ const request = { } describe("EntityRuntime", () => { - it.effect("records a handler span and tracks the active entity metric", () => { - const Telemetry = Entity.make("Telemetry", [Rpc.make("Ping", { success: Schema.String })]) + 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"), @@ -45,15 +48,16 @@ describe("EntityRuntime", () => { context, Metric.CurrentMetricAttributes.context({ type: Telemetry.type }) ) - let active = BigInt(0) + let activeDuringHandler = BigInt(0) const registration: EntityRegistration = { entity: Telemetry, build: Effect.succeed(Telemetry.of({ Ping: () => Effect.sync(() => { - active = ClusterMetrics.entities.valueUnsafe(metricContext).value + activeDuringHandler = ClusterMetrics.entities.valueUnsafe(metricContext).value return "pong" - }) + }), + Fail: () => Effect.die("boom") })), options: undefined, context @@ -63,12 +67,31 @@ describe("EntityRuntime", () => { const runtime = yield* makeEntityRuntime(registration, telemetryAddress, () => "reply") yield* runtime.run({ ...request, address: telemetryAddress } as any, Option.none(), false, () => Effect.void) - assert.strictEqual(active, BigInt(1)) - assert.strictEqual(ClusterMetrics.entities.valueUnsafe(metricContext).value, BigInt(0)) - assert.deepStrictEqual(spans.map((span) => span.name), ["CloudflareCluster.handler"]) + 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)) }) }) From 53816070fde57a242e1aa3696831585b8dd9b735 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 12:13:16 +0000 Subject: [PATCH 25/37] fix(platform-cloudflare): honor defect retry schedules --- .../cloudflare/src/internal/entityRuntime.ts | 24 ++++++++++--------- .../cloudflare/test/EntityRuntime.test.ts | 4 ++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index 5a0c4a01877..f00ec0a2e14 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -67,17 +67,19 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( return cached = { handlers, context, scope } }) - const runWithDefectRetry = (effect: Effect.Effect) => - Effect.flatMap(Effect.exit(effect), (first) => { - if ( - Exit.isSuccess(first) || !Cause.hasDies(first.cause) || registration.options?.defectRetryPolicy === undefined - ) { - return Effect.succeed(first) - } - return Effect.exit( - Effect.retry(effect, registration.options.defectRetryPolicy as Schedule.Schedule) - ) - }) + 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)) diff --git a/packages/platform/cloudflare/test/EntityRuntime.test.ts b/packages/platform/cloudflare/test/EntityRuntime.test.ts index 0f9c4dfacf7..f061cd3adb7 100644 --- a/packages/platform/cloudflare/test/EntityRuntime.test.ts +++ b/packages/platform/cloudflare/test/EntityRuntime.test.ts @@ -254,7 +254,7 @@ describe("EntityRuntime", () => { : Effect.succeed("recovered") }) }), - options: { defectRetryPolicy: Schedule.recurs(1) }, + options: { defectRetryPolicy: Schedule.recurs(2) }, context: Context.empty() } const runtime = yield* makeEntityRuntime(registration, address, () => `reply-${builds}-${attempts}`) @@ -265,7 +265,7 @@ describe("EntityRuntime", () => { replies.push(reply) })) - assert.strictEqual(attempts, 2) + assert.strictEqual(attempts, 3) assert.strictEqual(builds, 2) assert.strictEqual(replies.length, 1) assert.isTrue(Exit.isFailure(replies[0].exit)) From 80b53edcf689f8449e05ef7408594a6dda96fa7e Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 19:45:55 +0000 Subject: [PATCH 26/37] fix(platform-cloudflare): keep keep-alive handler private --- packages/effect/src/unstable/cluster/Entity.ts | 3 +-- .../platform/cloudflare/src/internal/entityKeepAlive.ts | 7 +++++++ packages/platform/cloudflare/src/internal/entityRuntime.ts | 5 +++-- packages/platform/cloudflare/test/EntityKeepAlive.test.ts | 4 ++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/effect/src/unstable/cluster/Entity.ts b/packages/effect/src/unstable/cluster/Entity.ts index fc30a350e4b..15b3d70b791 100644 --- a/packages/effect/src/unstable/cluster/Entity.ts +++ b/packages/effect/src/unstable/cluster/Entity.ts @@ -786,8 +786,7 @@ export class KeepAliveLatch extends Context.Service "effect/cluster/Entity/KeepAliveLatch" ) {} -/** @internal */ -export class KeepAliveHandler extends Context.Service< +class KeepAliveHandler extends Context.Service< KeepAliveHandler, (enabled: boolean) => Effect.Effect >()("effect/cluster/Entity/KeepAliveHandler") {} diff --git a/packages/platform/cloudflare/src/internal/entityKeepAlive.ts b/packages/platform/cloudflare/src/internal/entityKeepAlive.ts index 02dbf9b0eee..d8b62342dc1 100644 --- a/packages/platform/cloudflare/src/internal/entityKeepAlive.ts +++ b/packages/platform/cloudflare/src/internal/entityKeepAlive.ts @@ -1,7 +1,14 @@ /** @internal */ +import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Latch from "effect/Latch" +/** @internal */ +export class EntityKeepAliveHandler extends Context.Service< + EntityKeepAliveHandler, + (enabled: boolean) => Effect.Effect +>()("effect/cluster/Entity/KeepAliveHandler") {} + export interface EntityKeepAlive { readonly update: (enabled: boolean) => Effect.Effect readonly await: Effect.Effect diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index f00ec0a2e14..486bb9f45dc 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -9,13 +9,14 @@ import type * as Schedule from "effect/Schedule" import * as Scope from "effect/Scope" import * as Stream from "effect/Stream" import * as ClusterMetrics from "effect/unstable/cluster/ClusterMetrics" -import { CurrentAddress, CurrentRunnerAddress, KeepAliveHandler, Request } from "effect/unstable/cluster/Entity" +import { CurrentAddress, CurrentRunnerAddress, Request } from "effect/unstable/cluster/Entity" import type * as EntityAddress from "effect/unstable/cluster/EntityAddress" import type * 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 { EntityKeepAliveHandler } from "./entityKeepAlive.ts" import type { EntityRegistration } from "./entityRegistry.ts" import { CurrentEntityName } from "./entityReply.ts" @@ -60,7 +61,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( Context.add(Scope.Scope, scope) ) if (keepAlive !== undefined) { - context = Context.add(context, KeepAliveHandler, keepAlive) + context = Context.add(context, EntityKeepAliveHandler, keepAlive) } const handlers = yield* Effect.provideContext(registration.build, context) ClusterMetrics.entities.modifyUnsafe(BigInt(1), metricContext) diff --git a/packages/platform/cloudflare/test/EntityKeepAlive.test.ts b/packages/platform/cloudflare/test/EntityKeepAlive.test.ts index 3c7d2996a37..3cce9f2a110 100644 --- a/packages/platform/cloudflare/test/EntityKeepAlive.test.ts +++ b/packages/platform/cloudflare/test/EntityKeepAlive.test.ts @@ -1,4 +1,4 @@ -import { makeEntityKeepAlive } from "@effect/platform-cloudflare/internal/entityKeepAlive" +import { EntityKeepAliveHandler, 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" @@ -17,7 +17,7 @@ const makeFixture = Effect.gen(function*() { const provideKeepAlive = ( effect: Effect.Effect, keepAlive: ReturnType -) => Effect.provideService(effect, Entity.KeepAliveHandler, keepAlive.update) as Effect.Effect +) => Effect.provideService(effect, EntityKeepAliveHandler, keepAlive.update) as Effect.Effect describe("EntityKeepAlive", () => { it.effect("keeps the pin until the last holder releases", () => From ab2faa3b96f2b6eb1ec8b64a143c78b7e748fa6b Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 18 Aug 2026 20:02:42 +0000 Subject: [PATCH 27/37] test(platform-cloudflare): remove unused entity import --- packages/platform/cloudflare/test/EntityKeepAlive.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/platform/cloudflare/test/EntityKeepAlive.test.ts b/packages/platform/cloudflare/test/EntityKeepAlive.test.ts index 3cce9f2a110..59a89818999 100644 --- a/packages/platform/cloudflare/test/EntityKeepAlive.test.ts +++ b/packages/platform/cloudflare/test/EntityKeepAlive.test.ts @@ -2,7 +2,7 @@ import { EntityKeepAliveHandler, makeEntityKeepAlive } from "@effect/platform-cl 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" +import { EntityResource } from "effect/unstable/cluster" const makeFixture = Effect.gen(function*() { const started = yield* Deferred.make() From fc958ca0c52b91f9d7fb68b093fbe10e9a5f7a1c Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Wed, 19 Aug 2026 10:24:04 +1200 Subject: [PATCH 28/37] refactor(platform-cloudflare): simplicity pass over cluster internals - dedup the three module-level registries behind a shared makeRegistry - dedup bounded-map eviction (request targets, delivered queue items) - move request envelope encoding next to its decoder in entityWire - cache the per-rpc chunk values codec so parser compilation can memoize - carry reply metadata (kind/terminal) instead of re-parsing reply JSON in the entity session flow; loadNextReply returns its kind column - merge the duplicated delayed-duplicate branches and replay loops in ClusterEntity; skip replay decode for requests with an active session - split the mailbox capacity check into two indexed counts and add a partial index for unacked chunks; index queue position for MAX lookups - drop dead surface: PersistResult.lastReceivedReply, the reply_to migration fallback, clearReplies RPC, makeRunnerAddress, optional EntityStub methods that are always present - fast-path the 2 MB size check to avoid encoding small strings Co-Authored-By: Claude Fable 5 --- .../cloudflare/src/CloudflareCluster.ts | 86 +++---- .../src/CloudflareDurableObjects.ts | 209 ++++++++---------- .../cloudflare/src/internal/boundedMap.ts | 15 ++ .../cloudflare/src/internal/entityMailbox.ts | 64 +++--- .../cloudflare/src/internal/entityRegistry.ts | 15 +- .../cloudflare/src/internal/entityRuntime.ts | 3 +- .../cloudflare/src/internal/entityStorage.ts | 4 +- .../cloudflare/src/internal/entityWire.ts | 41 +++- .../cloudflare/src/internal/queueRuntime.ts | 10 +- .../cloudflare/src/internal/queueStorage.ts | 7 +- .../cloudflare/src/internal/registry.ts | 31 +++ .../src/internal/singletonRegistry.ts | 15 +- .../src/internal/workflowRegistry.ts | 15 +- .../cloudflare/test/CloudflareCluster.test.ts | 8 - .../cloudflare/test/ClusterCron.test.ts | 3 + .../cloudflare/test/EntityMailbox.test.ts | 33 ++- 16 files changed, 301 insertions(+), 258 deletions(-) create mode 100644 packages/platform/cloudflare/src/internal/boundedMap.ts create mode 100644 packages/platform/cloudflare/src/internal/registry.ts diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index 6bf1b859533..dfae2b3a63b 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -23,7 +23,6 @@ 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 RunnerAddress from "effect/unstable/cluster/RunnerAddress" import * as ShardId from "effect/unstable/cluster/ShardId" import { Sharding } from "effect/unstable/cluster/Sharding" import type { PersistedQueueFactory } from "effect/unstable/persistence/PersistedQueue" @@ -33,10 +32,11 @@ 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, registerReplyHandler, unregisterReplyHandler } from "./internal/entityReply.ts" -import { decodeReplyFor } from "./internal/entityWire.ts" +import { decodeReplyFor, encodeRequest } from "./internal/entityWire.ts" import { registerSingleton as registerSingletonHandler, unregisterSingleton } from "./internal/singletonRegistry.ts" /** @@ -103,20 +103,6 @@ export interface LayerOptions { readonly singletonNamespace: DurableObjectNamespace } -/** - * The synthetic runner address for a Durable Object, derived from its name. - * - * **Details** - * - * There is no runner fleet and no peer dialing on the Cloudflare path; the - * address only gives logs, metrics, and `Entity.CurrentRunnerAddress` a stable - * identity, with the port fixed to `0`. - * - * @category constructors - * @since 4.0.0 - */ -export const makeRunnerAddress = (objectName: string): RunnerAddress.RunnerAddress => RunnerAddress.make(objectName, 0) - const notImplemented = (method: string) => Effect.die( new Error(`CloudflareCluster: ${method} is not implemented yet on the Cloudflare Durable Object path`) @@ -133,8 +119,8 @@ interface EntityStub { readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | "AskDeduplicatedToTell" | undefined }> readonly acknowledge: (requestId: string, replyId: string) => Promise> - readonly interrupt?: (storageRequestId: string, clientRequestId?: string) => Promise - readonly reset?: (requestId: string) => Promise + readonly interrupt: (storageRequestId: string, clientRequestId?: string) => Promise + readonly reset: (requestId: string) => Promise } interface ClientTargetValue { @@ -181,16 +167,6 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { } const clock = yield* Clock const requestTargets = new Map() - const rememberRequestTarget = ( - requestId: string, - target: { readonly stub: EntityStub; storageRequestId: string } - ): void => { - requestTargets.delete(requestId) - requestTargets.set(requestId, target) - if (requestTargets.size <= requestTargetCapacity) return - const oldest = requestTargets.keys().next().value - if (oldest !== undefined) requestTargets.delete(oldest) - } const unknownEntity = (entity: Entity.Entity) => Effect.die( @@ -212,6 +188,12 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { const entries = new Map() let client!: Effect.Success>> + const dropReplyHandler = (entry: ClientEntry): void => { + if (entry.replyHandler === undefined) return + unregisterReplyHandler(entry.clientRequestId, entry.replyHandler) + unregisterReplyHandler(entry.storageRequestId, entry.replyHandler) + } + const deliverReplies = (entry: ClientEntry, replyTexts: ReadonlyArray): Effect.Effect => Effect.forEach( replyTexts, @@ -252,14 +234,9 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { Effect.orDie ) as unknown as Effect.Effect return Effect.flatMap(encode, (payload) => { - const envelope = JSON.stringify({ - _tag: "Request", + const envelope = encodeRequest({ requestId: clientRequestId, - address: { - shardId: target.address.shardId, - entityType: target.address.entityType, - entityId: target.address.entityId - }, + address: target.address, tag: message.tag, payload, headers: message.headers, @@ -314,8 +291,7 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { let replyHandler: ((reply: string) => Promise) | undefined if (delivery?.replyTo !== undefined) { replyHandler = async (reply) => { - unregisterReplyHandler(clientRequestId, replyHandler) - unregisterReplyHandler(entry.storageRequestId, replyHandler) + dropReplyHandler(entry) await Effect.runPromise(deliverReplies(entry, [reply])) } entry.replyHandler = replyHandler @@ -342,28 +318,17 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { if (replyHandler !== undefined && result.requestId !== clientRequestId) { registerReplyHandler(result.requestId, replyHandler) } - if (!discard && Context.get(rpc.annotations, Persisted)) { - rememberRequestTarget(clientRequestId, { + if (!discard && persisted) { + setWithEviction(requestTargets, clientRequestId, { stub: target.stub, storageRequestId: result.requestId - }) + }, requestTargetCapacity) } const deliver = discard ? Effect.void : deliverReplies(entry, result.replies) if (replyHandler === undefined || result.replies.length === 0) return deliver - return Effect.ensuring( - deliver, - Effect.sync(() => { - unregisterReplyHandler(clientRequestId, replyHandler) - unregisterReplyHandler(entry.storageRequestId, replyHandler) - }) - ) + return Effect.ensuring(deliver, Effect.sync(() => dropReplyHandler(entry))) }), - Effect.tapCause(() => - Effect.sync(() => { - unregisterReplyHandler(clientRequestId, replyHandler) - unregisterReplyHandler(entry.storageRequestId, replyHandler) - }) - ) + Effect.tapCause(() => Effect.sync(() => dropReplyHandler(entry))) ) }) } @@ -379,12 +344,9 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { const entry = entries.get(clientRequestId) entries.delete(clientRequestId) requestTargets.delete(clientRequestId) - unregisterReplyHandler(clientRequestId, entry?.replyHandler) - if (entry?.replyHandler !== undefined) { - unregisterReplyHandler(entry.storageRequestId, entry.replyHandler) - } - if (entry === undefined || target.stub.interrupt === undefined) return Effect.void - return Effect.promise(() => target.stub.interrupt!(entry.storageRequestId, clientRequestId)) + if (entry === undefined) return Effect.void + dropReplyHandler(entry) + return Effect.promise(() => target.stub.interrupt(entry.storageRequestId, clientRequestId)) } default: return Effect.void @@ -464,6 +426,8 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { 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 = { @@ -494,8 +458,8 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { notify: () => notImplemented("Sharding.notify"), reset: (requestId) => { const target = requestTargets.get(String(requestId)) - if (target === undefined || target.stub.reset === undefined) return Effect.succeed(false) - return Effect.as(Effect.promise(() => target.stub.reset!(target.storageRequestId)), true) + 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) diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index ad7d6edba11..0bdd704458f 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -48,7 +48,7 @@ import { makeEntityRuntime } from "./internal/entityRuntime.ts" import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/entityStorage.ts" import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts" import { makeQueueRuntime } from "./internal/queueRuntime.ts" -import { earliestLeaseExpiry } from "./internal/queueStorage.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" @@ -62,20 +62,42 @@ const notExposed = (className: string) => () => { ) } +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 EntityRuntime = Effect.Success> type WorkflowRuntime = ReturnType type QueueRuntime = ReturnType -type QueueItem = Awaited> - type SingletonRuntime = ReturnType +interface SessionReply { + readonly text: string + readonly terminal: boolean +} + +// Mirrors entityMailbox's StoredMessage: the exported class cannot reference +// an @internal type in its method signatures, even private ones. +interface ReplayMessage { + readonly requestId: string + readonly envelope: string + readonly lastSentChunk: string | undefined + readonly discard: boolean + readonly deliverAt?: number | undefined + readonly replyTos?: ReadonlyArray | undefined +} + interface ReplySession { - readonly replies: Array + readonly replies: Array readonly takers: Array<{ - readonly resolve: (reply: string | undefined) => void + readonly resolve: (reply: SessionReply | undefined) => void readonly reject: (error: unknown) => void }> done: boolean @@ -88,14 +110,6 @@ interface ReplySession { interrupt: (() => Promise) | undefined } -interface ReplayMessage { - readonly envelope: string - readonly lastSentChunk: string | undefined - readonly discard: boolean - readonly deliverAt?: number | undefined - readonly replyTos?: ReadonlyArray | undefined -} - interface InvokeResult { readonly requestId: string readonly replies: ReadonlyArray @@ -154,9 +168,7 @@ export class ClusterEntity extends DurableObject { entityId: EntityId.make(name.id) }) this.#keepAlive = makeEntityKeepAlive(() => { - const namespace = (this.#state.exports as Record).ClusterEntity as - | { readonly getByName: (name: string) => { readonly hold: () => Promise } } - | undefined + const namespace = exportedNamespace<{ readonly hold: () => Promise }>(this.#state, "ClusterEntity") if (namespace === undefined) { return Promise.reject( new Error("CloudflareCluster: ClusterEntity export is unavailable for keep-alive") @@ -198,26 +210,7 @@ export class ClusterEntity extends DurableObject { return Effect.gen({ self: this }, function*() { const storage = this.#state.storage const runtime = yield* this.#getRuntime(registration) - yield* Effect.forEach( - loadUnprocessed(storage.sql), - (row) => - this.#runStored( - registration, - runtime, - row.envelope, - row.lastSentChunk, - row.discard, - row.deliverAt === undefined - ? undefined - : { - scheduled: true, - ...(row.replyTos === undefined ? undefined : { replyTos: row.replyTos }) - } - ).pipe( - Effect.catchCause((cause) => this.#completeReplayFailure(registration, row, cause)) - ), - { discard: true } - ) + yield* this.#replayRows(registration, runtime, loadUnprocessed(storage.sql)) const envelope = yield* decodeRequest(registration, envelopeText) const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps @@ -280,22 +273,16 @@ export class ClusterEntity extends DurableObject { } const nextReply = loadNextReply(storage.sql, persisted.originalId) if (nextReply !== undefined) { - this.#releaseTerminalSession(persisted.originalId, nextReply) - return { result: { requestId: persisted.originalId, replies: [nextReply] } } + if (nextReply.kind === "WithExit") this.#sessions.delete(persisted.originalId) + return { result: { requestId: persisted.originalId, replies: [nextReply.reply] } } } if (persisted.processed) { return { result: { requestId: persisted.originalId, replies: [] } } } - if (delivery?.deliverAt !== undefined) { - yield* this.#armEarliestAlarm() - return this.#delayedOutcome( - persisted.originalId, - discard, - delivery.replyTo, - String(envelope.requestId) - ) - } - if (original.deliverAt !== undefined && original.deliverAt > Date.now()) { + if ( + delivery?.deliverAt !== undefined || + (original.deliverAt !== undefined && original.deliverAt > Date.now()) + ) { yield* this.#armEarliestAlarm() return this.#delayedOutcome( persisted.originalId, @@ -342,17 +329,13 @@ export class ClusterEntity extends DurableObject { acknowledge(requestId: string, replyId: string): Promise> { this.#state.storage.transactionSync(() => ackChunk(this.#state.storage.sql, requestId, replyId)) const session = this.#sessions.get(requestId) - if (session !== undefined) { - if (session.ack?.replyId === replyId) { - session.ack.resolve() - session.ack = undefined - return this.#takeReply(requestId, session) - } - const nextReply = loadNextReply(this.#state.storage.sql, requestId) - return Promise.resolve(nextReply === undefined ? [] : [nextReply]) + if (session?.ack?.replyId === replyId) { + session.ack.resolve() + session.ack = undefined + return this.#takeReply(requestId, session) } const nextReply = loadNextReply(this.#state.storage.sql, requestId) - return Promise.resolve(nextReply === undefined ? [] : [nextReply]) + return Promise.resolve(nextReply === undefined ? [] : [nextReply.reply]) } /** @internal Interrupts an in-memory handler execution. Persisted rows remain replayable. */ @@ -377,14 +360,9 @@ export class ClusterEntity extends DurableObject { return session.interrupt?.() ?? Promise.resolve() } - /** @internal */ - clearReplies(requestId: string): void { - this.#state.storage.transactionSync(() => clearReplies(this.#state.storage.sql, requestId)) - } - - /** @internal */ + /** @internal Clears stored replies so a reset request replays from scratch. */ reset(requestId: string): Promise { - this.clearReplies(requestId) + this.#state.storage.transactionSync(() => clearReplies(this.#state.storage.sql, requestId)) return Promise.resolve() } @@ -419,6 +397,35 @@ export class ClusterEntity extends DurableObject { ) } + #replayRows( + registration: EntityRegistration, + runtime: EntityRuntime, + rows: ReadonlyArray + ): Effect.Effect { + return Effect.forEach( + rows, + (row) => + // An active session already owns this request; replaying it would only + // decode the envelope to hit the same-session early return. + this.#sessions.has(row.requestId) ? Effect.void : this.#runStored( + registration, + runtime, + row.envelope, + row.lastSentChunk, + row.discard, + row.deliverAt === undefined + ? undefined + : { + scheduled: true, + ...(row.replyTos === undefined ? undefined : { replyTos: row.replyTos }) + } + ).pipe( + Effect.catchCause((cause) => this.#completeReplayFailure(registration, row, cause)) + ), + { discard: true } + ) + } + #runAlarm() { const registration = getEntityRegistration(this.#address.entityType) if (registration === undefined) { @@ -426,17 +433,7 @@ export class ClusterEntity extends DurableObject { } return Effect.gen({ self: this }, function*() { const runtime = yield* this.#getRuntime(registration) - yield* Effect.forEach( - loadDue(this.#state.storage.sql), - (row) => - this.#runStored(registration, runtime, row.envelope, row.lastSentChunk, row.discard, { - scheduled: true, - ...(row.replyTos === undefined ? undefined : { replyTos: row.replyTos }) - }).pipe( - Effect.catchCause((cause) => this.#completeReplayFailure(registration, row, cause)) - ), - { discard: true } - ) + yield* this.#replayRows(registration, runtime, loadDue(this.#state.storage.sql)) yield* this.#armEarliestAlarm() }).pipe( Effect.withSpan("CloudflareCluster.alarm", { @@ -461,15 +458,14 @@ export class ClusterEntity extends DurableObject { ): Effect.Effect { const storage = this.#state.storage return Effect.suspend(() => { - const encoded = JSON.parse(row.envelope) as { readonly requestId?: unknown; readonly tag?: unknown } - if (typeof encoded.requestId !== "string") return Effect.void + const encoded = JSON.parse(row.envelope) as { readonly tag?: unknown } if (row.discard || typeof encoded.tag !== "string") { - completeTell(storage.sql, encoded.requestId) + completeTell(storage.sql, row.requestId) return Effect.void } const rpc = registration.entity.protocol.requests.get(encoded.tag) as Rpc.AnyWithProps | undefined if (rpc === undefined) { - completeTell(storage.sql, encoded.requestId) + completeTell(storage.sql, row.requestId) return Effect.void } return Effect.flatMap( @@ -477,19 +473,19 @@ export class ClusterEntity extends DurableObject { registration, rpc, new Reply.WithExit({ - requestId: encoded.requestId as any, + requestId: row.requestId as any, id: crypto.randomUUID() as any, exit: Exit.failCause(cause) }) ), (reply) => Effect.sync(() => storage.transactionSync(() => saveReply(storage.sql, reply))).pipe( - Effect.andThen(this.#deliverScheduledReply(encoded.requestId as string, reply, row.replyTos)) + Effect.andThen(this.#deliverScheduledReply(row.requestId, reply, row.replyTos)) ) ).pipe( Effect.catchCause(() => Effect.sync(() => { - completeTell(storage.sql, encoded.requestId as string) + completeTell(storage.sql, row.requestId) }) ) ) @@ -518,7 +514,7 @@ export class ClusterEntity extends DurableObject { const active = this.#sessions.get(requestId) if (active !== undefined) { const nextReply = persisted ? loadNextReply(storage.sql, requestId) : undefined - return nextReply === undefined ? [] : [nextReply] + return nextReply === undefined ? [] : [nextReply.reply] } } @@ -535,7 +531,7 @@ export class ClusterEntity extends DurableObject { storage.transactionSync(() => saveReply(storage.sql, encoded)) } if (session !== undefined) { - yield* Effect.promise(() => this.#offerReply(session, encoded)) + yield* Effect.promise(() => this.#offerReply(session, reply, encoded)) } if (scheduled && reply._tag === "WithExit") { yield* this.#deliverScheduledReply(requestId, encoded, options?.replyTos) @@ -571,13 +567,12 @@ export class ClusterEntity extends DurableObject { } } if (replyTos === undefined) return Effect.void - const namespace = (this.#state.exports as Record).ClusterEntity as - | { - readonly getByName: ( - name: string - ) => { readonly deliverReply: (requestId: string, reply: string) => Promise } - } - | undefined + const namespace = exportedNamespace< + { readonly deliverReply: (requestId: string, reply: string) => Promise } + >( + this.#state, + "ClusterEntity" + ) if (namespace === undefined) { return Effect.logError( "Scheduled entity reply delivery failed", @@ -598,7 +593,7 @@ export class ClusterEntity extends DurableObject { ), Effect.catchCause((cause) => Effect.logError("Scheduled entity reply delivery failed", cause)) ), - { discard: true } + { concurrency: "unbounded", discard: true } ) } @@ -621,29 +616,29 @@ export class ClusterEntity extends DurableObject { return session } - #offerReply(session: ReplySession, reply: string): Promise { - const encoded = JSON.parse(reply) as { readonly _tag?: unknown; readonly id?: unknown } + #offerReply(session: ReplySession, reply: Reply.Reply, encoded: string): Promise { let acknowledged = Promise.resolve() - if (encoded._tag === "Chunk" && typeof encoded.id === "string") { + if (reply._tag === "Chunk") { let resolve!: () => void acknowledged = new Promise((resume) => { resolve = resume }) - session.ack = { replyId: encoded.id, resolve } + session.ack = { replyId: String(reply.id), resolve } } + const entry: SessionReply = { text: encoded, terminal: reply._tag === "WithExit" } const take = session.takers.shift() - if (take === undefined) session.replies.push(reply) - else take.resolve(reply) + if (take === undefined) session.replies.push(entry) + else take.resolve(entry) return acknowledged } async #takeReply(requestId: string, session: ReplySession): Promise> { const reply = session.replies.shift() ?? await (session.done ? session.failed ? Promise.reject(session.failure) : Promise.resolve(undefined) - : new Promise((resolve, reject) => session.takers.push({ resolve, reject }))) + : new Promise((resolve, reject) => session.takers.push({ resolve, reject }))) if (reply === undefined) return [] - this.#releaseTerminalSession(requestId, reply) - return [reply] + if (reply.terminal) this.#sessions.delete(requestId) + return [reply.text] } #finishSession(requestId: string, session: ReplySession, exit: Exit.Exit): void { @@ -661,12 +656,6 @@ export class ClusterEntity extends DurableObject { } } - #releaseTerminalSession(requestId: string, reply: string): void { - if ((JSON.parse(reply) as { readonly _tag?: unknown })._tag === "WithExit") { - this.#sessions.delete(requestId) - } - } - override fetch: () => never = notExposed("ClusterEntity") } @@ -723,13 +712,11 @@ export class ClusterWorkflow extends DurableObject { now: () => Date.now(), waitUntil: (promise) => this.#state.waitUntil(promise), getStub: (name) => { - const namespace = (this.#state.exports as Record).ClusterWorkflow as - | { readonly getByName: (name: string) => unknown } - | undefined + const namespace = exportedNamespace(this.#state, "ClusterWorkflow") if (namespace === undefined) { throw new Error("CloudflareCluster: ClusterWorkflow export is unavailable for workflow delivery") } - return namespace.getByName(name) as WorkflowStub + return namespace.getByName(name) } }) } 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/entityMailbox.ts b/packages/platform/cloudflare/src/internal/entityMailbox.ts index 1d9327e19fb..4f7e74b3b79 100644 --- a/packages/platform/cloudflare/src/internal/entityMailbox.ts +++ b/packages/platform/cloudflare/src/internal/entityMailbox.ts @@ -27,13 +27,15 @@ export type PersistResult = { } | { readonly _tag: "Duplicate" readonly originalId: string - readonly lastReceivedReply: string | undefined readonly processed: boolean } const textEncoder = new TextEncoder() -const encodedSize = (text: string): number => textEncoder.encode(text).byteLength +// 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 = ( @@ -44,7 +46,7 @@ export const persistRequest = ( deliverAt: number | null = null, replyTo: string | null = null ): PersistResult => { - if (encodedSize(envelopeText) > maximumEncodedSize) { + if (exceedsMaximumEncodedSize(envelopeText)) { throw new EncodedMessageTooLargeError("Encoded entity request exceeds 2 MB") } const envelope = JSON.parse(envelopeText) as { readonly _tag?: unknown; readonly requestId?: unknown } @@ -53,9 +55,8 @@ export const persistRequest = ( } const existing = sql.exec( - `SELECT m.request_id, m.discard, m.processed, m.reply_to, r.reply AS last_reply + `SELECT m.request_id, m.discard, m.processed, m.reply_to FROM cluster_messages m - LEFT JOIN cluster_replies r ON r.reply_id = m.last_reply_id WHERE m.request_id = ? OR (? IS NOT NULL AND m.message_id = ?) LIMIT 1`, envelope.requestId, @@ -75,20 +76,25 @@ export const persistRequest = ( return { _tag: "Duplicate", originalId: String(existing.request_id), - lastReceivedReply: typeof existing.last_reply === "string" ? existing.last_reply : undefined, processed: Number(existing.processed) === 1 } } - const count = sql.exec( - `SELECT COUNT(*) AS count - FROM cluster_messages m - WHERE m.processed = 0 OR EXISTS ( - SELECT 1 FROM cluster_replies r - WHERE r.request_id = m.request_id AND r.kind = 'Chunk' AND r.acked = 0 - )` - ).toArray()[0]?.count - if (Number(count) >= mailboxCapacity) { + // 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 = Number( + sql.exec("SELECT COUNT(*) AS count FROM cluster_messages WHERE processed = 0").toArray()[0]?.count + ) + const unacked = pending >= mailboxCapacity ? 0 : Number( + 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 + ) + if (pending + unacked >= mailboxCapacity) { throw new MailboxFullError("Entity mailbox has reached its 4096 request capacity") } @@ -108,7 +114,7 @@ export const persistRequest = ( /** @internal */ export const saveReply = (sql: SqlStorage, replyText: string): void => { - if (encodedSize(replyText) > maximumEncodedSize) { + if (exceedsMaximumEncodedSize(replyText)) { throw new EncodedMessageTooLargeError("Encoded entity reply chunk exceeds 2 MB") } const reply = JSON.parse(replyText) as { @@ -146,6 +152,7 @@ export const saveReply = (sql: SqlStorage, replyText: string): void => { /** @internal */ export interface StoredMessage { + readonly requestId: string readonly envelope: string readonly lastSentChunk: string | undefined readonly discard: boolean @@ -159,14 +166,15 @@ const decodeReplyTargets = (value: unknown): Array => { const decoded = JSON.parse(value) if (Array.isArray(decoded) && decoded.every((item) => typeof item === "string")) return decoded } catch { - // Rows written before reply targets became a collection contain one plain name. + // Malformed rows deliver nowhere instead of poisoning the replay. } - return [value] + return [] } const rowToMessage = (row: Record): StoredMessage => { const replyTos = decodeReplyTargets(row.reply_to) const message: StoredMessage = { + requestId: String(row.request_id), envelope: String(row.envelope), lastSentChunk: typeof row.last_reply === "string" ? row.last_reply : undefined, discard: Number(row.discard) === 1, @@ -179,7 +187,7 @@ const rowToMessage = (row: Record): StoredMessage => { /** @internal */ export const loadUnprocessed = (sql: SqlStorage, now = Date.now()): Array => sql.exec( - `SELECT m.envelope, m.discard, m.deliver_at, m.reply_to, r.reply AS last_reply + `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 <= ?) @@ -190,7 +198,7 @@ export const loadUnprocessed = (sql: SqlStorage, now = Date.now()): Array => sql.exec( - `SELECT m.envelope, m.discard, m.deliver_at, m.reply_to, r.reply AS last_reply + `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 <= ? @@ -201,7 +209,7 @@ export const loadDue = (sql: SqlStorage, now = Date.now()): Array /** @internal */ export const loadMessage = (sql: SqlStorage, requestId: string): StoredMessage | undefined => { const row = sql.exec( - `SELECT m.envelope, m.discard, m.deliver_at, m.reply_to, r.reply AS last_reply + `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 = ? @@ -212,22 +220,28 @@ export const loadMessage = (sql: SqlStorage, requestId: string): StoredMessage | } /** @internal */ -export const loadNextReply = (sql: SqlStorage, requestId: string): string | undefined => { +export interface NextReply { + readonly reply: string + readonly kind: "Chunk" | "WithExit" +} + +/** @internal */ +export const loadNextReply = (sql: SqlStorage, requestId: string): NextReply | undefined => { const row = sql.exec( - `SELECT reply + `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 + `SELECT reply, kind FROM cluster_replies WHERE request_id = ? AND kind = 'WithExit' LIMIT 1`, requestId ).toArray()[0] - return typeof row?.reply === "string" ? row.reply : undefined + return typeof row?.reply === "string" ? { reply: row.reply, kind: row.kind as NextReply["kind"] } : undefined } /** @internal */ diff --git a/packages/platform/cloudflare/src/internal/entityRegistry.ts b/packages/platform/cloudflare/src/internal/entityRegistry.ts index 6560f458c80..c49d8ef14ff 100644 --- a/packages/platform/cloudflare/src/internal/entityRegistry.ts +++ b/packages/platform/cloudflare/src/internal/entityRegistry.ts @@ -2,6 +2,7 @@ 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 @@ -15,19 +16,13 @@ export interface EntityRegistration { readonly context: Context.Context } -const registrations = new Map() +const registry = makeRegistry() /** @internal */ -export const getEntityRegistration = (type: string): EntityRegistration | undefined => registrations.get(type) +export const getEntityRegistration: (type: string) => EntityRegistration | undefined = registry.get /** @internal */ -export const registerEntity = (type: string, registration: EntityRegistration): boolean => { - if (registrations.has(type)) return false - registrations.set(type, registration) - return true -} +export const registerEntity: (type: string, registration: EntityRegistration) => boolean = registry.register /** @internal */ -export const unregisterEntity = (type: string, registration: EntityRegistration): void => { - if (registrations.get(type) === registration) registrations.delete(type) -} +export const unregisterEntity: (type: string, registration: EntityRegistration) => void = registry.unregister diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index 486bb9f45dc..b8d2a8bfb5d 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -16,6 +16,7 @@ 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 { EntityKeepAliveHandler } from "./entityKeepAlive.ts" import type { EntityRegistration } from "./entityRegistry.ts" import { CurrentEntityName } from "./entityReply.ts" @@ -31,7 +32,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( registration: EntityRegistration, address: EntityAddress.EntityAddress, nextId: () => string, - entityName = `${String(address.entityType).length}:${address.entityType}${address.entityId}`, + entityName = encodeName(address.entityType, address.entityId), keepAlive?: (enabled: boolean) => Effect.Effect ) { let cached: CachedHandlers | undefined diff --git a/packages/platform/cloudflare/src/internal/entityStorage.ts b/packages/platform/cloudflare/src/internal/entityStorage.ts index e42b10f1a5c..b559c7ee21c 100644 --- a/packages/platform/cloudflare/src/internal/entityStorage.ts +++ b/packages/platform/cloudflare/src/internal/entityStorage.ts @@ -32,7 +32,9 @@ const ddl = [ UNIQUE (request_id, sequence) )`, `CREATE INDEX IF NOT EXISTS cluster_messages_deliver_at_idx - ON cluster_messages (processed, deliver_at)` + 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 */ diff --git a/packages/platform/cloudflare/src/internal/entityWire.ts b/packages/platform/cloudflare/src/internal/entityWire.ts index aea6d45c25b..169b52755bf 100644 --- a/packages/platform/cloudflare/src/internal/entityWire.ts +++ b/packages/platform/cloudflare/src/internal/entityWire.ts @@ -21,10 +21,47 @@ export const runWith = ( context: Context.Context ): Effect.Effect => effect.pipe(Effect.provideContext(context as any), Effect.orDie) as Effect.Effect -const chunkValuesCodec = (rpc: Rpc.AnyWithProps) => - RpcSchema.isStreamSchema(rpc.successSchema) +// 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 = ( diff --git a/packages/platform/cloudflare/src/internal/queueRuntime.ts b/packages/platform/cloudflare/src/internal/queueRuntime.ts index 6ed2649b262..3307659690b 100644 --- a/packages/platform/cloudflare/src/internal/queueRuntime.ts +++ b/packages/platform/cloudflare/src/internal/queueRuntime.ts @@ -11,6 +11,7 @@ */ 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, @@ -63,13 +64,6 @@ export const makeQueueRuntime = (options: QueueRuntimeOptions): QueueRuntime => // release the already-leased item instead of stranding it. const delivered = new Map() - const rememberDelivered = (takerId: string, itemId: string): void => { - delivered.set(takerId, itemId) - if (delivered.size <= deliveredCapacity) return - const oldest = delivered.keys().next().value - if (oldest !== undefined) delivered.delete(oldest) - } - // 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 @@ -86,7 +80,7 @@ export const makeQueueRuntime = (options: QueueRuntimeOptions): QueueRuntime => continue } waiters.splice(index, 1) - rememberDelivered(waiter.takerId, item.id) + setWithEviction(delivered, waiter.takerId, item.id, deliveredCapacity) const expiry = now + waiter.leaseMillis if (earliest === undefined || expiry < earliest) earliest = expiry waiter.resolve(item) diff --git a/packages/platform/cloudflare/src/internal/queueStorage.ts b/packages/platform/cloudflare/src/internal/queueStorage.ts index 214a3a460f4..40fd540b8c4 100644 --- a/packages/platform/cloudflare/src/internal/queueStorage.ts +++ b/packages/platform/cloudflare/src/internal/queueStorage.ts @@ -25,7 +25,12 @@ const ddl = [ `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)` + 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 */ 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 index e617e301002..e7dcadee917 100644 --- a/packages/platform/cloudflare/src/internal/singletonRegistry.ts +++ b/packages/platform/cloudflare/src/internal/singletonRegistry.ts @@ -1,6 +1,7 @@ /** @internal */ import type * as Context from "effect/Context" import type * as Effect from "effect/Effect" +import { makeRegistry } from "./registry.ts" /** @internal */ export interface SingletonRegistration { @@ -8,19 +9,13 @@ export interface SingletonRegistration { readonly context: Context.Context } -const registrations = new Map() +const registry = makeRegistry() /** @internal */ -export const getSingletonRegistration = (name: string): SingletonRegistration | undefined => registrations.get(name) +export const getSingletonRegistration: (name: string) => SingletonRegistration | undefined = registry.get /** @internal */ -export const registerSingleton = (name: string, registration: SingletonRegistration): boolean => { - if (registrations.has(name)) return false - registrations.set(name, registration) - return true -} +export const registerSingleton: (name: string, registration: SingletonRegistration) => boolean = registry.register /** @internal */ -export const unregisterSingleton = (name: string, registration: SingletonRegistration): void => { - if (registrations.get(name) === registration) registrations.delete(name) -} +export const unregisterSingleton: (name: string, registration: SingletonRegistration) => void = registry.unregister diff --git a/packages/platform/cloudflare/src/internal/workflowRegistry.ts b/packages/platform/cloudflare/src/internal/workflowRegistry.ts index 49bb775995b..ff236ca20f2 100644 --- a/packages/platform/cloudflare/src/internal/workflowRegistry.ts +++ b/packages/platform/cloudflare/src/internal/workflowRegistry.ts @@ -11,6 +11,7 @@ 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 { @@ -22,22 +23,16 @@ export interface WorkflowRegistration { readonly context: Context.Context } -const registrations = new Map() +const registry = makeRegistry() /** @internal */ -export const getWorkflowRegistration = (name: string): WorkflowRegistration | undefined => registrations.get(name) +export const getWorkflowRegistration: (name: string) => WorkflowRegistration | undefined = registry.get /** @internal */ -export const registerWorkflow = (name: string, registration: WorkflowRegistration): boolean => { - if (registrations.has(name)) return false - registrations.set(name, registration) - return true -} +export const registerWorkflow: (name: string, registration: WorkflowRegistration) => boolean = registry.register /** @internal */ -export const unregisterWorkflow = (name: string, registration: WorkflowRegistration): void => { - if (registrations.get(name) === registration) registrations.delete(name) -} +export const unregisterWorkflow: (name: string, registration: WorkflowRegistration) => void = registry.unregister /** @internal */ export interface WorkflowRunOptions { diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index 7fb0ff0d3b6..338ca35de64 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -664,12 +664,4 @@ describe("CloudflareCluster", () => { assert.isTrue(Exit.isFailure(exit)) })) }) - - describe("makeRunnerAddress", () => { - it("derives a synthetic runner address from the object name", () => { - const address = CloudflareCluster.makeRunnerAddress("4:User42") - assert.strictEqual(address.host, "4:User42") - assert.strictEqual(address.port, 0) - }) - }) }) diff --git a/packages/platform/cloudflare/test/ClusterCron.test.ts b/packages/platform/cloudflare/test/ClusterCron.test.ts index b8633bbe7ea..e73b12acf5f 100644 --- a/packages/platform/cloudflare/test/ClusterCron.test.ts +++ b/packages/platform/cloudflare/test/ClusterCron.test.ts @@ -45,6 +45,9 @@ class FakeSql { 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), { diff --git a/packages/platform/cloudflare/test/EntityMailbox.test.ts b/packages/platform/cloudflare/test/EntityMailbox.test.ts index 6f3f104217e..430eaa79244 100644 --- a/packages/platform/cloudflare/test/EntityMailbox.test.ts +++ b/packages/platform/cloudflare/test/EntityMailbox.test.ts @@ -32,9 +32,14 @@ class FakeSql { 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 === 0 || Array.from(this.replies.entries()).some(([id, text]) => { + 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) }) @@ -99,6 +104,7 @@ class FakeSql { (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, @@ -117,7 +123,7 @@ class FakeSql { .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) }]) + 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]) @@ -125,7 +131,7 @@ class FakeSql { const value = JSON.parse(text) return value.requestId === request && value._tag === "WithExit" }) - return this.rows(reply === undefined ? [] : [{ reply }]) + return this.rows(reply === undefined ? [] : [{ reply, kind: "WithExit" }]) } if (query.includes("DELETE FROM cluster_replies")) { const request = String(bindings[0]) @@ -201,6 +207,7 @@ describe("EntityMailbox", () => { assert.strictEqual(sql.messages.get(requestId)?.reply_to, JSON.stringify(["7:Callercaller"])) assert.deepStrictEqual(loadUnprocessed(sql.sql, 1_999), []) assert.deepStrictEqual(loadDue(sql.sql, 2_000), [{ + requestId, envelope, lastSentChunk: undefined, discard: false, @@ -223,6 +230,7 @@ describe("EntityMailbox", () => { ) assert.deepStrictEqual(loadDue(sql.sql, 2_000), [{ + requestId, envelope, lastSentChunk: undefined, discard: false, @@ -245,7 +253,7 @@ describe("EntityMailbox", () => { assert.deepStrictEqual( persistRequest(sql.sql, withRequestId("0198bd72-6a81-72f1-8d87-5e9b5cf1e001"), primaryKey), - { _tag: "Duplicate", originalId: requestId, lastReceivedReply: reply, processed: true } + { _tag: "Duplicate", originalId: requestId, processed: true } ) }) @@ -261,7 +269,7 @@ describe("EntityMailbox", () => { }) saveReply(sql.sql, chunk) - assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ envelope, lastSentChunk: chunk, discard: false }]) + assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ requestId, envelope, lastSentChunk: chunk, discard: false }]) }) it("marks a persisted tell complete without storing a user-visible reply", () => { @@ -289,7 +297,12 @@ describe("EntityMailbox", () => { assert.isTrue(sql.acked.has("chunk-1")) clearReplies(sql.sql, requestId) - assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ envelope, lastSentChunk: undefined, discard: false }]) + assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ + requestId, + envelope, + lastSentChunk: undefined, + discard: false + }]) assert.strictEqual(sql.replies.size, 0) }) @@ -368,17 +381,17 @@ describe("EntityMailbox", () => { saveReply(sql.sql, chunk1) saveReply(sql.sql, terminal) - assert.strictEqual(loadNextReply(sql.sql, requestId), chunk0) + assert.deepStrictEqual(loadNextReply(sql.sql, requestId), { reply: chunk0, kind: "Chunk" }) ackChunk(sql.sql, requestId, "chunk-0") - assert.strictEqual(loadNextReply(sql.sql, requestId), chunk1) + assert.deepStrictEqual(loadNextReply(sql.sql, requestId), { reply: chunk1, kind: "Chunk" }) ackChunk(sql.sql, requestId, "chunk-1") - assert.strictEqual(loadNextReply(sql.sql, requestId), terminal) + assert.deepStrictEqual(loadNextReply(sql.sql, requestId), { reply: terminal, kind: "WithExit" }) }) it("retains tell discard mode for crash replay", () => { const sql = new FakeSql() persistRequest(sql.sql, envelope, null, true) - assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ envelope, lastSentChunk: undefined, discard: true }]) + assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ requestId, envelope, lastSentChunk: undefined, discard: true }]) }) }) From c50e4e8eb20cb813b4dbf2864405eb057fccfe86 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Wed, 19 Aug 2026 00:31:30 +0000 Subject: [PATCH 29/37] refactor(platform-cloudflare): idiomatic Effect cluster internals Move the ClusterEntity state machine out of the Durable Object class into an Effect-land entity manager in internal/entityRuntime.ts. The class methods are now one-line Effect.runPromise adapters; Effect.runPromise appears only at the DO RPC membrane. - Replace the hand-rolled #serial promise chain with a Semaphore(1) permit around invoke/alarm entry. - Replace ReplySession taker arrays with a Queue per session plus a Deferred for chunk acknowledgements; handler failure travels as Cause. - Replace #workerWaiters {resolve, reject} pairs with Deferred values. - Define the DO invoke result as a Schema tagged union in entityWire.ts; encode once in the DO, decode once in CloudflareCluster.ts, removing the string-literal error discriminants and the related casts. Same treatment for the replay-envelope tag peek. - Delete the module-global reply handler map in entityReply.ts; pinned callers now wait on a Deferred in a per-object reply registry provided through handler context, and deliverReply completes it. - Remove the `let client!` definite-assignment in CloudflareCluster.ts and the RequestId casts on client.write. Behavior preserving: no changes to the locked design, wire semantics beyond the invoke-result envelope shape (private same-package RPC), or SQLite schemas. Tests updated only where they fake the internal wire. Co-Authored-By: Claude Fable 5 --- .../cloudflare/src/CloudflareCluster.ts | 290 ++++----- .../src/CloudflareDurableObjects.ts | 573 ++---------------- .../cloudflare/src/internal/entityReply.ts | 64 +- .../cloudflare/src/internal/entityRuntime.ts | 556 ++++++++++++++++- .../cloudflare/src/internal/entityWire.ts | 38 ++ .../cloudflare/test/CloudflareCluster.test.ts | 67 +- .../test/CloudflareDurableObjects.test.ts | 4 +- .../cloudflare/test/ClusterCron.test.ts | 4 +- 8 files changed, 863 insertions(+), 733 deletions(-) diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index dfae2b3a63b..992d1c75a33 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -12,6 +12,7 @@ */ 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 Schema from "effect/Schema" @@ -28,6 +29,7 @@ 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" @@ -35,8 +37,8 @@ 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, registerReplyHandler, unregisterReplyHandler } from "./internal/entityReply.ts" -import { decodeReplyFor, encodeRequest } from "./internal/entityWire.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" /** @@ -113,11 +115,7 @@ interface EntityStub { readonly deliverAt?: number | undefined readonly primaryKey?: string | null | undefined readonly replyTo?: string | undefined - }) => Promise<{ - readonly requestId: string - readonly replies: ReadonlyArray - readonly error?: "MailboxFull" | "EncodedMessageTooLarge" | "AskDeduplicatedToTell" | undefined - }> + }) => Promise readonly acknowledge: (requestId: string, replyId: string) => Promise> readonly interrupt: (storageRequestId: string, clientRequestId?: string) => Promise readonly reset: (requestId: string) => Promise @@ -183,16 +181,17 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { readonly clientRequestId: string storageRequestId: string lastChunkId?: string - replyHandler?: (reply: string) => Promise } const entries = new Map() - let client!: Effect.Success>> - const dropReplyHandler = (entry: ClientEntry): void => { - if (entry.replyHandler === undefined) return - unregisterReplyHandler(entry.clientRequestId, entry.replyHandler) - unregisterReplyHandler(entry.storageRequestId, entry.replyHandler) - } + // `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( @@ -204,7 +203,7 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { return client.write({ _tag: "Chunk", clientId: 0, - requestId: entry.clientRequestId as any, + requestId: RequestId(entry.clientRequestId), values: reply.values }) } @@ -212,147 +211,156 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { return client.write({ _tag: "Exit", clientId: 0, - requestId: entry.clientRequestId as any, + requestId: RequestId(entry.clientRequestId), exit: reply.exit }) }), { discard: true } ) - client = yield* RpcClient.makeNoSerialization(entity.protocol, { - spanPrefix: `${entity.type}.client`, - supportsAck: true, - generateRequestId: () => uuidV7(clock.currentTimeMillisUnsafe()) as any, - onFromClient({ context, discard, message }): 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 - }) + 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 : Context.get(context, CurrentEntityName) - 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 } - let replyHandler: ((reply: string) => Promise) | undefined - if (delivery?.replyTo !== undefined) { - replyHandler = async (reply) => { - dropReplyHandler(entry) - await Effect.runPromise(deliverReplies(entry, [reply])) - } - entry.replyHandler = replyHandler - registerReplyHandler(clientRequestId, replyHandler) - } - return Effect.promise(() => target.stub.invoke(envelope, discard, delivery)).pipe( - Effect.flatMap((result) => { - if (result.error === "MailboxFull") { - return Effect.fail(new MailboxFull({ address: target.address }) as MailboxFull | PersistenceError) - } else if (result.error === "EncodedMessageTooLarge") { + }) + 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 : Context.get(context, CurrentEntityName) + const replyRegistry = discard ? undefined : Context.get(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") }) as - | MailboxFull - | PersistenceError + new PersistenceError({ cause: new Error("Encoded entity message exceeds 2 MB") }) ) - } else if (result.error === "AskDeduplicatedToTell") { + case "AskDeduplicatedToTell": return Effect.fail( new PersistenceError({ cause: new Error("Cannot deduplicate an ask onto a tell with the same PrimaryKey") - }) as MailboxFull | PersistenceError + }) ) + 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) } - entry.storageRequestId = result.requestId - if (replyHandler !== undefined && result.requestId !== clientRequestId) { - registerReplyHandler(result.requestId, replyHandler) - } - if (!discard && persisted) { - setWithEviction(requestTargets, clientRequestId, { - stub: target.stub, - storageRequestId: result.requestId - }, requestTargetCapacity) - } - const deliver = discard ? Effect.void : deliverReplies(entry, result.replies) - if (replyHandler === undefined || result.replies.length === 0) return deliver - return Effect.ensuring(deliver, Effect.sync(() => dropReplyHandler(entry))) - }), - Effect.tapCause(() => Effect.sync(() => dropReplyHandler(entry))) - ) - }) - } - 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 - dropReplyHandler(entry) - return Effect.promise(() => target.stub.interrupt(entry.storageRequestId, clientRequestId)) - } - default: - return Effect.void + 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 + return Effect.promise(() => target.stub.interrupt(entry.storageRequestId, clientRequestId)) + } + default: + return Effect.void } - }) + } return (entityId: string) => { const id = EntityId.make(entityId) @@ -372,9 +380,13 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { methodOptions?: { readonly context?: Context.Context } ) => { const currentEntityName = Context.get(ambient, CurrentEntityName) + const replyRegistry = Context.get(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) } diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 0bdd704458f..22b58dc5483 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -10,43 +10,16 @@ * @since 4.0.0 */ import { DurableObject } from "cloudflare:workers" -import * as Cause from "effect/Cause" -import * as Context from "effect/Context" import * as Effect from "effect/Effect" -import * as Exit from "effect/Exit" -import * as Fiber from "effect/Fiber" -import * as Option from "effect/Option" -import * as Result from "effect/Result" import * as ClusterMetrics from "effect/unstable/cluster/ClusterMetrics" -import { Persisted } from "effect/unstable/cluster/ClusterSchema" 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 type * as Rpc from "effect/unstable/rpc/Rpc" import { decodeName, encodeName } from "./internal/clusterName.ts" import { makeEntityKeepAlive } from "./internal/entityKeepAlive.ts" -import { - ackChunk, - clearReplies, - completeTell, - EncodedMessageTooLargeError, - loadDue, - loadMessage, - loadNextReply, - loadUnprocessed, - MailboxFullError, - persistRequest, - type PersistResult, - saveReply -} from "./internal/entityMailbox.ts" -import { type EntityRegistration, getEntityRegistration } from "./internal/entityRegistry.ts" -import { deliverReply as deliverEntityReply } from "./internal/entityReply.ts" -import { makeEntityRuntime } from "./internal/entityRuntime.ts" +import { makeEntityManager } from "./internal/entityRuntime.ts" import { armAlarm, earliestDeliverAt, ensureEntityStorage } from "./internal/entityStorage.ts" -import { decodeReplyFor, decodeRequest, encodeReplyFor } from "./internal/entityWire.ts" import { makeQueueRuntime } from "./internal/queueRuntime.ts" import { earliestLeaseExpiry, type QueueItem } from "./internal/queueStorage.ts" import { getSingletonRegistration } from "./internal/singletonRegistry.ts" @@ -70,7 +43,7 @@ const exportedNamespace = ( | { readonly getByName: (name: string) => Stub } | undefined -type EntityRuntime = Effect.Success> +type EntityManager = ReturnType type WorkflowRuntime = ReturnType @@ -78,47 +51,18 @@ type QueueRuntime = ReturnType type SingletonRuntime = ReturnType -interface SessionReply { - readonly text: string - readonly terminal: boolean -} - -// Mirrors entityMailbox's StoredMessage: the exported class cannot reference -// an @internal type in its method signatures, even private ones. -interface ReplayMessage { - readonly requestId: string - readonly envelope: string - readonly lastSentChunk: string | undefined - readonly discard: boolean - readonly deliverAt?: number | undefined - readonly replyTos?: ReadonlyArray | undefined -} - -interface ReplySession { - readonly replies: Array - readonly takers: Array<{ - readonly resolve: (reply: SessionReply | undefined) => void - readonly reject: (error: unknown) => void - }> - done: boolean - failed: boolean - failure: unknown - ack: { - readonly replyId: string - readonly resolve: () => void - } | undefined - interrupt: (() => Promise) | undefined -} - -interface InvokeResult { +// 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 error?: "MailboxFull" | "EncodedMessageTooLarge" | "AskDeduplicatedToTell" | undefined -} - -interface InvokeOutcome { - readonly result: InvokeResult - readonly deferred?: Promise | undefined +} | { + readonly _tag: "MailboxFull" +} | { + readonly _tag: "EncodedMessageTooLarge" +} | { + readonly _tag: "AskDeduplicatedToTell" } interface DeliveryOptions { @@ -127,12 +71,6 @@ interface DeliveryOptions { readonly replyTo?: string | undefined } -interface WorkerWaiter { - readonly clientRequestId: string - readonly resolve: (result: InvokeResult) => void - readonly reject: (error: unknown) => void -} - /** * The shared entity class. One instance holds one entity address; the handlers * for every `EntityType` are registered at Worker init. @@ -147,34 +85,37 @@ interface WorkerWaiter { * @since 4.0.0 */ export class ClusterEntity extends DurableObject { - readonly #state: DurableObjectState - readonly #address: EntityAddress.EntityAddress - readonly #name: string - #runtime: EntityRuntime | undefined - #serial: Promise = Promise.resolve() - readonly #sessions = new Map() - readonly #workerWaiters = new Map>() readonly #keepAlive + readonly #manager: EntityManager constructor(ctx: DurableObjectState, env: unknown) { super(ctx, env) - this.#state = ctx - this.#name = ctx.id.name ?? "" - const name = decodeName(this.#name) + const entityName = ctx.id.name ?? "" + const name = decodeName(entityName) if (name === undefined) throw new Error("ClusterEntity requires a canonical entity Durable Object name") - this.#address = EntityAddress.make({ - shardId: ShardId.make("default", 1), - entityType: EntityType.make(name.type), - entityId: EntityId.make(name.id) - }) this.#keepAlive = makeEntityKeepAlive(() => { - const namespace = exportedNamespace<{ readonly hold: () => Promise }>(this.#state, "ClusterEntity") + 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(this.#name).hold() + 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) @@ -185,9 +126,7 @@ export class ClusterEntity extends DurableObject { } override alarm(): Promise { - const operation = this.#serial.then(() => Effect.runPromise(this.#runAlarm())) - this.#serial = operation.then(() => void 0, () => void 0) - return operation + return Effect.runPromise(this.#manager.alarm) } /** @internal Keeps this object non-hibernateable while entity resources have holders. */ @@ -197,463 +136,27 @@ export class ClusterEntity extends DurableObject { /** @internal Same-Worker RPC transport used by `CloudflareCluster.layer`. */ invoke(envelopeText: string, discard: boolean, delivery?: DeliveryOptions): Promise { - const operation = this.#serial.then(() => Effect.runPromise(this.#invoke(envelopeText, discard, delivery))) - this.#serial = operation.then(() => void 0, () => void 0) - return operation.then((outcome) => outcome.deferred ?? outcome.result) - } - - #invoke(envelopeText: string, discard: boolean, delivery?: DeliveryOptions): Effect.Effect { - const registration = getEntityRegistration(this.#address.entityType) - if (registration === undefined) { - return Effect.die(`No handlers registered for entity type: ${this.#address.entityType}`) - } - return Effect.gen({ self: this }, function*() { - const storage = this.#state.storage - const runtime = yield* this.#getRuntime(registration) - yield* this.#replayRows(registration, runtime, loadUnprocessed(storage.sql)) - - 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 replies = yield* this.#run(registration, runtime, envelope, undefined, discard, false) - return { result: { requestId: String(envelope.requestId), replies } } - } - - const persistedResult = yield* Effect.result( - // Preserve unknown thrown values so the fallback defect is unchanged. - // @effect-diagnostics-next-line unknownInEffectCatch:off - Effect.try({ - try: () => - storage.transactionSync(() => - persistRequest( - storage.sql, - envelopeText, - delivery?.primaryKey ?? Envelope.primaryKey(envelope), - discard, - delivery?.deliverAt, - delivery?.replyTo - ) - ), - catch: (error) => error - }).pipe( - Effect.withSpan("CloudflareCluster.persist", { - attributes: { - entityType: registration.entity.type, - entityId: String(this.#address.entityId), - rpc: envelope.tag - } - }, { captureStackTrace: false }), - Effect.provideContext(registration.context) - ) - ) - if (Result.isFailure(persistedResult)) { - const error = persistedResult.failure - if (error instanceof MailboxFullError) { - return { result: { requestId: String(envelope.requestId), replies: [], error: "MailboxFull" as const } } - } else if (error instanceof EncodedMessageTooLargeError) { - return { - result: { requestId: String(envelope.requestId), replies: [], error: "EncodedMessageTooLarge" as const } - } - } - return yield* Effect.die(error) - } - const persisted: PersistResult = persistedResult.success - if (persisted._tag === "Duplicate") { - const original = loadMessage(storage.sql, persisted.originalId) - if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") - if (original.discard && !discard) { - return { - result: { - requestId: persisted.originalId, - replies: [], - error: "AskDeduplicatedToTell" as const - } - } - } - const nextReply = loadNextReply(storage.sql, persisted.originalId) - if (nextReply !== undefined) { - if (nextReply.kind === "WithExit") this.#sessions.delete(persisted.originalId) - return { result: { requestId: persisted.originalId, replies: [nextReply.reply] } } - } - if (persisted.processed) { - return { result: { requestId: persisted.originalId, replies: [] } } - } - if ( - delivery?.deliverAt !== undefined || - (original.deliverAt !== undefined && original.deliverAt > Date.now()) - ) { - yield* this.#armEarliestAlarm() - return this.#delayedOutcome( - persisted.originalId, - discard, - delivery?.replyTo, - String(envelope.requestId) - ) - } - const replies = yield* this.#runStored( - registration, - runtime, - original.envelope, - original.lastSentChunk, - original.discard - ) - return { result: { requestId: persisted.originalId, replies } } - } - if (delivery?.deliverAt !== undefined) { - yield* this.#armEarliestAlarm() - return this.#delayedOutcome(String(envelope.requestId), discard, delivery.replyTo) - } - const replies = yield* this.#run(registration, runtime, envelope, undefined, discard, true) - return { result: { requestId: String(envelope.requestId), replies } } - }) - } - - #delayedOutcome( - requestId: string, - discard: boolean, - replyTo: string | undefined, - clientRequestId = requestId - ): InvokeOutcome { - const result = { requestId, replies: [] } - if (discard || replyTo !== undefined) return { result } - const deferred = new Promise((resolve, reject) => { - const waiters = this.#workerWaiters.get(requestId) ?? [] - waiters.push({ clientRequestId, resolve, reject }) - this.#workerWaiters.set(requestId, waiters) - }) - return { result, deferred } + return Effect.runPromise(this.#manager.invoke(envelopeText, discard, delivery)) } /** @internal Acknowledges a streamed chunk. */ acknowledge(requestId: string, replyId: string): Promise> { - this.#state.storage.transactionSync(() => ackChunk(this.#state.storage.sql, requestId, replyId)) - const session = this.#sessions.get(requestId) - if (session?.ack?.replyId === replyId) { - session.ack.resolve() - session.ack = undefined - return this.#takeReply(requestId, session) - } - const nextReply = loadNextReply(this.#state.storage.sql, requestId) - return Promise.resolve(nextReply === undefined ? [] : [nextReply.reply]) + return Effect.runPromise(this.#manager.acknowledge(requestId, replyId)) } /** @internal Interrupts an in-memory handler execution. Persisted rows remain replayable. */ interrupt(storageRequestId: string, clientRequestId = storageRequestId): Promise { - const waiters = this.#workerWaiters.get(storageRequestId) - if (waiters !== undefined) { - const remaining = waiters.filter((waiter) => { - if (waiter.clientRequestId !== clientRequestId) return true - waiter.reject(new Error("Delayed entity request interrupted")) - return false - }) - if (remaining.length === 0) this.#workerWaiters.delete(storageRequestId) - else this.#workerWaiters.set(storageRequestId, remaining) - } - const session = this.#sessions.get(storageRequestId) - if (session === undefined) return Promise.resolve() - this.#sessions.delete(storageRequestId) - session.ack?.resolve() - session.ack = undefined - session.done = true - for (const take of session.takers.splice(0)) take.resolve(undefined) - return session.interrupt?.() ?? Promise.resolve() + return Effect.runPromise(this.#manager.interrupt(storageRequestId, clientRequestId)) } /** @internal Clears stored replies so a reset request replays from scratch. */ reset(requestId: string): Promise { - this.#state.storage.transactionSync(() => clearReplies(this.#state.storage.sql, requestId)) - return Promise.resolve() - } - - #getRuntime(registration: EntityRegistration) { - if (this.#runtime !== undefined) return Effect.succeed(this.#runtime) - return Effect.map( - makeEntityRuntime( - registration, - this.#address, - () => crypto.randomUUID(), - this.#name, - this.#keepAlive.update - ), - (runtime) => { - this.#runtime = runtime - return runtime - } - ) - } - - #runStored( - registration: EntityRegistration, - runtime: EntityRuntime, - envelopeText: string, - lastSentChunk: string | undefined, - discard: boolean, - options?: { readonly scheduled?: boolean; readonly replyTos?: ReadonlyArray | undefined } - ) { - return Effect.flatMap( - decodeRequest(registration, envelopeText), - (envelope) => this.#run(registration, runtime, envelope, lastSentChunk, discard, true, options) - ) - } - - #replayRows( - registration: EntityRegistration, - runtime: EntityRuntime, - rows: ReadonlyArray - ): Effect.Effect { - return Effect.forEach( - rows, - (row) => - // An active session already owns this request; replaying it would only - // decode the envelope to hit the same-session early return. - this.#sessions.has(row.requestId) ? Effect.void : this.#runStored( - registration, - runtime, - row.envelope, - row.lastSentChunk, - row.discard, - row.deliverAt === undefined - ? undefined - : { - scheduled: true, - ...(row.replyTos === undefined ? undefined : { replyTos: row.replyTos }) - } - ).pipe( - Effect.catchCause((cause) => this.#completeReplayFailure(registration, row, cause)) - ), - { discard: true } - ) - } - - #runAlarm() { - const registration = getEntityRegistration(this.#address.entityType) - if (registration === undefined) { - return Effect.die(`No handlers registered for entity type: ${this.#address.entityType}`) - } - return Effect.gen({ self: this }, function*() { - const runtime = yield* this.#getRuntime(registration) - yield* this.#replayRows(registration, runtime, loadDue(this.#state.storage.sql)) - yield* this.#armEarliestAlarm() - }).pipe( - Effect.withSpan("CloudflareCluster.alarm", { - attributes: { - entityType: registration.entity.type, - entityId: String(this.#address.entityId) - } - }, { captureStackTrace: false }), - Effect.provideContext(registration.context) - ) - } - - #armEarliestAlarm(): Effect.Effect { - const deliverAt = earliestDeliverAt(this.#state.storage.sql) - return deliverAt === undefined ? Effect.void : armAlarm(this.#state.storage, deliverAt) - } - - #completeReplayFailure( - registration: EntityRegistration, - row: ReplayMessage, - cause: Cause.Cause - ): Effect.Effect { - const storage = this.#state.storage - return Effect.suspend(() => { - const encoded = JSON.parse(row.envelope) as { readonly tag?: unknown } - if (row.discard || typeof encoded.tag !== "string") { - completeTell(storage.sql, row.requestId) - return Effect.void - } - const rpc = registration.entity.protocol.requests.get(encoded.tag) as Rpc.AnyWithProps | undefined - if (rpc === undefined) { - completeTell(storage.sql, row.requestId) - return Effect.void - } - return Effect.flatMap( - encodeReplyFor( - registration, - rpc, - new Reply.WithExit({ - requestId: row.requestId as any, - id: crypto.randomUUID() as any, - exit: Exit.failCause(cause) - }) - ), - (reply) => - Effect.sync(() => storage.transactionSync(() => saveReply(storage.sql, reply))).pipe( - Effect.andThen(this.#deliverScheduledReply(row.requestId, reply, row.replyTos)) - ) - ).pipe( - Effect.catchCause(() => - Effect.sync(() => { - completeTell(storage.sql, row.requestId) - }) - ) - ) - }) - } - - #run( - registration: EntityRegistration, - runtime: EntityRuntime, - envelope: Envelope.Request.Any, - lastSentChunkText: string | undefined, - discard: boolean, - persisted: boolean, - options?: { readonly scheduled?: boolean; readonly replyTos?: ReadonlyArray | undefined } - ): Effect.Effect> { - const rpc = registration.entity.protocol.requests.get(envelope.tag) as Rpc.AnyWithProps - const storage = this.#state.storage - return Effect.gen({ self: this }, function*() { - 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 = this.#sessions.get(requestId) - if (active !== undefined) { - const nextReply = persisted ? loadNextReply(storage.sql, requestId) : undefined - return nextReply === undefined ? [] : [nextReply.reply] - } - } - - const scheduled = options?.scheduled === true - const session = discard || scheduled ? undefined : this.#makeSession(requestId) - const execution = runtime.run( - envelope, - lastSentChunk, - discard, - (reply) => - Effect.gen({ self: this }, function*() { - const encoded = yield* encodeReplyFor(registration, rpc, reply) - if (persisted) { - storage.transactionSync(() => saveReply(storage.sql, encoded)) - } - if (session !== undefined) { - yield* Effect.promise(() => this.#offerReply(session, reply, encoded)) - } - if (scheduled && reply._tag === "WithExit") { - yield* this.#deliverScheduledReply(requestId, encoded, options?.replyTos) - } - }) - ) - if (discard || scheduled) { - yield* execution - if (discard && persisted) completeTell(storage.sql, requestId) - return [] - } - - const fiber = Effect.runFork(execution) - session!.interrupt = () => Effect.runPromise(Fiber.interrupt(fiber)) - const completion = Effect.runPromise(Fiber.await(fiber)).then((exit) => { - this.#finishSession(requestId, session!, exit) - }) - this.#state.waitUntil(completion) - return yield* Effect.promise(() => this.#takeReply(requestId, session!)) - }) - } - - #deliverScheduledReply( - requestId: string, - reply: string, - replyTos: ReadonlyArray | undefined - ): Effect.Effect { - const waiters = this.#workerWaiters.get(requestId) - if (waiters !== undefined) { - this.#workerWaiters.delete(requestId) - for (const waiter of waiters) { - waiter.resolve({ requestId, replies: [reply] }) - } - } - if (replyTos === undefined) return Effect.void - const namespace = exportedNamespace< - { readonly deliverReply: (requestId: string, reply: string) => Promise } - >( - this.#state, - "ClusterEntity" - ) - 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 } - ) + 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 deliverEntityReply(requestId, reply) - } - - #makeSession(requestId: string): ReplySession { - const session: ReplySession = { - replies: [], - takers: [], - done: false, - failed: false, - failure: undefined, - ack: undefined, - interrupt: undefined - } - this.#sessions.set(requestId, session) - return session - } - - #offerReply(session: ReplySession, reply: Reply.Reply, encoded: string): Promise { - let acknowledged = Promise.resolve() - if (reply._tag === "Chunk") { - let resolve!: () => void - acknowledged = new Promise((resume) => { - resolve = resume - }) - session.ack = { replyId: String(reply.id), resolve } - } - const entry: SessionReply = { text: encoded, terminal: reply._tag === "WithExit" } - const take = session.takers.shift() - if (take === undefined) session.replies.push(entry) - else take.resolve(entry) - return acknowledged - } - - async #takeReply(requestId: string, session: ReplySession): Promise> { - const reply = session.replies.shift() ?? await (session.done - ? session.failed ? Promise.reject(session.failure) : Promise.resolve(undefined) - : new Promise((resolve, reject) => session.takers.push({ resolve, reject }))) - if (reply === undefined) return [] - if (reply.terminal) this.#sessions.delete(requestId) - return [reply.text] - } - - #finishSession(requestId: string, session: ReplySession, exit: Exit.Exit): void { - session.done = true - if (Exit.isFailure(exit)) { - session.failed = true - session.failure = Cause.squash(exit.cause) - } - for (const take of session.takers.splice(0)) { - if (session.failed) take.reject(session.failure) - else take.resolve(undefined) - } - if (session.replies.length === 0 && session.ack === undefined) { - this.#sessions.delete(requestId) - } + return Effect.runPromise(this.#manager.deliverReply(requestId, reply)) } override fetch: () => never = notExposed("ClusterEntity") diff --git a/packages/platform/cloudflare/src/internal/entityReply.ts b/packages/platform/cloudflare/src/internal/entityReply.ts index 2981c457ebe..afc6d9de7cf 100644 --- a/packages/platform/cloudflare/src/internal/entityReply.ts +++ b/packages/platform/cloudflare/src/internal/entityReply.ts @@ -1,5 +1,7 @@ /** @internal */ import * as Context from "effect/Context" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" /** @internal */ export const CurrentEntityName = Context.Reference( @@ -7,34 +9,48 @@ export const CurrentEntityName = Context.Reference( { defaultValue: () => undefined } ) -type ReplyHandler = (reply: string) => Promise - -const handlers = new Map>() - -/** @internal */ -export const registerReplyHandler = (requestId: string, handler: ReplyHandler): void => { - const registered = handlers.get(requestId) ?? new Set() - registered.add(handler) - handlers.set(requestId, registered) +/** + * 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 unregisterReplyHandler = (requestId: string, handler?: ReplyHandler): void => { - if (handler === undefined) { - handlers.delete(requestId) - return +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 + } } - const registered = handlers.get(requestId) - if (registered === undefined) return - registered.delete(handler) - if (registered.size === 0) handlers.delete(requestId) } /** @internal */ -export const deliverReply = async (requestId: string, reply: string): Promise => { - const registered = handlers.get(requestId) - if (registered === undefined) return false - handlers.delete(requestId) - await Promise.all(Array.from(registered, (handler) => handler(reply))) - return true -} +export const CurrentReplyRegistry = Context.Reference( + "@effect/platform-cloudflare/CurrentReplyRegistry", + { defaultValue: () => undefined } +) diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index b8d2a8bfb5d..aba18750f8c 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -1,25 +1,51 @@ /** @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 * 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, Request } from "effect/unstable/cluster/Entity" import type * as EntityAddress from "effect/unstable/cluster/EntityAddress" -import type * as Envelope from "effect/unstable/cluster/Envelope" +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 { EntityKeepAliveHandler } from "./entityKeepAlive.ts" -import type { EntityRegistration } from "./entityRegistry.ts" -import { CurrentEntityName } from "./entityReply.ts" +import type { EntityKeepAlive } from "./entityKeepAlive.ts" +import { + ackChunk, + clearReplies, + completeTell, + EncodedMessageTooLargeError, + loadDue, + loadMessage, + loadNextReply, + loadUnprocessed, + MailboxFullError, + 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 } from "./entityStorage.ts" +import { decodeReplyFor, decodeRequest, encodeReplyFor, type InvokeResult, peekEnvelopeTag } from "./entityWire.ts" interface CachedHandlers { readonly handlers: Record any> @@ -33,7 +59,8 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( address: EntityAddress.EntityAddress, nextId: () => string, entityName = encodeName(address.entityType, address.entityId), - keepAlive?: (enabled: boolean) => Effect.Effect + keepAlive?: (enabled: boolean) => Effect.Effect, + replyRegistry?: EntityReplyRegistry ) { let cached: CachedHandlers | undefined const metricContext = Context.merge( @@ -64,6 +91,9 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( if (keepAlive !== undefined) { context = Context.add(context, EntityKeepAliveHandler, keepAlive) } + if (replyRegistry !== undefined) { + context = Context.add(context, CurrentReplyRegistry, replyRegistry) + } const handlers = yield* Effect.provideContext(registration.build, context) ClusterMetrics.entities.modifyUnsafe(BigInt(1), metricContext) return cached = { handlers, context, scope } @@ -161,3 +191,521 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( return { run, invalidate } as const }) + +type EntityRuntime = Effect.Success> + +interface SessionReply { + readonly text: string + readonly terminal: boolean +} + +interface Session { + readonly queue: Queue.Queue + ack: { + readonly replyId: string + readonly deferred: Deferred.Deferred + } | undefined + fiber: Fiber.Fiber | undefined +} + +interface WorkerWaiter { + readonly clientRequestId: string + readonly deferred: Deferred.Deferred +} + +type InvokeOutcome = { + readonly _tag: "Done" + readonly result: InvokeResult +} | { + readonly _tag: "Wait" + 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 +}) + +const done = (result: InvokeResult): InvokeOutcome => ({ _tag: "Done", result }) + +/** + * 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 invoke/alarm entry, matching single-threaded mailbox order. + const semaphore = Semaphore.makeUnsafe(1) + const sessions = new Map() + 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 } + ) + }) + + 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 ? loadNextReply(sql, requestId) : undefined + return nextReply === undefined ? [] : [nextReply.reply] + } + } + + const scheduled = runOptions?.scheduled === true + if (discard || scheduled) { + yield* entityRuntime.run(envelope, lastSentChunk, discard, (reply) => + Effect.gen(function*() { + const encoded = yield* encodeReplyFor(registration, rpc, reply) + if (persisted) { + storage.transactionSync(() => saveReply(sql, encoded)) + } + if (scheduled && reply._tag === "WithExit") { + yield* deliverScheduledReply(requestId, encoded, runOptions?.replyTos) + } + })) + if (discard && persisted) completeTell(sql, requestId) + return [] + } + + const queue = yield* Queue.make() + const session: Session = { queue, ack: undefined, fiber: undefined } + sessions.set(requestId, session) + const respond = (reply: Reply.Reply) => + Effect.gen(function*() { + const encoded = yield* encodeReplyFor(registration, rpc, reply) + if (persisted) { + storage.transactionSync(() => 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* Deferred.await(acknowledged) + else session.ack = undefined + } else { + yield* Queue.offer(queue, { text: encoded, terminal: true }) + } + }) + session.fiber = yield* Effect.forkDetach( + 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 yield* 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) { + completeTell(sql, row.requestId) + return Effect.void + } + const rpc = registration.entity.protocol.requests.get(tag) as Rpc.AnyWithProps | undefined + if (rpc === undefined) { + completeTell(sql, row.requestId) + return Effect.void + } + return Effect.flatMap( + encodeReplyFor( + registration, + rpc, + new Reply.WithExit({ + requestId: row.requestId as any, + id: crypto.randomUUID() as any, + exit: Exit.failCause(cause) + }) + ), + (reply) => + Effect.sync(() => storage.transactionSync(() => saveReply(sql, reply))).pipe( + Effect.andThen(deliverScheduledReply(row.requestId, reply, row.replyTos)) + ) + ).pipe( + Effect.catchCause(() => + Effect.sync(() => { + completeTell(sql, row.requestId) + }) + ) + ) + }) + ) + + const replayRows = ( + registration: EntityRegistration, + entityRuntime: EntityRuntime, + rows: ReadonlyArray + ): Effect.Effect => + Effect.forEach( + rows, + (row) => + // An active session already owns this request; replaying it would only + // decode the envelope to hit the same-session early return. + sessions.has(row.requestId) ? Effect.void : runStored( + registration, + entityRuntime, + row.envelope, + row.lastSentChunk, + row.discard, + row.deliverAt === undefined + ? undefined + : { + scheduled: true, + ...(row.replyTos === undefined ? undefined : { replyTos: row.replyTos }) + } + ).pipe( + Effect.catchCause((cause) => completeReplayFailure(registration, row, cause)) + ), + { discard: true } + ) + + const delayedOutcome = ( + requestId: string, + discard: boolean, + replyTo: string | undefined, + clientRequestId = requestId + ): InvokeOutcome => { + if (discard || replyTo !== undefined) return done(success(requestId, [])) + const deferred = Deferred.makeUnsafe() + const waiters = workerWaiters.get(requestId) ?? [] + waiters.push({ clientRequestId, deferred }) + workerWaiters.set(requestId, waiters) + return { _tag: "Wait", deferred } + } + + 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) + yield* replayRows(registration, entityRuntime, loadUnprocessed(sql)) + + 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 replies = yield* run(registration, entityRuntime, envelope, undefined, discard, false) + return done(success(String(envelope.requestId), replies)) + } + + const persistedResult = yield* Effect.result( + // Preserve unknown thrown values so the fallback defect is unchanged. + // @effect-diagnostics-next-line unknownInEffectCatch:off + Effect.try({ + try: () => + storage.transactionSync(() => + persistRequest( + sql, + envelopeText, + delivery?.primaryKey ?? Envelope.primaryKey(envelope), + discard, + delivery?.deliverAt, + delivery?.replyTo + ) + ), + catch: (error) => error + }).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)) { + const error = persistedResult.failure + if (error instanceof MailboxFullError) { + return done({ _tag: "MailboxFull" }) + } else if (error instanceof EncodedMessageTooLargeError) { + return done({ _tag: "EncodedMessageTooLarge" }) + } + return yield* Effect.die(error) + } + const persisted: PersistResult = persistedResult.success + if (persisted._tag === "Duplicate") { + const original = loadMessage(sql, persisted.originalId) + if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") + if (original.discard && !discard) { + return done({ _tag: "AskDeduplicatedToTell" }) + } + const nextReply = loadNextReply(sql, persisted.originalId) + if (nextReply !== undefined) { + if (nextReply.kind === "WithExit") sessions.delete(persisted.originalId) + return done(success(persisted.originalId, [nextReply.reply])) + } + if (persisted.processed) { + return done(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 replies = yield* runStored( + registration, + entityRuntime, + original.envelope, + original.lastSentChunk, + original.discard + ) + return done(success(persisted.originalId, replies)) + } + if (delivery?.deliverAt !== undefined) { + yield* armEarliestAlarm + return delayedOutcome(String(envelope.requestId), discard, delivery.replyTo) + } + const replies = yield* run(registration, entityRuntime, envelope, undefined, discard, true) + return done(success(String(envelope.requestId), replies)) + }) + } + + const invoke = ( + envelopeText: string, + discard: boolean, + delivery?: DeliveryOptions | undefined + ): Effect.Effect => + Semaphore.withPermit(semaphore, invokeEntry(envelopeText, discard, delivery)).pipe( + Effect.flatMap((outcome) => + outcome._tag === "Done" ? Effect.succeed(outcome.result) : Deferred.await(outcome.deferred) + ) + ) + + const acknowledge = (requestId: string, replyId: string): Effect.Effect> => + Effect.suspend(() => { + storage.transactionSync(() => ackChunk(sql, requestId, replyId)) + 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) + } + const nextReply = loadNextReply(sql, requestId) + return Effect.succeed(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) + return session.fiber === undefined ? Effect.void : Fiber.interrupt(session.fiber) + }) + + const reset = (requestId: string): Effect.Effect => + Effect.sync(() => { + storage.transactionSync(() => clearReplies(sql, requestId)) + }) + + const alarm = Semaphore.withPermit( + semaphore, + Effect.suspend(() => { + 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) + yield* replayRows(registration, entityRuntime, loadDue(sql)) + yield* armEarliestAlarm + }).pipe( + 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/entityWire.ts b/packages/platform/cloudflare/src/internal/entityWire.ts index 169b52755bf..0fe00687af6 100644 --- a/packages/platform/cloudflare/src/internal/entityWire.ts +++ b/packages/platform/cloudflare/src/internal/entityWire.ts @@ -15,6 +15,44 @@ 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, diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index 338ca35de64..0683a49c006 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -1,5 +1,9 @@ import * as CloudflareCluster from "@effect/platform-cloudflare/CloudflareCluster" -import { CurrentEntityName, deliverReply } from "@effect/platform-cloudflare/internal/entityReply" +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 { @@ -122,6 +126,7 @@ describe("CloudflareCluster", () => { invoke(envelopeText: string) { const envelope = JSON.parse(envelopeText) return Promise.resolve({ + _tag: "Success", requestId: envelope.requestId, replies: [JSON.stringify({ _tag: "WithExit", @@ -164,6 +169,7 @@ describe("CloudflareCluster", () => { const envelope = JSON.parse(envelopeText) envelopes.push(envelope) return Promise.resolve({ + _tag: "Success", requestId: envelope.requestId, replies: [JSON.stringify({ _tag: "WithExit", @@ -204,6 +210,7 @@ describe("CloudflareCluster", () => { 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] })] }) @@ -280,6 +287,7 @@ describe("CloudflareCluster", () => { const envelope = JSON.parse(envelopeText) deliveries.push({ discard, delivery }) return Promise.resolve({ + _tag: "Success", requestId: envelope.requestId, replies: discard ? [] : [JSON.stringify({ _tag: "WithExit", @@ -356,6 +364,7 @@ describe("CloudflareCluster", () => { yield* Effect.yieldNow assert.isUndefined(fiber.pollUnsafe()) resolve({ + _tag: "Success", requestId, replies: [JSON.stringify({ _tag: "WithExit", @@ -369,12 +378,13 @@ describe("CloudflareCluster", () => { }) 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(() => { - void deliverReply( + registry.deliver( envelope.requestId, JSON.stringify({ _tag: "WithExit", @@ -384,7 +394,7 @@ describe("CloudflareCluster", () => { }) ) }) - return Promise.resolve({ requestId: envelope.requestId, replies: [] }) + return Promise.resolve({ _tag: "Success", requestId: envelope.requestId, replies: [] }) }, acknowledge() { return Promise.resolve([]) @@ -401,13 +411,15 @@ describe("CloudflareCluster", () => { 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(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 @@ -440,25 +452,24 @@ describe("CloudflareCluster", () => { const makeClient = yield* Scheduled.client const client = makeClient("one") const request = { deliverAt: Date.now() + 60_000, id: "shared" } - const first = yield* Effect.forkChild( - client.Ask(request).pipe(Effect.provideService(CurrentEntityName, "6:Callerone")) - ) - const second = yield* Effect.forkChild( - client.Ask(request).pipe(Effect.provideService(CurrentEntityName, "6:Callerone")) - ) + 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({ requestId: storageRequestId, replies: [] }) + for (const resolve of pending) resolve({ _tag: "Success", requestId: storageRequestId, replies: [] }) yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))) - const delivered = yield* Effect.promise(() => - deliverReply( - storageRequestId, - JSON.stringify({ - _tag: "WithExit", - requestId: storageRequestId, - id: "terminal", - exit: { _tag: "Success", value: "callback" } - }) - ) + const delivered = registry.deliver( + storageRequestId, + JSON.stringify({ + _tag: "WithExit", + requestId: storageRequestId, + id: "terminal", + exit: { _tag: "Success", value: "callback" } + }) ) assert.isTrue(delivered) @@ -472,7 +483,7 @@ describe("CloudflareCluster", () => { const stub = { invoke() { invoked++ - return Promise.resolve({ requestId: "unused", replies: [] }) + return Promise.resolve({ _tag: "Success", requestId: "unused", replies: [] }) }, acknowledge() { return Promise.resolve([]) @@ -499,13 +510,10 @@ describe("CloudflareCluster", () => { }) it.effect("surfaces ask-to-tell deduplication as a persistence failure", () => { + const registry = makeReplyRegistry() const stub = { invoke() { - return Promise.resolve({ - requestId: "original-tell", - replies: [], - error: "AskDeduplicatedToTell" as const - }) + return Promise.resolve({ _tag: "AskDeduplicatedToTell" as const }) }, acknowledge() { return Promise.resolve([]) @@ -523,10 +531,11 @@ describe("CloudflareCluster", () => { 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(yield* Effect.promise(() => deliverReply("original-tell", "unused"))) + assert.isFalse(registry.deliver("original-tell", "unused")) }).pipe(Effect.provide(CloudflareCluster.layer(options))) }) @@ -538,6 +547,7 @@ describe("CloudflareCluster", () => { const envelope = JSON.parse(envelopeText) requestId = envelope.requestId return Promise.resolve({ + _tag: "Success", requestId, replies: [JSON.stringify({ _tag: "WithExit", @@ -582,6 +592,7 @@ describe("CloudflareCluster", () => { const requestId = JSON.parse(envelopeText).requestId requestIds.push(requestId) return Promise.resolve({ + _tag: "Success", requestId, replies: [JSON.stringify({ _tag: "WithExit", diff --git a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts index 3376ea5050d..e4472501240 100644 --- a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts +++ b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts @@ -240,7 +240,7 @@ describe("CloudflareDurableObjects", () => { `/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.error, "AskDeduplicatedToTell") + 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 }) @@ -266,7 +266,7 @@ describe("CloudflareDurableObjects", () => { 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.error, "AskDeduplicatedToTell") + assert.strictEqual(duplicate._tag, "AskDeduplicatedToTell") }), 60_000) it.effect("delivers a scheduled ask reply to the caller Durable Object", () => diff --git a/packages/platform/cloudflare/test/ClusterCron.test.ts b/packages/platform/cloudflare/test/ClusterCron.test.ts index e73b12acf5f..e5b3f59449d 100644 --- a/packages/platform/cloudflare/test/ClusterCron.test.ts +++ b/packages/platform/cloudflare/test/ClusterCron.test.ts @@ -142,7 +142,9 @@ class FakeCronDestination { const effect = delivery?.deliverAt === undefined ? Effect.void : armAlarm(this.alarm.storage, delivery.deliverAt) - return Effect.runPromise(Effect.as(effect, { requestId, replies: [] as ReadonlyArray })) + return Effect.runPromise( + Effect.as(effect, { _tag: "Success" as const, requestId, replies: [] as ReadonlyArray }) + ) } acknowledge() { From 41b0181f379191d2f081b7be2a73a20d9edf347e Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Wed, 19 Aug 2026 01:10:03 +0000 Subject: [PATCH 30/37] feat(platform-cloudflare): honor Entity.toLayer concurrency in entity Durable Objects Split the blanket invoke serialization in makeEntityManager into two levels: storage entry (decode, persist-before-run, dedupe, duplicate resume, alarm arming) keeps the single entry permit, while handler execution runs in forked fibers governed by a per-entity semaphore sized from the entity's concurrency build option. Replayed mailbox rows and alarm-due runs draw from the same budget, and a handler's permit is released while a stream chunk is parked on its client acknowledgement. Co-Authored-By: Claude Fable 5 --- .changeset/cloudflare-entity-concurrency.md | 13 + packages/platform/cloudflare/README.md | 20 ++ .../cloudflare/src/internal/entityRuntime.ts | 236 ++++++++++++++---- .../test/CloudflareDurableObjects.test.ts | 42 ++++ .../cloudflare/test/fixtures/worker.ts | 118 ++++++++- 5 files changed, 374 insertions(+), 55 deletions(-) create mode 100644 .changeset/cloudflare-entity-concurrency.md diff --git a/.changeset/cloudflare-entity-concurrency.md b/.changeset/cloudflare-entity-concurrency.md new file mode 100644 index 00000000000..00b36dcb658 --- /dev/null +++ b/.changeset/cloudflare-entity-concurrency.md @@ -0,0 +1,13 @@ +--- +"@effect/platform-cloudflare": patch +--- + +Honor `Entity.toLayer` `concurrency` inside the entity Durable Object. +Storage entry (decode, persist-before-run, dedupe, duplicate resume, and +alarm arming) stays serialized, while handler execution now draws from a +per-entity semaphore sized by the option: default 1 serializes as before, a +number allows that many in-flight handlers, and `"unbounded"` removes the +limit. Replayed mailbox rows and alarm-due runs share the same budget, and a +handler's permit is released while a stream chunk waits for its client +acknowledgement. This also lets ask cycles between entities complete when the +called-back entity has `concurrency` of at least 2. diff --git a/packages/platform/cloudflare/README.md b/packages/platform/cloudflare/README.md index 693f4546f2f..66159021789 100644 --- a/packages/platform/cloudflare/README.md +++ b/packages/platform/cloudflare/README.md @@ -123,6 +123,25 @@ are user code on the Worker. 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**. @@ -141,6 +160,7 @@ The status vocabulary is **maps 1:1**, **adapted**, and **out of scope**. | 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 | diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index aba18750f8c..d379478428e 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -6,6 +6,7 @@ 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" @@ -219,6 +220,9 @@ type InvokeOutcome = { } | { readonly _tag: "Wait" readonly deferred: Deferred.Deferred +} | { + readonly _tag: "Continue" + readonly effect: Effect.Effect } interface RunOptions { @@ -269,6 +273,62 @@ const success = (requestId: string, replies: ReadonlyArray): InvokeResul const done = (result: InvokeResult): InvokeOutcome => ({ _tag: "Done", result }) +const proceed = (effect: Effect.Effect): InvokeOutcome => ({ _tag: "Continue", effect }) + +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 @@ -280,13 +340,34 @@ const done = (result: InvokeResult): InvokeOutcome => ({ _tag: "Done", result }) export const makeEntityManager = (options: EntityManagerOptions): EntityManager => { const storage = options.storage const sql = storage.sql - // Serializes invoke/alarm entry, matching single-threaded mailbox order. + // 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 + // Handler executions share one budget across live, replayed, and alarm-due + // requests, sized from the entity's `concurrency` build option. + let handlerSemaphore: Semaphore.Semaphore | undefined + let handlerSemaphoreResolved = false + const getHandlerSemaphore = (registration: EntityRegistration): Semaphore.Semaphore | undefined => { + if (!handlerSemaphoreResolved) { + handlerSemaphoreResolved = true + const concurrency = registration.options?.concurrency ?? 1 + if (concurrency !== "unbounded") { + handlerSemaphore = Semaphore.makeUnsafe(concurrency) + } + } + return handlerSemaphore + } + const getRuntime = (registration: EntityRegistration): Effect.Effect => runtime !== undefined ? Effect.succeed(runtime) : Effect.map( makeEntityRuntime( @@ -353,6 +434,10 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager ) }) + // 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, @@ -373,24 +458,41 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager const active = sessions.get(requestId) if (active !== undefined) { const nextReply = persisted ? loadNextReply(sql, requestId) : undefined - return nextReply === undefined ? [] : [nextReply.reply] + return Effect.succeed(nextReply === undefined ? [] : [nextReply.reply]) } } + const permit = makeHandlerPermit(getHandlerSemaphore(registration)) const scheduled = runOptions?.scheduled === true if (discard || scheduled) { - yield* entityRuntime.run(envelope, lastSentChunk, discard, (reply) => - Effect.gen(function*() { - const encoded = yield* encodeReplyFor(registration, rpc, reply) - if (persisted) { - storage.transactionSync(() => saveReply(sql, encoded)) - } - if (scheduled && reply._tag === "WithExit") { - yield* deliverScheduledReply(requestId, encoded, runOptions?.replyTos) - } - })) - if (discard && persisted) completeTell(sql, requestId) - return [] + if (persisted) { + if (pendingRuns.has(requestId)) return Effect.succeed([]) + pendingRuns.add(requestId) + } + const fiber = yield* Effect.forkDetach( + permit.acquire( + entityRuntime.run(envelope, lastSentChunk, discard, (reply) => + Effect.gen(function*() { + const encoded = yield* encodeReplyFor(registration, rpc, reply) + if (persisted) { + storage.transactionSync(() => saveReply(sql, encoded)) + } + if (scheduled && reply._tag === "WithExit") { + yield* deliverScheduledReply(requestId, encoded, runOptions?.replyTos) + } + })) + ).pipe( + Effect.onExit((exit) => + Effect.sync(() => { + if (!persisted) return + if (discard && Exit.isSuccess(exit)) completeTell(sql, requestId) + pendingRuns.delete(requestId) + }) + ) + ) + ) + options.waitUntil(Fiber.await(fiber)) + return Effect.as(Fiber.join(fiber), []) } const queue = yield* Queue.make() @@ -408,14 +510,14 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager // 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* Deferred.await(acknowledged) + 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( - entityRuntime.run(envelope, lastSentChunk, discard, respond).pipe( + permit.acquire(entityRuntime.run(envelope, lastSentChunk, discard, respond)).pipe( Effect.onExit((exit) => Effect.sync(() => { if (Exit.isSuccess(exit)) Queue.endUnsafe(queue) @@ -428,7 +530,7 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager ) ) options.waitUntil(Fiber.await(session.fiber)) - return yield* takeReply(requestId, session) + return takeReply(requestId, session) }) const runStored = ( @@ -484,17 +586,20 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager }) ) + // 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 => + ): Effect.Effect>> => Effect.forEach( rows, (row) => - // An active session already owns this request; replaying it would only - // decode the envelope to hit the same-session early return. - sessions.has(row.requestId) ? Effect.void : runStored( + // 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, @@ -507,10 +612,18 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager ...(row.replyTos === undefined ? undefined : { replyTos: row.replyTos }) } ).pipe( - Effect.catchCause((cause) => completeReplayFailure(registration, row, cause)) - ), - { discard: true } - ) + Effect.flatMap((continuation) => + Effect.forkDetach( + Effect.catchCause( + Effect.asVoid(continuation), + (cause) => completeReplayFailure(registration, row, cause) + ) + ) + ), + Effect.tap((fiber) => Effect.sync(() => options.waitUntil(Fiber.await(fiber)))), + Effect.catchCause((cause) => Effect.as(completeReplayFailure(registration, row, cause), undefined)) + ) + ).pipe(Effect.map((fibers) => fibers.filter((fiber) => fiber !== undefined))) const delayedOutcome = ( requestId: string, @@ -537,14 +650,21 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager } return Effect.gen(function*() { const entityRuntime = yield* getRuntime(registration) - yield* replayRows(registration, entityRuntime, loadUnprocessed(sql)) + const replayFibers = yield* replayRows(registration, entityRuntime, loadUnprocessed(sql)) + // 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>): InvokeOutcome => + proceed( + (replayFibers.length === 0 ? continuation : 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 replies = yield* run(registration, entityRuntime, envelope, undefined, discard, false) - return done(success(String(envelope.requestId), replies)) + const continuation = yield* run(registration, entityRuntime, envelope, undefined, discard, false) + return finish(String(envelope.requestId), continuation) } const persistedResult = yield* Effect.result( @@ -610,21 +730,21 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager String(envelope.requestId) ) } - const replies = yield* runStored( + const continuation = yield* runStored( registration, entityRuntime, original.envelope, original.lastSentChunk, original.discard ) - return done(success(persisted.originalId, replies)) + return finish(persisted.originalId, continuation) } if (delivery?.deliverAt !== undefined) { yield* armEarliestAlarm return delayedOutcome(String(envelope.requestId), discard, delivery.replyTo) } - const replies = yield* run(registration, entityRuntime, envelope, undefined, discard, true) - return done(success(String(envelope.requestId), replies)) + const continuation = yield* run(registration, entityRuntime, envelope, undefined, discard, true) + return finish(String(envelope.requestId), continuation) }) } @@ -635,7 +755,11 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager ): Effect.Effect => Semaphore.withPermit(semaphore, invokeEntry(envelopeText, discard, delivery)).pipe( Effect.flatMap((outcome) => - outcome._tag === "Done" ? Effect.succeed(outcome.result) : Deferred.await(outcome.deferred) + outcome._tag === "Done" + ? Effect.succeed(outcome.result) + : outcome._tag === "Wait" + ? Deferred.await(outcome.deferred) + : outcome.effect ) ) @@ -681,28 +805,32 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager storage.transactionSync(() => clearReplies(sql, requestId)) }) - const alarm = Semaphore.withPermit( - semaphore, - Effect.suspend(() => { - 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) - yield* replayRows(registration, entityRuntime, loadDue(sql)) - yield* armEarliestAlarm - }).pipe( - Effect.withSpan("CloudflareCluster.alarm", { - attributes: { - entityType: registration.entity.type, - entityId: String(options.address.entityId) - } - }, { captureStackTrace: false }), - Effect.provideContext(registration.context) + 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) => replayRows(registration, entityRuntime, loadDue(sql)) ) - }) - ) + ).pipe( + Effect.flatMap((fibers) => fibers.length === 0 ? Effect.void : 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)) diff --git a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts index e4472501240..6432e47464b 100644 --- a/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts +++ b/packages/platform/cloudflare/test/CloudflareDurableObjects.test.ts @@ -269,6 +269,48 @@ describe("CloudflareDurableObjects", () => { 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 diff --git a/packages/platform/cloudflare/test/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts index 915032971e5..7a1d4c7a6cb 100644 --- a/packages/platform/cloudflare/test/fixtures/worker.ts +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -5,7 +5,7 @@ export { } from "@effect/platform-cloudflare/CloudflareDurableObjects" import { ClusterEntity as BaseClusterEntity } from "@effect/platform-cloudflare/CloudflareDurableObjects" import { registerEntity } from "@effect/platform-cloudflare/internal/entityRegistry" -import { Context, Effect, Schema, Stream } from "effect" +import { Context, Deferred, Effect, Schema, Stream } from "effect" import { ClusterSchema, Entity } from "effect/unstable/cluster" import { Rpc, RpcSchema } from "effect/unstable/rpc" @@ -30,6 +30,7 @@ export class ClusterEntity extends BaseClusterEntity { constructor(ctx: TestDurableObjectState, env: unknown) { super(ctx, env) this.#testState = ctx + entityEnv = env as Record } seedPoison(envelope: string): void { @@ -64,6 +65,102 @@ export class ClusterEntity extends BaseClusterEntity { } } +// 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 askEntity = (entityType: string, entityId: string, tag: string) => + Effect.promise((): Promise => + entityEnv!.CLUSTER_ENTITY + .getByName(`${entityType.length}:${entityType}${entityId}`) + .invoke(requestEnvelope(entityType, entityId, tag), false) + .then((result: { readonly replies: ReadonlyArray }) => String(JSON.parse(result.replies[0]).exit.value)) + ) + +const gates = new Map>() +const gateKey = (address: { readonly entityType: string; readonly entityId: string }) => + `${address.entityType}/${address.entityId}` +const makeGateEntity = (type: string) => + Entity.make(type, [ + Rpc.make("WaitTurn", { success: Schema.String }), + Rpc.make("Open", { success: Schema.String }) + ]) +const registerGateEntity = ( + type: string, + options: { readonly concurrency?: number | "unbounded" } | undefined +) => { + const entity = makeGateEntity(type) + 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({ @@ -173,6 +270,25 @@ export default { 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 stub = env.CLUSTER_ENTITY.getByName(`${type.length}:${type}${id}`) + const invokeGate = (tag: string) => + stub.invoke(requestEnvelope(type, id, tag), false).then( + (result: { readonly replies: ReadonlyArray }) => JSON.parse(result.replies[0]).exit.value + ) + const wait = invokeGate("WaitTurn") + await new Promise((resolve) => setTimeout(resolve, 100)) + const open = await invokeGate("Open") + return Response.json({ wait: await wait, open }) + } + if (url.pathname === "/cycle") { + const id = url.searchParams.get("id") ?? "cycle" + const stub = env.CLUSTER_ENTITY.getByName(`6:CycleA${id}`) + const result = await stub.invoke(requestEnvelope("CycleA", id, "Start"), false) + return Response.json({ value: JSON.parse(result.replies[0]).exit.value }) + } if (url.pathname === "/seed-poison") { const stub = env.CLUSTER_ENTITY.getByName("7:Mailboxcounter") await stub.seedPoison(JSON.stringify({ From a0a463e024e6db4411bd87ce495e80fa943923b2 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Wed, 19 Aug 2026 01:26:54 +0000 Subject: [PATCH 31/37] chore(platform-cloudflare): remove entity concurrency changeset --- .changeset/cloudflare-entity-concurrency.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .changeset/cloudflare-entity-concurrency.md diff --git a/.changeset/cloudflare-entity-concurrency.md b/.changeset/cloudflare-entity-concurrency.md deleted file mode 100644 index 00b36dcb658..00000000000 --- a/.changeset/cloudflare-entity-concurrency.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@effect/platform-cloudflare": patch ---- - -Honor `Entity.toLayer` `concurrency` inside the entity Durable Object. -Storage entry (decode, persist-before-run, dedupe, duplicate resume, and -alarm arming) stays serialized, while handler execution now draws from a -per-entity semaphore sized by the option: default 1 serializes as before, a -number allows that many in-flight handlers, and `"unbounded"` removes the -limit. Replayed mailbox rows and alarm-due runs share the same budget, and a -handler's permit is released while a stream chunk waits for its client -acknowledgement. This also lets ask cycles between entities complete when the -called-back entity has `concurrency` of at least 2. From a7c3eb6f3fbb502eb3d99b6d94d61d62b849cf0d Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Wed, 19 Aug 2026 01:44:16 +0000 Subject: [PATCH 32/37] refactor(platform-cloudflare): simplify entity concurrency internals - Replace the Done/Wait/Continue InvokeOutcome ADT with a nested effect: invokeEntry returns the post-permit continuation directly and invoke flattens it. - Move the handler semaphore into makeEntityRuntime next to the other build options, deleting the manager's lazy tri-state resolution. - Use a plain Semaphore.withPermit on the discard/scheduled path (no stream acks there) and build the pausable permit only for sessions. - Register one waitUntil per replay batch instead of one per row, skip replay entirely for an empty mailbox, and drop redundant empty-array guards around Fiber.awaitAll. - Reuse encodeName and a shared invoke helper in the test fixture. Co-Authored-By: Claude Fable 5 --- .../cloudflare/src/internal/entityRuntime.ts | 134 ++++++++---------- .../cloudflare/test/fixtures/worker.ts | 42 +++--- 2 files changed, 77 insertions(+), 99 deletions(-) diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index d379478428e..b816ffc9518 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -68,6 +68,10 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( 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 @@ -190,7 +194,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( } }) - return { run, invalidate } as const + return { run, invalidate, handlerSemaphore } as const }) type EntityRuntime = Effect.Success> @@ -214,17 +218,6 @@ interface WorkerWaiter { readonly deferred: Deferred.Deferred } -type InvokeOutcome = { - readonly _tag: "Done" - readonly result: InvokeResult -} | { - readonly _tag: "Wait" - readonly deferred: Deferred.Deferred -} | { - readonly _tag: "Continue" - readonly effect: Effect.Effect -} - interface RunOptions { readonly scheduled?: boolean readonly replyTos?: ReadonlyArray | undefined @@ -271,10 +264,6 @@ const success = (requestId: string, replies: ReadonlyArray): InvokeResul replies }) -const done = (result: InvokeResult): InvokeOutcome => ({ _tag: "Done", result }) - -const proceed = (effect: Effect.Effect): InvokeOutcome => ({ _tag: "Continue", effect }) - interface HandlerPermit { readonly acquire: (effect: Effect.Effect) => Effect.Effect readonly pause: (effect: Effect.Effect) => Effect.Effect @@ -353,21 +342,6 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager const replyRegistry = makeReplyRegistry() let runtime: EntityRuntime | undefined - // Handler executions share one budget across live, replayed, and alarm-due - // requests, sized from the entity's `concurrency` build option. - let handlerSemaphore: Semaphore.Semaphore | undefined - let handlerSemaphoreResolved = false - const getHandlerSemaphore = (registration: EntityRegistration): Semaphore.Semaphore | undefined => { - if (!handlerSemaphoreResolved) { - handlerSemaphoreResolved = true - const concurrency = registration.options?.concurrency ?? 1 - if (concurrency !== "unbounded") { - handlerSemaphore = Semaphore.makeUnsafe(concurrency) - } - } - return handlerSemaphore - } - const getRuntime = (registration: EntityRegistration): Effect.Effect => runtime !== undefined ? Effect.succeed(runtime) : Effect.map( makeEntityRuntime( @@ -462,34 +436,35 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager } } - const permit = makeHandlerPermit(getHandlerSemaphore(registration)) 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) { + storage.transactionSync(() => saveReply(sql, encoded)) + } + if (scheduled && reply._tag === "WithExit") { + yield* deliverScheduledReply(requestId, encoded, runOptions?.replyTos) + } + })) const fiber = yield* Effect.forkDetach( - permit.acquire( - entityRuntime.run(envelope, lastSentChunk, discard, (reply) => - Effect.gen(function*() { - const encoded = yield* encodeReplyFor(registration, rpc, reply) - if (persisted) { - storage.transactionSync(() => saveReply(sql, encoded)) - } - if (scheduled && reply._tag === "WithExit") { - yield* deliverScheduledReply(requestId, encoded, runOptions?.replyTos) - } - })) - ).pipe( - Effect.onExit((exit) => - Effect.sync(() => { - if (!persisted) return - if (discard && Exit.isSuccess(exit)) completeTell(sql, requestId) - pendingRuns.delete(requestId) - }) + (entityRuntime.handlerSemaphore === undefined + ? execute + : Semaphore.withPermit(entityRuntime.handlerSemaphore, execute)).pipe( + Effect.onExit((exit) => + Effect.sync(() => { + if (!persisted) return + if (discard && Exit.isSuccess(exit)) completeTell(sql, requestId) + pendingRuns.delete(requestId) + }) + ) ) - ) ) options.waitUntil(Fiber.await(fiber)) return Effect.as(Fiber.join(fiber), []) @@ -498,6 +473,7 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager const queue = yield* Queue.make() const session: Session = { queue, 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) @@ -594,7 +570,7 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager entityRuntime: EntityRuntime, rows: ReadonlyArray ): Effect.Effect>> => - Effect.forEach( + rows.length === 0 ? Effect.succeed([]) : Effect.forEach( rows, (row) => // An active session or in-flight run already owns this request; @@ -620,30 +596,40 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager ) ) ), - Effect.tap((fiber) => Effect.sync(() => options.waitUntil(Fiber.await(fiber)))), Effect.catchCause((cause) => Effect.as(completeReplayFailure(registration, row, cause), undefined)) ) - ).pipe(Effect.map((fibers) => fibers.filter((fiber) => fiber !== 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 - ): InvokeOutcome => { - if (discard || replyTo !== undefined) return done(success(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 { _tag: "Wait", deferred } + 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 => { + ): Effect.Effect> => { const registration = getEntityRegistration(options.address.entityType) if (registration === undefined) { return Effect.die(`No handlers registered for entity type: ${options.address.entityType}`) @@ -653,10 +639,12 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager const replayFibers = yield* replayRows(registration, entityRuntime, loadUnprocessed(sql)) // 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>): InvokeOutcome => - proceed( - (replayFibers.length === 0 ? continuation : Effect.andThen(Fiber.awaitAll(replayFibers), continuation)) - .pipe(Effect.map((replies) => success(requestId, replies))) + 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) @@ -697,9 +685,9 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager if (Result.isFailure(persistedResult)) { const error = persistedResult.failure if (error instanceof MailboxFullError) { - return done({ _tag: "MailboxFull" }) + return Effect.succeed({ _tag: "MailboxFull" }) } else if (error instanceof EncodedMessageTooLargeError) { - return done({ _tag: "EncodedMessageTooLarge" }) + return Effect.succeed({ _tag: "EncodedMessageTooLarge" }) } return yield* Effect.die(error) } @@ -708,15 +696,15 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager const original = loadMessage(sql, persisted.originalId) if (original === undefined) return yield* Effect.die("Duplicate mailbox row disappeared") if (original.discard && !discard) { - return done({ _tag: "AskDeduplicatedToTell" }) + return Effect.succeed({ _tag: "AskDeduplicatedToTell" }) } const nextReply = loadNextReply(sql, persisted.originalId) if (nextReply !== undefined) { if (nextReply.kind === "WithExit") sessions.delete(persisted.originalId) - return done(success(persisted.originalId, [nextReply.reply])) + return Effect.succeed(success(persisted.originalId, [nextReply.reply])) } if (persisted.processed) { - return done(success(persisted.originalId, [])) + return Effect.succeed(success(persisted.originalId, [])) } if ( delivery?.deliverAt !== undefined || @@ -753,15 +741,7 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager discard: boolean, delivery?: DeliveryOptions | undefined ): Effect.Effect => - Semaphore.withPermit(semaphore, invokeEntry(envelopeText, discard, delivery)).pipe( - Effect.flatMap((outcome) => - outcome._tag === "Done" - ? Effect.succeed(outcome.result) - : outcome._tag === "Wait" - ? Deferred.await(outcome.deferred) - : outcome.effect - ) - ) + Effect.flatten(Semaphore.withPermit(semaphore, invokeEntry(envelopeText, discard, delivery))) const acknowledge = (requestId: string, replyId: string): Effect.Effect> => Effect.suspend(() => { @@ -820,7 +800,7 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager (entityRuntime) => replayRows(registration, entityRuntime, loadDue(sql)) ) ).pipe( - Effect.flatMap((fibers) => fibers.length === 0 ? Effect.void : Effect.asVoid(Fiber.awaitAll(fibers))), + Effect.flatMap((fibers) => Effect.asVoid(Fiber.awaitAll(fibers))), Effect.andThen(Semaphore.withPermit(semaphore, armEarliestAlarm)), Effect.withSpan("CloudflareCluster.alarm", { attributes: { diff --git a/packages/platform/cloudflare/test/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts index 7a1d4c7a6cb..11601fe70fb 100644 --- a/packages/platform/cloudflare/test/fixtures/worker.ts +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -4,6 +4,7 @@ export { 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" @@ -83,27 +84,31 @@ const requestEnvelope = (entityType: string, entityId: string, tag: string) => 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((): Promise => - entityEnv!.CLUSTER_ENTITY - .getByName(`${entityType.length}:${entityType}${entityId}`) - .invoke(requestEnvelope(entityType, entityId, tag), false) - .then((result: { readonly replies: ReadonlyArray }) => String(JSON.parse(result.replies[0]).exit.value)) - ) + 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 makeGateEntity = (type: string) => - Entity.make(type, [ - Rpc.make("WaitTurn", { success: Schema.String }), - Rpc.make("Open", { success: Schema.String }) - ]) const registerGateEntity = ( type: string, options: { readonly concurrency?: number | "unbounded" } | undefined ) => { - const entity = makeGateEntity(type) + 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({ @@ -273,21 +278,14 @@ export default { if (url.pathname === "/gate") { const type = url.searchParams.get("type") ?? "GateSerial" const id = url.searchParams.get("id") ?? "gate" - const stub = env.CLUSTER_ENTITY.getByName(`${type.length}:${type}${id}`) - const invokeGate = (tag: string) => - stub.invoke(requestEnvelope(type, id, tag), false).then( - (result: { readonly replies: ReadonlyArray }) => JSON.parse(result.replies[0]).exit.value - ) - const wait = invokeGate("WaitTurn") + const wait = invokeValue(env.CLUSTER_ENTITY, type, id, "WaitTurn") await new Promise((resolve) => setTimeout(resolve, 100)) - const open = await invokeGate("Open") + 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" - const stub = env.CLUSTER_ENTITY.getByName(`6:CycleA${id}`) - const result = await stub.invoke(requestEnvelope("CycleA", id, "Start"), false) - return Response.json({ value: JSON.parse(result.replies[0]).exit.value }) + 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") From 38a4f7edb8bcff062d58d3badfbabfe56ba65ae8 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Wed, 19 Aug 2026 02:40:26 +0000 Subject: [PATCH 33/37] fix(platform-cloudflare): complete interrupted streams --- .changeset/tidy-cats-cancel.md | 5 + .../src/CloudflareDurableObjects.ts | 2 +- .../cloudflare/src/internal/entityRuntime.ts | 26 +++- .../cloudflare/test/EntityManager.test.ts | 129 ++++++++++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 .changeset/tidy-cats-cancel.md create mode 100644 packages/platform/cloudflare/test/EntityManager.test.ts diff --git a/.changeset/tidy-cats-cancel.md b/.changeset/tidy-cats-cancel.md new file mode 100644 index 00000000000..08af67a295f --- /dev/null +++ b/.changeset/tidy-cats-cancel.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-cloudflare": patch +--- + +Complete interrupted persisted entity streams so they cannot replay into an abandoned session. diff --git a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts index 22b58dc5483..97a0278c06a 100644 --- a/packages/platform/cloudflare/src/CloudflareDurableObjects.ts +++ b/packages/platform/cloudflare/src/CloudflareDurableObjects.ts @@ -144,7 +144,7 @@ export class ClusterEntity extends DurableObject { return Effect.runPromise(this.#manager.acknowledge(requestId, replyId)) } - /** @internal Interrupts an in-memory handler execution. Persisted rows remain replayable. */ + /** @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)) } diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index b816ffc9518..b8d1a38ddf3 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -206,6 +206,7 @@ interface SessionReply { interface Session { readonly queue: Queue.Queue + readonly completeInterrupt: Effect.Effect ack: { readonly replyId: string readonly deferred: Deferred.Deferred @@ -471,7 +472,27 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager } const queue = yield* Queue.make() - const session: Session = { queue, ack: undefined, fiber: undefined } + const completeInterrupt = persisted + ? Effect.flatMap( + encodeReplyFor( + registration, + rpc, + new Reply.WithExit({ + requestId: envelope.requestId, + id: crypto.randomUUID() as any, + exit: Exit.interrupt() + }) + ), + (reply) => + Effect.sync(() => + storage.transactionSync(() => { + 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) => @@ -777,7 +798,8 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager session.ack = undefined } Queue.endUnsafe(session.queue) - return session.fiber === undefined ? Effect.void : Fiber.interrupt(session.fiber) + 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 => diff --git a/packages/platform/cloudflare/test/EntityManager.test.ts b/packages/platform/cloudflare/test/EntityManager.test.ts new file mode 100644 index 00000000000..db3bfc10744 --- /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( + "SELECT processed FROM cluster_messages WHERE request_id = ?", + streamRequestId + ).toArray()[0] + assert.strictEqual(row.processed, 1) + assert.strictEqual(loadNextReply(storage.sql, streamRequestId)?.kind, "WithExit") + assert.strictEqual( + storage.sql.exec( + "SELECT COUNT(*) AS count FROM cluster_replies WHERE request_id = ? AND kind = 'Chunk' AND acked = 0", + streamRequestId + ).toArray()[0].count, + 0 + ) + })) +}) From f9d9e65b513de867085c485c069087564a4dc8f0 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 19 Aug 2026 15:15:11 +1200 Subject: [PATCH 34/37] Honor uninterruptible Cloudflare entity requests (#7345) --- .../cloudflare-uninterruptible-client.md | 5 +++ .../cloudflare/src/CloudflareCluster.ts | 1 + .../cloudflare/test/CloudflareCluster.test.ts | 41 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 .changeset/cloudflare-uninterruptible-client.md diff --git a/.changeset/cloudflare-uninterruptible-client.md b/.changeset/cloudflare-uninterruptible-client.md new file mode 100644 index 00000000000..8072eeea49e --- /dev/null +++ b/.changeset/cloudflare-uninterruptible-client.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-cloudflare": patch +--- + +Prevent client interruption from cancelling Cloudflare Durable Object handlers annotated as uninterruptible. diff --git a/packages/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index 992d1c75a33..f00192805f7 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -355,6 +355,7 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { 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: diff --git a/packages/platform/cloudflare/test/CloudflareCluster.test.ts b/packages/platform/cloudflare/test/CloudflareCluster.test.ts index 0683a49c006..f14932e40ec 100644 --- a/packages/platform/cloudflare/test/CloudflareCluster.test.ts +++ b/packages/platform/cloudflare/test/CloudflareCluster.test.ts @@ -21,6 +21,10 @@ 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) ]) @@ -280,6 +284,43 @@ describe("CloudflareCluster", () => { }).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 = { From 84d75323acf969cbd9068db12d3ca07febf3134a Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 19 Aug 2026 15:26:08 +1200 Subject: [PATCH 35/37] Fix concurrent Cloudflare entity handler builds (#7346) --- .changeset/cloudflare-entity-handler-cache.md | 5 ++ .../cloudflare/src/internal/entityRuntime.ts | 24 +++++- .../cloudflare/test/EntityRuntime.test.ts | 82 ++++++++++++++++++- 3 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 .changeset/cloudflare-entity-handler-cache.md diff --git a/.changeset/cloudflare-entity-handler-cache.md b/.changeset/cloudflare-entity-handler-cache.md new file mode 100644 index 00000000000..ce04971fb1f --- /dev/null +++ b/.changeset/cloudflare-entity-handler-cache.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-cloudflare": patch +--- + +Share an in-flight entity handler build between concurrent first requests. diff --git a/packages/platform/cloudflare/src/internal/entityRuntime.ts b/packages/platform/cloudflare/src/internal/entityRuntime.ts index b8d1a38ddf3..2b07af7b063 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -64,6 +64,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( replyRegistry?: EntityReplyRegistry ) { let cached: CachedHandlers | undefined + let building: Deferred.Deferred | undefined const metricContext = Context.merge( registration.context, Metric.CurrentMetricAttributes.context({ type: registration.entity.type }) @@ -84,8 +85,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( ) }) - const getHandlers = Effect.fnUntraced(function*() { - if (cached !== undefined) return cached + const buildHandlers = Effect.fnUntraced(function*() { const scope = yield* Scope.make() let context = registration.context.pipe( Context.add(CurrentAddress, address), @@ -99,11 +99,27 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( if (replyRegistry !== undefined) { context = Context.add(context, CurrentReplyRegistry, replyRegistry) } - const handlers = yield* Effect.provideContext(registration.build, context) + 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 cached = { handlers, context, scope } + 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) diff --git a/packages/platform/cloudflare/test/EntityRuntime.test.ts b/packages/platform/cloudflare/test/EntityRuntime.test.ts index f061cd3adb7..0e72515ce2e 100644 --- a/packages/platform/cloudflare/test/EntityRuntime.test.ts +++ b/packages/platform/cloudflare/test/EntityRuntime.test.ts @@ -1,7 +1,21 @@ 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, Effect, Exit, Metric, Option, Schedule, Schema, Stream, Tracer } from "effect" +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" @@ -131,6 +145,72 @@ describe("EntityRuntime", () => { 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", [ From 969c782895f7ad4c45ccbf50c0f375999848e03c Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Wed, 19 Aug 2026 03:43:27 +0000 Subject: [PATCH 36/37] Address PR review feedback - Consolidate the PR changesets into one effect patch changeset - Export Entity.KeepAliveHandler and use it from platform-cloudflare - Return Effects from the entity mailbox operations - Use services with optional access instead of References for the entity reply context - Build mutateAndWake inside a single Effect.suspend Co-Authored-By: Claude Fable 5 --- .changeset/cloudflare-cluster-cron.md | 5 - .../cloudflare-cluster-observability.md | 6 - .changeset/cloudflare-cluster-scaffold.md | 9 - .changeset/cloudflare-cluster.md | 16 + .changeset/cloudflare-entity-handler-cache.md | 5 - .changeset/cloudflare-persisted-queue.md | 11 - .changeset/cloudflare-singleton.md | 8 - .../cloudflare-uninterruptible-client.md | 5 - .changeset/cloudflare-workflow-engine.md | 16 - .changeset/tidy-cats-cancel.md | 5 - .../effect/src/unstable/cluster/Entity.ts | 15 +- .../cloudflare/src/CloudflareCluster.ts | 11 +- .../src/internal/entityKeepAlive.ts | 6 - .../cloudflare/src/internal/entityMailbox.ts | 355 ++++++++-------- .../cloudflare/src/internal/entityReply.ts | 14 +- .../cloudflare/src/internal/entityRuntime.ts | 124 +++--- .../cloudflare/src/internal/entityStorage.ts | 18 + .../cloudflare/src/internal/queueRuntime.ts | 5 +- .../cloudflare/test/ClusterCron.test.ts | 10 +- .../cloudflare/test/EntityKeepAlive.test.ts | 6 +- .../cloudflare/test/EntityMailbox.test.ts | 384 ++++++++++-------- .../cloudflare/test/EntityManager.test.ts | 2 +- 22 files changed, 517 insertions(+), 519 deletions(-) delete mode 100644 .changeset/cloudflare-cluster-cron.md delete mode 100644 .changeset/cloudflare-cluster-observability.md delete mode 100644 .changeset/cloudflare-cluster-scaffold.md create mode 100644 .changeset/cloudflare-cluster.md delete mode 100644 .changeset/cloudflare-entity-handler-cache.md delete mode 100644 .changeset/cloudflare-persisted-queue.md delete mode 100644 .changeset/cloudflare-singleton.md delete mode 100644 .changeset/cloudflare-uninterruptible-client.md delete mode 100644 .changeset/cloudflare-workflow-engine.md delete mode 100644 .changeset/tidy-cats-cancel.md diff --git a/.changeset/cloudflare-cluster-cron.md b/.changeset/cloudflare-cluster-cron.md deleted file mode 100644 index c192a25a66b..00000000000 --- a/.changeset/cloudflare-cluster-cron.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@effect/platform-cloudflare": patch ---- - -Run `ClusterCron` through Cloudflare singleton wakes and per-fire entity alarms. diff --git a/.changeset/cloudflare-cluster-observability.md b/.changeset/cloudflare-cluster-observability.md deleted file mode 100644 index b0e2519b6aa..00000000000 --- a/.changeset/cloudflare-cluster-observability.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@effect/platform-cloudflare": patch ---- - -Add entity mailbox spans, active entity and singleton metrics, Worker proxy -coverage, and the v1 compatibility and lifecycle guidance. diff --git a/.changeset/cloudflare-cluster-scaffold.md b/.changeset/cloudflare-cluster-scaffold.md deleted file mode 100644 index e36d3c7ab95..00000000000 --- a/.changeset/cloudflare-cluster-scaffold.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@effect/platform-cloudflare": minor ---- - -Add the `@effect/platform-cloudflare` package with the Worker and Durable -Object glue for running Effect Cluster on Cloudflare: the four SQLite-backed -Durable Object classes, the length-prefixed entity name encoding, and -`CloudflareCluster.layer` providing the cluster `Sharding` service from the -same-Worker namespace bindings. 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/cloudflare-entity-handler-cache.md b/.changeset/cloudflare-entity-handler-cache.md deleted file mode 100644 index ce04971fb1f..00000000000 --- a/.changeset/cloudflare-entity-handler-cache.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@effect/platform-cloudflare": patch ---- - -Share an in-flight entity handler build between concurrent first requests. diff --git a/.changeset/cloudflare-persisted-queue.md b/.changeset/cloudflare-persisted-queue.md deleted file mode 100644 index 4f354039ddd..00000000000 --- a/.changeset/cloudflare-persisted-queue.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@effect/platform-cloudflare": minor ---- - -Add `CloudflarePersistedQueue`, running persisted queues on the dedicated -queue Durable Object class. One queue name is one Durable Object: items, -attempt counts, and in-flight leases live on the object's SQLite storage -behind its single alarm, which acts as a watchdog redelivering items whose -worker died before completing them. `CloudflareCluster.layer` now also -provides the `PersistedQueueFactory` service, so the `DurableQueue` user API -works on the Cloudflare path out of the box. diff --git a/.changeset/cloudflare-singleton.md b/.changeset/cloudflare-singleton.md deleted file mode 100644 index c579d498b18..00000000000 --- a/.changeset/cloudflare-singleton.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@effect/platform-cloudflare": minor ---- - -Run cluster singletons on named `Singleton/` Durable Objects. Worker -Cron Triggers can call the object's `wake()` RPC to run the registered effect -once and then allow hibernation; concurrent duplicate wakes are coalesced and -an interrupted wake is recovered through the object's SQLite-backed alarm. diff --git a/.changeset/cloudflare-uninterruptible-client.md b/.changeset/cloudflare-uninterruptible-client.md deleted file mode 100644 index 8072eeea49e..00000000000 --- a/.changeset/cloudflare-uninterruptible-client.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@effect/platform-cloudflare": patch ---- - -Prevent client interruption from cancelling Cloudflare Durable Object handlers annotated as uninterruptible. diff --git a/.changeset/cloudflare-workflow-engine.md b/.changeset/cloudflare-workflow-engine.md deleted file mode 100644 index 3df6655cc2e..00000000000 --- a/.changeset/cloudflare-workflow-engine.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@effect/platform-cloudflare": minor -"effect": minor ---- - -Add `CloudflareWorkflowEngine`, running durable workflows on the dedicated -workflow Durable Object class. One workflow execution is one Durable Object: -run state, activity results keyed `${name}/${attempt}`, durable deferred -exits, and the clock due table live on the object's SQLite storage behind its -single alarm. `CloudflareCluster.layer` now also provides the -`WorkflowEngine` service. - -Every `DurableClock` is durable on this engine: `DurableClock.sleep` reads its -default in-memory threshold from the new `DurableClock.InMemoryThreshold` -reference, which the Cloudflare engine sets to zero so even sub-minute sleeps -persist a due row and arm the alarm. diff --git a/.changeset/tidy-cats-cancel.md b/.changeset/tidy-cats-cancel.md deleted file mode 100644 index 08af67a295f..00000000000 --- a/.changeset/tidy-cats-cancel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@effect/platform-cloudflare": patch ---- - -Complete interrupted persisted entity streams so they cannot replay into an abandoned session. diff --git a/packages/effect/src/unstable/cluster/Entity.ts b/packages/effect/src/unstable/cluster/Entity.ts index 15b3d70b791..a1c9dfc5802 100644 --- a/packages/effect/src/unstable/cluster/Entity.ts +++ b/packages/effect/src/unstable/cluster/Entity.ts @@ -786,7 +786,20 @@ export class KeepAliveLatch extends Context.Service "effect/cluster/Entity/KeepAliveLatch" ) {} -class KeepAliveHandler extends Context.Service< +/** + * 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/platform/cloudflare/src/CloudflareCluster.ts b/packages/platform/cloudflare/src/CloudflareCluster.ts index f00192805f7..f103644ba80 100644 --- a/packages/platform/cloudflare/src/CloudflareCluster.ts +++ b/packages/platform/cloudflare/src/CloudflareCluster.ts @@ -15,6 +15,7 @@ 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" @@ -261,8 +262,10 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { address: target.address, headers: message.headers } as any) - const replyTo = discard ? undefined : Context.get(context, CurrentEntityName) - const replyRegistry = discard ? undefined : Context.get(context, CurrentReplyRegistry) + 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( @@ -380,8 +383,8 @@ const make = Effect.fnUntraced(function*(options: LayerOptions) { ambient: Context.Context, methodOptions?: { readonly context?: Context.Context } ) => { - const currentEntityName = Context.get(ambient, CurrentEntityName) - const replyRegistry = Context.get(ambient, CurrentReplyRegistry) + 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) diff --git a/packages/platform/cloudflare/src/internal/entityKeepAlive.ts b/packages/platform/cloudflare/src/internal/entityKeepAlive.ts index d8b62342dc1..0a82f083dbb 100644 --- a/packages/platform/cloudflare/src/internal/entityKeepAlive.ts +++ b/packages/platform/cloudflare/src/internal/entityKeepAlive.ts @@ -1,14 +1,8 @@ /** @internal */ -import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Latch from "effect/Latch" /** @internal */ -export class EntityKeepAliveHandler extends Context.Service< - EntityKeepAliveHandler, - (enabled: boolean) => Effect.Effect ->()("effect/cluster/Entity/KeepAliveHandler") {} - export interface EntityKeepAlive { readonly update: (enabled: boolean) => Effect.Effect readonly await: Effect.Effect diff --git a/packages/platform/cloudflare/src/internal/entityMailbox.ts b/packages/platform/cloudflare/src/internal/entityMailbox.ts index 4f7e74b3b79..92ac34e15a9 100644 --- a/packages/platform/cloudflare/src/internal/entityMailbox.ts +++ b/packages/platform/cloudflare/src/internal/entityMailbox.ts @@ -1,9 +1,12 @@ /** - * SQLite mailbox primitives for a single entity Durable Object. + * 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 @@ -45,110 +48,113 @@ export const persistRequest = ( discard = false, deliverAt: number | null = null, replyTo: string | null = null -): PersistResult => { - if (exceedsMaximumEncodedSize(envelopeText)) { - throw 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") - } +): 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 && Number(existing.discard) === 0 && Number(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), - String(existing.request_id) - ) + 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 && Number(existing.discard) === 0 && Number(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), + String(existing.request_id) + ) + } + return Effect.succeed({ + _tag: "Duplicate", + originalId: String(existing.request_id), + processed: Number(existing.processed) === 1 + }) } - return { - _tag: "Duplicate", - originalId: String(existing.request_id), - processed: Number(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 = Number( + sql.exec("SELECT COUNT(*) AS count FROM cluster_messages WHERE processed = 0").toArray()[0]?.count + ) + const unacked = pending >= mailboxCapacity ? 0 : Number( + 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 + ) + if (pending + unacked >= mailboxCapacity) { + return Effect.fail(new MailboxFullError("Entity mailbox has reached its 4096 request capacity")) } - } - // 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 = Number( - sql.exec("SELECT COUNT(*) AS count FROM cluster_messages WHERE processed = 0").toArray()[0]?.count - ) - const unacked = pending >= mailboxCapacity ? 0 : Number( 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 - ) - if (pending + unacked >= mailboxCapacity) { - throw 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 { _tag: "Success" } -} + `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): void => { - if (exceedsMaximumEncodedSize(replyText)) { - throw 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 - ) -} +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 { @@ -185,39 +191,44 @@ const rowToMessage = (row: Record): StoredMessage => { } /** @internal */ -export const loadUnprocessed = (sql: SqlStorage, now = Date.now()): Array => - 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 - ).toArray().map(rowToMessage) +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 = Date.now()): Array => - 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 - ).toArray().map(rowToMessage) +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): StoredMessage | undefined => { - 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) -} +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 { @@ -226,52 +237,56 @@ export interface NextReply { } /** @internal */ -export const loadNextReply = (sql: SqlStorage, requestId: string): NextReply | undefined => { - 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 typeof row?.reply === "string" ? { reply: row.reply, kind: row.kind as NextReply["kind"] } : undefined -} +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 typeof row?.reply === "string" ? { reply: row.reply, kind: row.kind as NextReply["kind"] } : undefined + }) /** @internal */ -export const completeTell = (sql: SqlStorage, requestId: string): void => { - sql.exec( - `UPDATE cluster_messages - SET processed = 1, last_reply_id = NULL - WHERE request_id = ?`, - requestId - ) -} +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): void => { - sql.exec( - `UPDATE cluster_replies - SET acked = 1 - WHERE request_id = ? AND reply_id = ? AND kind = 'Chunk'`, - requestId, - replyId - ) -} +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): void => { - 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 - ) -} +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/entityReply.ts b/packages/platform/cloudflare/src/internal/entityReply.ts index afc6d9de7cf..5445a849089 100644 --- a/packages/platform/cloudflare/src/internal/entityReply.ts +++ b/packages/platform/cloudflare/src/internal/entityReply.ts @@ -4,10 +4,9 @@ import * as Deferred from "effect/Deferred" import * as Effect from "effect/Effect" /** @internal */ -export const CurrentEntityName = Context.Reference( - "@effect/platform-cloudflare/CurrentEntityName", - { defaultValue: () => undefined } -) +export class CurrentEntityName extends Context.Service()( + "@effect/platform-cloudflare/CurrentEntityName" +) {} /** * Completes the delayed asks made by one entity Durable Object's handlers. @@ -50,7 +49,6 @@ export const makeReplyRegistry = (): EntityReplyRegistry => { } /** @internal */ -export const CurrentReplyRegistry = Context.Reference( - "@effect/platform-cloudflare/CurrentReplyRegistry", - { defaultValue: () => undefined } -) +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 index 2b07af7b063..3bed82758b4 100644 --- a/packages/platform/cloudflare/src/internal/entityRuntime.ts +++ b/packages/platform/cloudflare/src/internal/entityRuntime.ts @@ -18,7 +18,7 @@ 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, Request } from "effect/unstable/cluster/Entity" +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" @@ -26,18 +26,15 @@ 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 { EntityKeepAliveHandler } from "./entityKeepAlive.ts" import type { EntityKeepAlive } from "./entityKeepAlive.ts" import { ackChunk, clearReplies, completeTell, - EncodedMessageTooLargeError, loadDue, loadMessage, loadNextReply, loadUnprocessed, - MailboxFullError, persistRequest, type PersistResult, saveReply, @@ -45,7 +42,7 @@ import { } from "./entityMailbox.ts" import { type EntityRegistration, getEntityRegistration } from "./entityRegistry.ts" import { CurrentEntityName, CurrentReplyRegistry, type EntityReplyRegistry, makeReplyRegistry } from "./entityReply.ts" -import { armAlarm, earliestDeliverAt } from "./entityStorage.ts" +import { armAlarm, earliestDeliverAt, withTransaction } from "./entityStorage.ts" import { decodeReplyFor, decodeRequest, encodeReplyFor, type InvokeResult, peekEnvelopeTag } from "./entityWire.ts" interface CachedHandlers { @@ -94,7 +91,7 @@ export const makeEntityRuntime = Effect.fnUntraced(function*( Context.add(Scope.Scope, scope) ) if (keepAlive !== undefined) { - context = Context.add(context, EntityKeepAliveHandler, keepAlive) + context = Context.add(context, KeepAliveHandler, keepAlive) } if (replyRegistry !== undefined) { context = Context.add(context, CurrentReplyRegistry, replyRegistry) @@ -448,7 +445,7 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager if (!discard) { const active = sessions.get(requestId) if (active !== undefined) { - const nextReply = persisted ? loadNextReply(sql, requestId) : undefined + const nextReply = persisted ? yield* loadNextReply(sql, requestId) : undefined return Effect.succeed(nextReply === undefined ? [] : [nextReply.reply]) } } @@ -464,7 +461,7 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager Effect.gen(function*() { const encoded = yield* encodeReplyFor(registration, rpc, reply) if (persisted) { - storage.transactionSync(() => saveReply(sql, encoded)) + yield* Effect.orDie(withTransaction(storage, saveReply(sql, encoded))) } if (scheduled && reply._tag === "WithExit") { yield* deliverScheduledReply(requestId, encoded, runOptions?.replyTos) @@ -475,10 +472,10 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager ? execute : Semaphore.withPermit(entityRuntime.handlerSemaphore, execute)).pipe( Effect.onExit((exit) => - Effect.sync(() => { - if (!persisted) return - if (discard && Exit.isSuccess(exit)) completeTell(sql, requestId) + Effect.suspend(() => { + if (!persisted) return Effect.void pendingRuns.delete(requestId) + return discard && Exit.isSuccess(exit) ? completeTell(sql, requestId) : Effect.void }) ) ) @@ -500,11 +497,8 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager }) ), (reply) => - Effect.sync(() => - storage.transactionSync(() => { - clearReplies(sql, requestId) - saveReply(sql, reply) - }) + Effect.orDie( + withTransaction(storage, Effect.andThen(clearReplies(sql, requestId), saveReply(sql, reply))) ) ) : Effect.void @@ -515,7 +509,7 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager Effect.gen(function*() { const encoded = yield* encodeReplyFor(registration, rpc, reply) if (persisted) { - storage.transactionSync(() => saveReply(sql, encoded)) + yield* Effect.orDie(withTransaction(storage, saveReply(sql, encoded))) } if (reply._tag === "Chunk") { const acknowledged = Deferred.makeUnsafe() @@ -567,13 +561,11 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager Effect.suspend(() => peekEnvelopeTag(row.envelope)).pipe( Effect.flatMap((tag) => { if (row.discard || tag === undefined) { - completeTell(sql, row.requestId) - return Effect.void + return completeTell(sql, row.requestId) } const rpc = registration.entity.protocol.requests.get(tag) as Rpc.AnyWithProps | undefined if (rpc === undefined) { - completeTell(sql, row.requestId) - return Effect.void + return completeTell(sql, row.requestId) } return Effect.flatMap( encodeReplyFor( @@ -586,15 +578,11 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager }) ), (reply) => - Effect.sync(() => storage.transactionSync(() => saveReply(sql, reply))).pipe( + withTransaction(storage, saveReply(sql, reply)).pipe( Effect.andThen(deliverScheduledReply(row.requestId, reply, row.replyTos)) ) ).pipe( - Effect.catchCause(() => - Effect.sync(() => { - completeTell(sql, row.requestId) - }) - ) + Effect.catchCause(() => completeTell(sql, row.requestId)) ) }) ) @@ -673,7 +661,10 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager } return Effect.gen(function*() { const entityRuntime = yield* getRuntime(registration) - const replayFibers = yield* replayRows(registration, entityRuntime, loadUnprocessed(sql)) + 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 = ( @@ -693,22 +684,17 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager } const persistedResult = yield* Effect.result( - // Preserve unknown thrown values so the fallback defect is unchanged. - // @effect-diagnostics-next-line unknownInEffectCatch:off - Effect.try({ - try: () => - storage.transactionSync(() => - persistRequest( - sql, - envelopeText, - delivery?.primaryKey ?? Envelope.primaryKey(envelope), - discard, - delivery?.deliverAt, - delivery?.replyTo - ) - ), - catch: (error) => error - }).pipe( + 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, @@ -720,22 +706,20 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager ) ) if (Result.isFailure(persistedResult)) { - const error = persistedResult.failure - if (error instanceof MailboxFullError) { - return Effect.succeed({ _tag: "MailboxFull" }) - } else if (error instanceof EncodedMessageTooLargeError) { - return Effect.succeed({ _tag: "EncodedMessageTooLarge" }) - } - return yield* Effect.die(error) + return Effect.succeed( + persistedResult.failure._tag === "MailboxFull" + ? { _tag: "MailboxFull" } + : { _tag: "EncodedMessageTooLarge" } + ) } const persisted: PersistResult = persistedResult.success if (persisted._tag === "Duplicate") { - const original = loadMessage(sql, persisted.originalId) + 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 = loadNextReply(sql, persisted.originalId) + 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])) @@ -781,18 +765,21 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager Effect.flatten(Semaphore.withPermit(semaphore, invokeEntry(envelopeText, discard, delivery))) const acknowledge = (requestId: string, replyId: string): Effect.Effect> => - Effect.suspend(() => { - storage.transactionSync(() => ackChunk(sql, requestId, replyId)) - 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) - } - const nextReply = loadNextReply(sql, requestId) - return Effect.succeed(nextReply === undefined ? [] : [nextReply.reply]) - }) + 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(() => { @@ -818,10 +805,7 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager return Effect.andThen(stop, session.completeInterrupt) }) - const reset = (requestId: string): Effect.Effect => - Effect.sync(() => { - storage.transactionSync(() => clearReplies(sql, requestId)) - }) + const reset = (requestId: string): Effect.Effect => withTransaction(storage, clearReplies(sql, requestId)) const alarm = Effect.suspend(() => { const registration = getEntityRegistration(options.address.entityType) @@ -835,7 +819,7 @@ export const makeEntityManager = (options: EntityManagerOptions): EntityManager semaphore, Effect.flatMap( getRuntime(registration), - (entityRuntime) => replayRows(registration, entityRuntime, loadDue(sql)) + (entityRuntime) => Effect.flatMap(loadDue(sql), (rows) => replayRows(registration, entityRuntime, rows)) ) ).pipe( Effect.flatMap((fibers) => Effect.asVoid(Fiber.awaitAll(fibers))), diff --git a/packages/platform/cloudflare/src/internal/entityStorage.ts b/packages/platform/cloudflare/src/internal/entityStorage.ts index b559c7ee21c..b38b2af85a6 100644 --- a/packages/platform/cloudflare/src/internal/entityStorage.ts +++ b/packages/platform/cloudflare/src/internal/entityStorage.ts @@ -7,10 +7,28 @@ */ 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 +/** + * 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, diff --git a/packages/platform/cloudflare/src/internal/queueRuntime.ts b/packages/platform/cloudflare/src/internal/queueRuntime.ts index 3307659690b..48516b164d6 100644 --- a/packages/platform/cloudflare/src/internal/queueRuntime.ts +++ b/packages/platform/cloudflare/src/internal/queueRuntime.ts @@ -89,7 +89,10 @@ export const makeQueueRuntime = (options: QueueRuntimeOptions): QueueRuntime => }) const mutateAndWake = (mutate: () => void): Promise => - Effect.runPromise(Effect.andThen(Effect.sync(mutate), wakeWaiters)) + Effect.runPromise(Effect.suspend(() => { + mutate() + return wakeWaiters + })) return { offer: (id, element) => diff --git a/packages/platform/cloudflare/test/ClusterCron.test.ts b/packages/platform/cloudflare/test/ClusterCron.test.ts index e5b3f59449d..02d38e9855c 100644 --- a/packages/platform/cloudflare/test/ClusterCron.test.ts +++ b/packages/platform/cloudflare/test/ClusterCron.test.ts @@ -132,7 +132,7 @@ class FakeCronDestination { delivery?: { readonly deliverAt?: number; readonly primaryKey?: string | null } ) { const requestId = String(JSON.parse(envelope).requestId) - persistRequest( + const persist = persistRequest( this.sql.sql, envelope, delivery?.primaryKey ?? null, @@ -140,8 +140,8 @@ class FakeCronDestination { delivery?.deliverAt ?? null ) const effect = delivery?.deliverAt === undefined - ? Effect.void - : armAlarm(this.alarm.storage, delivery.deliverAt) + ? persist + : Effect.andThen(persist, armAlarm(this.alarm.storage, delivery.deliverAt)) return Effect.runPromise( Effect.as(effect, { _tag: "Success" as const, requestId, replies: [] as ReadonlyArray }) ) @@ -156,14 +156,14 @@ class FakeCronDestination { const nextReplyId = () => `reply-${this.#nextReplyId++}` return Effect.gen(function*() { alarm.current = null - for (const row of loadDue(sql.sql, now)) { + 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) - completeTell(sql.sql, String(request.requestId)) + yield* completeTell(sql.sql, String(request.requestId)) } const next = earliestDeliverAt(sql.sql) if (next !== undefined) yield* armAlarm(alarm.storage, next) diff --git a/packages/platform/cloudflare/test/EntityKeepAlive.test.ts b/packages/platform/cloudflare/test/EntityKeepAlive.test.ts index 59a89818999..3c7d2996a37 100644 --- a/packages/platform/cloudflare/test/EntityKeepAlive.test.ts +++ b/packages/platform/cloudflare/test/EntityKeepAlive.test.ts @@ -1,8 +1,8 @@ -import { EntityKeepAliveHandler, makeEntityKeepAlive } from "@effect/platform-cloudflare/internal/entityKeepAlive" +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 { EntityResource } from "effect/unstable/cluster" +import { Entity, EntityResource } from "effect/unstable/cluster" const makeFixture = Effect.gen(function*() { const started = yield* Deferred.make() @@ -17,7 +17,7 @@ const makeFixture = Effect.gen(function*() { const provideKeepAlive = ( effect: Effect.Effect, keepAlive: ReturnType -) => Effect.provideService(effect, EntityKeepAliveHandler, keepAlive.update) as Effect.Effect +) => Effect.provideService(effect, Entity.KeepAliveHandler, keepAlive.update) as Effect.Effect describe("EntityKeepAlive", () => { it.effect("keeps the pin until the last holder releases", () => diff --git a/packages/platform/cloudflare/test/EntityMailbox.test.ts b/packages/platform/cloudflare/test/EntityMailbox.test.ts index 430eaa79244..e9fbbc091a7 100644 --- a/packages/platform/cloudflare/test/EntityMailbox.test.ts +++ b/packages/platform/cloudflare/test/EntityMailbox.test.ts @@ -13,6 +13,7 @@ import { saveReply } from "@effect/platform-cloudflare/internal/entityMailbox" import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" interface MessageRow { readonly request_id: string @@ -190,208 +191,231 @@ const envelope = JSON.stringify({ const withRequestId = (id: string) => JSON.stringify({ ...JSON.parse(envelope), requestId: id }) describe("EntityMailbox", () => { - it("persists a durable request before its handler can run", () => { - const sql = new FakeSql() - const result = persistRequest(sql.sql, envelope, null) + 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) - }) + assert.deepStrictEqual(result, { _tag: "Success" }) + assert.strictEqual(sql.messages.get(requestId)?.envelope, envelope) + assert.strictEqual(sql.messages.get(requestId)?.processed, 0) + })) - it("persists future delivery metadata and only loads the row when due", () => { - const sql = new FakeSql() - persistRequest(sql.sql, envelope, "scheduled", false, 2_000, "7:Callercaller") + 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(loadUnprocessed(sql.sql, 1_999), []) - assert.deepStrictEqual(loadDue(sql.sql, 2_000), [{ - requestId, - envelope, - lastSentChunk: undefined, - discard: false, - deliverAt: 2_000, - replyTos: ["7:Callercaller"] - }]) - }) - - it("preserves every reply target when a scheduled request is deduplicated", () => { - const sql = new FakeSql() - const primaryKey = "Counter/one/Increment/scheduled" - persistRequest(sql.sql, envelope, primaryKey, false, 2_000, "7:Callerfirst") - persistRequest( - sql.sql, - withRequestId("0198bd72-6a81-72f1-8d87-5e9b5cf1e001"), - primaryKey, - false, - null, - "7:Callersecond" - ) - - assert.deepStrictEqual(loadDue(sql.sql, 2_000), [{ - requestId, - envelope, - lastSentChunk: undefined, - discard: false, - deliverAt: 2_000, - replyTos: ["7:Callerfirst", "7:Callersecond"] - }]) - }) + 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("maps a primary-key duplicate to the original request and last reply", () => { - const sql = new FakeSql() - const primaryKey = "Counter/one/Increment/operation-1" - persistRequest(sql.sql, envelope, primaryKey) - const reply = JSON.stringify({ - _tag: "WithExit", - requestId, - id: "reply-1", - exit: { _tag: "Success", value: 1 } - }) - saveReply(sql.sql, reply) + 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( - persistRequest(sql.sql, withRequestId("0198bd72-6a81-72f1-8d87-5e9b5cf1e001"), primaryKey), - { _tag: "Duplicate", originalId: requestId, processed: true } - ) - }) + assert.deepStrictEqual(yield* loadDue(sql.sql, 2_000), [{ + requestId, + envelope, + lastSentChunk: undefined, + discard: false, + deliverAt: 2_000, + replyTos: ["7:Callerfirst", "7:Callersecond"] + }]) + })) - it("replays an unprocessed row with its last sent chunk after a crash", () => { - const sql = new FakeSql() - persistRequest(sql.sql, envelope, null) - const chunk = JSON.stringify({ - _tag: "Chunk", - requestId, - id: "chunk-1", - sequence: 0, - values: [1] - }) - saveReply(sql.sql, chunk) + 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(loadUnprocessed(sql.sql), [{ requestId, envelope, lastSentChunk: chunk, discard: false }]) - }) + assert.deepStrictEqual( + yield* persistRequest(sql.sql, withRequestId("0198bd72-6a81-72f1-8d87-5e9b5cf1e001"), primaryKey), + { _tag: "Duplicate", originalId: requestId, processed: true } + ) + })) - it("marks a persisted tell complete without storing a user-visible reply", () => { - const sql = new FakeSql() - persistRequest(sql.sql, envelope, null) - completeTell(sql.sql, requestId) + 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.strictEqual(sql.messages.get(requestId)?.processed, 1) - assert.strictEqual(sql.replies.size, 0) - assert.deepStrictEqual(loadUnprocessed(sql.sql), []) - }) + assert.deepStrictEqual(yield* loadUnprocessed(sql.sql), [{ + requestId, + envelope, + lastSentChunk: chunk, + discard: false + }]) + })) - it("acknowledges stream chunks and clearReplies resumes the request", () => { - const sql = new FakeSql() - persistRequest(sql.sql, envelope, null) - const chunk = JSON.stringify({ - _tag: "Chunk", - requestId, - id: "chunk-1", - sequence: 0, - values: [1] - }) - saveReply(sql.sql, chunk) - ackChunk(sql.sql, requestId, "chunk-1") - assert.isTrue(sql.acked.has("chunk-1")) + 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) - clearReplies(sql.sql, requestId) - assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ - requestId, - envelope, - lastSentChunk: undefined, - discard: false - }]) - assert.strictEqual(sql.replies.size, 0) - }) + assert.strictEqual(sql.messages.get(requestId)?.processed, 1) + assert.strictEqual(sql.replies.size, 0) + assert.deepStrictEqual(yield* loadUnprocessed(sql.sql), []) + })) - it("rejects the 4097th unprocessed request", () => { - 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 + 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] }) - } - assert.throws(() => persistRequest(sql.sql, envelope, null), MailboxFullError) - }) + yield* saveReply(sql.sql, chunk) + yield* ackChunk(sql.sql, requestId, "chunk-1") + assert.isTrue(sql.acked.has("chunk-1")) - it("counts a completed stream with an unacknowledged chunk against capacity", () => { - const sql = new FakeSql() - for (let index = 0; index < 4095; index++) { - sql.messages.set(String(index), { - request_id: String(index), - message_id: null, + yield* clearReplies(sql.sql, requestId) + assert.deepStrictEqual(yield* loadUnprocessed(sql.sql), [{ + requestId, envelope, - discard: 0, - processed: 0, - last_reply_id: null + 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)] }) - } - persistRequest(sql.sql, envelope, null) - saveReply(sql.sql, JSON.stringify({ _tag: "Chunk", requestId, id: "chunk", sequence: 0, values: [1] })) - saveReply( - sql.sql, - JSON.stringify({ + 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 } }) - ) - - assert.throws( - () => persistRequest(sql.sql, withRequestId("0198bd72-6a83-72f1-8d87-5e9b5cf1e003"), null), - MailboxFullError - ) - }) - - it("rejects encoded requests and chunks over 2 MB", () => { - const sql = new FakeSql() - const largeRequest = JSON.stringify({ ...JSON.parse(envelope), payload: "x".repeat(maximumEncodedSize) }) - assert.throws(() => persistRequest(sql.sql, largeRequest, null), EncodedMessageTooLargeError) + yield* saveReply(sql.sql, chunk0) + yield* saveReply(sql.sql, chunk1) + yield* saveReply(sql.sql, terminal) - const largeChunk = JSON.stringify({ - _tag: "Chunk", - requestId, - id: "chunk-large", - sequence: 0, - values: ["x".repeat(maximumEncodedSize)] - }) - assert.throws(() => saveReply(sql.sql, largeChunk), EncodedMessageTooLargeError) - }) + 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("releases persisted stream replies one chunk per acknowledgement", () => { - const sql = new FakeSql() - 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 } - }) - saveReply(sql.sql, chunk0) - saveReply(sql.sql, chunk1) - saveReply(sql.sql, terminal) + 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(loadNextReply(sql.sql, requestId), { reply: chunk0, kind: "Chunk" }) - ackChunk(sql.sql, requestId, "chunk-0") - assert.deepStrictEqual(loadNextReply(sql.sql, requestId), { reply: chunk1, kind: "Chunk" }) - ackChunk(sql.sql, requestId, "chunk-1") - assert.deepStrictEqual(loadNextReply(sql.sql, requestId), { reply: terminal, kind: "WithExit" }) - }) - - it("retains tell discard mode for crash replay", () => { - const sql = new FakeSql() - persistRequest(sql.sql, envelope, null, true) - - assert.deepStrictEqual(loadUnprocessed(sql.sql), [{ requestId, envelope, lastSentChunk: undefined, discard: 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 index db3bfc10744..d98bea48239 100644 --- a/packages/platform/cloudflare/test/EntityManager.test.ts +++ b/packages/platform/cloudflare/test/EntityManager.test.ts @@ -117,7 +117,7 @@ describe("EntityManager", () => { streamRequestId ).toArray()[0] assert.strictEqual(row.processed, 1) - assert.strictEqual(loadNextReply(storage.sql, streamRequestId)?.kind, "WithExit") + assert.strictEqual((yield* loadNextReply(storage.sql, streamRequestId))?.kind, "WithExit") assert.strictEqual( storage.sql.exec( "SELECT COUNT(*) AS count FROM cluster_replies WHERE request_id = ? AND kind = 'Chunk' AND acked = 0", From c8f4b6028b582267ded0d581bec114ca6e78224a Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Fri, 21 Aug 2026 00:31:57 +0000 Subject: [PATCH 37/37] fix(platform-cloudflare): type SQLite query results --- .../cloudflare/src/internal/entityMailbox.ts | 73 +++++++++++++------ .../cloudflare/src/internal/entityStorage.ts | 9 ++- .../cloudflare/src/internal/queueStorage.ts | 19 +++-- .../src/internal/singletonStorage.ts | 11 ++- .../src/internal/workflowStorage.ts | 54 ++++++++++---- .../cloudflare/test/EntityManager.test.ts | 4 +- .../cloudflare/test/fixtures/worker.ts | 11 ++- 7 files changed, 126 insertions(+), 55 deletions(-) diff --git a/packages/platform/cloudflare/src/internal/entityMailbox.ts b/packages/platform/cloudflare/src/internal/entityMailbox.ts index 92ac34e15a9..11bc99953a5 100644 --- a/packages/platform/cloudflare/src/internal/entityMailbox.ts +++ b/packages/platform/cloudflare/src/internal/entityMailbox.ts @@ -33,6 +33,17 @@ export type PersistResult = { 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 @@ -58,7 +69,7 @@ export const persistRequest = ( throw new TypeError("Expected an encoded Request envelope") } - const existing = sql.exec( + 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 = ?) @@ -68,36 +79,36 @@ export const persistRequest = ( primaryKey ).toArray()[0] if (existing !== undefined) { - if (replyTo !== null && Number(existing.discard) === 0 && Number(existing.processed) === 0) { + 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), - String(existing.request_id) + existing.request_id ) } return Effect.succeed({ _tag: "Duplicate", - originalId: String(existing.request_id), - processed: Number(existing.processed) === 1 + 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 = Number( - sql.exec("SELECT COUNT(*) AS count FROM cluster_messages WHERE processed = 0").toArray()[0]?.count - ) - const unacked = pending >= mailboxCapacity ? 0 : Number( - sql.exec( + 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 - ) + ).toArray()[0]?.count ?? 0 if (pending + unacked >= mailboxCapacity) { return Effect.fail(new MailboxFullError("Entity mailbox has reached its 4096 request capacity")) } @@ -166,6 +177,15 @@ export interface StoredMessage { 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 { @@ -177,14 +197,14 @@ const decodeReplyTargets = (value: unknown): Array => { return [] } -const rowToMessage = (row: Record): StoredMessage => { +const rowToMessage = (row: StoredMessageRow): StoredMessage => { const replyTos = decodeReplyTargets(row.reply_to) const message: StoredMessage = { - requestId: String(row.request_id), - envelope: String(row.envelope), - lastSentChunk: typeof row.last_reply === "string" ? row.last_reply : undefined, - discard: Number(row.discard) === 1, - ...(typeof row.deliver_at === "number" ? { deliverAt: row.deliver_at } : undefined), + 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 @@ -193,7 +213,7 @@ const rowToMessage = (row: Record): StoredMessage => { /** @internal */ export const loadUnprocessed = (sql: SqlStorage, now?: number): Effect.Effect> => Effect.sync(() => - sql.exec( + 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 @@ -206,7 +226,7 @@ export const loadUnprocessed = (sql: SqlStorage, now?: number): Effect.Effect> => Effect.sync(() => - sql.exec( + 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 @@ -219,7 +239,7 @@ export const loadDue = (sql: SqlStorage, now?: number): Effect.Effect => Effect.sync(() => { - const row = sql.exec( + 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 @@ -236,24 +256,29 @@ export interface NextReply { 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( + 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( + ).toArray()[0] ?? sql.exec( `SELECT reply, kind FROM cluster_replies WHERE request_id = ? AND kind = 'WithExit' LIMIT 1`, requestId ).toArray()[0] - return typeof row?.reply === "string" ? { reply: row.reply, kind: row.kind as NextReply["kind"] } : undefined + return row }) /** @internal */ diff --git a/packages/platform/cloudflare/src/internal/entityStorage.ts b/packages/platform/cloudflare/src/internal/entityStorage.ts index b38b2af85a6..4b4b006d117 100644 --- a/packages/platform/cloudflare/src/internal/entityStorage.ts +++ b/packages/platform/cloudflare/src/internal/entityStorage.ts @@ -12,6 +12,10 @@ 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 @@ -64,11 +68,10 @@ export const ensureEntityStorage = (sql: SqlStorage): void => { /** @internal */ export const earliestDeliverAt = (sql: SqlStorage): number | undefined => { - const rows = sql.exec( + const rows = sql.exec( "SELECT min(deliver_at) AS deliver_at FROM cluster_messages WHERE processed = 0 AND deliver_at IS NOT NULL" ).toArray() - const deliverAt = rows[0]?.deliver_at - return typeof deliverAt === "number" ? deliverAt : undefined + return rows[0]?.deliver_at ?? undefined } /** @internal */ diff --git a/packages/platform/cloudflare/src/internal/queueStorage.ts b/packages/platform/cloudflare/src/internal/queueStorage.ts index 40fd540b8c4..ef337672e3b 100644 --- a/packages/platform/cloudflare/src/internal/queueStorage.ts +++ b/packages/platform/cloudflare/src/internal/queueStorage.ts @@ -47,6 +47,16 @@ export interface QueueItem { 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( @@ -64,7 +74,7 @@ export const leaseNextItem = ( leaseUntil: number, maxAttempts: number ): QueueItem | undefined => { - const row = sql.exec( + 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`, @@ -73,7 +83,7 @@ export const leaseNextItem = ( ).toArray()[0] if (row === undefined) return undefined sql.exec("UPDATE queue_items SET lease_until = ? WHERE id = ?", leaseUntil, row.id) - return { id: String(row.id), element: String(row.element), attempts: Number(row.attempts) } + return row } /** @internal */ @@ -110,9 +120,8 @@ export const expireLeases = (sql: SqlStorage, now: number): void => { /** @internal */ export const earliestLeaseExpiry = (sql: SqlStorage): number | undefined => { - const row = sql.exec( + const row = sql.exec( "SELECT min(lease_until) AS lease_until FROM queue_items WHERE lease_until IS NOT NULL" ).toArray()[0] - const leaseUntil = row?.lease_until - return typeof leaseUntil === "number" ? leaseUntil : undefined + return row?.lease_until ?? undefined } diff --git a/packages/platform/cloudflare/src/internal/singletonStorage.ts b/packages/platform/cloudflare/src/internal/singletonStorage.ts index 23088931f45..1f97b9c45e9 100644 --- a/packages/platform/cloudflare/src/internal/singletonStorage.ts +++ b/packages/platform/cloudflare/src/internal/singletonStorage.ts @@ -24,12 +24,17 @@ export interface SingletonState { 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] + const row = sql.exec("SELECT name, wake_at FROM singleton_state WHERE id = 1").toArray()[0] return { - name: typeof row?.name === "string" ? row.name : undefined, - wakeAt: typeof row?.wake_at === "number" ? row.wake_at : undefined + name: row?.name ?? undefined, + wakeAt: row?.wake_at ?? undefined } } diff --git a/packages/platform/cloudflare/src/internal/workflowStorage.ts b/packages/platform/cloudflare/src/internal/workflowStorage.ts index 34c9749c773..01afbecbd83 100644 --- a/packages/platform/cloudflare/src/internal/workflowStorage.ts +++ b/packages/platform/cloudflare/src/internal/workflowStorage.ts @@ -56,21 +56,44 @@ export interface ExecutionRow { 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( + 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: String(row.workflow_name), - executionId: String(row.execution_id), - payload: String(row.payload), - parent: typeof row.parent_name === "string" && typeof row.parent_execution_id === "string" + 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: typeof row.result === "string" ? row.result : undefined, + result: row.result ?? undefined, resumePending: row.resume_pending === 1 } } @@ -119,8 +142,8 @@ export const setResumePending = (sql: SqlStorage, pending: boolean): void => { /** @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 === undefined ? undefined : String(row.exit) + const row = sql.exec("SELECT exit FROM workflow_activities WHERE key = ?", key).toArray()[0] + return row?.exit } /** @internal */ @@ -130,8 +153,8 @@ export const saveActivity = (sql: SqlStorage, key: string, exit: string): void = /** @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 === undefined ? undefined : String(row.exit) + const row = sql.exec("SELECT exit FROM workflow_deferreds WHERE name = ?", name).toArray()[0] + return row?.exit } /** @@ -158,11 +181,10 @@ export const saveClock = (sql: SqlStorage, name: string, deferredName: string, w /** @internal */ export const earliestClockWakeUp = (sql: SqlStorage): number | undefined => { - const row = sql.exec( + const row = sql.exec( "SELECT min(wake_up) AS wake_up FROM workflow_clocks WHERE fired = 0" ).toArray()[0] - const wakeUp = row?.wake_up - return typeof wakeUp === "number" ? wakeUp : undefined + return row?.wake_up ?? undefined } /** @internal */ @@ -170,12 +192,12 @@ export const dueClocks = ( sql: SqlStorage, now: number ): Array<{ readonly name: string; readonly deferredName: string }> => - sql.exec( + sql.exec( "SELECT name, deferred_name FROM workflow_clocks WHERE fired = 0 AND wake_up <= ?", now ).toArray().map((row) => ({ - name: String(row.name), - deferredName: String(row.deferred_name) + name: row.name, + deferredName: row.deferred_name })) /** @internal */ diff --git a/packages/platform/cloudflare/test/EntityManager.test.ts b/packages/platform/cloudflare/test/EntityManager.test.ts index d98bea48239..1a9658fd09d 100644 --- a/packages/platform/cloudflare/test/EntityManager.test.ts +++ b/packages/platform/cloudflare/test/EntityManager.test.ts @@ -112,14 +112,14 @@ describe("EntityManager", () => { assert.strictEqual(streamRuns, 1) assert.isTrue(waitUntilFibers.every((fiber) => fiber.pollUnsafe() !== undefined)) - const row = storage.sql.exec( + 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( + 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, diff --git a/packages/platform/cloudflare/test/fixtures/worker.ts b/packages/platform/cloudflare/test/fixtures/worker.ts index 11601fe70fb..3d9e20f679f 100644 --- a/packages/platform/cloudflare/test/fixtures/worker.ts +++ b/packages/platform/cloudflare/test/fixtures/worker.ts @@ -24,6 +24,13 @@ const Watch = Rpc.make("Watch", { 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 @@ -44,8 +51,8 @@ export class ClusterEntity extends BaseClusterEntity { ) } - scheduledRows(): Array> { - return this.#testState.storage.sql.exec( + 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`