Skip to content

Cluster sharding on Cloudflare Durable Objects - #7322

Open
tim-smart wants to merge 36 commits into
mainfrom
eff-698-cloudflare-cluster
Open

Cluster sharding on Cloudflare Durable Objects#7322
tim-smart wants to merge 36 commits into
mainfrom
eff-698-cloudflare-cluster

Conversation

@tim-smart

@tim-smart tim-smart commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes EFF-698

Shared PR for the Cluster Sharding on Cloudflare Durable Objects effort. Serial slices land here one by one; this first slice adds the package scaffold and the Worker/Durable Object glue.

What's in this slice

  • New package @effect/platform-cloudflare (cluster plus the minimum Worker/DO glue; no HttpServer/Crypto/FS parity).
  • Four SQLite-backed Durable Object classes for the Worker to re-export and bind: ClusterEntity (shared entity class), ClusterWorkflow, ClusterDurableQueue, and ClusterSingleton (placeholders reserving their bindings).
  • Cheap entity constructor: open SQLite, ensure the mailbox tables, re-arm the single alarm. User handlers are never built in the constructor.
  • Length-prefixed name codec `${type.length}:${type}${id}` shared by entity/workflow/queue/singleton addresses, with strict decode.
  • CloudflareCluster.layer({ entities, entityNamespace, workflowNamespace, queueNamespace, singletonNamespace }) providing the cluster Sharding service. Entity.client stays the user API; clients resolve objects via getByName; unknown entity types fail at the Worker before contacting a Durable Object. Handlers register per EntityType at Worker init.
  • CloudflareCluster.makeRunnerAddress: synthetic CurrentRunnerAddress stub from DO identity, no peer dialing.
  • Wrangler example in the package README binding the four classes.

Tests

  • Unit tests against fakes: name encode/decode round-trips and strict decode failures, layer wiring (client resolution, unknown-type failure, duplicate/unbound handler registration), constructor storage helpers (table DDL, alarm re-arm semantics).
  • One Miniflare smoke test booting the four classes as SQLite-backed Durable Objects in workerd and verifying the bindings resolve and direct fetch of an object is rejected.

Handler concurrency slice (EFF-727)

Entity.toLayer(..., { concurrency }) is now honored inside the entity Durable Object. Storage entry (decode, persist-before-run, dedupe, duplicate resume, alarm arming) keeps the single entry permit; handler execution runs in forked fibers governed by a per-entity semaphore sized from the option (default 1, numeric N, "unbounded"). Replayed rows and alarm-due runs draw from the same budget, and a handler's permit is released while a stream chunk waits for its client acknowledgement.

Why not RpcServer.makeNoSerialization({ concurrency }): it was evaluated and does not fit the Durable Object flow. Its permit is held for a stream handler's whole lifetime, including client-ack waits; on Cloudflare acks arrive from remote callers over separate DO requests, so an unacked or abandoned stream would pin the only default permit and brick the entity (the existing "acknowledges stream chunks without holding the entity lock" test fails under that model). It also owns request lifecycle state that this path keeps in SQLite: persist-before-run and PrimaryKey duplicate resume must consult the mailbox before any handler starts, replies must be written through saveReply inside transactionSync, and lastSentChunk sequence resume plus the per-registration defectRetryPolicy rebuild live in the hand-rolled runtime. Wiring the server in would mean re-adapting all of that around it, as the classic entityManager does, for no net simplification. A scoped permit protocol on the existing loop keeps the locked persist/dedupe semantics untouched.

Later slices (mailbox, DeliverAt, keepAlive, workflow engine, queue, singleton/cron, proxies/metrics/docs) push to this branch.

Interrupted persisted stream cancellation (EFF-731)

  • Explicit cancellation now replaces stored stream chunks with an interrupted terminal reply after the handler fiber stops.
  • Cancelled requests no longer replay on the next Durable Object contact, retain mailbox capacity, or leave an unsettled waitUntil session.
  • A real-SQLite regression test covers interrupt → subsequent invoke and the durable terminal state.

