Skip to content

Cluster workerd processes over a shared bucket - #7994

Merged
ry merged 41 commits into
mainfrom
ry/cluster-init
Oct 29, 2026
Merged

ry merged 41 commits into
mainfrom
ry/cluster-init

Conversation

@ry

@ry ry commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

This is the beginning of merging celld's functionality into workerd. It lets several workerd processes network into a cluster by giving each of them access to the same S3-compatible bucket (Config.cluster, --experimental). Each Durable Object has one owner at a time, requests reach it from any process, and when its owner dies another process picks it up from the bucket. Objects log their commits to the bucket as Cap'n Proto frames of SQLite pages (frames.capnp) and compact the log into snapshots. For now an object acknowledges a write only once it is fully synced to the bucket; that is much too slow and expensive for most uses. Next PRs will ack writes with the cluster and add support for alarms.

To try it, add a cluster block to the config naming the bucket and a key the nodes share, then start each node with its own address:

workerd serve --experimental config.capnp --cluster-addr 10.0.0.1:7000

A request for any Durable Object can go to any node.

This is the beginning of merging celld's functionality into workerd. It
lets several workerd processes network into a cluster by giving each of
them access to the same S3-compatible bucket (`Config.cluster`,
`--experimental`). Each Durable Object has one owner at a time, requests
reach it from any process, and when its owner dies another process picks
it up from the bucket. Objects use the bucket as cold storage with
Litestream's algorithm and file formats. For now an object acknowledges a
write only once it is fully synced to the bucket; that is much too slow
and expensive for most uses. Next we will acknowledge writes once peers
in the cluster hold them and buffer the bucket writes. Alarms are also
not supported yet.
@ry
ry force-pushed the ry/cluster-init branch from f844530 to 8900056 Compare October 26, 2026 06:58
ry added 5 commits October 26, 2026 13:01
capture.rs read the WAL with the Unix-only read_exact_at; it seeks and
reads instead. The ltx test VFS copied 160 bytes out of SQLite's
152-byte io_methods into a struct whose tail is a non-null fn pointer;
each file now keeps its patched table in its own allocation, so nothing
is overread or leaked. Drop a word clippy's doc_markdown flags.
Windows SQLite uses the KJ VFS, which cannot report WAL writes, so a
cluster object there acknowledged commits it never uploaded: the takeover
test on Windows recovered nothing. Refuse walWrite on Windows and skip
the WAL-report and cluster tests there.
The capture tests register a Rust VFS with SQLite. A downstream release
build compiles SQLite with -fsanitize=cfi-icall, so its first call into
the Rust xOpen traps (SIGILL). Production calls Rust from C++ directly
through cxx and is unaffected.
Clustered actors' SQLite opens files through a kj::Directory wrapper,
as edgeworker's storage relay does: opening checks the lease, and a WAL
file reports each successful write. It has no fd, so SQLite uses the KJ
VFS on every platform. This drops VfsOptions::afterOpen and walWrite and
their native-VFS plumbing, leaving sqlite.c++'s VFS as on main, and the
cluster test runs on Windows again.
An object's log is a stream of frames (frames.capnp), each a change to
one lane, written by an ownership epoch in sequence. The database is a
sqlitePages lane: a commit is a frame of the pages it wrote, so merging
a run of commits keeps one copy of each page, and a snapshot is a header
and the database. Restore and compaction replay frames into a file,
which replaces LTX's k-way merge; a frame a wider file already applied
is skipped by its sequence. Each file is an LZ4 frame with a content
checksum. Lanes leave room for other kinds of state beside the
database, and for commits that span them.
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

The generated output of @cloudflare/workers-types matches the snapshot in types/generated-snapshot 🎉

ry added 7 commits October 26, 2026 21:42
A file says whether it holds frames or a snapshot, so replay no longer
needs the level from its key, and a later field or kind of file has a
place to go.
A process that restarts at the same address before its predecessor's
lease runs out finds the predecessor's record live and routed to it,
which is itself: the call came back in and routed again, with no hop
limit. Refuse instead, until the lease ends and the object can be
claimed.
Every failure returned silently, and a gap in the chain stranded the
files past it while the prefix kept compacting. On a soak, one epoch
grew 1,454 L0 files after its last snapshot with nothing in the log.
Main now checks in crate_universe's lockfile, so a Cargo.toml that adds
crates must repin it (CARGO_BAZEL_REPIN=1). The repin also catches up
main's Cargo.lock bumps that the lockfile had not followed.
…ords

