Repository navigation
cluster: call Durable Objects' drain() before a draining instance lets them go - #3
Merged
Merged
Conversation
A draining instance stops claiming objects but, unlike start_draining, keeps renewing its lease: the objects it owns stay its own, their commits are still acknowledged, and no peer can claim them while they flush. Signed-off-by: Gogo <botiverse+gogo@agents.stamp.build> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On SIGTERM a cluster instance now first stops claiming (keeping its lease), then calls the drain() method of every running clustered Durable Object whose class defines one, all at once, and waits for them and for what they wrote to be durable, for at most cluster.drainTimeoutMs (default 6000; 0 skips it). The listeners keep serving meanwhile. Then it lets its objects go and leaves the cluster as before (start_draining, abort_all, deregister). drain() is a custom event on the actor's own request, started the way its alarms and hibernated WebSocket events are (ActorContainer's start_request); the factory's DrainCustomEvent looks the method up on the instance, as [restore]() is, so classes without one are skipped. Each namespace logs how many actors drained, had no drain(), failed and were cut off, and how long it took. Signed-off-by: Gogo <botiverse+gogo@agents.stamp.build> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Gogo <botiverse+gogo@agents.stamp.build> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While only stop_claiming is in effect, an owner record naming this node is taken up here (Owned), so an object it owns but does not have open wakes locally while its lease holds; it is just not drained. Claiming an unowned object, or one whose owner died, is still refused, with the existing error the server returns as DISCONNECTED. start_draining refuses both, as before. Signed-off-by: Gogo <botiverse+gogo@agents.stamp.build> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- DurableObjectNamespace.drain (default false): only the namespaces that opt in are drained, so a class's own method named drain is never called by surprise. ClusterConfig.drainTimeoutMs stays the node-wide deadline, now at most 10000 (a larger value is logged and clamped). - An actor's drain() method is looked up under the isolate lock but outside any request (actor_drain_method), so an actor without one is skipped without waiting behind its input gate; the event waits for output locks only when the method ran. - At most 32 actors of a namespace drain at once, under one deadline. - A listener failing during the drain no longer skips start_draining, abort_all and deregister: the instance leaves the cluster, then returns the error (leave_cluster in run.rs). - The per-namespace log line names the service too. Signed-off-by: Gogo <botiverse+gogo@agents.stamp.build> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in(), drainTimeoutMs = 0 Signed-off-by: Gogo <botiverse+gogo@agents.stamp.build> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cluster::acquire_or_route checked may_keep before its awaits (advance, open_fresh, retries): a resolve could pass it, start_draining and abort_all could then run, and the resolve would insert a container that nothing aborts. It now checks again right before returning Route::Owned, after which the server inserts the container with no await between, and returns the draining error if the drain began. actor_drain_method also finds drain() without running the app's code: it walks the prototype chain with GetOwnPropertyDescriptor and counts only a data property that is a function; an accessor or a Proxy leaves it to the request. Signed-off-by: Gogo <botiverse+gogo@agents.stamp.build> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Holt x Orleans proposal P0-2: before a draining cluster instance lets its Durable Objects go, give every running clustered DO a bounded chance to flush.
On SIGTERM a cluster instance now:
Cluster::stop_claiming, new). The lease loop keeps renewing, so commits keep being acknowledged and no peer can claim the objects this node owns. Objects already running here keep serving requests. An object whose owner record names this node but that isn't open here still wakes here (may_keep). Claiming an unowned object, or one whose owner died, is still refused with the existing "this node is draining" error, which the server returns as DISCONNECTED.drain()on opted-in namespaces only. A namespace opts in withdurableObjectNamespaces[].drain = true(new field, default false), so an existing app method nameddrainis never called by surprise. The node callsdrain()on each running root actor of those namespaces, at most 32 per namespace at once. It waits for each method, and for what it wrote to be durable (its output gate), until a single deadline:cluster.drainTimeoutMs(default 6000; 0 skips the calls; values above 10000 are logged and clamped to 10000).actor_drain_method). It walks the prototype chain withGetOwnPropertyDescriptorand counts only a data property that is a function, so no app code runs. An accessor or a Proxy falls back to the request. An actor with nodrainis therefore skipped without waiting behind its input gate.drain()still running at the deadline, or not yet started, is cancelled and counted as timed out.start_draining()(parks the lease loop and stops waking its own objects),abort_all(None)for each clustered namespace,deregister(). If a listener fails during the drain, these steps still run and the error is returned afterwards.Each opted-in namespace logs one line:
cluster drain; service = "<service>"; namespace = "<class>"; drained = N; skipped = N; failed = N; timed_out = N; ms = N. It is INFO normally and WARNING when anything failed or timed out, and it contains no object ids.How
drain()is aWorkerInterface::CustomEvent(DrainCustomEvent,factory/worker-factory-actor.c++). It is started on the actor's own request throughActorContainer::start_request, the same path alarms and hibernated WebSocket events use. The event looks the method up on the instance (ExportedHandler::self), as[restore]()does, soExportedHandlerand the TS types are unchanged and classes without the method are skipped. After the method resolves it awaitswaitForOutputLocks(), so "drained" means "durable in the bucket".worker_drainandactor_drain_method(server/bridge.rs),ActorNamespace::drains/drain_allandActorContainer::drain(server/actor.rs), andleave_cluster/drain_actorsinserver/run.rs.Not done (TODO in
run.rs)The optional step of releasing each drained object's
own.json(node"") so peers claim it at once, instead of after up to 3 s of listing cache, is left as a TODO. Releasing at the end of a drain would let a peer open the next epoch while the actor still serves (stale) reads here untilabort_all. Even after the abort, the eviction it starts (Owners::evict) still tiers the log into the bucket while the peer recovers from it, and nothing yet shows that race is safe.Tests
cluster/cluster.rs:a_node_that_stopped_claiming_keeps_its_objects_and_its_lease. Afterstop_claimingthe node refuses new claims, its lease loop is not parked and still renews, and a peer routes to its object instead of claiming it.tests/cluster/cluster-test.mjs:drain()and read back on the peer that takes over.drainTimeoutMs(3 s in the test config) after SIGTERM, logstimed_out = 1, and whatdrain()committed before it hung is the next owner's.drain()is not called, and the namespace logs no drain line.skipped = 1in under 1 s.failed = 1, exits with code 0, and what it wrote before throwing is the next owner's.server/cluster-test.rs:drainTimeoutMsis clamped to 10 s.cluster/cluster.rs:a_node_that_starts_draining_mid_claim_does_not_keep_the_object. A drain that starts whileacquire_or_routeawaits the bucket (between reading the owner record and advancing its epoch) makes the claim fail instead of returningOwned.acquire_or_routechecksmay_keepagain right before returningOwned, and the test fails without that check.stop_claimingunit test now also checks that the node still wakes its own object, and that it refuses to afterstart_draining.Run locally (Linux x64,
bazel test)://src/workerd/server/cluster:cluster_test(59 pass),//src/workerd/server/server:workerd-server_test,//src/workerd/server:server-test@, and//src/workerd/server/tests/cluster:cluster-test(32 pass, 1 pre-existing TODO; 235 s).--config=lint(clippy) is clean on the server and cluster crates, andtools/cross/format.py --checkpasses on the changed files.Building a holt runtime binary
mainhas no botiverse build workflow, so this branch gets no binary from CI..github/workflows/botiverse-build.ymlexists only on thebotiverse/*branches (botiverse/cluster-alarms,-v2,base-6780). It runs on push tobotiverse/**and buildsbazel build //src/workerd/server:workerd --config=releasefrom scratch on a standard runner (a few hours), then uploads the artifactworkerd-linux-x64-<sha>. To get a binary, push these commits plus that workflow to abotiverse/...branch. The binary then goes into holt's releases bucket asplatform/<version>/workerdwithSHA256SUMS(holtdocs/overview.md).Open questions / risks
drainTimeoutMslonger. The holt agent is to take the node out of Caddy before SIGTERM.drain()to tell its WebSocket clients to reconnect.drain()can call into its facets.🤖 Generated with Claude Code