Skip to content

HBASE-27691 Prevent filters from seeing synthetic scan start cells - #8485

Merged
junegunn merged 6 commits into
apache:masterfrom
noslowerdna:HBASE-27691
Aug 7, 2026
Merged

HBASE-27691 Prevent filters from seeing synthetic scan start cells#8485
junegunn merged 6 commits into
apache:masterfrom
noslowerdna:HBASE-27691

Conversation

@noslowerdna

@noslowerdna noslowerdna commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

HBASE-27691

What changes were proposed in this pull request?

This patch disables StoreScanner's initial lazy seek for filtered non-Get scans. Doing so prevents synthetic lazy-seek Cells from being exposed to server-side Filters and Comparators, while retaining lazy seeking for unfiltered Scans and Gets.

Why are the changes needed?

A region start boundary is not guaranteed to be a valid application row key. Passing its synthetic Cell to a RowFilter comparator violates the Filter contract that filterRowKey receives the first actual Cell of a row and can result in unexpected exceptions when Filter / Comparator code attempts to parse a seemingly truncated or otherwise malformed row key.

In our case that manifested like this:

Caused by: java.lang.RuntimeException: java.io.EOFException
	at com.package.MyComparator.compareTo(MyComparator.java:133)
	at org.apache.hadoop.hbase.PrivateCellUtil.compareRow(PrivateCellUtil.java:1240)
	at org.apache.hadoop.hbase.filter.CompareFilter.compareRow(CompareFilter.java:148)
	at org.apache.hadoop.hbase.filter.RowFilter.filterRowKey(RowFilter.java:90)
	at org.apache.hadoop.hbase.filter.FilterListWithOR.filterRowKey(FilterListWithOR.java:345)
	at org.apache.hadoop.hbase.filter.FilterList.filterRowKey(FilterList.java:152)
	at org.apache.hadoop.hbase.filter.FilterListWithAND.filterRowKey(FilterListWithAND.java:227)
	at org.apache.hadoop.hbase.filter.FilterList.filterRowKey(FilterList.java:152)
	at org.apache.hadoop.hbase.filter.FilterWrapper.filterRowKey(FilterWrapper.java:108)
	at org.apache.hadoop.hbase.regionserver.HRegion$RegionScannerImpl.filterRowKey(HRegion.java:7545)
	at org.apache.hadoop.hbase.regionserver.HRegion$RegionScannerImpl.nextInternal(HRegion.java:7361)
	at org.apache.hadoop.hbase.regionserver.HRegion$RegionScannerImpl.nextRaw(HRegion.java:7153)
	at org.apache.hadoop.hbase.regionserver.RSRpcServices.scan(RSRpcServices.java:3330)
	...

This change reinstates the protection intended by HBASE-6562, using its later proposed (unmerged) eager initial seek patch.

Are there any concerns?

First let's provide some background context.

How does lazy seeking work, and why is its design problematic?

Lazy seeking uses synthetic Cells as part of the scanner's control flow. They are essentially placeholders indicating "start looking from here." A problem with this design is that before a real Cell has been read from a StoreFile, this placeholder is made visible to a filter with no supplemental context to be able to differentiate it as such. A filter has no definitive means to distinguish a synthetic Cell from a real one, so it may attempt to unsafely process the row key (decoding it, comparing it, using the value to decide to end the scan, etc). Filter or comparator code therefore can be required to have overly broad exception handling.

What is the proposal for fixing this design problem?

To elaborate on what was stated above, this patch ensures a filtered non-Get scan reads a real first Cell before invoking the filter. The correction is limited to this specific case where correctness requires that filters see a real Cell. Scans without explicit columns already do an eager initial seek. Unfiltered scans, Gets, and subsequent post-initialization repositioning still retain the existing lazy seek behavior.

What are the risks of making this correction?

A known risk of making this correction is potentially unnecessary work done at the beginning of the scan. A StoreScanner is the scanner for one column family in one region scan. On opening it, HBase may have multiple underlying scanners, one for each StoreFile plus one for the Region Server MemStore. Previously with an explicit-column scan, HBase could tell each underlying scanner "don't seek yet - wait until I know you are needed." The patch instead says that filtered non-Get scans must "seek now, so the first Cell I present to the filter is real." If a column family has many StoreFiles, each selected StoreFile scanner may do an HFile index lookup and potentially read blocks to position itself. The MemStore scanner is also positioned. The cost is proportional to the number of underlying scanners opened, not the number of rows returned. That overhead can materially matter for short filtered scans over stores with many files if the scan would otherwise have avoided touching some of them.

Is there a better way to fix this?

