Shard manager Leader Election Implementation - #3824
Conversation
✅ Deploy Preview for golemcloud canceled.
|
…fence its state writes on the leadership
f4712f4 to
771f0b5
Compare
| } | ||
| }, | ||
| _ = shutdown.cancelled() => { | ||
| join_set.abort_all(); |
There was a problem hiding this comment.
abort_all() only requests cancellation; it does not wait for the tasks to stop. A task that is already running can continue while release_leadership() revokes the lease, and could send an executor command after another replica becomes leader. Please use join_set.shutdown().await (or otherwise wait for every task to stop) before releasing leadership. The startup-error path above has the same issue.
| stream, | ||
| // The handshake above only returns once etcd has answered it with a positive TTL, so | ||
| // this instant is a renewal etcd acknowledged - the one the watchdog measures from. | ||
| watchdog: RenewalWatchdog::armed_at(Instant::now(), granted_ttl), |
There was a problem hiding this comment.
The watchdog is armed when this response is received, but etcd starts the renewed TTL when it processes the keepalive, before sending the response. Any response delay is therefore added to the local deadline. If later renewal traffic is lost, this replica can keep its gRPC port open after etcd has expired the lease and elected a successor. Please capture the time before calling keep_alive() and arm the watchdog from that, as subsequent renewals already do.
| // revoke may not run and that lease then expires on its own; it holds only a queue slot. | ||
| tokio::select! { | ||
| biased; | ||
| _ = self.shutdown.cancelled() => Err(ShardManagerError::ShutdownRequested), |
There was a problem hiding this comment.
Cancellation here drops campaign_loop() and campaign_once() directly. If etcd has already completed the campaign, or the confirming read is still running, the cleanup revoke in campaign_once() never runs. Etcd then keeps the winning key until the lease expires, so a graceful shutdown can block the next replica for a full TTL. Please make shutdown go through the cleanup path, or otherwise revoke a granted lease before returning.
| Ok(Ok((keeper, stream))) => { | ||
| self.keeper = keeper; | ||
| self.stream = stream; | ||
| self.watchdog.forget_unacknowledged(); |
There was a problem hiding this comment.
LeaseClient::keep_alive() sends an initial renewal and waits for a positive-TTL response before returning. On a successful reconnect, this replaces the stream but leaves the watchdog's old deadline unchanged. If reconnect succeeds near that deadline, drive() can immediately report RenewalDeadlineExceeded even though etcd just renewed the lease for a full TTL. Please record the reconnect attempt's send time and re-arm the watchdog from it.
| let registry_service = Arc::new(GrpcRegistryService::new( | ||
| &shard_manager_config.registry_service, | ||
| )); | ||
| ensure_shard_count_matches( |
There was a problem hiding this comment.
This check runs after ShardManagement::new(), which has already health-checked executors, spawned the worker, and called notify_one(). On the multi-thread runtime, the worker can run first and persist changes, remove executors, or contact them before this mismatch is rejected. Please validate the stored shard count immediately after reading persistence and before starting work with that state.
…unt check from review
resolves GOL-447
Make the shard manager elect a single leader in distributed mode and fence its state writes on the leadership
Summary
In etcd mode any number of shard manager replicas can now run. Exactly one wins an etcd lease campaign and drives all topology decisions; the rest wait without opening their gRPC port. Every write of the shard lease state carries a second precondition, that the leader's election key still exists at the creation revision its campaign returned, so a replica that has lost the leadership cannot overwrite the new leader's work even when its cached revision is still current. Losing the lease stops the process; stopping the process releases the lease; a graceful stop hands over in milliseconds.
Local mode (Postgres, SQLite) is unchanged apart from two things that apply to both modes: the configured shard count is checked against the stored one at startup instead of being silently ignored, and a shutdown signal is honoured during startup.
Before this change the shard manager was a documented singleton enforced nowhere, and #3790's compare-and-swap detected lost updates without preventing two leaders.
Changes
Election (
sharding/leader_election.rs, new)LeaderElectioncampaigns through etcd's server-side Election API on its own untimed client. The lease keepalive runs concurrently with the campaign in one biased select, together with the shutdown token and a periodic "still standing by" announcement.Campaignreturns when the key ahead is deleted and never re-checks the campaigner's own key; every failure after a lease grant revokes it through one path.LeaseKeepAlive::run(self) -> LeaseLosthas no success case. It reconnects the stream on transport errors; only etcd reporting the lease gone, or a full TTL with no acknowledged renewal, ends a leader. The watchdog is anchored to the last renewal etcd acknowledged, by send time, so restarting the renewal loop cannot extend the belief window.LeaderFencecomparescreate_revisionof the leader key, which is whatLeaderKey::rev()exists for.LeadershipHandle::step_downrevokes the lease; a lease etcd no longer knows counts as released.Connections and retries (
sharding/etcd_connection.rs,sharding/etcd_retry.rs, new)request_timeoutbecomes a tonic channel-level timeout, andElection.Campaignis a unary RPC etcd does not answer until you win, so the election client has no channel timeout and bounds every non-campaign RPC per call instead.Persistence (
sharding/persistence/etcd.rs)LeadershipLostapart fromConcurrentModification. The fence is a required constructor argument; forgetting it is a compile error.with_clientletsrun()open the KV client before campaigning;stored_number_of_shardsreads the stored count without a fence.Startup and shutdown (
lib.rs,server.rs)run()takes aDeployment:Standalone { shutdown }may block until elected and carries the shutdown token;Embeddedrefuses etcd mode, because the single binary awaitsrun()inline and a campaign there would hang the whole process silently.serve_until_stopped: on a task failure, on all tasks finishing, and on shutdown it aborts the remaining tasks and then releases the lease, in that order, so no executor command can follow the deletion of the election key. A failed release is a warning, never an exit code.server.rshandles SIGTERM and Ctrl-C and exits 0; a standby told to stop leaves the election queue at once.mutate_and_persistmutates a clone and swaps it in only after the write is durable, so a caller dropped mid-persist leaves memory untouched.Configuration, metrics, docs
persistence.config.leader_lease_ttl(default 10s, whole seconds, at least 2s). Startup refusesrequest_timeoutabove half the TTL.shard_manager_is_leader,shard_manager_leader_since_epoch,shard_manager_campaign_attempt_failures_total.deploy.mdxdescribes the two modes, probes (liveness on HTTP, readiness on gRPC), failover bounds, the plaintext unauthenticated etcd requirement, the single-endpoint recommendation, and the registration caveat below.golem-common: serialization versions 1 and 2 return an error instead of panicking.Deviations from the spec, and why
etcd-client0.19 and tonic: the request timeout is applied to the whole channel. A shared client aborts every standby's campaign afterrequest_timeout.IF NOT EXISTSplus watch. The hand-rolled form makes every loser watch one key (a thundering herd on failover) and has a window where a replica can miss the delete between its failed txn and its new watch. The Election API is a FIFO by creation revision and returns thecreate_revisionthe fence needs.NotLeaderresponse, no proto change.GetRoutingTablereads a process-local snapshot loaded once, and a stale snapshot silently truncates the table rather than erroring. Serving that from a standby is worse than refusing. The closed gRPC port is the readiness signal.is_alive()check. It is a cached belief that can disagree with etcd in both directions; the worker performs a fenced write before it touches any executor, so a demoted leader fails before issuing a single RPC.create_revision, not on the lease id. Strictly stronger at the same cost: the key path already encodes the lease id, and creation revisions are monotonic, so a recreated key can never collide.http://endpoints are rejected with a message naming the gap.Decisions made during review
panic = "abort", so it only appeared to work undercargo test.Adversarial review: what was found and what was done
Two rounds. The first was a four-agent sweep of the change set; the second was a 127-agent exploration of the election and lease boundary (eight angles, three-lens refutation of every finding, a completeness critic) followed by sequential remediation with an independent reviewer per item. Fifteen findings survived refutation; twenty-three were refuted.
?after a won campaign exited holding the election key, blocking every replica for a TTLrequest_timeoutserve_until_stopped; release on every path, abort before revokestep_downerrors made a graceful exit non-zero; gauges were sticky; a failed revoke was discarded silently;request_timeoutunordered against the TTLmutate_and_persistrolled back on error, which cannot run for a caller dropped mid-persistTests
97 unit tests and 101 integration tests; 60 are new. Every new or changed test was proven by a mutation: the guarded code was broken, the test went red with its assertion quoted, the mutation was reverted, and the test went green again. The etcd-backed modules live under
tests/etcd_backed/as one sequential suite because they share a fixed state key;tests/etcd_backed/proxy.rsis a test-local TCP proxy that can drop connections (an etcd restart) or silently swallow bytes (a black hole). Integration tests need Docker.Not covered in-process: the signal-to-token translation in
server.rs, and anything needing a spawned shard manager pointed at etcd, whichgolem-test-frameworkcannot do yet.Known limitations, each with its home
Registeracknowledged just before the leader steps down or loses its lease may never be persisted, and the executor does not retry. Characterised by a test; ticket 4 redesigns the Register write path.desert_core::read_bytes; the fix belongs in desert-rust.🤖 Generated with Claude Code