Nothing set or read `facets`, so own.json always carried `"facets":[]`.
Facets come in their own change, which can add the field back. Each
record's fields now say what they hold, and NodeRecord's `node` and
`address` say what they are: a random id and `host:port`, not a public
key and sockaddr bytes.
The cluster test runs on Windows since WAL capture moved to a directory,
but Node's child.kill('SIGTERM') is TerminateProcess there: the node dies
without draining, so the two drain tests read ECONNRESET.
@codecov-commenter

codecov-commenter commented Oct 27, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 9.00791% with 2990 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.30%. Comparing base (fb27a83) to head (48fcee0).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/workerd/server/cluster/owner.rs 0.00% 414 Missing ⚠️
src/workerd/server/cluster/log.rs 0.00% 388 Missing ⚠️
src/workerd/server/tests/cluster/cluster-worker.js 0.00% 314 Missing ⚠️
src/workerd/server/cluster/s3.rs 0.00% 275 Missing ⚠️
src/workerd/server/server/cluster.rs 12.14% 275 Missing ⚠️
src/workerd/server/server/actor.rs 21.73% 198 Missing ⚠️
src/workerd/server/cluster/cluster.rs 0.00% 182 Missing ⚠️
src/workerd/server/cluster/ownership.rs 0.00% 161 Missing ⚠️
src/workerd/server/cluster/http.rs 0.00% 158 Missing ⚠️
...rc/workerd/server/factory/worker-factory-actor.c++ 14.48% 120 Missing and 4 partials ⚠️
... and 15 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7994      +/-   ##
==========================================
- Coverage   69.38%   68.30%   -1.08%     
==========================================
  Files         696      710      +14     
  Lines      181896   185140    +3244     
  Branches    29221    29252      +31     
==========================================
+ Hits       126212   126466     +254     
- Misses      38610    41592    +2982     
- Partials    17074    17082       +8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

ry added 5 commits October 27, 2026 15:47
The log's frames are the storage relay's, field for field: each commit
is a write of the bytes SQLite wrote to the WAL, at an offset that keeps
growing across checkpoints, each marked by a consume, plus newLane,
initialize and deleteLane. A batch starts and ends at a commit. A
snapshot is a SnapshotHeader and then the database file, never a WAL:
replay checkpoints committed WAL frames into the file first. Members
workerd leaves unset are kept for wire compatibility and marked unused.

Compaction follows SRS's rule: snapshot a session once the log since its
last snapshot reaches the database's size, from that snapshot and the
batches past it, then delete what the new one covers. Batches are never
merged, so the page merge and the level-numbered chain are gone.

Keys match SRS's layout, and none depends on a name's length:

  do/<namespace>/<actor>/log/<session>/<end frame>
                        /snapshot/<session>/<frame>
                        /own.json

<namespace> is base64url of the first 16 bytes of a labelled SHA-256 of
the tenant (empty for now) and the namespace's uniqueKey; <actor> is the
actor ID's hex. Numbers are SRS's length-prefixed hex, sessions and
snapshot frames descending, so a listing comes newest first. The
FileHeader is dropped: the key says what a file is.
A draining node kept serving its own objects: the owner check came
before the drain check, so it reopened them for any request that
reached it. It also stayed listed until its last connection finished,
so peers routed its objects to a node that no longer accepted them, for
up to a lease.

Now a draining node refuses its own objects as it refuses claims, and
deregisters when the drain starts: peers drop it at their next listing
and take its objects over. A renewal in flight at that moment may find
the record gone; once draining, its outcome is ignored rather than read
as a fence.
Drop the unused accessor that handed out the raw key.
Logging the WAL's bytes put a page a hot object rewrites in the log once
per commit: a batch of grouped commits carried every copy, and the log
reached the database's size, and so a snapshot, as fast as commits came.

The log is a `sqlitePages` lane instead, workerd's own lane type at new
ordinals of SRS's schema, which is otherwise unchanged. A commit is a
frame of the pages it wrote; a batch merges each run of them to one
frame with the newest version of each page, covering the commits
`first..=sequence`. A hundred updates of one row log one page, not a
hundred: 4 KB instead of 418 KB, 199 bytes instead of 3.3 KB after LZ4.

A new database is still SRS's `initialize`, snapshots still SRS's
header and the database file, and compaction still SRS's rule.
@ry
ry marked this pull request as ready for review October 28, 2026 14:53
@ry
ry requested review from a team as code owners October 28, 2026 14:53
@clanky-the-clanker

clanky-the-clanker Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

💬 Approved with comments — The changes since the last review (gzip, the alarm refusal revert, the scheduler VFS) have no defects. Only INFO notes remain: one new hardening note on uncapped gzip output, and earlier notes in unchanged code.

Findings: 10 info

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.


📋 Code Quality: 💬 9 info

No new defects. Earlier INFO notes in unchanged code are still open.

Recommendation: 💬 Optional Nits

  • [INFO] The Rust unit tests are inline mod tests blocks. The repository rule is a <module>-test.rs file beside the module. (src/workerd/server/cluster/log.rs:608)
  • [INFO] The cluster crate lists a nix dependency that nothing uses. (src/workerd/server/cluster/BUILD.bazel:27)
  • [INFO] owned.epoch as u32 truncates silently. Separately, Owners::close has no caller. (src/workerd/server/cluster/owner.rs:212)
  • [INFO] upload's doc comment sits above prune_older. The doc comment on send in s3.rs carries history that belongs in a commit message. (src/workerd/server/cluster/owner.rs:613)
  • [INFO] Registry::peers fails the whole listing when a single node record cannot be read or parsed. (src/workerd/server/cluster/registry.rs:186)
  • [INFO] A RoutedChannel stays bound to its first resolution, so a stub to a remote owner that has died never re-routes. (src/workerd/server/server/actor.rs:293)
  • [INFO] cluster-test.mjs has stale comments. The header lists scenario 7 as covered, but its test is todo. The drain test's comment describes the listener rather than the refusal to claim while draining. (src/workerd/server/tests/cluster/cluster-test.mjs:21)
  • [INFO] SqliteDatabase::close() has no direct tests for closing with an open transaction, closing mid-iteration, a double close, or reset() after close. (src/workerd/util/sqlite.c++:1094)
  • [INFO] fake-s3.js leaves orphaned chunk entries after a DELETE or a smaller overwrite. (src/workerd/server/tests/cluster/fake-s3.js:87)
View full review

Changed since the last review

  • cluster/log.rs, gzip:
    • Replay::apply reads each batch until the stream ends, so flate2 checks the trailer before the batch counts as applied.
    • snapshot() drains the reader to its end for the same check.
  • Dependencies: Cargo.toml, Cargo.lock and cargo-bazel-lock.json agree, and no reference to lz4 or twox-hash remains.
  • worker-factory-actor.c++ and worker-factory-impl.h:
    • Members are declared in a safe order.
    • The scheduler opens its database after cluster.join(), so the lease is already held.
    • metadata.sqlite-wal writes reach the cluster under the name metadata. No owned object has that name, because clustered ids are hex, so the writes are ignored.
  • Tests: a_missing_batch_fails_to_restore_or_compact covers both restore() and compact().

Still open from earlier reviews (INFO, code unchanged): listed in the items below.


🔒 Security: 💬 1 info

One hardening note: gzip output from the bucket is not capped.

Recommendation: 💬 Optional Nits

  • [INFO] Gzip output from bucket files is not capped. A snapshot's self-declared database_size is copied to disk with no ceiling, so anyone who can write to the bucket could fill the disk during a restore. Consider a maximum size or a sanity check on the compression ratio. (src/workerd/server/cluster/log.rs:248)
View full review
  • cluster/log.rs: gzip output has no size cap.
    • A snapshot's database_size comes from the file's own header and is copied to disk through take(size) with no ceiling. A batch is bounded only by the capnp traversal limit.
    • A small crafted file in the bucket could therefore expand into a very large database or spool file.
    • Exploiting this needs write access to the bucket, which is already the root of trust for cluster state, so this is hardening rather than a vulnerability.
  • Unchanged and still sound:
    • CONNECT is authenticated with a MAC compared in constant time.
    • The cluster key must be at least 32 bytes.
    • Bucket responses held in memory are capped at 16 MiB.
    • TLS uses the platform verifier.
  • Agreed follow-ups, not yet in this pull request:
    • The dialer should verify that the acceptor holds the cluster key.
    • Peer sessions need liveness detection.

✅ All other checks passed

📖 AGENTS.md: ✅ LGTM

No issues found.


⚡ Performance: ✅ LGTM

  • gzip uses fast compression through buffered, streamed I/O on the blocking pool.
  • The scheduler's VFS change adds no meaningful cost.
  • Holding a whole pending transaction in memory in capture.rs is still an agreed follow-up: the plan is to spill it to disk.

📝 Documentation: ✅ LGTM

No new documentation issues. The stale comments in cluster-test.mjs are listed under Code Quality.


Reviewed commit: 48fcee06 — same changes as 18efd126, so this is that commit's review (no new review was run; line numbers are as of 18efd126)

@ask-bonk

ask-bonk Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Since last review: 0 resolved, 1 still open, 0 new.

The added tests cover shared cluster-token keys and missing-log-batch compaction.

Not re-run: api-compat, docs, design-simplicity, jsg-gc (no author changes in their files since the last review)

Specialist findings not posted (2)
  • correctness#1 [BLOCKING] src/workerd/server/server/actor.rs:549: A routed stub caches its first Resolved::Remote forever. If that owner process dies, the disconnect watcher only removes the entry in ClusterNode.sessions; it cannot clear this channel's bound v… — The stale RoutedChannel binding is a real concern, but it was already published by another reviewer and is outside this re-review's author delta.
  • security#1 [WARNING] src/workerd/server/server/cluster.rs:151: refuse_public() accepts every non-SocketAddr unchanged, but bind() passes that value to TcpListener::bind() and peer addresses are later dialed from the same string. Thus an operator can set `… — The hostname public-address bypass is outside this re-review's author delta; no cluster address code changed since the prior review.

Reviewed commit: 9d48e083 · github run

Comment thread src/workerd/server/cluster/http.rs
Comment thread src/workerd/server/server/config.rs
workerd exits without dropping its tokio runtime, leaving idle
blocking-pool threads unjoined, which TSan reports as leaked: the
drain handoff test's owner then exits 66, not 0. Set KJ_CLEAN_SHUTDOWN
for the nodes, as the asan and coverage configs do.
Comment thread src/workerd/server/server/run.rs Outdated
Comment thread src/workerd/server/server/cluster.rs
Every namespace is clustered, but drain aborted only evictable ones, so
an object with preventEviction kept serving its in-flight requests after
its node left the cluster, while a peer took it over.
ry added 3 commits October 28, 2026 13:11
Concurrent first routes to a peer each saw no session, dialed their
own, and overwrote each other's in the map, while the stubs they
returned kept every one of those sessions alive. The map now holds the
dial itself, as a shared future, so they wait on the same one.
Once an epoch opens, own.json names it and its session starts with a
snapshot of everything the recovered one held, so no restore reads an
earlier session again. Nothing deleted them, leaving another copy of
the object in the bucket at every handoff. The next tier pass after an
epoch opens deletes every earlier session, including what a superseded
owner uploaded after the takeover.

@jasnell jasnell left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These review comments were generated by an AI assistant and may not be perfect.

Not repeating findings already raised by bonk, clanky or @piscisaureus. Summary:

  • Restore does not verify snapshot integrity, treats a truncated LZ4 file as complete, and lets a corrupt bucket object trigger an unbounded capnp allocation (log.rs). Combined with the existing gap finding, a bad restore can be compacted into a new snapshot and made permanent.
  • Enabling cluster on an existing localDisk deletes each object's existing data at first access (owner.rs).
  • setAlarm() is refused at commit time, which rolls back the implicit transaction and breaks the actor rather than throwing to the caller; the test passes only via the outer worker's catch.
  • Drain aborts every namespace, and abort() always closes storage, so in-flight requests fail at drain start, including non-clustered namespaces.
  • An authenticated peer receives the full debug-port capability, though the cluster only ever uses getActor.
  • Peer links: no liveness detection on cached sessions, no minimum key strength, and only the dialer is authenticated.

Comment thread src/workerd/server/cluster/log.rs Outdated
Comment thread src/workerd/server/cluster/log.rs Outdated
Comment thread src/workerd/server/cluster/log.rs Outdated
Comment thread src/workerd/server/cluster/owner.rs
Comment thread src/workerd/server/factory/worker-factory-actor.c++
Comment thread src/workerd/server/server/run.rs Outdated
Comment thread src/workerd/server/server/cluster.rs
Comment thread src/workerd/server/server/cluster.rs Outdated
Comment thread src/workerd/server/server/cluster.rs Outdated
Comment thread src/workerd/server/server/cluster.rs Outdated
Comment thread src/workerd/server/channel-token.c++