I have been unable to determine a more appropriate way to address this and am open to ideas. Deeper API design changes could involve enhancing the Filter abstract class or Cell interface and do not appear advisable for such a narrow problem surface area, as that would introduce major risks.

How was this patch tested?

A ROWCOL Bloom regression test verifies that a Comparator sees only the persisted row, not a synthetic region-boundary Cell. Focused tests also cover each lazy-seek decision branch.

mvn -ntp -pl hbase-server -am \
  -Dtest=TestScanner \
  -Dsurefire.failIfNoSpecifiedTests=false \
  test

@noslowerdna

Copy link
Copy Markdown
Contributor Author

@apurtell @virajjasani @Apache9 Would appreciate a review when you have a moment.

@noslowerdna

Copy link
Copy Markdown
Contributor Author

@junegunn Would appreciate a review when you have a moment. Thanks!

@junegunn

junegunn commented Jul 31, 2026

Copy link
Copy Markdown
Member

I could reproduce the problem locally:

java_import org.apache.hadoop.hbase.CompareOperator
java_import org.apache.hadoop.hbase.filter.BinaryComparator
java_import org.apache.hadoop.hbase.filter.RowFilter
java_import org.apache.hadoop.hbase.filter.WhileMatchFilter

create 't', 'd'
put 't', 'row1', 'd:foo', 'bar'
put 't', 'row2', 'd:foo', 'bar'
put 't', 'row3', 'd:foo', 'bar'
flush 't'

# 3 rows
scan 't', FILTER => WhileMatchFilter.new(RowFilter.new(CompareOperator::NOT_EQUAL,
                                                       BinaryComparator.new(''.to_java_bytes)))

# no rows
scan 't', FILTER => WhileMatchFilter.new(RowFilter.new(CompareOperator::NOT_EQUAL,
                                                       BinaryComparator.new(''.to_java_bytes))),
          COLUMNS => ['d:foo']

The history behind this issue is quite involved, so I don't feel confident making a judgment call on my own. From what I understand, this patch was first suggested by Lars Hofhansl in early 2013:

He raised a concern about the performance impact of eager seeks, but the question was never answered. Adding hasFilterRowKey was briefly discussed as a way to limit the cases where the lazy seek optimization is lost, but the community never reached a consensus.

So the question still remains. Do you have a view on the performance impact?

The eager seek is once per scanner open rather than per row, and scans without explicit columns already take that path, so it may well be fine. But it applies to every filtered explicit-column non-Get scan, so we should understand the cost before making it the default.

To be clear, I am not suggesting we leave the bug unfixed. Correctness should take priority over performance. I would just like to understand the cost before we commit.

@noslowerdna

noslowerdna commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@junegunn Thanks for the thoughtful review. I have added another unit test based on how you reproduced the issue with the WhileMatchFilter. I also updated the description of this PR to express the potential cost concern. I do not know how to best simulate a reasonable worst-case scenario to gather concrete statistics however. Any advice?

For awareness I do have another branch with a new configuration property implemented to be able to revert this behavior change in cases where the current behavior is preferable (if unset, the default is to fix this bug).

@junegunn

junegunn commented Aug 4, 2026

Copy link
Copy Markdown
Member

Thanks, the updated description reads well.

I'd like to ask the original members who started the discussion, but unfortunately, they are no longer active in this project.

For unit tests, you might want to check out TestSeekOptimizations. The existing cases seem to compare lazy seeks against eager. They never set a filter though.

I also find it hard to come up with a realistic scenario that would actually suffer from the regression, since it depends on several factors at once:

  • many store files per store
  • a dataset well beyond the block cache, so seeks actually hit disk
  • short scans, since the cost is once per scanner open and a long scan amortizes it away
  • explicit columns and a filter (any filter, not only those implementing filterRowKey)

Regarding the new configuration property, I don't think we should add a flag to re-introduce a correctness bug unless the performance hit is really unacceptable, and we don't know that yet. But I doubt it is.

Out of curiosity: How did you run into this issue? Do you have a production workload that was affected by the bug? If so, what does that workload look like?

@noslowerdna

noslowerdna commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

For unit tests, you might want to check out TestSeekOptimizations. The existing cases seem to compare lazy seeks against eager. They never set a filter though.

Sounds good from here. I will see today what corresponding updates can be made there.

Update: I have added a new test case to that class.

Regarding the new configuration property, I don't think we should add a flag to re-introduce a correctness bug unless the performance hit is really unacceptable, and we don't know that yet. But I doubt it is.

Agreed.

I also find it hard to come up with a realistic scenario that would actually suffer from the regression, since it depends on several factors at once: ...

Right, for that reason my team's consensus opinion is that the risk of this correction being problematic for any real-world application is extremely small.

Out of curiosity: How did you run into this issue? Do you have a production workload that was affected by the bug? If so, what does that workload look like?

Yes, we did. The issue was encountered in a dev environment with an existing legacy data processing workload being migrated from HBase 1.x to 2.x.

It has a custom multi-segment row key binary serialization format with a RowFilter for scan efficiency (CompareOperator.EQUAL + ByteArrayComparable impl). We use the DataInputStream class to decode its segments. Reading the stream for a row ended unexpectedly when a 0x00 terminal byte was not found for a string segment resulting in an EOFException thrown. A generic IOException catch block wrapped this in a RuntimeException, and a batch processing workflow failed to start.

At first we were confused how a row key could have become corrupted in such a way when encoded from its natural structured form, then noticed the value was a region start boundary. That led us to this HBase JIRA opened a few years ago.

For now, our temporary mitigation is catching any Exception (and returning 1 to skip the row) as analysis showed several different possibilities if decoding arbitrary bytes. While the code is certainly more robust now with that guardrail in place, any issue where the encoding of real row keys is malformed would effectively cause quiet (apart from region server error logging) downstream data loss rather than failing fast which is what we would want.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adjusts StoreScanner’s initial seek behavior so that server-side Filters/Comparators are never invoked with a synthetic “scan start” Cell for filtered, non-Get scans, preventing malformed row-key observations while preserving lazy seeking where safe.

Changes:

  • Disable initial lazy seek for filtered non-Get scans in StoreScanner.
  • Add/extend tests to ensure filters/comparators only see real persisted rows and to validate the lazy-seek decision matrix.
  • Extend seek-optimization tests to assert filtered explicit-column scans always eager-seek.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
hbase-server/src/main/java/org/apache/hadoop/hbase/regionserver/StoreScanner.java Disables initial lazy seek when a Filter is present on a non-Get scan to avoid exposing synthetic Cells to filters.
hbase-server/src/test/java/org/apache/hadoop/hbase/regionserver/TestScanner.java Adds regression + focused tests covering comparator/filter exposure and initial lazy-seek decision branches.
hbase-server/src/test/java/org/apache/hadoop/hbase/regionserver/TestSeekOptimizations.java Refactors setup and adds a test asserting filtered scans always eager-seek; introduces ScanResult tracking.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@noslowerdna

Copy link
Copy Markdown
Contributor Author

The large-wave-1 Yetus failure was due to TestEditsBehindDroppedTableTiming repeatedly timing out. I could not reproduce it locally.

[ERROR] Failures: 
[ERROR] org.apache.hadoop.hbase.replication.TestEditsBehindDroppedTableTiming.testEditsBehindDroppedTableTiming
[ERROR]   Run 1: TestEditsBehindDroppedTableTiming.testEditsBehindDroppedTableTiming:107->ReplicationDroppedTablesTestBase.verifyReplicationProceeded:161 Waited too much time for put replication
[ERROR]   Run 2: TestEditsBehindDroppedTableTiming.testEditsBehindDroppedTableTiming:107->ReplicationDroppedTablesTestBase.verifyReplicationProceeded:161 Waited too much time for put replication
[ERROR]   Run 3: TestEditsBehindDroppedTableTiming.testEditsBehindDroppedTableTiming:107->ReplicationDroppedTablesTestBase.verifyReplicationProceeded:161 Waited too much time for put replication

@junegunn

junegunn commented Aug 6, 2026

Copy link
Copy Markdown
Member

I extended testSeeksEagerlyWhenFiltered to sweep the row count, and turned on the class's existing VERBOSE flag so it reports the seek count for the whole scan rather than only at scanner open.

@@ -77,10 +77,11 @@ public class TestSeekOptimizations {
   private static final int PUTS_PER_ROW_COL = 50;
   private static final int DELETES_PER_ROW_COL = 10;
 
-  private static final int NUM_ROWS = 3;
+  // Single digit only: rowStr() is "row" + i, so row10 would sort before row2.
+  private static final int NUM_ROWS = 9;
   private static final int NUM_COLS = 3;
 
-  private static final boolean VERBOSE = false;
+  private static final boolean VERBOSE = true;
 
   /**
    * Disable this when this test fails hopelessly and you need to debug a simpler case.
@@ -235,7 +236,7 @@ public class TestSeekOptimizations {
       columnArr.length == 0 ? "all columns" : ("columns=" + Arrays.toString(columnArr));
     final String testDesc = "Bloom=" + bloomType + ", compr=" + comprAlgo + ", "
       + (scan.isGetScan() ? "Get" : "Scan") + ": " + columnRestrictionStr + ", " + rowRestrictionStr
-      + ", maxVersions=" + maxVersions + ", lazySeek=" + lazySeekEnabled;
+      + ", maxVersions=" + maxVersions + ", lazySeek=" + lazySeekEnabled + ", filtered=" + filtered;
     long seekCount = StoreFileScanner.getSeekCount() - initialSeekCount;
     if (VERBOSE) {
       System.err.println("Seek count: " + seekCount + ", KVs returned: " + actualKVs.size() + ". "
@@ -467,6 +468,12 @@ public class TestSeekOptimizations {
   public void testSeeksEagerlyWhenFiltered() throws IOException {
     ScanResult filteredLazyResults = testScan(new int[] { 0 }, true, 0, 2, 1, true);
     ScanResult filteredEagerResults = testScan(new int[] { 0 }, false, 0, 2, 1, true);
+    // Does the extra cost scale with rows scanned? filtered=false is the behavior before
+    // this patch. endRow never equals startRow: that would be a Get, which this patch exempts.
+    for (int endRow : new int[] { 1, 2, 5, 8 }) {
+      testScan(new int[] { 0 }, true, 0, endRow, 1, false);
+      testScan(new int[] { 0 }, true, 0, endRow, 1, true);
+    }
     assertKVListsEqual("Filtered explicit column scan results differ with lazy seeking enabled",
       filteredEagerResults.cells, filteredLazyResults.cells);
     assertEquals(filteredEagerResults.scannerOpenSeekCount,

Distilling the columns=[0] lines out of the output, identical for every bloom type and codec:

rows lazy (filtered=false) eager (filtered=true) delta
2 15 35 20
3 22 49 27
6 43 91 48
9 64 133 69

The fixture has 7 store files, and the numbers fit exactly:

lazy  =  7*rows + 1
eager = 14*rows + 7

So in this fixture the eager initial seek does not cost a fixed N seeks at open. It doubles the per-row seek count for the whole scan. That contradicts the description:

The cost is proportional to the number of underlying scanners opened, not the number of rows returned.

and it also inverts what I said earlier about long scans amortizing the cost away.

I haven't worked out what's causing the doubling, could you take a look?

@noslowerdna

Copy link
Copy Markdown
Contributor Author

I haven't worked out what's causing the doubling, could you take a look?

I confirmed it's the mere presence of the filter, not this patch unexpectedly changing per-row behavior. The higher seek count in this test for a filtered scan is seen with unpatched code as well.

Without a filter, HBase sees the newest version and knows it can move immediately to the next row. That is why the counter is naturally lower. For this unfiltered case, it takes trySkipToNextRow (INCLUDE_AND_SEEK_NEXT_ROW), which succeeds without a counted seek or reseek.

With a filter, HBase must be more cautious because a general filter might decide to reject the newest version and accept an older one. So in this case it ends up taking a different internal route: trySkipToNextColumn (SEEK_NEXT_COL). That leads to repositioning each of the 7 file scanners for the next row - the extra counted seeks.

The patch modifies only the initial positioning, ensuring the first Cell shown to the filter is real. It doesn't change the subsequent seek/reseek logic.

Please let me know if you need additional supporting evidence.

@junegunn

junegunn commented Aug 7, 2026

Copy link
Copy Markdown
Member

You're right, thanks for looking into it.

Reverting just the StoreScanner line so the filter is present in both cases:

rows unfiltered, lazy filtered, lazy (unpatched) filtered, eager (patched) patch cost
2 15 29 35 6
3 22 43 49 6
6 43 85 91 6
9 64 127 133 6

That puts us back where we were before: a fixed cost bounded by the store file count, paid once per scanner open. And since any scan this patch affects already has a filter, it is already paying the per-row overhead, so the flat 6 is a small share of the total: around 20% at 2 rows, under 5% at 9, shrinking from there. +1 from me.

@junegunn
junegunn merged commit 59fc59c into apache:master Aug 7, 2026
12 of 13 checks passed
junegunn pushed a commit that referenced this pull request Aug 7, 2026
…8485)

Co-authored-by: Andrew Olson <aolson1@cerner.com>
Signed-off-by: Junegunn Choi <junegunn@apache.org>
junegunn pushed a commit that referenced this pull request Aug 7, 2026
…8485)

Co-authored-by: Andrew Olson <aolson1@cerner.com>
Signed-off-by: Junegunn Choi <junegunn@apache.org>
@junegunn

junegunn commented Aug 7, 2026

Copy link
Copy Markdown
Member

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