Skip to content

Commit 8371eae

Browse files
authored
HBASE-29974: Persist filter hints across scan circuit breaks (#7882) (#8464)
Generated-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> (cherry picked from commit 21d3fdf) Signed-off-by: Viraj Jasani <vjasani@apache.org>
1 parent 6259535 commit 8371eae

7 files changed

Lines changed: 1161 additions & 2 deletions

File tree

hbase-client/src/main/java/org/apache/hadoop/hbase/filter/Filter.java

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
* <li>{@link #reset()} : reset the filter state before filtering a new row.</li>
3131
* <li>{@link #filterAllRemaining()}: true means row scan is over; false means keep going.</li>
3232
* <li>{@link #filterRowKey(Cell)}: true means drop this row; false means include.</li>
33+
* <li>{@link #getHintForRejectedRow(Cell)}: if {@code filterRowKey} returned true, optionally
34+
* provide a seek hint to skip past the rejected row efficiently.</li>
35+
* <li>{@link #getSkipHint(Cell)}: when a cell is structurally skipped (time-range, column, or
36+
* version gate) before {@code filterCell} is reached, optionally provide a seek hint.</li>
3337
* <li>{@link #filterCell(Cell)}: decides whether to include or exclude this Cell. See
3438
* {@link ReturnCode}.</li>
3539
* <li>{@link #transformCell(Cell)}: if the Cell is included, let the filter transform the Cell.
@@ -219,6 +223,85 @@ public enum ReturnCode {
219223
*/
220224
abstract public Cell getNextCellHint(final Cell currentCell) throws IOException;
221225

226+
/**
227+
* Provides a seek hint to bypass row-by-row scanning after {@link #filterRowKey(Cell)} rejects a
228+
* row. When {@code filterRowKey} returns {@code true} the scan pipeline would normally iterate
229+
* through every remaining cell in the rejected row one-by-one (via {@code nextRow()}) before
230+
* moving on. If the filter can determine a better forward position — for example, the next range
231+
* boundary in a {@code MultiRowRangeFilter} — it should return that target cell here, allowing
232+
* the scanner to seek directly past the unwanted rows.
233+
* <p>
234+
* Contract:
235+
* <ul>
236+
* <li>Only called after {@link #filterRowKey(Cell)} has returned {@code true} for the same
237+
* {@code firstRowCell}.</li>
238+
* <li>Implementations may use state that was set during {@link #filterRowKey(Cell)} (e.g. an
239+
* updated range pointer), but <strong>must not</strong> invoke {@link #filterCell(Cell)} logic —
240+
* the caller guarantees that {@code filterCell} has not been called for this row.</li>
241+
* <li>The returned {@link Cell}, if non-null, must be an
242+
* {@link org.apache.hadoop.hbase.ExtendedCell} because filters are evaluated on the server
243+
* side.</li>
244+
* <li>Returning {@code null} (the default) falls through to the existing {@code nextRow()}
245+
* behaviour, preserving full backward compatibility.</li>
246+
* <li>For reversed scans ({@link org.apache.hadoop.hbase.client.Scan#isReversed()}), the hint
247+
* must point to a <em>smaller</em> row key (earlier in reverse-scan direction). The scanner
248+
* validates hint direction and falls back to {@code nextRow()} if the hint does not advance in
249+
* the scan direction.</li>
250+
* <li><strong>Composite filter limitation:</strong> {@code FilterList}, {@code SkipFilter}, and
251+
* {@code WhileMatchFilter} do not currently delegate this method to wrapped sub-filters. Hints
252+
* from filters used inside these wrappers will be silently ignored.</li>
253+
* </ul>
254+
* @param firstRowCell the first cell encountered in the rejected row; contains the row key that
255+
* was passed to {@code filterRowKey}
256+
* @return a {@link Cell} representing the earliest position the scanner should seek to, or
257+
* {@code null} if this filter cannot provide a better position than a sequential skip
258+
* @throws IOException in case an I/O or filter-specific failure needs to be signaled
259+
* @see #filterRowKey(Cell)
260+
*/
261+
public Cell getHintForRejectedRow(final Cell firstRowCell) throws IOException {
262+
return null;
263+
}
264+
265+
/**
266+
* Provides a seek hint for cells that are structurally skipped by the scan pipeline
267+
* <em>before</em> {@link #filterCell(Cell)} is ever reached. The pipeline short-circuits on
268+
* several criteria — time-range mismatch, column-set exclusion, and version-limit exhaustion —
269+
* and in each case the filter is bypassed entirely. When an implementation can compute a
270+
* meaningful forward position purely from the cell's coordinates (without needing the
271+
* {@code filterCell} call sequence), it should return that position here so the scanner can seek
272+
* ahead instead of advancing one cell at a time.
273+
* <p>
274+
* Contract:
275+
* <ul>
276+
* <li>May be called for cells that have <strong>never</strong> been passed to
277+
* {@link #filterCell(Cell)}.</li>
278+
* <li>Implementations <strong>must not</strong> modify any filter state; this method is treated
279+
* as logically stateless. Only filters whose hint computation is based solely on immutable
280+
* configuration (e.g. a fixed column range or a fuzzy-row pattern) should override this.</li>
281+
* <li>The returned {@link Cell}, if non-null, must be an
282+
* {@link org.apache.hadoop.hbase.ExtendedCell} because filters are evaluated on the server
283+
* side.</li>
284+
* <li>Returning {@code null} (the default) falls through to the existing structural skip/seek
285+
* behaviour, preserving full backward compatibility.</li>
286+
* <li>For reversed scans, the returned cell must have a <em>smaller</em> row key (i.e., earlier
287+
* in reverse-scan direction) than the {@code skippedCell}. Hints that do not advance in the scan
288+
* direction are silently ignored.</li>
289+
* <li><strong>Composite filter limitation:</strong> {@code FilterList}, {@code SkipFilter}, and
290+
* {@code WhileMatchFilter} do not currently delegate this method to wrapped sub-filters. Hints
291+
* from filters used inside these wrappers will be silently ignored.</li>
292+
* </ul>
293+
* @param skippedCell the cell that was rejected by the time-range, column, or version gate before
294+
* {@code filterCell} could be consulted
295+
* @return a {@link Cell} representing the earliest position the scanner should seek to, or
296+
* {@code null} if this filter cannot provide a better position than the structural hint
297+
* @throws IOException in case an I/O or filter-specific failure needs to be signaled
298+
* @see #filterCell(Cell)
299+
* @see #getNextCellHint(Cell)
300+
*/
301+
public Cell getSkipHint(final Cell skippedCell) throws IOException {
302+
return null;
303+
}
304+
222305
/**
223306
* Check that given column family is essential for filter to check row. Most filters always return
224307
* true here. But some could have more sophisticated logic which could significantly reduce

hbase-client/src/main/java/org/apache/hadoop/hbase/filter/FilterBase.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,28 @@ public Cell getNextCellHint(Cell currentCell) throws IOException {
112112
return null;
113113
}
114114

115+
/**
116+
* Filters that cannot provide a seek hint after row-key rejection can inherit this no-op
117+
* implementation. Subclasses whose row-key logic (e.g. a range pointer advanced inside
118+
* {@link #filterRowKey(Cell)}) makes a better seek target available should override this.
119+
* {@inheritDoc}
120+
*/
121+
@Override
122+
public Cell getHintForRejectedRow(Cell firstRowCell) throws IOException {
123+
return null;
124+
}
125+
126+
/**
127+
* Filters that cannot provide a structural-skip seek hint can inherit this no-op implementation.
128+
* Subclasses with purely configuration-driven, stateless hint computation (e.g. a fixed column
129+
* range or fuzzy-row pattern) may override this to avoid cell-by-cell advancement when the
130+
* time-range, column, or version gate fires. {@inheritDoc}
131+
*/
132+
@Override
133+
public Cell getSkipHint(Cell skippedCell) throws IOException {
134+
return null;
135+
}
136+
115137
/**
116138
* By default, we require all scan's column families to be present. Our subclasses may be more
117139
* precise. {@inheritDoc}

hbase-server/src/main/java/org/apache/hadoop/hbase/filter/FilterWrapper.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,26 @@ public Cell getNextCellHint(Cell currentCell) throws IOException {
9393
return this.filter.getNextCellHint(currentCell);
9494
}
9595

96+
/**
97+
* Delegates to the wrapped filter's {@link Filter#getHintForRejectedRow(Cell)} so that the scan
98+
* pipeline can seek directly past a rejected row rather than iterating cell-by-cell via
99+
* {@code nextRow()}. {@inheritDoc}
100+
*/
101+
@Override
102+
public Cell getHintForRejectedRow(Cell firstRowCell) throws IOException {
103+
return this.filter.getHintForRejectedRow(firstRowCell);
104+
}
105+
106+
/**
107+
* Delegates to the wrapped filter's {@link Filter#getSkipHint(Cell)} so that the scan pipeline
108+
* can seek ahead when a cell is structurally skipped before {@code filterCell} is reached.
109+
* {@inheritDoc}
110+
*/
111+
@Override
112+
public Cell getSkipHint(Cell skippedCell) throws IOException {
113+
return this.filter.getSkipHint(skippedCell);
114+
}
115+
96116
@Override
97117
public boolean filterRowKey(byte[] buffer, int offset, int length) throws IOException {
98118
// No call to this.

hbase-server/src/main/java/org/apache/hadoop/hbase/regionserver/RegionScannerImpl.java

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class RegionScannerImpl implements RegionScanner, Shipper, RpcCallback {
8181

8282
protected final byte[] stopRow;
8383
protected final boolean includeStopRow;
84+
protected final boolean reversed;
8485
protected final HRegion region;
8586
protected final CellComparator comparator;
8687

@@ -123,6 +124,7 @@ private static boolean hasNonce(HRegion region, long nonce) {
123124
defaultScannerContext = ScannerContext.newBuilder().setBatchLimit(scan.getBatch()).build();
124125
this.stopRow = scan.getStopRow();
125126
this.includeStopRow = scan.includeStopRow();
127+
this.reversed = scan.isReversed();
126128
this.operationId = scan.getId();
127129

128130
// synchronize on scannerReadPoints so that nobody calculates
@@ -492,11 +494,18 @@ private boolean nextInternal(List<Cell> results, ScannerContext scannerContext)
492494
if (isFilterDoneInternal()) {
493495
return scannerContext.setScannerState(NextState.NO_MORE_VALUES).hasMoreValues();
494496
}
497+
// HBASE-29974: ask the filter for a seek hint so we can jump directly past the rejected
498+
// row instead of iterating through its cells one-by-one via nextRow().
499+
Cell rowHint = getHintForRejectedRow(current);
495500
// Typically the count of rows scanned is incremented inside #populateResult. However,
496501
// here we are filtering a row based purely on its row key, preventing us from calling
497-
// #populateResult. Thus, perform the necessary increment here to rows scanned metric
502+
// #populateResult. Thus, perform the necessary increment here to rows scanned metric.
503+
// Placed after getHintForRejectedRow so that a buggy filter throwing DNRIOE doesn't
504+
// leave the metric incremented for a row that was never actually processed.
498505
incrementCountOfRowsScannedMetric(scannerContext);
499-
boolean moreRows = nextRow(scannerContext, current);
506+
boolean moreRows = (rowHint != null)
507+
? nextRowViaHint(scannerContext, current, rowHint)
508+
: nextRow(scannerContext, current);
500509
if (!moreRows) {
501510
return scannerContext.setScannerState(NextState.NO_MORE_VALUES).hasMoreValues();
502511
}
@@ -721,6 +730,76 @@ protected boolean nextRow(ScannerContext scannerContext, Cell curRowCell) throws
721730
|| this.region.getCoprocessorHost().postScannerFilterRow(this, curRowCell);
722731
}
723732

733+
/**
734+
* Fast-path alternative to {@link #nextRow} used when the filter has provided a seek hint via
735+
* {@link org.apache.hadoop.hbase.filter.Filter#getHintForRejectedRow(Cell)}. Instead of iterating
736+
* through every cell in the rejected row one-by-one, this method issues a single seek to jump
737+
* directly to the filter's suggested position ({@code requestSeek} for forward scans,
738+
* {@code backwardSeek} for reversed scans).
739+
* <p>
740+
* The filter state is reset after the seek so the next row starts with a clean filter context.
741+
* <p>
742+
* <strong>Stop-row invariant:</strong> This method does not validate that {@code hint} falls
743+
* within the scan's stop row. If the hint overshoots, the next iteration's
744+
* {@link #shouldStop(Cell)} check catches it and returns NO_MORE_VALUES. One wasted seek may
745+
* occur, but correctness is maintained.
746+
* <p>
747+
* <strong>Metrics note:</strong> The rows-scanned metric is incremented once by the caller for
748+
* the rejected row. Rows physically skipped by the seek are not individually counted — this
749+
* reflects the fact that no per-row work was done for those rows.
750+
* <p>
751+
* <strong>Coprocessor note:</strong> {@code postScannerFilterRow} is invoked once with
752+
* {@code curRowCell}, not once per skipped row. Coprocessors counting filtered rows should be
753+
* aware of this semantic when the hint path is used.
754+
* @param scannerContext scanner context used for limit tracking
755+
* @param curRowCell the first cell of the row that was rejected by {@code filterRowKey};
756+
* passed to the coprocessor hook for observability
757+
* @param hint the {@link Cell} returned by the filter; the scanner will seek to this
758+
* position
759+
* @return {@code true} if scanning should continue, {@code false} if a coprocessor requests an
760+
* early stop (mirrors the contract of {@link #nextRow})
761+
* @throws IOException if the seek or the coprocessor hook signals a failure
762+
*/
763+
private boolean nextRowViaHint(ScannerContext scannerContext, Cell curRowCell, Cell hint)
764+
throws IOException {
765+
assert this.joinedContinuationRow == null : "Trying to go to next row during joinedHeap read.";
766+
767+
int difference = comparator.compareRows(hint, curRowCell);
768+
if ((!reversed && difference > 0) || (reversed && difference < 0)) {
769+
if (reversed) {
770+
// ReversedKeyValueHeap does not support requestSeek; use backwardSeek
771+
// to position at-or-before the hint within the target row.
772+
// seekToPreviousRow would skip past the hint row entirely.
773+
this.storeHeap.backwardSeek(hint);
774+
} else {
775+
this.storeHeap.requestSeek(hint, true, true);
776+
}
777+
778+
resetFilters();
779+
780+
return this.region.getCoprocessorHost() == null
781+
|| this.region.getCoprocessorHost().postScannerFilterRow(this, curRowCell);
782+
}
783+
784+
return nextRow(scannerContext, curRowCell);
785+
}
786+
787+
/**
788+
* Asks the current {@link org.apache.hadoop.hbase.filter.FilterWrapper} for a seek hint to use
789+
* after a row has been rejected by {@link #filterRowKey}. If the wrapped filter overrides
790+
* {@link org.apache.hadoop.hbase.filter.Filter#getHintForRejectedRow(Cell)}, this returns its
791+
* answer; otherwise returns {@code null}.
792+
* @param rowCell the first cell of the rejected row (same cell passed to {@code filterRowKey})
793+
* @return a {@link Cell} seek target, or {@code null} if the filter provides no hint
794+
* @throws IOException if the filter signals an I/O failure
795+
*/
796+
private Cell getHintForRejectedRow(Cell rowCell) throws IOException {
797+
if (filter == null) {
798+
return null;
799+
}
800+
return filter.getHintForRejectedRow(rowCell);
801+
}
802+
724803
protected boolean shouldStop(Cell currentRowCell) {
725804
if (currentRowCell == null) {
726805
return true;

0 commit comments

Comments
 (0)