[ 
https://issues.apache.org/jira/browse/HBASE-30327?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Ichsan Said updated HBASE-30327:
--------------------------------
    Description: 
h3. Problem

When parallel seek is enabled 
({{hbase.storescanner.parallel.seek.enable=true}}), all StoreFileScanner seek 
operations are submitted to the {{RS_PARALLEL_SEEK}} thread pool. The pool uses 
an unbounded {{LinkedBlockingQueue}}, so submissions never block. However, when 
the pool is saturated under high concurrency:

1. All tasks queue up in the unbounded queue
2. The calling thread blocks on {{CountDownLatch.await()}} until all tasks 
complete
3. This can lead to increased latency and thread starvation

h3. Proposed Solution

Introduce an *Adaptive Parallel Seek* strategy that gracefully handles thread 
pool saturation:

1. *Check capacity before submission*: Use a conservative approach - only 
report capacity when the task queue is empty AND active threads < pool size
2. *Sequential fallback*: When capacity is 0, seek the scanner synchronously on 
the calling thread instead of queuing
3. *Opportunistic parallelization*: After each sequential seek, re-check 
capacity. If slots become available, submit remaining scanners (up to available 
capacity) for parallel execution
4. *Truly concurrent execution*: Sequential seeks on the calling thread overlap 
with submitted parallel tasks - no intermediate blocking between batches
5. *Single await at the end*: All parallel tasks share one {{CountDownLatch}}; 
the calling thread only blocks once after all submissions and sequential seeks 
are done

h3. Algorithm Flow

{code}
// Pre-count StoreFileScanners to size the shared latch
int parallelCount = count of StoreFileScanner instances in scanners

CountDownLatch latch = new CountDownLatch(parallelCount)

index = 0
while (index < scannerCount):
    capacity = getAvailableCapacity()

    if capacity == 0:
        // Pool saturated → seek inline on calling thread
        if scanner instanceof StoreFileScanner:
            scanner.seek(key)
            latch.countDown()    // treated as done inline
        else:
            scanner.seek(key)    // memstore, no latch
        index++
    else:
        // Pool has slots → submit batch, DO NOT await, continue immediately
        batchEnd = min(index + capacity, scannerCount)
        for i in [index, batchEnd):
            if StoreFileScanner: executor.submit(handler with shared latch)
            else: scanner.seek(key)   // memstore inline
        index = batchEnd
        // ← no await here, calling thread continues immediately

// Single await for all parallel tasks
latch.await()

// Check all handlers for errors
{code}

h3. Timeline Illustration

*Scenario*: 8 StoreFileScanners, pool size = 3, pool initially saturated 
(active=3)

*Before - current parallelSeek (pool saturated):*
{code}
Time 
──────────────────────────────────────────────────────────────────────────────►

Calling  │ submit P1..P8 (all queued) │                AWAIT                │ 
done
Thread   └─────────────────────────── ┴─────────────────────────────────────┘
                                                          ▲
                                             blocks until all 8 complete

Workers  │ [P1        ]│ [P4        ]│ [P7        ]│
         │ [P2        ]│ [P5        ]│ [P8        ]│
         │ [P3        ]│ [P6        ]│
{code}

*After - adaptive parallelSeek (pool saturated):*
{code}
Time 
──────────────────────────────────────────────────────────────────────────────►

Calling  │ seq  │ seq  │ submit │ seq  │ submit │ seq  │ submit │        │ done
Thread   │  S1  │  S2  │ P3,P4  │  S5  │  P6,P7 │  S8  │        │ AWAIT  │
         └──────┴──────┴────────┴──────┴────────┴──────┴────────┴────────┘
                                                                      ▲
                                                            wait once at end

Workers  │              │ [P3        ]│              │ [P6        ]│
         │              │ [P4        ]│              │ [P7        ]│
{code}

*Key insight*: When pool is saturated, adaptive seek falls back to sequential 
(S1, S2)
instead of queuing all tasks. As slots free up, remaining scanners are submitted
opportunistically (P3,P4 then P6,P7). Only one {{CountDownLatch.await()}} at 
the end.


h3. Comparison: Current vs Adaptive

|| Aspect || Current parallelSeek || Adaptive parallelSeek ||
| Pool saturated | All tasks queued, block until all done | Sequential 
fallback, no queue buildup |
| Mid-loop blocking | N/A | None - calling thread continues immediately after 
submit |
| Parallel + sequential overlap | No | Yes - truly concurrent |
| Latency under load | Spikes due to queue wait | Predictable, graceful 
degradation |
| Pool available | All parallel | All parallel (same behavior) |
| CountDownLatch.await() calls | Once | Once (shared latch across all batches) |

h3. Configuration

New configuration property:
{code:java}
hbase.storescanner.adaptive.parallel.seek.enable=false (default)
{code}

Configuration interaction:
|| parallel.seek.enable || adaptive.parallel.seek.enable || Behavior ||
| false | * | Sequential seek only |
| true | false | Existing parallel seek (current behavior) |
| true | true | Adaptive parallel seek (new) |

h3. Implementation Approach

Modify {{StoreScanner.seekScanners()}} to dispatch to new 
{{adaptiveParallelSeek()}} method when both configs are enabled. The new method:
- Pre-counts {{StoreFileScanner}} instances to size a single shared 
{{CountDownLatch}}
- Uses single loop through scanners (same pattern as existing {{parallelSeek}})
- Checks {{instanceof StoreFileScanner}} inline (same as {{parallelSeek}})
- Calls {{latch.countDown()}} inline for sequentially-seeked 
{{StoreFileScanner}} instances
- Uses {{executor.getExecutorThreadPool(ExecutorType.RS_PARALLEL_SEEK)}} only 
for capacity checking
- Uses {{executor.submit(handler)}} for task submission (same as 
{{parallelSeek}})
- Single {{latch.await()}} after the loop completes

h3. Race Condition Analysis

The capacity check is inherently racy (TOCTOU), but acceptable:
- *Over-estimation*: Tasks get queued - handled by unbounded queue, no 
correctness issue
- *Under-estimation*: Sequential seek when parallel possible - slower but 
correct
- *Multiple scanners racing*: Conservative queue-empty check mitigates runaway 
queue buildup

h3. Benefits

- Reduces latency under high concurrency (no queue blocking)
- Parallel and sequential seeks overlap - calling thread never idles between 
batches
- Single {{CountDownLatch.await()}} instead of one per batch
- Graceful degradation when pool is saturated
- Backward compatible (disabled by default)
- Minimal code change (reuses existing {{ParallelSeekHandler}} infrastructure)

h3. Known Limitations / Future Work

- No metrics for adaptive behavior monitoring (can be added in follow-up)
- Capacity check is best-effort estimate due to {{getActiveCount()}} 
approximation
- Two configuration properties required (maintaining backward compatibility)


  was:
h3. Problem

When parallel seek is enabled 
({{hbase.storescanner.parallel.seek.enable=true}}), all StoreFileScanner seek 
operations are submitted to the {{RS_PARALLEL_SEEK}} thread pool. The pool uses 
an unbounded {{LinkedBlockingQueue}}, so submissions never block. However, when 
the pool is saturated under high concurrency:

1. All tasks queue up in the unbounded queue
2. The calling thread blocks on {{CountDownLatch.await()}} until all tasks 
complete
3. This can lead to increased latency and thread starvation

h3. Proposed Solution

Introduce an *Adaptive Parallel Seek* strategy that gracefully handles thread 
pool saturation:

1. *Check capacity before submission*: Use a conservative approach - only 
report capacity when the task queue is empty AND active threads < pool size
2. *Sequential fallback*: When capacity is 0, seek the scanner synchronously on 
the calling thread instead of queuing
3. *Opportunistic parallelization*: After each sequential seek, re-check 
capacity. If slots become available, submit remaining scanners (up to available 
capacity) for parallel execution
4. *No blocking on full queue*: Avoids latency spikes from queue buildup

h3. Algorithm Flow

{code}
while (index < scannerCount):
    capacity = getAvailableCapacity()
    
    if capacity == 0:
        // Pool saturated → sequential fallback
        scanner[index].seek(key)
        index++
    else:
        // Pool has slots → parallel batch
        batchSize = min(capacity, remaining scanners)
        for each scanner in batch:
            if StoreFileScanner: submit to pool
            else: seek inline (memstore)
        latch.await()
        index += batchSize
        
check handlers for errors
{code}

h3. Timeline Illustration

*Scenario*: 8 StoreFileScanners, pool size = 3, pool initially busy (0 capacity)

{code}
Time   Pool State          Action                        Result
────   ──────────────      ─────────────────────────     ──────────────────────
T0     active=3, queue=[]  capacity=0                    Sequential: seek 
scanner[0]
T1     active=3, queue=[]  capacity=0                    Sequential: seek 
scanner[1]
T2     active=2, queue=[]  capacity=1 (slot freed!)      Parallel: submit 
scanner[2], await
T3     active=3, queue=[]  capacity=0                    Sequential: seek 
scanner[3]
T4     active=1, queue=[]  capacity=2 (2 slots freed!)   Parallel: submit 
scanner[4,5], await
T5     active=3, queue=[]  capacity=0                    Sequential: seek 
scanner[6]
T6     active=2, queue=[]  capacity=1                    Parallel: submit 
scanner[7], await
T7     done                check errors                  Return
{code}

*Key insight*: Instead of submitting all 8 scanners and blocking on a long 
queue, we adaptively mix sequential and parallel based on real-time pool 
availability.

h3. Comparison: Current vs Adaptive

|| Aspect || Current parallelSeek || Adaptive parallelSeek ||
| Pool saturated | All 8 tasks queued, block until all done | Sequential 
fallback, no queue buildup |
| Latency under load | Spikes due to queue wait | Predictable, graceful 
degradation |
| Pool available | All parallel | All parallel (same behavior) |

h3. Configuration

New configuration property:
{code:java}
hbase.storescanner.adaptive.parallel.seek.enable=false (default)
{code}

Configuration interaction:
|| parallel.seek.enable || adaptive.parallel.seek.enable || Behavior ||
| false | false | Sequential seek only |
| true | false | Existing parallel seek (current behavior) |
| true | true | Adaptive parallel seek (new) |

h3. Implementation Approach

Modify {{StoreScanner.seekScanners()}} to dispatch to new 
{{adaptiveParallelSeek()}} method when both configs are enabled. The new method:
- Uses single loop through scanners (same pattern as existing {{parallelSeek}})
- Checks {{instanceof StoreFileScanner}} inline (same as {{parallelSeek}})
- Uses {{executor.getExecutorThreadPool(ExecutorType.RS_PARALLEL_SEEK)}} only 
for capacity checking
- Uses {{executor.submit(handler)}} for task submission (same as 
{{parallelSeek}})

h3. Race Condition Analysis

The capacity check is inherently racy (TOCTOU), but acceptable:
- *Over-estimation*: Tasks get queued - handled by unbounded queue, no 
correctness issue
- *Under-estimation*: Sequential seek when parallel possible - slower but 
correct
- *Multiple scanners racing*: Conservative queue-empty check mitigates runaway 
queue buildup

h3. Benefits

- Reduces latency under high concurrency (no queue blocking)
- Graceful degradation when pool is saturated
- Backward compatible (disabled by default)
- Minimal code change (reuses existing {{ParallelSeekHandler}} infrastructure)

h3. Known Limitations / Future Work

- No metrics for adaptive behavior monitoring (can be added in follow-up)
- Capacity check is best-effort estimate due to getActiveCount() approximation
- Two configuration properties required (maintaining backward compatibility)




> Adaptive Parallel Seek: Fallback to sequential when thread pool is saturated
> ----------------------------------------------------------------------------
>
>                 Key: HBASE-30327
>                 URL: https://issues.apache.org/jira/browse/HBASE-30327
>             Project: HBase
>          Issue Type: Improvement
>          Components: regionserver, Scanners
>            Reporter: Ichsan Said
>            Priority: Major
>              Labels: Scanner, performance, scan
>
> h3. Problem
> When parallel seek is enabled 
> ({{hbase.storescanner.parallel.seek.enable=true}}), all StoreFileScanner seek 
> operations are submitted to the {{RS_PARALLEL_SEEK}} thread pool. The pool 
> uses an unbounded {{LinkedBlockingQueue}}, so submissions never block. 
> However, when the pool is saturated under high concurrency:
> 1. All tasks queue up in the unbounded queue
> 2. The calling thread blocks on {{CountDownLatch.await()}} until all tasks 
> complete
> 3. This can lead to increased latency and thread starvation
> h3. Proposed Solution
> Introduce an *Adaptive Parallel Seek* strategy that gracefully handles thread 
> pool saturation:
> 1. *Check capacity before submission*: Use a conservative approach - only 
> report capacity when the task queue is empty AND active threads < pool size
> 2. *Sequential fallback*: When capacity is 0, seek the scanner synchronously 
> on the calling thread instead of queuing
> 3. *Opportunistic parallelization*: After each sequential seek, re-check 
> capacity. If slots become available, submit remaining scanners (up to 
> available capacity) for parallel execution
> 4. *Truly concurrent execution*: Sequential seeks on the calling thread 
> overlap with submitted parallel tasks - no intermediate blocking between 
> batches
> 5. *Single await at the end*: All parallel tasks share one 
> {{CountDownLatch}}; the calling thread only blocks once after all submissions 
> and sequential seeks are done
> h3. Algorithm Flow
> {code}
> // Pre-count StoreFileScanners to size the shared latch
> int parallelCount = count of StoreFileScanner instances in scanners
> CountDownLatch latch = new CountDownLatch(parallelCount)
> index = 0
> while (index < scannerCount):
>     capacity = getAvailableCapacity()
>     if capacity == 0:
>         // Pool saturated → seek inline on calling thread
>         if scanner instanceof StoreFileScanner:
>             scanner.seek(key)
>             latch.countDown()    // treated as done inline
>         else:
>             scanner.seek(key)    // memstore, no latch
>         index++
>     else:
>         // Pool has slots → submit batch, DO NOT await, continue immediately
>         batchEnd = min(index + capacity, scannerCount)
>         for i in [index, batchEnd):
>             if StoreFileScanner: executor.submit(handler with shared latch)
>             else: scanner.seek(key)   // memstore inline
>         index = batchEnd
>         // ← no await here, calling thread continues immediately
> // Single await for all parallel tasks
> latch.await()
> // Check all handlers for errors
> {code}
> h3. Timeline Illustration
> *Scenario*: 8 StoreFileScanners, pool size = 3, pool initially saturated 
> (active=3)
> *Before - current parallelSeek (pool saturated):*
> {code}
> Time 
> ──────────────────────────────────────────────────────────────────────────────►
> Calling  │ submit P1..P8 (all queued) │                AWAIT                │ 
> done
> Thread   └─────────────────────────── ┴─────────────────────────────────────┘
>                                                           ▲
>                                              blocks until all 8 complete
> Workers  │ [P1        ]│ [P4        ]│ [P7        ]│
>          │ [P2        ]│ [P5        ]│ [P8        ]│
>          │ [P3        ]│ [P6        ]│
> {code}
> *After - adaptive parallelSeek (pool saturated):*
> {code}
> Time 
> ──────────────────────────────────────────────────────────────────────────────►
> Calling  │ seq  │ seq  │ submit │ seq  │ submit │ seq  │ submit │        │ 
> done
> Thread   │  S1  │  S2  │ P3,P4  │  S5  │  P6,P7 │  S8  │        │ AWAIT  │
>          └──────┴──────┴────────┴──────┴────────┴──────┴────────┴────────┘
>                                                                       ▲
>                                                             wait once at end
> Workers  │              │ [P3        ]│              │ [P6        ]│
>          │              │ [P4        ]│              │ [P7        ]│
> {code}
> *Key insight*: When pool is saturated, adaptive seek falls back to sequential 
> (S1, S2)
> instead of queuing all tasks. As slots free up, remaining scanners are 
> submitted
> opportunistically (P3,P4 then P6,P7). Only one {{CountDownLatch.await()}} at 
> the end.
> h3. Comparison: Current vs Adaptive
> || Aspect || Current parallelSeek || Adaptive parallelSeek ||
> | Pool saturated | All tasks queued, block until all done | Sequential 
> fallback, no queue buildup |
> | Mid-loop blocking | N/A | None - calling thread continues immediately after 
> submit |
> | Parallel + sequential overlap | No | Yes - truly concurrent |
> | Latency under load | Spikes due to queue wait | Predictable, graceful 
> degradation |
> | Pool available | All parallel | All parallel (same behavior) |
> | CountDownLatch.await() calls | Once | Once (shared latch across all 
> batches) |
> h3. Configuration
> New configuration property:
> {code:java}
> hbase.storescanner.adaptive.parallel.seek.enable=false (default)
> {code}
> Configuration interaction:
> || parallel.seek.enable || adaptive.parallel.seek.enable || Behavior ||
> | false | * | Sequential seek only |
> | true | false | Existing parallel seek (current behavior) |
> | true | true | Adaptive parallel seek (new) |
> h3. Implementation Approach
> Modify {{StoreScanner.seekScanners()}} to dispatch to new 
> {{adaptiveParallelSeek()}} method when both configs are enabled. The new 
> method:
> - Pre-counts {{StoreFileScanner}} instances to size a single shared 
> {{CountDownLatch}}
> - Uses single loop through scanners (same pattern as existing 
> {{parallelSeek}})
> - Checks {{instanceof StoreFileScanner}} inline (same as {{parallelSeek}})
> - Calls {{latch.countDown()}} inline for sequentially-seeked 
> {{StoreFileScanner}} instances
> - Uses {{executor.getExecutorThreadPool(ExecutorType.RS_PARALLEL_SEEK)}} only 
> for capacity checking
> - Uses {{executor.submit(handler)}} for task submission (same as 
> {{parallelSeek}})
> - Single {{latch.await()}} after the loop completes
> h3. Race Condition Analysis
> The capacity check is inherently racy (TOCTOU), but acceptable:
> - *Over-estimation*: Tasks get queued - handled by unbounded queue, no 
> correctness issue
> - *Under-estimation*: Sequential seek when parallel possible - slower but 
> correct
> - *Multiple scanners racing*: Conservative queue-empty check mitigates 
> runaway queue buildup
> h3. Benefits
> - Reduces latency under high concurrency (no queue blocking)
> - Parallel and sequential seeks overlap - calling thread never idles between 
> batches
> - Single {{CountDownLatch.await()}} instead of one per batch
> - Graceful degradation when pool is saturated
> - Backward compatible (disabled by default)
> - Minimal code change (reuses existing {{ParallelSeekHandler}} infrastructure)
> h3. Known Limitations / Future Work
> - No metrics for adaptive behavior monitoring (can be added in follow-up)
> - Capacity check is best-effort estimate due to {{getActiveCount()}} 
> approximation
> - Two configuration properties required (maintaining backward compatibility)



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to