[server] Parallelize replica leader transitions - #3999
Conversation
|
could you help review this? @fresh-borzoni |
fresh-borzoni
left a comment
There was a problem hiding this comment.
@zuston Thank you for the PR, left some comments and questions, PTAL
| new NotifyLeaderAndIsrResultForBucket(secondBucket)); | ||
| assertThat(spyingReplicaManager.getReplicaOrException(firstBucket).isLeader()).isTrue(); | ||
| assertThat(spyingReplicaManager.getReplicaOrException(secondBucket).isLeader()).isTrue(); | ||
| } |
There was a problem hiding this comment.
Could we also cover one bucket failing while the others succeed? That is the part this refactor could break, and a stale bucket epoch is enough to make Replica.makeLeader throw for just that bucket.
| @Test | ||
| void testMakeLeadersInParallel() throws Exception { | ||
| ReplicaManager spyingReplicaManager = spy(replicaManager); | ||
| replicaManager = spyingReplicaManager; |
There was a problem hiding this comment.
Is this assignment needed? spyingReplicaManager is used everywhere below.
| public static final class OfflineReplica implements HostedReplica {} | ||
|
|
||
| public void shutdown() throws InterruptedException { | ||
| ExecutorUtils.gracefulShutdown(5, TimeUnit.SECONDS, replicaTransitionExecutor); |
There was a problem hiding this comment.
shutdownNow() on replicaStateChangeExecutor used to interrupt the thread doing the transition. It now interrupts a thread waiting in join(), so the workers are only interrupted here, after scheduler.shutdown() and scannerManager.close().
Could we create the pool in TabletServer like ioExecutor and drain it at TabletServer:483, so gracefulShutdown reaches the worker threads again?
fresh-borzoni
left a comment
There was a problem hiding this comment.
@zuston Thank you, LGTM overall, one comment, PTAL
|
|
||
| try { | ||
| if (replicaTransitionExecutor != null) { | ||
| ExecutorUtils.gracefulShutdown(5, TimeUnit.SECONDS, replicaTransitionExecutor); |
There was a problem hiding this comment.
drain the transition pool before replicaStateChangeExecutor, since allOf().join() ignores the interrupt and the workers are what need signalling.
fresh-borzoni
left a comment
There was a problem hiding this comment.
@zuston Thank you, LGTM 👍
cc @swuferhong
swuferhong
left a comment
There was a problem hiding this comment.
Hi, @zuston, thanks for your contributions, I left some comments:
| public static final ConfigOption<Integer> TABLET_SERVER_REPLICA_TRANSITION_THREAD_NUM = | ||
| key("tablet-server.replica-transition-thread-num") | ||
| .intType() | ||
| .defaultValue(10) |
There was a problem hiding this comment.
The configured value only bounds the number of bucket transitions; it does not bound the actual recovery concurrency.
Each KV leader recovery creates a private RemoteLogFetcher executor, whose default size is 3. With the proposed default of 10 transitions, a mass leader election may therefore create up to 30 remote-log download threads, in addition to the 10 transition threads, shared snapshot-transfer work, and RocksDB background initialization/compaction threads.
This burst happens precisely during failover or rebalancing, when the server is already under pressure, and may amplify CPU, native-memory, file-descriptor, disk-I/O, and remote-storage load. Ten concurrent RocksDB initializations are also potentially expensive even when remote-log replay is not required.
Could we either:
- use a substantially more conservative default, such as 1 or 2;
- share and globally bound the remote-log recovery executor instead of creating one per bucket; or
provide a multi-bucket recovery benchmark/stress test demonstrating that the default of 10 is safe?
I think this should be evaluated before making 10 the production default. WDYT @platinumhamburg
There was a problem hiding this comment.
Makes sense. For now, we could use a more conservative default. How about setting it to 2?
|
|
||
| try { | ||
| if (replicaTransitionExecutor != null) { | ||
| ExecutorUtils.gracefulShutdown(5, TimeUnit.SECONDS, replicaTransitionExecutor); |
There was a problem hiding this comment.
ExecutorUtils.gracefulShutdown() eventually calls shutdownNow(), but queued CompletableFuture$AsyncSupply tasks returned by shutdownNow() are only discarded; their corresponding futures are not automatically completed or cancelled.
If the pool is saturated, a running transition exceeds the five-second timeout, and other transitions remain queued, makeLeaders() can stay blocked forever in CompletableFuture.allOf(...).join(). Shutting down replicaStateChangeExecutor afterwards cannot release that thread because join() is not interruptible.
Meanwhile, ReplicaManager and tablet resources continue shutting down and may race with transition workers that are still running.
Could we explicitly track and cancel the transition futures during forced shutdown, use an interruptible wait, and ensure every submitted future reaches a terminal state? Please also add a deterministic shutdown test with a one-thread transition pool, one blocked transition, and a second queued transition.
| } | ||
| return invocation.callRealMethod(); | ||
| }) | ||
| .when(spyingReplicaManager) |
There was a problem hiding this comment.
Could we avoid Mockito for test?
|
|
||
| List<CompletableFuture<NotifyLeaderAndIsrResultForBucket>> makeLeaderFutures = | ||
| new ArrayList<>(replicasToBeLeader.size()); | ||
| for (NotifyLeaderAndIsrData data : replicasToBeLeader) { |
There was a problem hiding this comment.
Can we guarantee or validate that replicasToBeLeader contains each TableBucket only once?
The normal coordinator path currently builds the request from a map, but the RPC representation is a repeated list and the TabletServer does not enforce uniqueness. Duplicate entries would schedule concurrent transitions for the same Replica.
Replica.makeLeader() is protected by its per-replica lock, but registerReplica() and the lake-snapshot refresh run before that lock, so their side effects can still race.
Please either reject duplicate buckets, deduplicate the request before submission, or serialize transition tasks by TableBucket.
There was a problem hiding this comment.
Good point, this is necessary to ensure the unique before entering the parallelize transitions
Purpose
This PR reduces the latency of replica leader transitions by processing independent buckets concurrently within a
NotifyLeaderAndIsrrequest.Previously,
ReplicaManager.makeLeaders()transitioned replicas sequentially. This increased request latency when multiple replicas became leaders together, especially for primary-key tables where each transition may initialize RocksDB by restoring a remote snapshot and replaying logs.Snapshot files within a single bucket are already downloaded concurrently through
ioExecutor. The main benefit of this change is parallelizing the bucket-level transition: independent buckets can overlap snapshot download, RocksDB initialization and replay, snapshot metadata reads, and remote-log setup.Brief change log
Introduce the
tablet-server.replica-transition-thread-numoption to control concurrency, with a default value of 10.In this first phase,
makeFollowersremains on the existing state-change executor and is not moved into the background transition pool.Tests
API and Format
Documentation