Skip to content

[Do not Merge] Skip channel lock when channel is active#137

Draft
adasari wants to merge 3 commits into
masterfrom
adasari/lock-wait
Draft

[Do not Merge] Skip channel lock when channel is active#137
adasari wants to merge 3 commits into
masterfrom
adasari/lock-wait

Conversation

@adasari

@adasari adasari commented Feb 20, 2026

Copy link
Copy Markdown
Contributor

Under high query concurrency, broker queries to PremiumProfileEngagements (and similar large tables) exhibited SubmitDelayMs values of 126–174ms across 30 servers, contributing to a total query latency of 503ms against a table with 940 segments. See broker log below.

requestId=523646417000000000,table=CatagegoryEngagements,timeMs=503,docs=98/37660979621,
entries=5/294,segments(queried/processed/matched/consumingQueried/consumingProcessed/consumingMatched/unavailable):940/171/92/0/0/0/0,consumingFreshnessTimeMs=0,servers=30/30,groupLimitReached=false,groupWarningLimitReached=false,brokerReduceTimeMs=39,exceptions=0,
serverStats=(Server=SubmitDelayMs,ResponseDelayMs,ResponseSize,DeserializationTimeMs,RequestSentDelayMs);
host108764_O=126,58,550,0,40;host138853_O=129,51,558,5,35;host11316_O=131,49,526,7,33;
host102773_O=132,52,470,0,33;host97796_O=135,45,562,8,29;host171036_O=137,43,529,6,30;
host122403_O=138,46,525,0,27;host71863_O=140,40,499,4,26;host12775_O=142,38,529,4,25;
host167278_O=144,36,527,5,23;host112464_O=146,34,555,4,21;host114041_O=149,35,556,0,18;
host122805_O=151,29,470,7,15;host90525_O=154,26,533,4,14;host106410_O=154,30,497,0,12;
host107062_O=154,30,497,0,13;host112483_O=156,24,471,5,11;host122383_O=156,28,529,0,11;
host128479_O=158,22,530,4,10;host97174_O=159,21,558,4,9;host105347_O=160,24,468,0,7;
host122546_O=162,18,500,5,8;host122426_O=164,17,497,0,9;host159703_O=166,15,496,0,7;
host85167_O=168,15,579,0,7;host136918_O=170,15,528,0,7;host92306_O=171,15,470,0,8;
host107009_O=172,18,500,0,12;host112478_O=172,13,412,0,12;host122692_O=174,14,501,0,7,
offlineThreadCpuTimeNs(total/thread/sysActivity/resSer):521574043/458956502/62267416/350125,
realtimeThreadCpuTimeNs(total/thread/sysActivity/resSer):0/0/0/0,clientIp=unknown,queryEngine=singleStage,
offlineMemAllocatedBytes(total/thread/resSer):1044893320/1044829984/63336,
realtimeMemAllocatedBytes(total/thread/resSer):0/0/0,replicaGroups=[0],workloadName=default,
query=<query>

Root cause:
ServerChannel maintains a single Netty Channel per server, protected by a ReentrantLock
The lock is necessary to make the check-reconnect-write sequence atomic i.e two threads must not both see _channel == null and simultaneously attempt a TCP reconnect.
Potential reasons for high SubmitDelayMs:

  1. Channel Lock Contention
  2. Sequential scatter loop i.e requests to servers submitted sequentially
    Improving sequential scatter loop is out of scope.

Options evaluated for the Lock:

  1. Option A: volatile _channel + narrow reconnect lock
  2. Option B: ReadWriteLock i.e Replace ReentrantLock with ReentrantReadWriteLock - shared read lock for sends (concurrent), exclusive write lock for reconnect.

Option A has been implemented in this PR, as reconnects are rare events and this approach is more efficient than using a ReadWriteLock under such conditions.

Future enhancements:

  • Introduce Netty’s IdleStateHandler to automatically reconnect the channel, ensuring it is always active when a query arrives. This would make reconnection under lock a rare edge case rather than a routine occurrence.

Benchmark results and Summary (Generated using Claude):

Setup

  • 8 threads per group, Throughput mode, ops/s
  • Warmup: 2 × 5 s | Measurement: 3 × 3 iterations (Cnt = 9)
  • Two scenarios: hot path (all senders, channel always active) and mixed (7 senders + 1 disconnector)

Raw Results