Closes EFF-731

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 969c782

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 31 packages
Name Type
effect Patch
@effect/ai-anthropic Patch
@effect/ai-openai Patch
@effect/ai-openai-compat Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-cloudflare Patch
@effect/platform-deno Patch
@effect/platform-node Patch
@effect/platform-node-shared Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/vitest Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-slopcop effect-slopcop Bot added enhancement New feature or request 4.0 labels Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 6.96 KB 6.96 KB 0.00 KB (0.00%)
batching.ts 9.77 KB 9.77 KB 0.00 KB (0.00%)
brand.ts 6.55 KB 6.55 KB 0.00 KB (0.00%)
cache.ts 10.67 KB 10.67 KB 0.00 KB (0.00%)
config.ts 21.10 KB 21.10 KB 0.00 KB (0.00%)
differ.ts 20.04 KB 20.04 KB 0.00 KB (0.00%)
http-client.ts 21.64 KB 21.64 KB 0.00 KB (0.00%)
logger.ts 10.91 KB 10.91 KB 0.00 KB (0.00%)
metric.ts 8.89 KB 8.89 KB 0.00 KB (0.00%)
optic.ts 6.71 KB 6.71 KB 0.00 KB (0.00%)
pubsub.ts 14.94 KB 14.94 KB 0.00 KB (0.00%)
queue.ts 11.61 KB 11.61 KB 0.00 KB (0.00%)
schedule.ts 10.77 KB 10.77 KB 0.00 KB (0.00%)
schema-class.ts 19.66 KB 19.66 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.86 KB 29.86 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.85 KB 25.85 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.53 KB 13.53 KB 0.00 KB (0.00%)
schema-string.ts 11.03 KB 11.03 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.33 KB 15.33 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 21.78 KB 21.78 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.23 KB 24.23 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.00 KB 19.00 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 18.82 KB 18.82 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.69 KB 18.69 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 23.18 KB 23.18 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.33 KB 19.33 KB 0.00 KB (0.00%)
schema.ts 18.91 KB 18.91 KB 0.00 KB (0.00%)
stm.ts 12.69 KB 12.69 KB 0.00 KB (0.00%)
stream.ts 9.71 KB 9.71 KB 0.00 KB (0.00%)

@effect-bot
effect-bot changed the base branch from main to v4/next-minor August 18, 2026 22:24
@effect-slopcop effect-slopcop Bot removed the 4.0 label Aug 18, 2026
tim-smart and others added 23 commits August 19, 2026 10:43
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 <noreply@anthropic.com>
…onstructor

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 <noreply@anthropic.com>
…harding

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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…clocks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
…aths

- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
tim-smart and others added 5 commits August 19, 2026 10:43
- 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 <noreply@anthropic.com>
@tim-smart
tim-smart force-pushed the eff-698-cloudflare-cluster branch from eb32985 to fc958ca Compare August 18, 2026 22:43
Comment thread .changeset/cloudflare-workflow-engine.md Outdated
Comment thread packages/effect/src/unstable/cluster/Entity.ts Outdated
Comment thread packages/platform/cloudflare/src/internal/entityKeepAlive.ts Outdated
Comment thread packages/platform/cloudflare/src/internal/entityMailbox.ts Outdated
Comment thread packages/platform/cloudflare/src/internal/entityMailbox.ts Outdated
Comment thread packages/platform/cloudflare/src/internal/entityMailbox.ts
Comment thread packages/platform/cloudflare/src/internal/entityReply.ts Outdated
Comment thread packages/platform/cloudflare/src/internal/queueRuntime.ts Outdated
Comment thread packages/platform/cloudflare/src/CloudflareDurableObjects.ts Outdated
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 <noreply@anthropic.com>
tim-smart and others added 7 commits August 19, 2026 01:10
… 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
@tim-smart
tim-smart changed the base branch from v4/next-minor to main August 19, 2026 04:23
Comment thread packages/platform/cloudflare/src/internal/entityRuntime.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants