Skip to content

feat(cluster): bound runner entity residency and storage reads - #7206

Merged
tim-smart merged 10 commits into
mainfrom
agent/claude-engineer/c840176c
Aug 13, 2026
Merged

feat(cluster): bound runner entity residency and storage reads#7206
tim-smart merged 10 commits into
mainfrom
agent/claude-engineer/c840176c

Conversation

@tim-smart

Copy link
Copy Markdown
Contributor

Closes EFF-603

Adds admission control to cluster runners so a backlog of persisted messages for many distinct entity ids can no longer materialize unbounded entities and OOM the runner.

Changes

ShardingConfig gains two knobs (env config accepts integers only):

  • maxResidentEntities (default 10_000): a runner-wide cap on resident entities. The spawn gate sits in front of the entity ResourceMap, so the sequential storage read loop and concurrent volatile sendLocal share one counter and neither can bypass the cap. Workflow clock entities count toward the same cap. "unbounded" (programmatic only) restores the previous spawn behaviour.
  • unprocessedMessageBatchSize (default 1024): bounds each storage read. Cap unit is entities, batch unit is messages.

Storage read loop is now two-step on each wake:

  1. Read due messages for already-resident addresses (limit = batch size) and deliver them, so residents keep moving even at the cap.
  2. If spawn budget remains, walk the remaining due rows in rowid order, admitting a new address only while a slot is free.

Latch behaviour: a full batch with delivery progress reopens the read latch immediately; at the cap the loop waits for an entity removal (any entities.remove opens the latch) or the poll interval instead of spinning. Rows skipped at the cap have their claims released (resetAddress) so they are re-read as soon as a slot frees up.

At-cap send paths:

  • Storage loop skips new addresses and does not start resumeEntityFromStorage; the resident-entity MailboxFull resume path for a full per-entity mailbox is unchanged.
  • Volatile sends to a new address fail with the existing MailboxFull error, which now also means runner-full (documented).
  • Persisted sends still persist + notify and return success; waitUntilRead notifications resolve successfully at the cap instead of hanging or failing.

MessageStorage.unprocessedMessages (decoded + encoded, SQL + memory + noop) accepts { limit, addresses } and claims (last_read) only the rows actually returned; unclaimed rows stay eligible.

Tests

  • ShardingConfig: defaults, env int-only parsing, programmatic "unbounded".
  • Sharding: cap held while a backlog drains via idle reaping, volatile MailboxFull at the cap while persisted sends succeed, resident-prefer delivery with a newer-id backlog in front, full-batch drain within one poll interval, no busy-polling at the cap, "unbounded" behaviour.
  • MessageStorage (memory) and SqlMessageStorage.integration (pg / mysql / sqlite): limit, addresses filter, and last_read set only on returned rows.
  • Node cluster-integration (Residency.test.ts, pg + mysql): a single runner with maxResidentEntities: 3 holds the cap against a backlog of new ids, residents keep receiving messages, and the backlog drains in waves once slots free up.

pnpm check, pnpm lint, the full effect test suite (8135 tests), platform-node cluster tests, and the cluster-integration suite all pass locally.

🤖 Generated with Claude Code

Adds admission control to cluster runners:

- ShardingConfig.maxResidentEntities (default 10_000) caps how many
  entities can be resident on a runner at once. The spawn gate lives in
  front of the entity ResourceMap, so the storage read loop and volatile
  sends share one counter. At the cap, volatile sends to new addresses
  fail with MailboxFull, persisted sends still succeed, and messages for
  new addresses stay in storage until a slot frees up.
- ShardingConfig.unprocessedMessageBatchSize (default 1024) bounds each
  storage read. The read loop delivers to already-resident addresses
  first, then admits new addresses while slots remain, reopening the
  read latch after a full batch and waking on entity removal at the cap.
- MessageStorage.unprocessedMessages accepts { limit, addresses } and
  claims only the rows it returns (SQL, memory and noop drivers).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d8061b5

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

