Skip to content

[server] Parallelize replica leader transitions - #3999

Open
zuston wants to merge 10 commits into
apache:mainfrom
zuston:makeleader
Open

[server] Parallelize replica leader transitions#3999
zuston wants to merge 10 commits into
apache:mainfrom
zuston:makeleader

Conversation

@zuston

@zuston zuston commented Aug 14, 2026

Copy link
Copy Markdown
Member

Purpose

This PR reduces the latency of replica leader transitions by processing independent buckets concurrently within a NotifyLeaderAndIsr request.
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-num option to control concurrency, with a default value of 10.

In this first phase, makeFollowers remains on the existing state-change executor and is not moved into the background transition pool.

Tests

API and Format

Documentation

@zuston

zuston commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

could you help review this? @fresh-borzoni

@fresh-borzoni fresh-borzoni left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@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();
}

@fresh-borzoni fresh-borzoni Aug 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

added.

@Test
void testMakeLeadersInParallel() throws Exception {
ReplicaManager spyingReplicaManager = spy(replicaManager);
replicaManager = spyingReplicaManager;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

@zuston
zuston requested a review from fresh-borzoni August 29, 2026 14:09

@fresh-borzoni fresh-borzoni left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@zuston Thank you, LGTM overall, one comment, PTAL


try {
if (replicaTransitionExecutor != null) {
ExecutorUtils.gracefulShutdown(5, TimeUnit.SECONDS, replicaTransitionExecutor);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

drain the transition pool before replicaStateChangeExecutor, since allOf().join() ignores the interrupt and the workers are what need signalling.

@zuston
zuston requested a review from fresh-borzoni August 31, 2026 14:40

@fresh-borzoni fresh-borzoni left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@zuston Thank you, LGTM 👍

cc @swuferhong

@swuferhong
swuferhong self-requested a review September 2, 2026 01:14

@swuferhong swuferhong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@zuston zuston Sep 2, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we avoid Mockito for test?


List<CompletableFuture<NotifyLeaderAndIsrResultForBucket>> makeLeaderFutures =
new ArrayList<>(replicasToBeLeader.size());
for (NotifyLeaderAndIsrData data : replicasToBeLeader) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point, this is necessary to ensure the unique before entering the parallelize transitions

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.

3 participants