001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.apache.hadoop.hbase.filter;
019
020import static org.junit.jupiter.api.Assertions.assertEquals;
021import static org.junit.jupiter.api.Assertions.assertTrue;
022
023import java.io.IOException;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.List;
027import java.util.concurrent.atomic.AtomicInteger;
028import org.apache.hadoop.hbase.Cell;
029import org.apache.hadoop.hbase.CellUtil;
030import org.apache.hadoop.hbase.HBaseTestingUtil;
031import org.apache.hadoop.hbase.PrivateCellUtil;
032import org.apache.hadoop.hbase.TableName;
033import org.apache.hadoop.hbase.client.ColumnFamilyDescriptorBuilder;
034import org.apache.hadoop.hbase.client.Durability;
035import org.apache.hadoop.hbase.client.Put;
036import org.apache.hadoop.hbase.client.RegionInfo;
037import org.apache.hadoop.hbase.client.RegionInfoBuilder;
038import org.apache.hadoop.hbase.client.Scan;
039import org.apache.hadoop.hbase.client.TableDescriptor;
040import org.apache.hadoop.hbase.client.TableDescriptorBuilder;
041import org.apache.hadoop.hbase.regionserver.HRegion;
042import org.apache.hadoop.hbase.regionserver.RegionScanner;
043import org.apache.hadoop.hbase.testclassification.FilterTests;
044import org.apache.hadoop.hbase.testclassification.MediumTests;
045import org.apache.hadoop.hbase.util.Bytes;
046import org.junit.jupiter.api.AfterEach;
047import org.junit.jupiter.api.BeforeEach;
048import org.junit.jupiter.api.Tag;
049import org.junit.jupiter.api.Test;
050import org.junit.jupiter.api.TestInfo;
051
052@Tag(FilterTests.TAG)
053@Tag(MediumTests.TAG)
054public class TestFilterHintForRejectedRow {
055
056  private static final HBaseTestingUtil TEST_UTIL = new HBaseTestingUtil();
057
058  private static final byte[] FAMILY = Bytes.toBytes("f");
059  private static final byte[] FAMILY2 = Bytes.toBytes("g");
060  private static final int CELLS_PER_ROW = 10;
061  private static final byte[] VALUE = Bytes.toBytes("value");
062
063  private HRegion region;
064
065  @BeforeEach
066  public void setUp(TestInfo testInfo) throws Exception {
067    TableDescriptor tableDescriptor =
068      TableDescriptorBuilder.newBuilder(TableName.valueOf(testInfo.getTestMethod().get().getName()))
069        .setColumnFamily(ColumnFamilyDescriptorBuilder.of(FAMILY))
070        .setColumnFamily(ColumnFamilyDescriptorBuilder.of(FAMILY2)).build();
071    RegionInfo info = RegionInfoBuilder.newBuilder(tableDescriptor.getTableName()).build();
072    this.region = HBaseTestingUtil.createRegionAndWAL(info, TEST_UTIL.getDataTestDir(),
073      TEST_UTIL.getConfiguration(), tableDescriptor);
074  }
075
076  @AfterEach
077  public void tearDown() throws Exception {
078    HBaseTestingUtil.closeRegionAndWAL(this.region);
079  }
080
081  private void writeRows(String prefix, int count) throws IOException {
082    for (int i = 0; i < count; i++) {
083      byte[] row = Bytes.toBytes(String.format("%s-%02d", prefix, i));
084      Put p = new Put(row);
085      p.setDurability(Durability.SKIP_WAL);
086      for (int j = 0; j < CELLS_PER_ROW; j++) {
087        p.addColumn(FAMILY, Bytes.toBytes(String.format("q-%02d", j)), VALUE);
088      }
089      this.region.put(p);
090    }
091    this.region.flush(true);
092  }
093
094  private void writeRowsMultiFamily(String prefix, int count) throws IOException {
095    for (int i = 0; i < count; i++) {
096      byte[] row = Bytes.toBytes(String.format("%s-%02d", prefix, i));
097      Put p = new Put(row);
098      p.setDurability(Durability.SKIP_WAL);
099      for (int j = 0; j < CELLS_PER_ROW; j++) {
100        byte[] qual = Bytes.toBytes(String.format("q-%02d", j));
101        p.addColumn(FAMILY, qual, VALUE);
102        p.addColumn(FAMILY2, qual, VALUE);
103      }
104      this.region.put(p);
105    }
106    this.region.flush(true);
107  }
108
109  private List<Cell> scanAll(Scan scan) throws IOException {
110    List<Cell> results = new ArrayList<>();
111    try (RegionScanner scanner = region.getScanner(scan)) {
112      List<Cell> rowCells = new ArrayList<>();
113      boolean hasMore;
114      do {
115        hasMore = scanner.next(rowCells);
116        results.addAll(rowCells);
117        rowCells.clear();
118      } while (hasMore);
119    }
120    return results;
121  }
122
123  @Test
124  public void testHintAndNoHintReturnSameCellsOnSameData() throws IOException {
125    final String prefix = "row";
126    final int rejectedCount = 5;
127    final int acceptedCount = 5;
128    writeRows(prefix, rejectedCount + acceptedCount);
129
130    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, rejectedCount));
131    final AtomicInteger hintCallCount = new AtomicInteger(0);
132
133    FilterBase hintFilter = new FilterBase() {
134      @Override
135      public boolean filterRowKey(Cell cell) {
136        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
137          acceptedStartRow, 0, acceptedStartRow.length) < 0;
138      }
139
140      @Override
141      public Cell getHintForRejectedRow(Cell firstRowCell) {
142        hintCallCount.incrementAndGet();
143        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
144      }
145    };
146
147    FilterBase noHintFilter = new FilterBase() {
148      @Override
149      public boolean filterRowKey(Cell cell) {
150        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
151          acceptedStartRow, 0, acceptedStartRow.length) < 0;
152      }
153    };
154
155    List<Cell> hintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(hintFilter));
156    List<Cell> noHintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(noHintFilter));
157
158    assertEquals(noHintResults.size(), hintResults.size(),
159      "Both paths must return the same number of cells");
160    for (int i = 0; i < hintResults.size(); i++) {
161      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
162        "Cell mismatch at index " + i);
163    }
164    assertEquals(acceptedCount * CELLS_PER_ROW, hintResults.size());
165    assertEquals(1, hintCallCount.get(),
166      "getHintForRejectedRow must be called exactly once: the hint skips all rejected rows");
167  }
168
169  @Test
170  public void testHintBeyondLastRowTerminatesScanGracefully() throws IOException {
171    final String prefix = "hint-beyond";
172    writeRows(prefix, 5);
173
174    final byte[] beyondAllRows = Bytes.toBytes("zzz-beyond-table-end");
175
176    FilterBase beyondHintFilter = new FilterBase() {
177      @Override
178      public boolean filterRowKey(Cell cell) {
179        return true;
180      }
181
182      @Override
183      public Cell getHintForRejectedRow(Cell firstRowCell) {
184        return PrivateCellUtil.createFirstOnRow(beyondAllRows);
185      }
186    };
187
188    List<Cell> results = scanAll(new Scan().addFamily(FAMILY).setFilter(beyondHintFilter));
189    assertTrue(results.isEmpty(),
190      "When the hint is past the last row, no cells should be returned");
191  }
192
193  @Test
194  public void testPerRowHintCalledOncePerRejectedRow() throws IOException {
195    final String prefix = "hint-perrow";
196    final int rejectedCount = 4;
197    final int acceptedCount = 2;
198    writeRows(prefix, rejectedCount + acceptedCount);
199
200    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, rejectedCount));
201    final AtomicInteger hintCallCount = new AtomicInteger(0);
202
203    FilterBase perRowHintFilter = new FilterBase() {
204      @Override
205      public boolean filterRowKey(Cell cell) {
206        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
207          acceptedStartRow, 0, acceptedStartRow.length) < 0;
208      }
209
210      @Override
211      public Cell getHintForRejectedRow(Cell firstRowCell) {
212        hintCallCount.incrementAndGet();
213        return PrivateCellUtil.createFirstOnNextRow(firstRowCell);
214      }
215    };
216
217    List<Cell> results = scanAll(new Scan().addFamily(FAMILY).setFilter(perRowHintFilter));
218
219    assertEquals(acceptedCount * CELLS_PER_ROW, results.size());
220    for (Cell c : results) {
221      assertTrue(
222        Bytes.compareTo(c.getRowArray(), c.getRowOffset(), c.getRowLength(), acceptedStartRow, 0,
223          acceptedStartRow.length) >= 0,
224        "Every returned cell must belong to the accepted row range");
225    }
226    assertEquals(rejectedCount, hintCallCount.get(),
227      "getHintForRejectedRow must be called once per rejected row in the per-row hint strategy");
228  }
229
230  @Test
231  public void testReversedScanWithHint() throws IOException {
232    final String prefix = "row";
233    final int totalRows = 10;
234    writeRows(prefix, totalRows);
235
236    // Accept rows 00-04 (lower half), reject rows 05-09 (upper half).
237    // In a reversed scan, the scanner starts at row-09 and moves backward.
238    final byte[] rejectThreshold = Bytes.toBytes(String.format("%s-%02d", prefix, 5));
239    final byte[] hintTarget = Bytes.toBytes(String.format("%s-%02d", prefix, 4));
240    final AtomicInteger hintCallCount = new AtomicInteger(0);
241
242    FilterBase hintFilter = new FilterBase() {
243      @Override
244      public boolean filterRowKey(Cell cell) {
245        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
246          rejectThreshold, 0, rejectThreshold.length) >= 0;
247      }
248
249      @Override
250      public Cell getHintForRejectedRow(Cell firstRowCell) {
251        hintCallCount.incrementAndGet();
252        // In reversed scan, hint must point backward (to a smaller row key).
253        return PrivateCellUtil.createFirstOnRow(hintTarget);
254      }
255    };
256
257    FilterBase noHintFilter = new FilterBase() {
258      @Override
259      public boolean filterRowKey(Cell cell) {
260        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
261          rejectThreshold, 0, rejectThreshold.length) >= 0;
262      }
263    };
264
265    Scan reversedHintScan = new Scan().addFamily(FAMILY).setReversed(true).setFilter(hintFilter);
266    Scan reversedNoHintScan =
267      new Scan().addFamily(FAMILY).setReversed(true).setFilter(noHintFilter);
268
269    List<Cell> hintResults = scanAll(reversedHintScan);
270    List<Cell> noHintResults = scanAll(reversedNoHintScan);
271
272    assertEquals(noHintResults.size(), hintResults.size(),
273      "Both paths must return the same number of cells");
274    for (int i = 0; i < hintResults.size(); i++) {
275      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
276        "Cell mismatch at index " + i);
277    }
278    assertEquals(5 * CELLS_PER_ROW, hintResults.size());
279    assertEquals(1, hintCallCount.get(),
280      "getHintForRejectedRow must be called exactly once for reversed scan");
281  }
282
283  @Test
284  public void testGetSkipHintWithTimeRangeIntegration() throws IOException {
285    final long insideTs = 2000;
286    final long outsideTs = 500;
287    final int rowCount = 5;
288
289    for (int i = 0; i < rowCount; i++) {
290      byte[] row = Bytes.toBytes(String.format("skiprow-%02d", i));
291      Put p = new Put(row);
292      p.setDurability(Durability.SKIP_WAL);
293      p.addColumn(FAMILY, Bytes.toBytes("q"), insideTs, VALUE);
294      p.addColumn(FAMILY, Bytes.toBytes("q"), outsideTs, VALUE);
295      region.put(p);
296    }
297    region.flush(true);
298
299    final AtomicInteger skipHintCalls = new AtomicInteger(0);
300
301    FilterBase skipHintFilter = new FilterBase() {
302      @Override
303      public Cell getSkipHint(Cell skippedCell) {
304        skipHintCalls.incrementAndGet();
305        return PrivateCellUtil.createFirstOnNextRow(skippedCell);
306      }
307    };
308
309    Scan hintScan = new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setFilter(skipHintFilter);
310    Scan noHintScan = new Scan().addFamily(FAMILY).setTimeRange(1000, 3000);
311
312    List<Cell> hintResults = scanAll(hintScan);
313    List<Cell> noHintResults = scanAll(noHintScan);
314
315    assertEquals(noHintResults.size(), hintResults.size(),
316      "Both paths must return the same number of cells");
317    for (int i = 0; i < hintResults.size(); i++) {
318      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
319        "Cell mismatch at index " + i);
320    }
321    assertEquals(rowCount, hintResults.size());
322  }
323
324  @Test
325  public void testBackwardHintFallsBackToNextRow() throws IOException {
326    final String prefix = "row";
327    writeRows(prefix, 5);
328
329    final byte[] row00 = Bytes.toBytes(String.format("%s-%02d", prefix, 0));
330    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, 2));
331
332    FilterBase backwardHintFilter = new FilterBase() {
333      @Override
334      public boolean filterRowKey(Cell cell) {
335        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
336          acceptedStartRow, 0, acceptedStartRow.length) < 0;
337      }
338
339      @Override
340      public Cell getHintForRejectedRow(Cell firstRowCell) {
341        return PrivateCellUtil.createFirstOnRow(row00);
342      }
343    };
344
345    FilterBase noHintFilter = new FilterBase() {
346      @Override
347      public boolean filterRowKey(Cell cell) {
348        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
349          acceptedStartRow, 0, acceptedStartRow.length) < 0;
350      }
351    };
352
353    List<Cell> hintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(backwardHintFilter));
354    List<Cell> noHintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(noHintFilter));
355
356    assertEquals(noHintResults.size(), hintResults.size(),
357      "Backward hint must produce same results as no-hint path");
358    for (int i = 0; i < hintResults.size(); i++) {
359      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
360        "Cell mismatch at index " + i);
361    }
362  }
363
364  @Test
365  public void testSameRowHintFallsBackToNextRow() throws IOException {
366    final String prefix = "row";
367    writeRows(prefix, 5);
368
369    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, 2));
370    final AtomicInteger hintCallCount = new AtomicInteger(0);
371
372    FilterBase sameRowHintFilter = new FilterBase() {
373      @Override
374      public boolean filterRowKey(Cell cell) {
375        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
376          acceptedStartRow, 0, acceptedStartRow.length) < 0;
377      }
378
379      @Override
380      public Cell getHintForRejectedRow(Cell firstRowCell) {
381        hintCallCount.incrementAndGet();
382        return PrivateCellUtil.createFirstOnRow(CellUtil.cloneRow(firstRowCell));
383      }
384    };
385
386    FilterBase noHintFilter = new FilterBase() {
387      @Override
388      public boolean filterRowKey(Cell cell) {
389        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
390          acceptedStartRow, 0, acceptedStartRow.length) < 0;
391      }
392    };
393
394    List<Cell> hintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(sameRowHintFilter));
395    List<Cell> noHintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(noHintFilter));
396
397    assertEquals(noHintResults.size(), hintResults.size(),
398      "Same-row hint must produce same results as no-hint path");
399    for (int i = 0; i < hintResults.size(); i++) {
400      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
401        "Cell mismatch at index " + i);
402    }
403    assertEquals(3 * CELLS_PER_ROW, hintResults.size());
404  }
405
406  @Test
407  public void testCoprocessorHookCalledOncePerHintedRejection() throws IOException {
408    final String prefix = "row";
409    final int rejectedCount = 3;
410    final int acceptedCount = 3;
411    writeRows(prefix, rejectedCount + acceptedCount);
412
413    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, rejectedCount));
414    final AtomicInteger hintCallCount = new AtomicInteger(0);
415
416    FilterBase hintFilter = new FilterBase() {
417      @Override
418      public boolean filterRowKey(Cell cell) {
419        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
420          acceptedStartRow, 0, acceptedStartRow.length) < 0;
421      }
422
423      @Override
424      public Cell getHintForRejectedRow(Cell firstRowCell) {
425        hintCallCount.incrementAndGet();
426        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
427      }
428    };
429
430    List<Cell> results = scanAll(new Scan().addFamily(FAMILY).setFilter(hintFilter));
431
432    assertEquals(1, hintCallCount.get());
433    assertEquals(acceptedCount * CELLS_PER_ROW, results.size());
434  }
435
436  @Test
437  public void testMultiFamilyScanWithHint() throws IOException {
438    final String prefix = "row";
439    final int rejectedCount = 3;
440    final int acceptedCount = 3;
441    writeRowsMultiFamily(prefix, rejectedCount + acceptedCount);
442
443    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, rejectedCount));
444    final AtomicInteger hintCallCount = new AtomicInteger(0);
445
446    FilterBase hintFilter = new FilterBase() {
447      @Override
448      public boolean filterRowKey(Cell cell) {
449        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
450          acceptedStartRow, 0, acceptedStartRow.length) < 0;
451      }
452
453      @Override
454      public Cell getHintForRejectedRow(Cell firstRowCell) {
455        hintCallCount.incrementAndGet();
456        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
457      }
458    };
459
460    FilterBase noHintFilter = new FilterBase() {
461      @Override
462      public boolean filterRowKey(Cell cell) {
463        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
464          acceptedStartRow, 0, acceptedStartRow.length) < 0;
465      }
466    };
467
468    // Scan both families.
469    Scan hintScan = new Scan().addFamily(FAMILY).addFamily(FAMILY2).setFilter(hintFilter);
470    Scan noHintScan = new Scan().addFamily(FAMILY).addFamily(FAMILY2).setFilter(noHintFilter);
471
472    List<Cell> hintResults = scanAll(hintScan);
473    List<Cell> noHintResults = scanAll(noHintScan);
474
475    assertEquals(noHintResults.size(), hintResults.size(),
476      "Both paths must return the same number of cells");
477    for (int i = 0; i < hintResults.size(); i++) {
478      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
479        "Cell mismatch at index " + i);
480    }
481    assertEquals(2 * CELLS_PER_ROW * acceptedCount, hintResults.size());
482    assertEquals(1, hintCallCount.get());
483  }
484
485  @Test
486  public void testDataCorrectnessVsUnfilteredScan() throws IOException {
487    final String prefix = "row";
488    final int totalRows = 8;
489    writeRows(prefix, totalRows);
490
491    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, 3));
492
493    FilterBase hintFilter = new FilterBase() {
494      @Override
495      public boolean filterRowKey(Cell cell) {
496        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
497          acceptedStartRow, 0, acceptedStartRow.length) < 0;
498      }
499
500      @Override
501      public Cell getHintForRejectedRow(Cell firstRowCell) {
502        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
503      }
504    };
505
506    List<Cell> filteredResults = scanAll(new Scan().addFamily(FAMILY).setFilter(hintFilter));
507
508    // Unfiltered scan starting at acceptedStartRow — should return the exact same cells.
509    Scan unfilteredScan = new Scan().addFamily(FAMILY).withStartRow(acceptedStartRow);
510    List<Cell> unfilteredResults = scanAll(unfilteredScan);
511
512    assertEquals(unfilteredResults.size(), filteredResults.size(),
513      "Filtered and unfiltered scans must return same cell count");
514    for (int i = 0; i < filteredResults.size(); i++) {
515      assertTrue(CellUtil.equals(filteredResults.get(i), unfilteredResults.get(i)),
516        "Cell mismatch at index " + i);
517    }
518  }
519
520  @Test
521  public void testHintOvershootingStopRowTerminatesGracefully() throws IOException {
522    final String prefix = "row";
523    final int totalRows = 10;
524    writeRows(prefix, totalRows);
525
526    // Scan rows 00-06 (stopRow=row-07, exclusive). Reject rows 00-02, hint to row-09.
527    // The hint overshoots the stop row, so the scan should terminate with no results
528    // from the rejected range and only include rows 03-06 from the non-rejected range.
529    final byte[] stopRow = Bytes.toBytes(String.format("%s-%02d", prefix, 7));
530    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, 3));
531    final byte[] hintTarget = Bytes.toBytes(String.format("%s-%02d", prefix, 9));
532
533    FilterBase hintFilter = new FilterBase() {
534      @Override
535      public boolean filterRowKey(Cell cell) {
536        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
537          acceptedStartRow, 0, acceptedStartRow.length) < 0;
538      }
539
540      @Override
541      public Cell getHintForRejectedRow(Cell firstRowCell) {
542        // Hint past the stop row — scanner should terminate gracefully.
543        return PrivateCellUtil.createFirstOnRow(hintTarget);
544      }
545    };
546
547    Scan scan = new Scan().addFamily(FAMILY).withStopRow(stopRow).setFilter(hintFilter);
548    List<Cell> results = scanAll(scan);
549
550    // The hint jumps past stopRow, so no cells should be returned.
551    assertTrue(results.isEmpty(), "Hint past stop row must terminate scan with no results");
552  }
553
554  @Test
555  public void testHintWithinStopRowReturnsCorrectResults() throws IOException {
556    final String prefix = "row";
557    final int totalRows = 10;
558    writeRows(prefix, totalRows);
559
560    // Scan rows 00-06 (stopRow=row-07, exclusive). Reject rows 00-02, hint to row-03.
561    final byte[] stopRow = Bytes.toBytes(String.format("%s-%02d", prefix, 7));
562    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, 3));
563
564    FilterBase hintFilter = new FilterBase() {
565      @Override
566      public boolean filterRowKey(Cell cell) {
567        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
568          acceptedStartRow, 0, acceptedStartRow.length) < 0;
569      }
570
571      @Override
572      public Cell getHintForRejectedRow(Cell firstRowCell) {
573        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
574      }
575    };
576
577    Scan hintScan = new Scan().addFamily(FAMILY).withStopRow(stopRow).setFilter(hintFilter);
578    List<Cell> hintResults = scanAll(hintScan);
579
580    // Should get rows 03-06 (4 rows).
581    Scan baselineScan =
582      new Scan().addFamily(FAMILY).withStartRow(acceptedStartRow).withStopRow(stopRow);
583    List<Cell> baselineResults = scanAll(baselineScan);
584
585    assertEquals(baselineResults.size(), hintResults.size(),
586      "Hint within stop row must return same results as baseline");
587    for (int i = 0; i < hintResults.size(); i++) {
588      assertTrue(CellUtil.equals(hintResults.get(i), baselineResults.get(i)),
589        "Cell mismatch at index " + i);
590    }
591    assertEquals(4 * CELLS_PER_ROW, hintResults.size());
592  }
593
594  @Test
595  public void testGetSkipHintCalledForReversedScan() throws IOException {
596    final long insideTs = 1500;
597    final long tooNewTs = 5000;
598    final int rowCount = 5;
599    final byte[] qInside = Bytes.toBytes("q-inside");
600    final byte[] qNew = Bytes.toBytes("q-new");
601
602    // Write two qualifiers per row: "q-inside" at ts=1500 (within range) and "q-new" at
603    // ts=5000 (above the range upper bound). The time-range gate fires for the too-new cell
604    // before version tracking can absorb it, ensuring getSkipHint is consulted.
605    for (int i = 0; i < rowCount; i++) {
606      byte[] row = Bytes.toBytes(String.format("skiprev-%02d", i));
607      Put p = new Put(row);
608      p.setDurability(Durability.SKIP_WAL);
609      p.addColumn(FAMILY, qInside, insideTs, VALUE);
610      p.addColumn(FAMILY, qNew, tooNewTs, VALUE);
611      region.put(p);
612    }
613    region.flush(true);
614
615    final AtomicInteger skipHintCalls = new AtomicInteger(0);
616
617    // Return null to verify the code path is reached without altering scan semantics.
618    // The unit test TestUserScanQueryMatcher#testSkipHintConsultedForReversedScan validates
619    // that a non-null hint is correctly accepted by the reversed-scan guard.
620    FilterBase skipHintFilter = new FilterBase() {
621      @Override
622      public Cell getSkipHint(Cell skippedCell) {
623        skipHintCalls.incrementAndGet();
624        return null;
625      }
626    };
627
628    // Reversed scan with time range [1000, 3000): insideTs cells pass, tooNewTs cells are
629    // structurally skipped (tsCmp > 0). The skip hint should be consulted.
630    Scan hintScan = new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setReversed(true)
631      .setFilter(skipHintFilter);
632    Scan noHintScan = new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setReversed(true);
633
634    List<Cell> hintResults = scanAll(hintScan);
635    List<Cell> noHintResults = scanAll(noHintScan);
636
637    assertEquals(noHintResults.size(), hintResults.size(),
638      "Both paths must return the same number of cells");
639    for (int i = 0; i < hintResults.size(); i++) {
640      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
641        "Cell mismatch at index " + i);
642    }
643    assertEquals(rowCount, hintResults.size());
644    assertTrue(skipHintCalls.get() > 0,
645      "getSkipHint must be called at least once for reversed scan");
646  }
647
648  // ---- FilterList AND hint delegation integration tests ----
649
650  @Test
651  public void testFilterListANDHintDelegation() throws IOException {
652    final String prefix = "row";
653    final int rejectedCount = 5;
654    final int acceptedCount = 5;
655    writeRows(prefix, rejectedCount + acceptedCount);
656
657    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, rejectedCount));
658    final AtomicInteger hintCallsA = new AtomicInteger(0);
659    final AtomicInteger hintCallsB = new AtomicInteger(0);
660
661    // Both filters reject the same rows; both provide hints to the accepted start.
662    // AND merging takes max — both point to the same target here.
663    FilterBase filterA = new FilterBase() {
664      @Override
665      public boolean filterRowKey(Cell cell) {
666        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
667          acceptedStartRow, 0, acceptedStartRow.length) < 0;
668      }
669
670      @Override
671      public Cell getHintForRejectedRow(Cell firstRowCell) {
672        hintCallsA.incrementAndGet();
673        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
674      }
675    };
676
677    FilterBase filterB = new FilterBase() {
678      @Override
679      public boolean filterRowKey(Cell cell) {
680        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
681          acceptedStartRow, 0, acceptedStartRow.length) < 0;
682      }
683
684      @Override
685      public Cell getHintForRejectedRow(Cell firstRowCell) {
686        hintCallsB.incrementAndGet();
687        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
688      }
689    };
690
691    FilterList andFilter =
692      new FilterList(FilterList.Operator.MUST_PASS_ALL, Arrays.asList(filterA, filterB));
693
694    FilterBase noHintFilter = new FilterBase() {
695      @Override
696      public boolean filterRowKey(Cell cell) {
697        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
698          acceptedStartRow, 0, acceptedStartRow.length) < 0;
699      }
700    };
701
702    List<Cell> hintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(andFilter));
703    List<Cell> noHintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(noHintFilter));
704
705    assertEquals(noHintResults.size(), hintResults.size(),
706      "AND FilterList with hints must return same cells as no-hint path");
707    for (int i = 0; i < hintResults.size(); i++) {
708      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
709        "Cell mismatch at index " + i);
710    }
711    assertEquals(acceptedCount * CELLS_PER_ROW, hintResults.size());
712    assertTrue(hintCallsA.get() > 0, "Sub-filter A hint must be consulted");
713    assertTrue(hintCallsB.get() > 0, "Sub-filter B hint must be consulted");
714  }
715
716  @Test
717  public void testFilterListORHintDelegation() throws IOException {
718    final String prefix = "row";
719    final int rejectedCount = 5;
720    final int acceptedCount = 5;
721    writeRows(prefix, rejectedCount + acceptedCount);
722
723    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, rejectedCount));
724
725    // Both filters reject the same rows, OR requires ALL to reject.
726    // Both provide hints to the accepted start. OR merging takes min — same target.
727    FilterBase filterA = new FilterBase() {
728      @Override
729      public boolean filterRowKey(Cell cell) {
730        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
731          acceptedStartRow, 0, acceptedStartRow.length) < 0;
732      }
733
734      @Override
735      public Cell getHintForRejectedRow(Cell firstRowCell) {
736        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
737      }
738    };
739
740    FilterBase filterB = new FilterBase() {
741      @Override
742      public boolean filterRowKey(Cell cell) {
743        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
744          acceptedStartRow, 0, acceptedStartRow.length) < 0;
745      }
746
747      @Override
748      public Cell getHintForRejectedRow(Cell firstRowCell) {
749        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
750      }
751    };
752
753    FilterList orFilter =
754      new FilterList(FilterList.Operator.MUST_PASS_ONE, Arrays.asList(filterA, filterB));
755
756    FilterBase noHintFilter = new FilterBase() {
757      @Override
758      public boolean filterRowKey(Cell cell) {
759        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
760          acceptedStartRow, 0, acceptedStartRow.length) < 0;
761      }
762    };
763
764    List<Cell> hintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(orFilter));
765    List<Cell> noHintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(noHintFilter));
766
767    assertEquals(noHintResults.size(), hintResults.size(),
768      "OR FilterList with hints must return same cells as no-hint path");
769    for (int i = 0; i < hintResults.size(); i++) {
770      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
771        "Cell mismatch at index " + i);
772    }
773    assertEquals(acceptedCount * CELLS_PER_ROW, hintResults.size());
774  }
775
776  @Test
777  public void testFilterListANDWithOneNullHintSubFilter() throws IOException {
778    final String prefix = "row";
779    final int rejectedCount = 3;
780    final int acceptedCount = 3;
781    writeRows(prefix, rejectedCount + acceptedCount);
782
783    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, rejectedCount));
784    final AtomicInteger hintCalls = new AtomicInteger(0);
785
786    // One sub-filter provides a hint, the other returns null.
787    // AND ignores nulls, so the non-null hint should be used.
788    FilterBase hintProvider = new FilterBase() {
789      @Override
790      public boolean filterRowKey(Cell cell) {
791        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
792          acceptedStartRow, 0, acceptedStartRow.length) < 0;
793      }
794
795      @Override
796      public Cell getHintForRejectedRow(Cell firstRowCell) {
797        hintCalls.incrementAndGet();
798        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
799      }
800    };
801
802    FilterBase noHintProvider = new FilterBase() {
803      @Override
804      public boolean filterRowKey(Cell cell) {
805        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
806          acceptedStartRow, 0, acceptedStartRow.length) < 0;
807      }
808    };
809
810    FilterList andFilter = new FilterList(FilterList.Operator.MUST_PASS_ALL,
811      Arrays.asList(hintProvider, noHintProvider));
812
813    List<Cell> results = scanAll(new Scan().addFamily(FAMILY).setFilter(andFilter));
814    assertEquals(acceptedCount * CELLS_PER_ROW, results.size());
815    assertEquals(1, hintCalls.get(),
816      "Hint provider must be called; AND ignores the null from the other sub-filter");
817  }
818
819  @Test
820  public void testFilterListORWithOneNullHintSubFilter() throws IOException {
821    final String prefix = "row";
822    final int rejectedCount = 3;
823    final int acceptedCount = 3;
824    writeRows(prefix, rejectedCount + acceptedCount);
825
826    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, rejectedCount));
827
828    // One sub-filter provides a hint, the other returns null.
829    // OR returns null if ANY sub-filter returns null, so no hint optimization.
830    FilterBase hintProvider = new FilterBase() {
831      @Override
832      public boolean filterRowKey(Cell cell) {
833        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
834          acceptedStartRow, 0, acceptedStartRow.length) < 0;
835      }
836
837      @Override
838      public Cell getHintForRejectedRow(Cell firstRowCell) {
839        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
840      }
841    };
842
843    FilterBase noHintProvider = new FilterBase() {
844      @Override
845      public boolean filterRowKey(Cell cell) {
846        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
847          acceptedStartRow, 0, acceptedStartRow.length) < 0;
848      }
849    };
850
851    FilterList orFilter = new FilterList(FilterList.Operator.MUST_PASS_ONE,
852      Arrays.asList(hintProvider, noHintProvider));
853
854    FilterBase baseline = new FilterBase() {
855      @Override
856      public boolean filterRowKey(Cell cell) {
857        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
858          acceptedStartRow, 0, acceptedStartRow.length) < 0;
859      }
860    };
861
862    List<Cell> orResults = scanAll(new Scan().addFamily(FAMILY).setFilter(orFilter));
863    List<Cell> baselineResults = scanAll(new Scan().addFamily(FAMILY).setFilter(baseline));
864
865    assertEquals(baselineResults.size(), orResults.size(),
866      "OR with one null hint must still return correct results (falls back to no-hint path)");
867    for (int i = 0; i < orResults.size(); i++) {
868      assertTrue(CellUtil.equals(orResults.get(i), baselineResults.get(i)),
869        "Cell mismatch at index " + i);
870    }
871  }
872
873  @Test
874  public void testNestedFilterListHintDelegation() throws IOException {
875    final String prefix = "row";
876    final int rejectedCount = 4;
877    final int acceptedCount = 4;
878    writeRows(prefix, rejectedCount + acceptedCount);
879
880    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, rejectedCount));
881
882    // Nested: AND(OR(hintA, hintB), hintC)
883    // All filters reject the same rows and hint to the same target.
884    FilterBase hintFilter = new FilterBase() {
885      @Override
886      public boolean filterRowKey(Cell cell) {
887        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
888          acceptedStartRow, 0, acceptedStartRow.length) < 0;
889      }
890
891      @Override
892      public Cell getHintForRejectedRow(Cell firstRowCell) {
893        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
894      }
895    };
896
897    FilterBase hintFilter2 = new FilterBase() {
898      @Override
899      public boolean filterRowKey(Cell cell) {
900        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
901          acceptedStartRow, 0, acceptedStartRow.length) < 0;
902      }
903
904      @Override
905      public Cell getHintForRejectedRow(Cell firstRowCell) {
906        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
907      }
908    };
909
910    FilterBase hintFilter3 = new FilterBase() {
911      @Override
912      public boolean filterRowKey(Cell cell) {
913        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
914          acceptedStartRow, 0, acceptedStartRow.length) < 0;
915      }
916
917      @Override
918      public Cell getHintForRejectedRow(Cell firstRowCell) {
919        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
920      }
921    };
922
923    FilterList innerOR =
924      new FilterList(FilterList.Operator.MUST_PASS_ONE, Arrays.asList(hintFilter, hintFilter2));
925    FilterList outerAND =
926      new FilterList(FilterList.Operator.MUST_PASS_ALL, Arrays.asList(innerOR, hintFilter3));
927
928    FilterBase baseline = new FilterBase() {
929      @Override
930      public boolean filterRowKey(Cell cell) {
931        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
932          acceptedStartRow, 0, acceptedStartRow.length) < 0;
933      }
934    };
935
936    List<Cell> nestedResults = scanAll(new Scan().addFamily(FAMILY).setFilter(outerAND));
937    List<Cell> baselineResults = scanAll(new Scan().addFamily(FAMILY).setFilter(baseline));
938
939    assertEquals(baselineResults.size(), nestedResults.size(),
940      "Nested FilterList must return same results as baseline");
941    for (int i = 0; i < nestedResults.size(); i++) {
942      assertTrue(CellUtil.equals(nestedResults.get(i), baselineResults.get(i)),
943        "Cell mismatch at index " + i);
944    }
945    assertEquals(acceptedCount * CELLS_PER_ROW, nestedResults.size());
946  }
947
948  @Test
949  public void testWhileMatchFilterHintDelegation() throws IOException {
950    final String prefix = "row";
951    final int rejectedCount = 3;
952    final int acceptedCount = 3;
953    writeRows(prefix, rejectedCount + acceptedCount);
954
955    final byte[] acceptedStartRow = Bytes.toBytes(String.format("%s-%02d", prefix, rejectedCount));
956    final AtomicInteger hintCalls = new AtomicInteger(0);
957
958    FilterBase innerHintFilter = new FilterBase() {
959      @Override
960      public boolean filterRowKey(Cell cell) {
961        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
962          acceptedStartRow, 0, acceptedStartRow.length) < 0;
963      }
964
965      @Override
966      public Cell getHintForRejectedRow(Cell firstRowCell) {
967        hintCalls.incrementAndGet();
968        return PrivateCellUtil.createFirstOnRow(acceptedStartRow);
969      }
970    };
971
972    WhileMatchFilter wmFilter = new WhileMatchFilter(innerHintFilter);
973
974    // WhileMatchFilter delegates filterRowKey and sets filterAllRemaining on first true.
975    // The scanner checks isFilterDoneInternal() BEFORE calling getHintForRejectedRow,
976    // so the hint path is short-circuited and the scan terminates immediately.
977    List<Cell> results = scanAll(new Scan().addFamily(FAMILY).setFilter(wmFilter));
978
979    assertTrue(results.isEmpty(), "WhileMatchFilter terminates scan on first rejection");
980    assertEquals(0, hintCalls.get(),
981      "WhileMatchFilter sets filterAllRemaining before getHintForRejectedRow is consulted");
982  }
983
984  @Test
985  public void testFilterListANDReversedScanHint() throws IOException {
986    final String prefix = "row";
987    final int totalRows = 10;
988    writeRows(prefix, totalRows);
989
990    // Accept rows 00-04, reject rows 05-09.
991    // In reversed scan, scanner starts at row-09 and moves backward.
992    final byte[] rejectThreshold = Bytes.toBytes(String.format("%s-%02d", prefix, 5));
993    final byte[] hintTarget = Bytes.toBytes(String.format("%s-%02d", prefix, 4));
994
995    FilterBase rejectFilter = new FilterBase() {
996      @Override
997      public boolean filterRowKey(Cell cell) {
998        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
999          rejectThreshold, 0, rejectThreshold.length) >= 0;
1000      }
1001
1002      @Override
1003      public Cell getHintForRejectedRow(Cell firstRowCell) {
1004        return PrivateCellUtil.createFirstOnRow(hintTarget);
1005      }
1006    };
1007
1008    FilterList andFilter =
1009      new FilterList(FilterList.Operator.MUST_PASS_ALL, Arrays.asList(rejectFilter));
1010
1011    FilterBase noHintFilter = new FilterBase() {
1012      @Override
1013      public boolean filterRowKey(Cell cell) {
1014        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
1015          rejectThreshold, 0, rejectThreshold.length) >= 0;
1016      }
1017    };
1018
1019    Scan hintScan = new Scan().addFamily(FAMILY).setReversed(true).setFilter(andFilter);
1020    Scan noHintScan = new Scan().addFamily(FAMILY).setReversed(true).setFilter(noHintFilter);
1021
1022    List<Cell> hintResults = scanAll(hintScan);
1023    List<Cell> noHintResults = scanAll(noHintScan);
1024
1025    assertEquals(noHintResults.size(), hintResults.size(),
1026      "Reversed AND FilterList must return same cells");
1027    for (int i = 0; i < hintResults.size(); i++) {
1028      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
1029        "Cell mismatch at index " + i);
1030    }
1031    assertEquals(5 * CELLS_PER_ROW, hintResults.size());
1032  }
1033
1034  @Test
1035  public void testFilterListORReversedScanHint() throws IOException {
1036    final String prefix = "row";
1037    final int totalRows = 10;
1038    writeRows(prefix, totalRows);
1039
1040    final byte[] rejectThreshold = Bytes.toBytes(String.format("%s-%02d", prefix, 5));
1041    final byte[] hintTarget = Bytes.toBytes(String.format("%s-%02d", prefix, 4));
1042
1043    FilterBase rejectFilterA = new FilterBase() {
1044      @Override
1045      public boolean filterRowKey(Cell cell) {
1046        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
1047          rejectThreshold, 0, rejectThreshold.length) >= 0;
1048      }
1049
1050      @Override
1051      public Cell getHintForRejectedRow(Cell firstRowCell) {
1052        return PrivateCellUtil.createFirstOnRow(hintTarget);
1053      }
1054    };
1055
1056    FilterBase rejectFilterB = new FilterBase() {
1057      @Override
1058      public boolean filterRowKey(Cell cell) {
1059        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
1060          rejectThreshold, 0, rejectThreshold.length) >= 0;
1061      }
1062
1063      @Override
1064      public Cell getHintForRejectedRow(Cell firstRowCell) {
1065        return PrivateCellUtil.createFirstOnRow(hintTarget);
1066      }
1067    };
1068
1069    FilterList orFilter = new FilterList(FilterList.Operator.MUST_PASS_ONE,
1070      Arrays.asList(rejectFilterA, rejectFilterB));
1071
1072    FilterBase noHintFilter = new FilterBase() {
1073      @Override
1074      public boolean filterRowKey(Cell cell) {
1075        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
1076          rejectThreshold, 0, rejectThreshold.length) >= 0;
1077      }
1078    };
1079
1080    Scan hintScan = new Scan().addFamily(FAMILY).setReversed(true).setFilter(orFilter);
1081    Scan noHintScan = new Scan().addFamily(FAMILY).setReversed(true).setFilter(noHintFilter);
1082
1083    List<Cell> hintResults = scanAll(hintScan);
1084    List<Cell> noHintResults = scanAll(noHintScan);
1085
1086    assertEquals(noHintResults.size(), hintResults.size(),
1087      "Reversed OR FilterList must return same cells");
1088    for (int i = 0; i < hintResults.size(); i++) {
1089      assertTrue(CellUtil.equals(hintResults.get(i), noHintResults.get(i)),
1090        "Cell mismatch at index " + i);
1091    }
1092    assertEquals(5 * CELLS_PER_ROW, hintResults.size());
1093  }
1094
1095  @Test
1096  public void testColumnRangeFilterGetSkipHintIntegration() throws IOException {
1097    final long insideTs = 2000;
1098    final long outsideTs = 500;
1099    final int rowCount = 5;
1100
1101    for (int i = 0; i < rowCount; i++) {
1102      byte[] row = Bytes.toBytes(String.format("colrange-%02d", i));
1103      Put p = new Put(row);
1104      p.setDurability(Durability.SKIP_WAL);
1105      // Qualifiers: "a", "b", "c", "d" — ColumnRangeFilter will select "b" to "c".
1106      p.addColumn(FAMILY, Bytes.toBytes("a"), insideTs, VALUE);
1107      p.addColumn(FAMILY, Bytes.toBytes("a"), outsideTs, VALUE);
1108      p.addColumn(FAMILY, Bytes.toBytes("b"), insideTs, VALUE);
1109      p.addColumn(FAMILY, Bytes.toBytes("b"), outsideTs, VALUE);
1110      p.addColumn(FAMILY, Bytes.toBytes("c"), insideTs, VALUE);
1111      p.addColumn(FAMILY, Bytes.toBytes("c"), outsideTs, VALUE);
1112      p.addColumn(FAMILY, Bytes.toBytes("d"), insideTs, VALUE);
1113      p.addColumn(FAMILY, Bytes.toBytes("d"), outsideTs, VALUE);
1114      region.put(p);
1115    }
1116    region.flush(true);
1117
1118    ColumnRangeFilter colFilter =
1119      new ColumnRangeFilter(Bytes.toBytes("b"), true, Bytes.toBytes("c"), true);
1120
1121    // Baseline: same column range logic via filterCell, but no getSkipHint override.
1122    FilterBase noHintBaseline = new FilterBase() {
1123      private final ColumnRangeFilter delegate =
1124        new ColumnRangeFilter(Bytes.toBytes("b"), true, Bytes.toBytes("c"), true);
1125
1126      @Override
1127      public ReturnCode filterCell(Cell c) throws IOException {
1128        return delegate.filterCell(c);
1129      }
1130    };
1131
1132    // Time range [1000, 3000): insideTs cells pass, outsideTs cells hit the time-range gate.
1133    // ColumnRangeFilter.getSkipHint() should be consulted for structurally skipped cells.
1134    Scan hintScan = new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setFilter(colFilter);
1135    Scan noHintScan =
1136      new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setFilter(noHintBaseline);
1137
1138    List<Cell> hintResults = scanAll(hintScan);
1139    List<Cell> noHintResults = scanAll(noHintScan);
1140
1141    assertEquals(noHintResults.size(), hintResults.size(),
1142      "Hint-aware scan must return same cells as no-hint baseline");
1143    // Should get "b" and "c" qualifiers for each row, only insideTs versions.
1144    assertEquals(rowCount * 2, hintResults.size());
1145  }
1146
1147  @Test
1148  public void testColumnPrefixFilterGetSkipHintIntegration() throws IOException {
1149    final long insideTs = 2000;
1150    final long outsideTs = 500;
1151    final int rowCount = 5;
1152
1153    for (int i = 0; i < rowCount; i++) {
1154      byte[] row = Bytes.toBytes(String.format("colpfx-%02d", i));
1155      Put p = new Put(row);
1156      p.setDurability(Durability.SKIP_WAL);
1157      // Qualifiers: "aaa", "abc", "abd", "xyz"
1158      p.addColumn(FAMILY, Bytes.toBytes("aaa"), insideTs, VALUE);
1159      p.addColumn(FAMILY, Bytes.toBytes("aaa"), outsideTs, VALUE);
1160      p.addColumn(FAMILY, Bytes.toBytes("abc"), insideTs, VALUE);
1161      p.addColumn(FAMILY, Bytes.toBytes("abc"), outsideTs, VALUE);
1162      p.addColumn(FAMILY, Bytes.toBytes("abd"), insideTs, VALUE);
1163      p.addColumn(FAMILY, Bytes.toBytes("abd"), outsideTs, VALUE);
1164      p.addColumn(FAMILY, Bytes.toBytes("xyz"), insideTs, VALUE);
1165      p.addColumn(FAMILY, Bytes.toBytes("xyz"), outsideTs, VALUE);
1166      region.put(p);
1167    }
1168    region.flush(true);
1169
1170    // ColumnPrefixFilter with prefix "ab" should match "abc" and "abd".
1171    ColumnPrefixFilter prefixFilter = new ColumnPrefixFilter(Bytes.toBytes("ab"));
1172
1173    // Baseline: same prefix logic via filterCell, but no getSkipHint override.
1174    FilterBase noHintBaseline = new FilterBase() {
1175      private final ColumnPrefixFilter delegate = new ColumnPrefixFilter(Bytes.toBytes("ab"));
1176
1177      @Override
1178      public ReturnCode filterCell(Cell c) throws IOException {
1179        return delegate.filterCell(c);
1180      }
1181    };
1182
1183    Scan hintScan = new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setFilter(prefixFilter);
1184    Scan noHintScan =
1185      new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setFilter(noHintBaseline);
1186
1187    List<Cell> hintResults = scanAll(hintScan);
1188    List<Cell> noHintResults = scanAll(noHintScan);
1189
1190    assertEquals(noHintResults.size(), hintResults.size(),
1191      "Hint-aware scan must return same cells as no-hint baseline");
1192    // Should get "abc" and "abd" qualifiers for each row, only insideTs versions.
1193    assertEquals(rowCount * 2, hintResults.size());
1194  }
1195
1196  @Test
1197  public void testMultipleColumnPrefixFilterGetSkipHintIntegration() throws IOException {
1198    final long insideTs = 2000;
1199    final long outsideTs = 500;
1200    final int rowCount = 5;
1201
1202    for (int i = 0; i < rowCount; i++) {
1203      byte[] row = Bytes.toBytes(String.format("mcpfx-%02d", i));
1204      Put p = new Put(row);
1205      p.setDurability(Durability.SKIP_WAL);
1206      // Qualifiers: "aaa", "abc", "abd", "bbb", "xyz"
1207      p.addColumn(FAMILY, Bytes.toBytes("aaa"), insideTs, VALUE);
1208      p.addColumn(FAMILY, Bytes.toBytes("aaa"), outsideTs, VALUE);
1209      p.addColumn(FAMILY, Bytes.toBytes("abc"), insideTs, VALUE);
1210      p.addColumn(FAMILY, Bytes.toBytes("abc"), outsideTs, VALUE);
1211      p.addColumn(FAMILY, Bytes.toBytes("abd"), insideTs, VALUE);
1212      p.addColumn(FAMILY, Bytes.toBytes("abd"), outsideTs, VALUE);
1213      p.addColumn(FAMILY, Bytes.toBytes("bbb"), insideTs, VALUE);
1214      p.addColumn(FAMILY, Bytes.toBytes("bbb"), outsideTs, VALUE);
1215      p.addColumn(FAMILY, Bytes.toBytes("xyz"), insideTs, VALUE);
1216      p.addColumn(FAMILY, Bytes.toBytes("xyz"), outsideTs, VALUE);
1217      region.put(p);
1218    }
1219    region.flush(true);
1220
1221    // MultipleColumnPrefixFilter with prefixes "ab" and "bb" should match "abc", "abd", "bbb".
1222    MultipleColumnPrefixFilter mcpFilter =
1223      new MultipleColumnPrefixFilter(new byte[][] { Bytes.toBytes("ab"), Bytes.toBytes("bb") });
1224
1225    // Baseline: same prefix logic via filterCell, but no getSkipHint override.
1226    FilterBase noHintBaseline = new FilterBase() {
1227      private final MultipleColumnPrefixFilter delegate =
1228        new MultipleColumnPrefixFilter(new byte[][] { Bytes.toBytes("ab"), Bytes.toBytes("bb") });
1229
1230      @Override
1231      public ReturnCode filterCell(Cell c) throws IOException {
1232        return delegate.filterCell(c);
1233      }
1234    };
1235
1236    Scan hintScan = new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setFilter(mcpFilter);
1237    Scan noHintScan =
1238      new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setFilter(noHintBaseline);
1239
1240    List<Cell> hintResults = scanAll(hintScan);
1241    List<Cell> noHintResults = scanAll(noHintScan);
1242
1243    assertEquals(noHintResults.size(), hintResults.size(),
1244      "Hint-aware scan must return same cells as no-hint baseline");
1245    // Should get "abc", "abd", "bbb" qualifiers for each row, only insideTs versions.
1246    assertEquals(rowCount * 3, hintResults.size());
1247  }
1248
1249  @Test
1250  public void testFilterListANDGetSkipHintComposition() throws IOException {
1251    final long insideTs = 2000;
1252    final long outsideTs = 500;
1253    final int rowCount = 5;
1254
1255    for (int i = 0; i < rowCount; i++) {
1256      byte[] row = Bytes.toBytes(String.format("composed-%02d", i));
1257      Put p = new Put(row);
1258      p.setDurability(Durability.SKIP_WAL);
1259      p.addColumn(FAMILY, Bytes.toBytes("a"), insideTs, VALUE);
1260      p.addColumn(FAMILY, Bytes.toBytes("a"), outsideTs, VALUE);
1261      p.addColumn(FAMILY, Bytes.toBytes("b"), insideTs, VALUE);
1262      p.addColumn(FAMILY, Bytes.toBytes("b"), outsideTs, VALUE);
1263      p.addColumn(FAMILY, Bytes.toBytes("c"), insideTs, VALUE);
1264      p.addColumn(FAMILY, Bytes.toBytes("c"), outsideTs, VALUE);
1265      region.put(p);
1266    }
1267    region.flush(true);
1268
1269    // Compose ColumnRangeFilter("b","c") AND a custom skip-hint filter.
1270    // The AND composition should take the max hint.
1271    final AtomicInteger skipHintCalls = new AtomicInteger(0);
1272    ColumnRangeFilter colRange =
1273      new ColumnRangeFilter(Bytes.toBytes("b"), true, Bytes.toBytes("c"), true);
1274    FilterBase customSkipHint = new FilterBase() {
1275      @Override
1276      public Cell getSkipHint(Cell skippedCell) {
1277        skipHintCalls.incrementAndGet();
1278        return PrivateCellUtil.createFirstOnNextRow(skippedCell);
1279      }
1280    };
1281
1282    FilterList andFilter =
1283      new FilterList(FilterList.Operator.MUST_PASS_ALL, Arrays.asList(colRange, customSkipHint));
1284
1285    // Baseline: same column range via filterCell, no getSkipHint.
1286    FilterBase noHintBaseline = new FilterBase() {
1287      private final ColumnRangeFilter delegate =
1288        new ColumnRangeFilter(Bytes.toBytes("b"), true, Bytes.toBytes("c"), true);
1289
1290      @Override
1291      public ReturnCode filterCell(Cell c) throws IOException {
1292        return delegate.filterCell(c);
1293      }
1294    };
1295
1296    Scan hintScan = new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setFilter(andFilter);
1297    Scan noHintScan =
1298      new Scan().addFamily(FAMILY).setTimeRange(1000, 3000).setFilter(noHintBaseline);
1299
1300    List<Cell> hintResults = scanAll(hintScan);
1301    List<Cell> noHintResults = scanAll(noHintScan);
1302
1303    assertEquals(noHintResults.size(), hintResults.size(),
1304      "AND composed skip-hint must return same cells as no-hint baseline");
1305    // Should get "b" and "c" for each row, only insideTs.
1306    assertEquals(rowCount * 2, hintResults.size());
1307  }
1308
1309  @Test
1310  public void testFilterListANDDivergentHints() throws IOException {
1311    final String prefix = "row";
1312    final int totalRows = 10;
1313    writeRows(prefix, totalRows);
1314
1315    // Filter A rejects rows 0-4, hints to row-03 (a conservative hint).
1316    // Filter B rejects rows 0-6, hints to row-07 (a more aggressive hint).
1317    // Both reject rows 0-4 (overlap). AND merges => takes max => row-07.
1318    // Rows 0-6 are rejected by the composite (at least one rejects each).
1319    // Scan should return rows 07-09.
1320    final byte[] rejectThresholdA = Bytes.toBytes(String.format("%s-%02d", prefix, 5));
1321    final byte[] hintTargetA = Bytes.toBytes(String.format("%s-%02d", prefix, 3));
1322    final byte[] rejectThresholdB = Bytes.toBytes(String.format("%s-%02d", prefix, 7));
1323    final byte[] hintTargetB = Bytes.toBytes(String.format("%s-%02d", prefix, 7));
1324
1325    FilterBase filterA = new FilterBase() {
1326      @Override
1327      public boolean filterRowKey(Cell cell) {
1328        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
1329          rejectThresholdA, 0, rejectThresholdA.length) < 0;
1330      }
1331
1332      @Override
1333      public Cell getHintForRejectedRow(Cell firstRowCell) {
1334        return PrivateCellUtil.createFirstOnRow(hintTargetA);
1335      }
1336    };
1337
1338    FilterBase filterB = new FilterBase() {
1339      @Override
1340      public boolean filterRowKey(Cell cell) {
1341        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
1342          rejectThresholdB, 0, rejectThresholdB.length) < 0;
1343      }
1344
1345      @Override
1346      public Cell getHintForRejectedRow(Cell firstRowCell) {
1347        return PrivateCellUtil.createFirstOnRow(hintTargetB);
1348      }
1349    };
1350
1351    FilterList andFilter =
1352      new FilterList(FilterList.Operator.MUST_PASS_ALL, Arrays.asList(filterA, filterB));
1353
1354    FilterBase baseline = new FilterBase() {
1355      @Override
1356      public boolean filterRowKey(Cell cell) {
1357        return Bytes.compareTo(cell.getRowArray(), cell.getRowOffset(), cell.getRowLength(),
1358          rejectThresholdB, 0, rejectThresholdB.length) < 0;
1359      }
1360    };
1361
1362    List<Cell> hintResults = scanAll(new Scan().addFamily(FAMILY).setFilter(andFilter));
1363    List<Cell> baselineResults = scanAll(new Scan().addFamily(FAMILY).setFilter(baseline));
1364
1365    assertEquals(baselineResults.size(), hintResults.size(),
1366      "AND with divergent hints must return same cells as baseline");
1367    for (int i = 0; i < hintResults.size(); i++) {
1368      assertTrue(CellUtil.equals(hintResults.get(i), baselineResults.get(i)),
1369        "Cell mismatch at index " + i);
1370    }
1371    assertEquals(3 * CELLS_PER_ROW, hintResults.size());
1372  }
1373}