This PR includes changesets to release 30 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-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 the 4.0 label Aug 12, 2026
@github-actions

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.91 KB 6.91 KB 0.00 KB (0.00%)
batching.ts 9.72 KB 9.72 KB 0.00 KB (0.00%)
brand.ts 6.50 KB 6.50 KB 0.00 KB (0.00%)
cache.ts 10.63 KB 10.63 KB 0.00 KB (0.00%)
config.ts 21.05 KB 21.05 KB 0.00 KB (0.00%)
differ.ts 19.99 KB 19.99 KB 0.00 KB (0.00%)
http-client.ts 21.56 KB 21.56 KB 0.00 KB (0.00%)
logger.ts 10.88 KB 10.88 KB 0.00 KB (0.00%)
metric.ts 8.86 KB 8.86 KB 0.00 KB (0.00%)
optic.ts 6.67 KB 6.67 KB 0.00 KB (0.00%)
pubsub.ts 14.90 KB 14.90 KB 0.00 KB (0.00%)
queue.ts 11.57 KB 11.57 KB 0.00 KB (0.00%)
schedule.ts 10.74 KB 10.74 KB 0.00 KB (0.00%)
schema-class.ts 19.61 KB 19.61 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.57 KB 29.57 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.81 KB 25.81 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.49 KB 13.49 KB 0.00 KB (0.00%)
schema-string.ts 10.99 KB 10.99 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.30 KB 15.30 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 21.73 KB 21.73 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.20 KB 24.20 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 18.96 KB 18.96 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 18.78 KB 18.78 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.65 KB 18.65 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.81 KB 22.81 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.30 KB 19.30 KB 0.00 KB (0.00%)
schema.ts 18.87 KB 18.87 KB 0.00 KB (0.00%)
stm.ts 12.66 KB 12.66 KB 0.00 KB (0.00%)
stream.ts 9.66 KB 9.66 KB 0.00 KB (0.00%)

tim-smart and others added 8 commits August 12, 2026 22:39
Simplicity pass over the admission-control change, no behavior changes:

- Derive the capped state from cappedAddresses instead of a parallel
  boolean, and merge the notification sweep into one guarded block.
- Read-and-process helper for the two storage read steps; hoisted
  markDelivered effect instead of per-message closures.
- Drop resetAddress from the encoded storage driver contract; drivers
  implement only the batched resetAddresses and the singular form is
  derived in makeEncoded. Group the SQL bulk reset with IN lists.
- One shared UnprocessedOptions type (ReadonlyArray) for the decoded and
  encoded storage APIs, removing the conversion glue in makeEncoded.
- Hoist option handling out of the per-dialect query builders; reuse
  Arr.groupBy for the address filter.
- Memory driver uses one addressKey helper and a named claim-window
  constant; entity reaper no longer depends on the spawn error union.
- Shared test config defaults and a saveGetUserRequest helper replace
  duplicated envelope plumbing in Sharding.test.ts.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…929db96c

# Conflicts:
#	packages/effect/test/cluster/MessageStorage.test.ts
Workflow and durable clock entities now use a ten-second idle time
instead of the one-minute entityMaxIdleTime default. Their state is
durable, so a completed or suspended execution can be evicted safely
and is rebuilt from storage when its next message arrives. This keeps
finished executions from holding maxResidentEntities slots.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…alues

The ten-second workflow entity idle time is now a cap applied with
Duration.min over the configured entityMaxIdleTime, so operators who
tune the global idle time below ten seconds keep their value. The
config is read optionally from the layer context, keeping ShardingConfig
out of ClusterWorkflowEngine.layer's public requirements; without it
the cap applies as-is.

The eviction test now resumes the workflow after its entities were
released and asserts it completes, and a new test pins the sub-cap
config behaviour.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/effect/src/unstable/cluster/ClusterWorkflowEngine.ts Outdated
Comment thread packages/effect/src/unstable/cluster/MessageStorage.ts Outdated
Comment thread packages/effect/src/unstable/cluster/ShardingConfig.ts Outdated
Comment thread packages/effect/test/cluster/ClusterWorkflowEngine.test.ts Outdated
@tim-smart
tim-smart merged commit 6ff5396 into main Aug 13, 2026
18 checks passed
@tim-smart
tim-smart deleted the agent/claude-engineer/c840176c branch August 13, 2026 02:06
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.

1 participant