Benchmark Role Cnt Score (ops/s) Error ± (ops/s)
alwaysLock 8 senders, always lock 9 435,486 15,272
volatileCheck 8 senders, volatile fast-path 9 2,216,330,630 258,555,041
alwaysLockMixed (group total) 7 senders + 1 disconnector, always lock 9 428,039 21,880
alwaysLockMixed — sender 7 senders 9 374,541 19,148
alwaysLockMixed — disconnector 1 disconnector 9 53,497 2,732
volatileCheckMixed (group total) 7 senders + 1 disconnector, volatile fast-path 9 721,670,844 103,605,009
volatileCheckMixed — sender 7 senders 9 710,215,957 107,797,550
volatileCheckMixed — disconnector 1 disconnector 9 11,454,886 4,417,767

Head-to-Head Comparison

Scenario Role alwaysLock (ops/s) volatileCheck (ops/s) Speedup
Hot path (no reconnects) All senders 435,486 2,216,330,630 ~5,089x
Mixed (with reconnects) Sender 374,541 710,215,957 ~1,896x
Mixed (with reconnects) Disconnector 53,497 11,454,886 ~214x
Mixed (with reconnects) Group total 428,039 721,670,844 ~1,686x

Stability (Coefficient of Variation)

Benchmark Score (ops/s) Error ± (ops/s) CV Stable?
alwaysLock 435,486 15,272 3.5% Yes
alwaysLockMixed — sender 374,541 19,148 5.1% Yes
alwaysLockMixed — disconnector 53,497 2,732 5.1% Yes
volatileCheck 2,216,330,630 258,555,041 11.7% Yes
volatileCheckMixed — sender 710,215,957 107,797,550 15.2% Yes
volatileCheckMixed — disconnector 11,454,886 4,417,767 38.6% Noisy

Summary

Hot Path (~5,089x improvement)

When the channel stays active (steady-state production traffic), the optimized path replaces a
ReentrantLock.lock()/unlock() pair on every sendRequest call with a single volatile field
read. Under 8-thread contention, lock acquisition requires a CAS loop and full memory barrier —
extremely expensive relative to the actual write work. The volatile read costs nearly nothing by
comparison, yielding a ~5,000x throughput gain.

Mixed Scenario (~1,896x sender improvement)

With 7 concurrent senders and 1 thread simulating periodic disconnects, senders still take the
fast path the vast majority of the time (channel is active between reconnects). Sender throughput
improves by ~1,896x. A secondary benefit: because senders no longer compete for the lock,
the disconnector itself faces far less contention and its throughput rises ~214x as a side effect,
even though its code path is unchanged.

Variance

alwaysLock variants are highly stable (CV < 6%) — lock serialization naturally smooths variance.
volatileCheckMixed disconnector shows elevated variance (CV ~39%) because each reconnect briefly
causes a burst of senders to fall through to the slow path, creating a short-lived lock storm.
In production this is negligible given the low frequency of actual channel reconnects.

Testing:

  1. Unit tests
  2. (Dev test is pending)

@adasari adasari self-assigned this Feb 20, 2026
@adasari adasari force-pushed the adasari/lock-wait branch from 1d7acc9 to b397e9d Compare March 2, 2026 05:39
@siddharthteotia

siddharthteotia commented Mar 10, 2026

Copy link
Copy Markdown
Collaborator

Nice finding! Thanks for proposing the improvement.

Just curious if we also have any cpu profiles from the time you observed this problem ?

@siddharthteotia

siddharthteotia commented Mar 10, 2026

Copy link
Copy Markdown
Collaborator

Can we please add tests ?

[Code Review via Claude Code]


PR #137 Review — Skip channel lock when channel is active

Overview

The PR addresses broker-to-server SubmitDelayMs of 126–174ms under high query concurrency. Root cause: every query thread was acquiring a ReentrantLock on ServerChannel even when the channel was healthy and just needed a writeAndFlush. The fix introduces an optimistic fast path: skip the lock entirely when the channel is already active, and only lock for the rare reconnect case. Two commits: the first introduces the fast path, the second (TOCTOU fix) captures the channel as a local snapshot before returning it.


Correctness

The core approach is sound. This is a classic "optimistic check / lock on miss" pattern. It is valid here because:

  1. Netty's Channel.writeAndFlush() is thread-safe — multiple threads can call it concurrently on the same channel. The original comment (lock to protect channel as requests must be written into channel sequentially) is incorrect; the lock was never needed for write ordering. It was needed only for the check-reconnect-assign sequence, which the PR correctly preserves.

  2. The TOCTOU fix in commit 2 is correct and important. Taking Channel channel = _channel as a snapshot before the isActive() check means we write to the same channel object we tested — even if _channel field is reassigned by another thread after we return.

  3. The double-check inside connectWithoutLocking() (if (_channel == null || !_channel.isActive())) is correct — threads queued behind the reconnect leader will find the channel already active and skip the TCP connect.

Potential concern — window between ensureConnected and sendRequestWithoutLocking:

Channel channel = ensureConnected(timeoutMs);  // channel is active here
// ... channel could go inactive here ...
sendRequestWithoutLocking(channel, ...);        // writes to possibly dead channel

If the channel dies in this window, writeAndFlush fails silently — the query just times out. This is a pre-existing race (the old code had the same window between connectWithoutLocking() and sendRequestWithoutLocking(), just inside the lock). The PR doesn't make it worse. But it's worth noting that write failures from the listener are not currently surfaced — f.isSuccess() is never checked.


Issues

1. Stale field comment (line 156)

// lock to protect channel as requests must be written into channel sequentially
final ReentrantLock _channelLock = new ReentrantLock();

This comment is now wrong. The lock does not serialize writes — Netty channels accept concurrent writes. It serializes the reconnect sequence. Should be updated to reflect the new semantics, e.g.:

// Lock to serialize reconnect: prevents two threads from simultaneously seeing _channel == null
// and each initiating a TCP reconnect. Normal sends bypass this lock entirely.

2. tryLock(timeoutMs, ...) in slow path mixes two different timeouts

if (_channelLock.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) {

timeoutMs is the query timeout (e.g. 10 s or 30 s). Using it as the lock-acquisition deadline means a thread can block waiting for the reconnect lock for the entire query lifetime. The connect() method already has a dedicated constant TRY_CONNECT_CHANNEL_LOCK_TIMEOUT_MS = 5_000L for exactly this purpose. Consider capping it:

long lockTimeoutMs = Math.min(timeoutMs, TRY_CONNECT_CHANNEL_LOCK_TIMEOUT_MS);
if (_channelLock.tryLock(lockTimeoutMs, TimeUnit.MILLISECONDS)) {

This is pre-existing behavior, but now that the slow path is a distinct code path it's more visible.

3. Write failure not surfaced to the query

In sendRequestWithoutLocking, the write listener doesn't check f.isSuccess():

channel.writeAndFlush(...).addListener(f -> {
    // f.isSuccess() never checked
    asyncQueryResponse.markRequestSent(serverRoutingInstance, requestSentLatencyMs);
});

If the channel dies right after the fast-path check, the write fails silently and the query eventually times out without a clear error. Consider checking f.isSuccess() and calling asyncQueryResponse.markRequestFailed(...) or similar on failure. Pre-existing issue, but this PR makes the failure window slightly larger (since the old code sent inside the lock while holding a connected channel).

4. No tests

PR notes "Testing: (Pending)". At minimum, a concurrent test that spawns N threads hitting sendRequest simultaneously on a single ServerChannel with an active channel would validate that:

  • No request is lost
  • No NPE/race on _channel
  • Lock contention does not occur in the fast path

Minor

  • sendRequestWithoutLocking is package-private. Since it's only called from sendRequest, and the Channel parameter it now takes is an implementation detail, consider making it private (unless tests in the same package depend on it).
  • The ensureConnected Javadoc comment says "guaranteed to have been active at check time" which is accurate but could add: "caller must handle the case where the channel becomes inactive before the write completes."

Summary

Category Assessment
Core optimization correctness ✅ Valid — Netty channels support concurrent writes
TOCTOU fix (commit 2) ✅ Correct and necessary
Reconnect serialization ✅ Preserved correctly under lock
Stale comment on _channelLock ⚠️ Should be updated
Lock timeout semantics ⚠️ Minor — consider TRY_CONNECT_CHANNEL_LOCK_TIMEOUT_MS cap
Write failure handling ⚠️ Pre-existing gap, slightly more exposed now
Tests ❌ Pending — needed before merge

@adasari

adasari commented Mar 12, 2026

Copy link
Copy Markdown
Contributor Author

Nice finding! Thanks for proposing the improvement.

Just curious if we also have any cpu profiles from the time you observed this problem ?

No. High SubmitDelayMs noticed from logs.

@adasari adasari force-pushed the adasari/lock-wait branch from 01b5c47 to e78fd42 Compare March 12, 2026 23:03
@adasari adasari force-pushed the adasari/lock-wait branch from e0379cf to f7a2f64 Compare March 14, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants