shuturmurgh opened a new issue, #19611:
URL: https://github.com/apache/pinot/issues/19611

   ## Summary
   
   `BaseTableDataManager` acquires the per-segment lock and the server-wide 
`SegmentReloadSemaphore` in **opposite orders** on the refresh and reload paths:
   
   | Path | Order | Location on `master` (`7f79c65`) |
   |---|---|---|
   | `replaceSegmentInternal` → `doReplaceSegment` (refresh) | segment lock → 
semaphore | `BaseTableDataManager.java:537` then `:556` |
   | `reloadSegment` (reload) | semaphore → segment lock | 
`BaseTableDataManager.java:1106` then `:1108` |
   
   When a refresh and a reload target the same segment, this deadlocks 
permanently. Because `max.parallel.refresh.threads` defaults to **1** 
(`HelixInstanceDataManagerConfig:259`), the single permit is then held forever 
and *every* segment refresh and reload on the server stops — for all tables, 
not just the two involved. Neither acquisition has a timeout 
(`SegmentReloadSemaphore.acquire` calls bare `Semaphore.acquire()`; the reload 
path calls bare `Lock.lock()`), so the server never recovers without a restart.
   
   We hit this in production and it silently stopped all segment refresh on 
four servers for ~62 hours.
   
   ## Setup
   
   An offline job refreshes a fixed set of segments for several tables daily. 
Each run rewrites the segments under the same names with new CRCs, so every run 
triggers `SegmentRefreshMessage` for each segment.
   
   ## Deadlock timeline
   
   1. `table_B`'s segments are uploaded with modified CRCs, triggering 
refreshes. `table_B` is missing its Lucene text index in the generated 
segments, so `SegmentPreProcessor` builds it at load time — **~20 minutes per 
segment**, all of it while holding the server-wide semaphore. A backlog forms.
   
   2. `table_A`'s segments are uploaded with modified CRCs, triggering 
refreshes for `table_A_00001` … `table_A_00010`. The refresh for 
`table_A_00001` takes that segment's lock, then blocks waiting for the 
semaphore, **still holding the segment lock**.
   
   3. A table reload is issued for `table_A`. The reload for `table_A_00001` 
acquires the semaphore first, then reaches for the segment lock.
   
   4. Deadlock. The refresh holds the segment lock and waits for the permit; 
the reload holds the permit and waits for the segment lock. With one permit, 
all other queued refreshes now wait forever too, and since each occupies one of 
the 40 threads in the Helix message-handler pool 
(`HelixTaskExecutor.DEFAULT_PARALLEL_TASKS`), no refresh or reload for any 
table can start.
   
   ## Evidence
   
   ### Thread dump
   
   Refresh handler — **holds** the segment lock, waiting on the semaphore:
   
   ```
   "HelixTaskExecutor-message_handle_thread_62" daemon prio=5 waiting on 
condition
      java.lang.Thread.State: WAITING (parking)
        at jdk.internal.misc.Unsafe.park([email protected]/Native Method)
        - parking to wait for  <0x0000ffbeef659ca0> (a 
java.util.concurrent.Semaphore$FairSync)
        at 
java.util.concurrent.Semaphore.acquire([email protected]/Semaphore.java:318)
        at 
org.apache.pinot.segment.local.utils.SegmentReloadSemaphore.acquire(SegmentReloadSemaphore.java:39)
        at 
org.apache.pinot.core.data.manager.BaseTableDataManager.doReplaceSegment(BaseTableDataManager.java:481)
        at 
org.apache.pinot.core.data.manager.BaseTableDataManager.replaceSegment(BaseTableDataManager.java:464)
        at 
org.apache.pinot.server.starter.helix.HelixInstanceDataManager.replaceSegment(HelixInstanceDataManager.java:343)
        at 
org.apache.pinot.server.starter.helix.SegmentMessageHandlerFactory$SegmentRefreshMessageHandler.handleMessage(SegmentMessageHandlerFactory.java:110)
        at org.apache.helix.messaging.handling.HelixTask.call(HelixTask.java:97)
   
      Locked ownable synchronizers:
        - <0x0000ffbe9cbfea00> (a 
java.util.concurrent.locks.ReentrantLock$NonfairSync)
   ```
   
   Reload worker — **holds** the permit, waiting on that same lock 
`0x0000ffbe9cbfea00`:
   
   ```
   "segment-reload-thread-0" prio=5 waiting on condition
      java.lang.Thread.State: WAITING (parking)
        at jdk.internal.misc.Unsafe.park([email protected]/Native Method)
        - parking to wait for  <0x0000ffbe9cbfea00> (a 
java.util.concurrent.locks.ReentrantLock$NonfairSync)
        at 
java.util.concurrent.locks.ReentrantLock.lock([email protected]/ReentrantLock.java:322)
        at 
org.apache.pinot.core.data.manager.BaseTableDataManager.reloadSegment(BaseTableDataManager.java:803)
        at 
org.apache.pinot.core.data.manager.BaseTableDataManager.reloadSegment(BaseTableDataManager.java:790)
        at 
org.apache.pinot.core.data.manager.BaseTableDataManager.lambda$reloadSegments$2(BaseTableDataManager.java:747)
        at 
java.util.concurrent.CompletableFuture$AsyncRun.run([email protected]/CompletableFuture.java:1804)
   ```
   
   (Line numbers above are from the version we ran; on `master` they map to 
`doReplaceSegment:556`, `replaceSegmentInternal:537` and `reloadSegment:1108`.)
   
   Pool census — 40 idle state-transition threads, and 40 user-defined-message 
threads all blocked:
   
   | Count | State |
   |---|---|
   | 37 | refresh, blocked at `doReplaceSegment:481` on the semaphore, each 
holding one segment lock |
   | 2 | `reloadAllSegments:569` → `reloadSegments:756`, blocked on 
`CompletableFuture.get` |
   | 1 | refresh, blocked at `replaceSegment:462` on the same segment lock |
   
   Two dumps taken 21 minutes apart show **byte-identical CPU counters** for 
every participant (thread 62: `2455564.32 ms` in both) and an identical set of 
37 waiters — a hang, not slow progress.
   
   ### Semaphore queue growth
   
   ```
   10:45:12  table_B_00007  Acquired lock to reload segment (lock-time=0ms, 
queue-length=0)
   10:46:46  table_B_00009  Waiting for lock to reload, queue-length: 1
   11:04:47  table_B_00001  Waiting for lock to reload, queue-length: 8
   11:05:53  table_B_00002  Acquired lock to reload segment 
(lock-time=1205789ms, queue-length=8)
   11:23:32  table_A_00001  Waiting for lock to reload, queue-length: 8     <- 
holds its segment lock
   11:26:25  table_B_00003  Acquired lock to reload segment 
(lock-time=2378918ms, queue-length=8)
   ```
   
   ## Note: `jstack` does not report this as a deadlock
   
   `jstack -l` prints no `Found one Java-level deadlock` section. The JVM's 
detector only follows monitors and exclusively-owned AQS synchronizers; a 
`Semaphore` has no owning thread, so a cycle passing through one is invisible 
to it. This makes the bug considerably harder to attribute — the observable 
symptom is just "Helix completed-task counter flat, 40 active threads, restart 
fixes it," which is easy to misdiagnose.
   
   ## Proposed fix
   
   Make `reloadSegment` acquire the segment lock **before** the semaphore, 
matching `doReplaceSegment`. In `BaseTableDataManager.java:1106-1108`:
   
   ```java
   // current
   _segmentReloadSemaphore.acquire(segmentName, _logger);
   Lock segmentLock = getSegmentLock(segmentName);
   segmentLock.lock();
   
   // proposed
   Lock segmentLock = getSegmentLock(segmentName);
   segmentLock.lock();
   _segmentReloadSemaphore.acquire(segmentName, _logger);
   ```
   
   with the `finally` block at `:1221-1224` swapped to match:
   
   ```java
   } finally {
     _segmentReloadSemaphore.release();
     segmentLock.unlock();
   }
   ```
   
   One wrinkle: `acquire` throws `InterruptedException`, so it needs to move 
inside the existing `try` (or get its own try/finally) to guarantee the lock is 
released if the acquire is interrupted.
   
   Converging the other way — refresh taking the permit before the lock — also 
removes the cycle, but is a worse trade: both paths could then hold the single 
global permit while blocked on a fine-grained lock, converting a per-segment 
stall into a server-wide one. Holding the segment lock while throttled only 
delays that one segment.
   
   Better still would be a single helper that both paths call, so the ordering 
cannot diverge again. The bug exists because the same two-resource protocol is 
implemented independently in two places.
   
   Worth considering alongside:
   
   - Release the semaphore after download/untar and before index construction. 
It exists to bound concurrent segment *loads*, and there is already a separate 
instance-level download semaphore for deep-store throttling — in the trace 
above the permit was held 20 minutes for 12 seconds of I/O.
   - Scope the semaphore per table, or at least document that the default of 1 
makes any slow refresh a server-wide stall.
   - Timed `tryAcquire`/`tryLock` that fail the Helix message so it surfaces as 
ERROR and the controller can retry, rather than hanging indefinitely.
   - A gauge for semaphore queue depth and permit hold time. Both values are 
already printed in the existing log line (`queue-length`, `lock-time`) but are 
not exposed as metrics.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to