@jasnell jasnell left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking concerns... handful of things flagged by the AI reviewer.

Comment thread src/workerd/server/server/cluster.rs Outdated
ry added 7 commits October 28, 2026 13:38
A namespace's storage decides at link whether its database writes go
to the cluster. Workflow namespaces linked before the cluster existed,
so they routed through the cluster but replicated nothing, and a
takeover restored stale state. The cluster is now made first and set
on each workflow namespace before its storage opens.
Drain aborted every namespace, so in-memory ones, which have nothing to
hand off, failed their in-flight requests too.
A takeover replaces an object's local database with the bucket's copy,
so adding cluster to a config whose localDisk already held Durable
Objects deleted each one on first access. A namespace directory is now
marked the first time cluster mode uses it, and one that holds
databases without the mark is refused at startup.
A restore read less than a damaged file held without failing:

- a snapshot's database was copied without checking its length, and the
  frame was never read to its end, where the LZ4 checksum is checked;
- the LZ4 decoder takes a frame cut at a block boundary, or an empty
  file, for a whole one, so a batch's last commits were dropped;
- with no traversal limit, a corrupt message's segment table was
  allocated at whatever size it claimed, aborting the process.

Every file is now checked to be one whole LZ4 frame before it is read,
a snapshot must hold exactly its database, and a message is bounded at
1 GiB.
The refusal was thrown from scheduleRun(), which runs when the write
commits, after setAlarm() had resolved: the implicit transaction failed,
rolling back the writes since the last await and breaking the actor,
and the caller could not catch it. ActorSqlite::Hooks gains
checkAlarm(), which setAlarm() calls before it changes anything; the
default allows every alarm.
The MAC's other inputs cross the wire, so a short or guessable key can
be found offline from one captured CONNECT, and the key grants the
whole debug port. The check is a config error, so server-test now
covers it and the --experimental refusal.
Records, listings and errors are read into memory, so an endpoint that
kept sending could exhaust the process before the body was parsed.
Comment thread src/workerd/server/factory/worker-factory.c++ Outdated
Comment thread src/workerd/server/cluster/log.rs
Comment thread src/workerd/server/cluster/capture.rs
Comment thread src/workerd/server/cluster/http.rs
ry added 4 commits October 28, 2026 14:11
The shared dial moves into Sessions, apart from the factory it needs,
so its guarantees are tested on their own: concurrent callers share one
dial, and a failed dial or a disconnected session is forgotten, so the
next route dials again. A failure kept instead would leave the peer
unreachable from this node until it restarts.
A Worker whose service name began with workerd: could use experimental
compatibility flags without --experimental, and the name is the
config's to choose. No built-in service uses the exemption.
A batch that starts past the frame the log has reached is skipped, for a
later, wider batch covers it when uploads race. When none does, a batch
between is missing, and the restore succeeded with the state before it,
which compaction could then make permanent. The replay now remembers
the furthest skipped batch and fails unless it got past it.
Comment thread src/workerd/server/factory/worker-factory.c++
Comment thread src/workerd/server/cluster/log.rs
ry added 6 commits October 28, 2026 14:59
Handlers given the same cluster key decode each other's channel tokens,
and one given another key rejects them.
This reverts 0067094, which added ActorSqlite::Hooks::checkAlarm() to
core io/ only to move a temporary error message in experimental code.
Alarm support in cluster mode replaces the refusal.
A namespace opened its scheduler's database through a second VFS, to
keep it outside the cluster's wrapper. The wrapper needs no exception:
the cluster ignores WAL writes of files no object owns, and the
scheduler opens its database at startup, under the lease. So a
namespace has one VFS again, as outside cluster mode.
flate2 is already a dependency, so lz4_flex and twox-hash go. Gzip's
trailer holds the content's CRC-32 and length, which the decoder
checks, so a corrupt or truncated file fails on its own, without
walking the frame first.
@ry
ry enabled auto-merge (squash) October 28, 2026 22:55
@ry
ry disabled auto-merge October 28, 2026 23:00
@ry
ry merged commit aa49479 into main Oct 29, 2026
26 of 28 checks passed
@ry
ry deleted the ry/cluster-init branch October 29, 2026 03:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants