This is an automated email from the ASF dual-hosted git repository.
virajjasani pushed a commit to branch 5.3
in repository https://gitbox.apache.org/repos/asf/phoenix.git
The following commit(s) were added to refs/heads/5.3 by this push:
new 35ce1b0655 PHOENIX-8015 Enforce uncovered global index limits after
verification (#2637)
35ce1b0655 is described below
commit 35ce1b0655ee9773c8a2ab7877d894de00017fbe
Author: Jing Yu <[email protected]>
AuthorDate: Tue Oct 20 12:27:19 2026 -0700
PHOENIX-8015 Enforce uncovered global index limits after verification
(#2637)
---
.../UncoveredGlobalIndexRegionScanner.java | 119 +++++-
.../end2end/index/UncoveredGlobalIndexLimitIT.java | 421 +++++++++++++++++++++
.../index/UncoveredGlobalIndexRegionScannerIT.java | 117 ++++++
.../UncoveredGlobalIndexRegionScannerTest.java | 165 ++++++++
4 files changed, 804 insertions(+), 18 deletions(-)
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/UncoveredGlobalIndexRegionScanner.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/UncoveredGlobalIndexRegionScanner.java
index f457a9f633..8af5f19008 100644
---
a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/UncoveredGlobalIndexRegionScanner.java
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/UncoveredGlobalIndexRegionScanner.java
@@ -28,15 +28,19 @@ import java.util.TreeSet;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hbase.Cell;
+import org.apache.hadoop.hbase.DoNotRetryIOException;
import org.apache.hadoop.hbase.client.Result;
import org.apache.hadoop.hbase.client.ResultScanner;
import org.apache.hadoop.hbase.client.Scan;
import org.apache.hadoop.hbase.client.Table;
import org.apache.hadoop.hbase.client.metrics.ScanMetrics;
import org.apache.hadoop.hbase.coprocessor.RegionCoprocessorEnvironment;
+import org.apache.hadoop.hbase.filter.PageFilter;
import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
import org.apache.hadoop.hbase.regionserver.Region;
import org.apache.hadoop.hbase.regionserver.RegionScanner;
+import org.apache.hadoop.hbase.regionserver.ScannerContext;
import org.apache.hadoop.hbase.util.Bytes;
import org.apache.hadoop.hbase.util.Pair;
import org.apache.phoenix.compat.hbase.CompatScanMetrics;
@@ -76,6 +80,8 @@ public class UncoveredGlobalIndexRegionScanner extends
UncoveredIndexRegionScann
protected final HTableFactory hTableFactory;
private final boolean isScanMetricsEnabled;
private List<DataTableScanMetricsWithScanTime> dataTableScanMetrics;
+ private final long maxRowCount;
+ private long returnedRowCount;
// This relies on Hadoop Configuration to handle warning about deprecated
configs and
// to set the correct non-deprecated configs when an old one shows up.
@@ -89,28 +95,105 @@ public class UncoveredGlobalIndexRegionScanner extends
UncoveredIndexRegionScann
final TupleProjector tupleProjector, final IndexMaintainer indexMaintainer,
final byte[][] viewConstants, final ImmutableBytesWritable ptr, final long
pageSizeMs,
final long queryLimit, boolean isDistinct) throws IOException {
- super(innerScanner, region, scan, env, dataTableScan, tupleProjector,
indexMaintainer,
- viewConstants, ptr, pageSizeMs, queryLimit, isDistinct);
- final Configuration config = env.getConfiguration();
- hTableFactory = IndexWriterUtils.getDefaultDelegateHTableFactory(env);
- rowCountPerTask =
- config.getInt(INDEX_ROW_COUNTS_PER_TASK_CONF_KEY,
DEFAULT_INDEX_ROW_COUNTS_PER_TASK);
- byte[] dataTableName = scan.getAttribute(PHYSICAL_DATA_TABLE_NAME);
- dataHTable = hTableFactory.getTable(new ImmutableBytesPtr(dataTableName));
- regionEndKeys =
-
hTableFactory.getConnection().getRegionLocator(dataHTable.getName()).getEndKeys();
- if (indexMaintainer.isUncovered()) {
- // Empty column should also be added to the data columns to join for
uncovered
- // global indexes. This is required to verify the index row against the
data table row and
- // repair it
+ // Index rows can be rejected when joined with the data table. Take the
PageFilter limit
+ // here so only verified rows count toward it. Otherwise HBase's
PageFilter, or
+ // TTLRegionScanner which takes over the PageFilter lazily on its first
next(), would
+ // count rows that are later rejected. This must happen before
TTLRegionScanner.init().
+ this(innerScanner, region, scan, env, dataTableScan, tupleProjector,
indexMaintainer,
+ viewConstants, ptr, pageSizeMs, queryLimit, isDistinct,
+ !indexMaintainer.isCDCIndex() ? ScanUtil.removePageFilter(scan) : null);
+ }
+
+ private UncoveredGlobalIndexRegionScanner(final RegionScanner innerScanner,
final Region region,
+ final Scan scan, final RegionCoprocessorEnvironment env, final Scan
dataTableScan,
+ final TupleProjector tupleProjector, final IndexMaintainer indexMaintainer,
+ final byte[][] viewConstants, final ImmutableBytesWritable ptr, final long
pageSizeMs,
+ final long queryLimit, boolean isDistinct, PageFilter pageFilter) throws
IOException {
+ // Keep a stable delegate for the superclass, and reopen only inside the
guarded body.
+ // If superclass initialization fails, the caller still owns the original
scanner.
+ super(pageFilter == null ? innerScanner : new
DelegateRegionScanner(innerScanner), region, scan,
+ env, dataTableScan, tupleProjector, indexMaintainer, viewConstants, ptr,
pageSizeMs,
+ pageFilter == null ? queryLimit : pageFilter.getPageSize(), isDistinct);
+ maxRowCount = pageFilter == null ? Long.MAX_VALUE :
pageFilter.getPageSize();
+ RegionScanner reopened = null;
+ HTableFactory tableFactory = null;
+ Table table = null;
+ try {
+ if (pageFilter != null) {
+ reopened = reopenScannerWithoutPageFilter(innerScanner, scan,
pageFilter);
+ ((DelegateRegionScanner) this.innerScanner).delegate = reopened;
+ }
+ final Configuration config = env.getConfiguration();
+ tableFactory = IndexWriterUtils.getDefaultDelegateHTableFactory(env);
+ rowCountPerTask =
+ config.getInt(INDEX_ROW_COUNTS_PER_TASK_CONF_KEY,
DEFAULT_INDEX_ROW_COUNTS_PER_TASK);
+ byte[] dataTableName = scan.getAttribute(PHYSICAL_DATA_TABLE_NAME);
+ table = tableFactory.getTable(new ImmutableBytesPtr(dataTableName));
+ regionEndKeys =
tableFactory.getConnection().getRegionLocator(table.getName()).getEndKeys();
+ // Include the empty column to verify the index row against its data row
and repair it.
ScanUtil.addEmptyColumnToScan(dataTableScan,
indexMaintainer.getDataEmptyKeyValueCF(),
indexMaintainer.getEmptyKeyValueQualifierForDataTable());
+ isScanMetricsEnabled =
+ scan.isScanMetricsEnabled() &&
CompatScanMetrics.supportsFineGrainedReadMetrics();
+ if (isScanMetricsEnabled) {
+ dataTableScanMetrics = new ArrayList<>();
+ }
+ hTableFactory = tableFactory;
+ dataHTable = table;
+ } catch (IOException | RuntimeException | Error failure) {
+ // Until construction succeeds, the caller cannot see or close the
replacement scanner.
+ // The caller still owns the original scanner, so close only what was
opened here.
+ if (reopened != null) {
+ closeOnFailure(reopened, failure);
+ }
+ if (table != null) {
+ closeOnFailure(table, failure);
+ }
+ if (tableFactory != null) {
+ closeOnFailure(tableFactory::shutdown, failure);
+ }
+ throw failure;
}
- isScanMetricsEnabled =
- scan.isScanMetricsEnabled() &&
CompatScanMetrics.supportsFineGrainedReadMetrics();
- if (isScanMetricsEnabled) {
- dataTableScanMetrics = new ArrayList<>();
+ }
+
+ private static void closeOnFailure(AutoCloseable resource, Throwable
failure) {
+ try {
+ resource.close();
+ } catch (Throwable cleanupFailure) {
+ if (cleanupFailure != failure) {
+ failure.addSuppressed(cleanupFailure);
+ }
+ }
+ }
+
+ private static RegionScanner reopenScannerWithoutPageFilter(RegionScanner
scanner, Scan scan,
+ PageFilter pageFilter) throws IOException {
+ if (pageFilter == null) {
+ return scanner;
+ }
+ try {
+ DelegateRegionScanner delegate = (DelegateRegionScanner) scanner;
+ scanner.close();
+ return delegate.getNewRegionScanner(scan);
+ } catch (ClassCastException e) {
+ throw new DoNotRetryIOException(e);
+ }
+ }
+
+ @Override
+ public boolean next(List<Cell> result, ScannerContext scannerContext) throws
IOException {
+ if (returnedRowCount >= maxRowCount) {
+ result.clear();
+ return false;
+ }
+ boolean hasMore = super.next(result, scannerContext);
+ if (!result.isEmpty() && !ScanUtil.isDummy(result)) {
+ returnedRowCount++;
+ if (returnedRowCount >= maxRowCount) {
+ return false;
+ }
}
+ return hasMore;
}
@Override
diff --git
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexLimitIT.java
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexLimitIT.java
new file mode 100644
index 0000000000..afd7f28c5b
--- /dev/null
+++
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexLimitIT.java
@@ -0,0 +1,421 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.end2end.index;
+
+import static
org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants.SERVER_PAGE_SIZE_MS;
+import static org.apache.phoenix.query.explain.ExplainPlanTestUtil.assertPlan;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.Get;
+import org.apache.hadoop.hbase.client.Result;
+import org.apache.hadoop.hbase.client.ResultScanner;
+import org.apache.hadoop.hbase.client.Scan;
+import org.apache.hadoop.hbase.client.Table;
+import org.apache.hadoop.hbase.coprocessor.ObserverContext;
+import org.apache.hadoop.hbase.coprocessor.RegionCoprocessorEnvironment;
+import org.apache.hadoop.hbase.coprocessor.SimpleRegionObserver;
+import org.apache.hadoop.hbase.regionserver.InternalScanner;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest;
+import org.apache.phoenix.filter.PagingFilter;
+import org.apache.phoenix.query.BaseTest;
+import org.apache.phoenix.query.QueryServices;
+import org.apache.phoenix.query.QueryServicesOptions;
+import org.apache.phoenix.util.EnvironmentEdgeManager;
+import org.apache.phoenix.util.ManualEnvironmentEdge;
+import org.apache.phoenix.util.ReadOnlyProps;
+import org.apache.phoenix.util.ScanUtil;
+import org.junit.After;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+/** Regression tests for PHOENIX-8015: LIMIT counts verified uncovered-index
rows. */
+@Category(NeedsOwnMiniClusterTest.class)
+public class UncoveredGlobalIndexLimitIT extends BaseTest {
+ private static final long AGE_THRESHOLD_MS = 60_000;
+ // Keep stale entries live in HBase and in Phoenix while testing the
independent repair threshold.
+ private static final int TTL_SECONDS = 3600;
+ private static final int ROW_COUNT = 20;
+ private static final int LIMIT = 5;
+ // Force multiple index batches before and after the first valid row.
+ private static final long INDEX_PAGE_SIZE_IN_ROWS = 3;
+ private static final long UPDATED_TIME_BASE = 1_700_000_000_000L;
+ private final ManualEnvironmentEdge clock = new ManualEnvironmentEdge();
+
+ @BeforeClass
+ public static synchronized void doSetup() throws Exception {
+ Map<String, String> props = new HashMap<>();
+ props.put(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB,
+ Long.toString(AGE_THRESHOLD_MS));
+ props.put(QueryServices.PHOENIX_COMPACTION_ENABLED,
Boolean.toString(true));
+ props.put(QueryServices.USE_STATS_FOR_PARALLELIZATION,
Boolean.toString(false));
+ props.put(QueryServices.INDEX_PAGE_SIZE_IN_ROWS,
Long.toString(INDEX_PAGE_SIZE_IN_ROWS));
+ // BaseTest defaults to a dummy result after every row; exercise a
complete server batch here.
+ props.put(QueryServices.PHOENIX_SERVER_PAGE_SIZE_MS,
Long.toString(60_000));
+ props.put("hbase.coprocessor.region.classes",
LimitTestObserver.class.getName());
+ setUpTestDriver(new ReadOnlyProps(props.entrySet().iterator()));
+ // BaseTest overwrites the age threshold with zero during mini-cluster
configuration.
+ // Restore it before creating user tables so their coprocessors inherit
the intended value.
+ getUtility().getConfiguration()
+
.setLong(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB,
AGE_THRESHOLD_MS);
+ getUtility().getHBaseCluster().getRegionServerThreads().forEach(thread -> {
+ thread.getRegionServer().getConfiguration().setLong(
+ QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB,
AGE_THRESHOLD_MS);
+ assertEquals(AGE_THRESHOLD_MS,
thread.getRegionServer().getConfiguration()
+
.getLong(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB, -1));
+ });
+ }
+
+ @After
+ public void resetClock() throws Exception {
+ LimitTestObserver.indexTable = null;
+ LimitTestObserver.dataTable = null;
+ LimitTestObserver.limitedTable = null;
+ LimitTestObserver.lookupTable = null;
+ EnvironmentEdgeManager.reset();
+ assertFalse("refCount leaked", isAnyStoreRefCountLeaked());
+ }
+
+ @Test
+ public void testLimitWithLeadingStaleRowsAndTTLMasking() throws Exception {
+ assertLimitWithLeadingStaleRows(true);
+ }
+
+ @Test
+ public void testLimitWithLeadingStaleRowsWithoutTTLMasking() throws
Exception {
+ assertLimitWithLeadingStaleRows(false);
+ }
+
+ private void assertLimitWithLeadingStaleRows(boolean finiteTTL) throws
Exception {
+ try (Connection conn = DriverManager.getConnection(getUrl())) {
+ Fixture fixture = createFixture(conn, finiteTTL, 1000);
+ String query = fixture.indexQuery("") + " LIMIT " + LIMIT;
+ assertPlan(conn, query).table(fixture.indexTable).scanType("FULL SCAN")
+ .serverRowLimit((long) LIMIT);
+ assertRows(conn, query, LIMIT);
+ assertStaleEntriesPresent(fixture);
+ }
+ }
+
+ @Test
+ public void testLimitControlsWithLeadingStaleRows() throws Exception {
+ try (Connection conn = DriverManager.getConnection(getUrl())) {
+ // Use the same FOREVER-TTL configuration as the reproduced PageFilter
failure.
+ Fixture fixture = createFixture(conn, false, 1000);
+ String baseQuery = "SELECT /*+ NO_INDEX */ ID, PAYLOAD, UPDATED_TIME
FROM "
+ + fixture.dataTable + " ORDER BY UPDATED_TIME ASC LIMIT " + LIMIT;
+ assertPlan(conn, baseQuery).table(fixture.dataTable);
+ assertRows(conn, baseQuery, LIMIT);
+
+ String boundedQuery =
+ fixture.indexQuery(" WHERE UPDATED_TIME > TO_TIMESTAMP('2000-01-01')")
+ " LIMIT " + LIMIT;
+ assertPlan(conn, boundedQuery).table(fixture.indexTable).scanType("RANGE
SCAN");
+ assertRows(conn, boundedQuery, LIMIT);
+
+ // PAYLOAD comes from the base row, so this exercises INDEX_FILTER /
INDEX_LIMIT.
+ String filteredQuery = fixture.indexQuery(" WHERE PAYLOAD IS NOT NULL")
+ " LIMIT " + LIMIT;
+ assertPlan(conn, filteredQuery).table(fixture.indexTable);
+ assertRows(conn, filteredQuery, LIMIT);
+
+ assertRows(conn, fixture.indexQuery(""), ROW_COUNT);
+ // All controls leave the young stale entries in place for the same
reason as the LIMIT IT.
+ assertStaleEntriesPresent(fixture);
+ }
+ }
+
+ @Test
+ public void testLimitWithPartiallyStaleLeadingRows() throws Exception {
+ for (boolean finiteTTL : new boolean[] { false, true }) {
+ try (Connection conn = DriverManager.getConnection(getUrl())) {
+ Fixture fixture = createFixture(conn, finiteTTL, 1000, 3);
+ assertRows(conn, fixture.indexQuery("") + " LIMIT " + LIMIT, LIMIT);
+ assertStaleEntriesPresent(fixture);
+ }
+ EnvironmentEdgeManager.reset();
+ }
+ }
+
+ @Test
+ public void testLimitLargerThanValidRowCount() throws Exception {
+ for (boolean finiteTTL : new boolean[] { false, true }) {
+ try (Connection conn = DriverManager.getConnection(getUrl())) {
+ Fixture fixture = createFixture(conn, finiteTTL, 1000);
+ assertRows(conn, fixture.indexQuery("") + " LIMIT " + (ROW_COUNT * 3),
ROW_COUNT);
+ assertStaleEntriesPresent(fixture);
+ }
+ EnvironmentEdgeManager.reset();
+ }
+ }
+
+ @Test
+ public void testLimitWithOffset() throws Exception {
+ try (Connection conn = DriverManager.getConnection(getUrl())) {
+ Fixture fixture = createFixture(conn, false, 1000);
+ assertRows(conn, fixture.indexQuery("") + " LIMIT " + LIMIT + " OFFSET
3", LIMIT, 3);
+ assertStaleEntriesPresent(fixture);
+ }
+ }
+
+ @Test
+ public void testLimitAcrossDummyPages() throws Exception {
+ try (Connection conn = DriverManager.getConnection(getUrl())) {
+ Fixture fixture = createFixture(conn, false, 1000);
+ LimitTestObserver.dummyResults.set(0);
+ LimitTestObserver.indexTable = fixture.indexTable;
+ LimitTestObserver.dataTable = fixture.dataTable;
+ assertRows(conn, fixture.indexQuery("") + " LIMIT " + LIMIT, LIMIT);
+ assertTrue("The query must return actual PagingFilter dummy results",
+ LimitTestObserver.dummyResults.get() > 0);
+ assertStaleEntriesPresent(fixture);
+ }
+ }
+
+ @Test
+ public void testSmallLimitCapsDataLookupsAtDefaultBatchSize() throws
Exception {
+ // Use the production default rather than the small batches used by the
other regressions.
+ getUtility().getHBaseCluster().getRegionServerThreads()
+ .forEach(thread -> thread.getRegionServer().getConfiguration().setLong(
+ QueryServices.INDEX_PAGE_SIZE_IN_ROWS,
+ QueryServicesOptions.DEFAULT_INDEX_PAGE_SIZE_IN_ROWS));
+ try (Connection conn = DriverManager.getConnection(getUrl())) {
+ for (int staleRowCount : new int[] { 0, 3 }) {
+ try {
+ Fixture fixture = createFixture(conn, false, 1000, staleRowCount);
+ LimitTestObserver.lookupRows.set(0);
+ LimitTestObserver.lookupTable = fixture.dataTable;
+ String query = fixture.indexQuery("") + " LIMIT 1";
+ assertPlan(conn, query).table(fixture.indexTable).serverRowLimit(1L);
+ assertRows(conn, query, 1);
+ assertEquals("LIMIT 1 must fetch only the stale prefix and the first
valid candidate",
+ staleRowCount + 1, LimitTestObserver.lookupRows.get());
+ assertStaleEntriesPresent(fixture);
+ } finally {
+ EnvironmentEdgeManager.reset();
+ }
+ }
+ } finally {
+ getUtility().getHBaseCluster().getRegionServerThreads()
+ .forEach(thread -> thread.getRegionServer().getConfiguration()
+ .setLong(QueryServices.INDEX_PAGE_SIZE_IN_ROWS,
INDEX_PAGE_SIZE_IN_ROWS));
+ }
+ }
+
+ @Test
+ public void testServerLimitCountsOnlyVerifiedRows() throws Exception {
+ for (boolean finiteTTL : new boolean[] { false, true }) {
+ try (Connection conn = DriverManager.getConnection(getUrl())) {
+ Fixture fixture = createFixture(conn, finiteTTL, 1000);
+ LimitTestObserver.returnedRows.set(0);
+ LimitTestObserver.limitedTable = fixture.indexTable;
+ assertRows(conn, fixture.indexQuery("") + " LIMIT " + LIMIT, LIMIT);
+ // Client-side LIMIT can hide an unbounded server scan. Count rows
actually sent by HBase.
+ assertEquals("Server LIMIT must count verified rows", LIMIT,
+ LimitTestObserver.returnedRows.get());
+ assertStaleEntriesPresent(fixture);
+ } finally {
+ LimitTestObserver.limitedTable = null;
+ EnvironmentEdgeManager.reset();
+ }
+ }
+ }
+
+ private Fixture createFixture(Connection conn, boolean finiteTTL, long
updateDelayMs)
+ throws Exception {
+ return createFixture(conn, finiteTTL, updateDelayMs, ROW_COUNT);
+ }
+
+ private Fixture createFixture(Connection conn, boolean finiteTTL, long
updateDelayMs,
+ int staleRowCount) throws Exception {
+ String dataTable = generateUniqueName();
+ String indexTable = generateUniqueName();
+ try (Statement stmt = conn.createStatement()) {
+ stmt.execute("CREATE TABLE " + dataTable
+ + " (ID VARCHAR NOT NULL PRIMARY KEY, UPDATED_TIME TIMESTAMP, PAYLOAD
VARCHAR)"
+ + (finiteTTL ? " TTL=" + TTL_SECONDS + ", IS_STRICT_TTL=true" : ""));
+ stmt.execute("CREATE UNCOVERED INDEX " + indexTable + " ON " + dataTable
+ " (UPDATED_TIME)");
+ }
+ clock.setValue(EnvironmentEdgeManager.currentTimeMillis() + 1);
+ EnvironmentEdgeManager.injectEdge(clock);
+ long insertTime = clock.currentTime();
+ upsertRows(conn, dataTable, false, staleRowCount);
+ List<Result> initialRows = readPhysicalRows(indexTable);
+ assertEquals(ROW_COUNT, initialRows.size());
+ // This unsalted index orders NULL-keyed entries before the non-NULL
entries.
+ List<Result> staleRows = new ArrayList<>(initialRows.subList(0,
staleRowCount));
+ assertEquals("Initial NULL-keyed index entries", staleRowCount,
staleRows.size());
+ for (Result stale : staleRows) {
+ assertEquals(insertTime, stale.rawCells()[0].getTimestamp());
+ }
+
+ clock.incrementValue(updateDelayMs);
+ // Supply the indexed column explicitly to take the write-optimized
uncovered-index path.
+ upsertRows(conn, dataTable, true, staleRowCount);
+ Fixture fixture = new Fixture(dataTable, indexTable, staleRows);
+ assertEquals("Both stale and current index entries must physically exist",
+ ROW_COUNT + staleRowCount, readPhysicalRows(indexTable).size());
+ assertStaleEntriesPresent(fixture);
+ for (Result data : readPhysicalRows(dataTable)) {
+ assertEquals("Base rows must have the recent update timestamp",
clock.currentTime(),
+ data.rawCells()[0].getTimestamp());
+ }
+ return fixture;
+ }
+
+ private void upsertRows(Connection conn, String dataTable, boolean
nonNullTimestamp,
+ int staleRowCount) throws Exception {
+ try (PreparedStatement stmt = conn.prepareStatement(
+ "UPSERT INTO " + dataTable + " (ID, UPDATED_TIME, PAYLOAD) VALUES (?, ?,
?)")) {
+ for (int i = 0; i < ROW_COUNT; i++) {
+ stmt.setString(1, "tenant-" + i);
+ if (nonNullTimestamp || i >= staleRowCount) {
+ stmt.setTimestamp(2, new Timestamp(UPDATED_TIME_BASE + i * 1000L));
+ } else {
+ stmt.setNull(2, Types.TIMESTAMP);
+ }
+ stmt.setString(3, "payload-" + i);
+ stmt.executeUpdate();
+ }
+ conn.commit();
+ }
+ }
+
+ private List<Result> readPhysicalRows(String tableName) throws Exception {
+ // No Phoenix scan attributes: inspect live HBase entries without
uncovered verification.
+ List<Result> rows = new ArrayList<>();
+ try (Table table =
getUtility().getConnection().getTable(TableName.valueOf(tableName));
+ ResultScanner scanner = table.getScanner(new Scan())) {
+ for (Result result : scanner) {
+ rows.add(result);
+ }
+ }
+ return rows;
+ }
+
+ private void assertStaleEntriesPresent(Fixture fixture) throws Exception {
+ try (
+ Table index =
getUtility().getConnection().getTable(TableName.valueOf(fixture.indexTable))) {
+ for (Result stale : fixture.staleRows) {
+ Result current = index.get(new Get(stale.getRow()));
+ assertFalse("Fixture lost a stale index entry", current.isEmpty());
+ assertEquals("Stale entry timestamp must not advance with the data
row",
+ stale.rawCells()[0].getTimestamp(),
current.rawCells()[0].getTimestamp());
+ }
+ }
+ }
+
+ private void assertRows(Connection conn, String query, int expectedRows)
throws Exception {
+ assertRows(conn, query, expectedRows, 0);
+ }
+
+ private void assertRows(Connection conn, String query, int expectedRows, int
firstRow)
+ throws Exception {
+ try (Statement stmt = conn.createStatement(); ResultSet rs =
stmt.executeQuery(query)) {
+ int count = 0;
+ while (rs.next()) {
+ assertEquals("tenant-" + (firstRow + count), rs.getString(1));
+ assertEquals("payload-" + (firstRow + count), rs.getString(2));
+ assertEquals(new Timestamp(UPDATED_TIME_BASE + (firstRow + count) *
1000L),
+ rs.getTimestamp(3));
+ count++;
+ }
+ assertEquals("Valid rows returned by: " + query, expectedRows, count);
+ }
+ }
+
+ public static class LimitTestObserver extends SimpleRegionObserver {
+ private static volatile String indexTable;
+ private static volatile String dataTable;
+ private static final AtomicInteger dummyResults = new AtomicInteger();
+ private static volatile String limitedTable;
+ private static final AtomicInteger returnedRows = new AtomicInteger();
+ private static volatile String lookupTable;
+ private static final AtomicInteger lookupRows = new AtomicInteger();
+
+ @Override
+ public void preScannerOpen(ObserverContext<RegionCoprocessorEnvironment>
context, Scan scan) {
+ String table =
+
context.getEnvironment().getRegion().getTableDescriptor().getTableName().getNameAsString();
+ if (table.equals(indexTable) || table.equals(dataTable)) {
+ scan.setAttribute(SERVER_PAGE_SIZE_MS, Bytes.toBytes(0L));
+ if (scan.getFilter() instanceof PagingFilter) {
+ scan
+ .setFilter(new PagingFilter(((PagingFilter)
scan.getFilter()).getDelegateFilter(), 0));
+ }
+ }
+ }
+
+ @Override
+ public boolean
postScannerNext(ObserverContext<RegionCoprocessorEnvironment> context,
+ InternalScanner scanner, List<Result> results, int limit, boolean
hasMore) {
+ String table =
+
context.getEnvironment().getRegion().getTableDescriptor().getTableName().getNameAsString();
+ for (Result result : results) {
+ if (table.equals(indexTable) || table.equals(dataTable)) {
+ if (ScanUtil.isDummy(result)) {
+ dummyResults.incrementAndGet();
+ }
+ }
+ if (table.equals(lookupTable) && !result.isEmpty() &&
!ScanUtil.isDummy(result)) {
+ lookupRows.incrementAndGet();
+ }
+ }
+ if (table.equals(limitedTable)) {
+ for (Result result : results) {
+ if (!result.isEmpty() && !ScanUtil.isDummy(result)) {
+ returnedRows.incrementAndGet();
+ }
+ }
+ }
+ return hasMore;
+ }
+ }
+
+ private static class Fixture {
+ final String dataTable;
+ final String indexTable;
+ final List<Result> staleRows;
+
+ Fixture(String dataTable, String indexTable, List<Result> staleRows) {
+ this.dataTable = dataTable;
+ this.indexTable = indexTable;
+ this.staleRows = staleRows;
+ }
+
+ String indexQuery(String predicate) {
+ return "SELECT /*+ INDEX(" + dataTable + " " + indexTable
+ + ") */ ID, PAYLOAD, UPDATED_TIME FROM " + dataTable + predicate
+ + " ORDER BY UPDATED_TIME ASC";
+ }
+ }
+}
diff --git
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScannerIT.java
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScannerIT.java
index f3c8503457..a29dc7b1e5 100644
---
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScannerIT.java
+++
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScannerIT.java
@@ -912,6 +912,123 @@ public class UncoveredGlobalIndexRegionScannerIT extends
BaseTest {
}
}
+ @Test
+ public void testUncoveredIndexLimitSkipsStaleRows() throws Exception {
+ assumeTrue(uncovered);
+ assertUncoveredIndexLimit(false, 0);
+ }
+
+ @Test
+ public void testUncoveredIndexLimitWithTTLMasking() throws Exception {
+ assumeTrue(uncovered);
+ assertUncoveredIndexLimit(true, 0);
+ }
+
+ @Test
+ public void testUncoveredIndexLimitWithOffset() throws Exception {
+ assumeTrue(uncovered);
+ assertUncoveredIndexLimit(false, 2);
+ }
+
+ private void assertUncoveredIndexLimit(boolean finiteTTL, int offset) throws
Exception {
+ Map<Configuration, String> originalThresholds = new LinkedHashMap<>();
+ Configuration clusterConfig = getUtility().getConfiguration();
+ originalThresholds.put(clusterConfig,
+
clusterConfig.getRaw(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB));
+ getUtility().getHBaseCluster().getRegionServerThreads().forEach(thread -> {
+ Configuration config = thread.getRegionServer().getConfiguration();
+ originalThresholds.putIfAbsent(config,
+
config.getRaw(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB));
+ });
+ EnvironmentEdge originalClock = EnvironmentEdgeManager.getDelegate();
+ ManualEnvironmentEdge clock = new ManualEnvironmentEdge();
+ try {
+ // Keep stale entries young so LIMIT correctness cannot depend on
read-repair deletion.
+ originalThresholds.keySet().forEach(config -> config
+
.setLong(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB,
60_000));
+ try (Connection conn = DriverManager.getConnection(getUrl());
+ Statement stmt = conn.createStatement()) {
+ String dataTable = generateUniqueName();
+ String indexTable = generateUniqueName();
+ stmt.execute("CREATE TABLE " + dataTable
+ + " (ID VARCHAR NOT NULL PRIMARY KEY, UPDATED_TIME TIMESTAMP, VAL
VARCHAR)"
+ + (finiteTTL ? " TTL=3600, IS_STRICT_TTL=true" : "")
+ + (salted ? (finiteTTL ? ", " : " ") + "SALT_BUCKETS=4" : ""));
+ stmt.execute("CREATE UNCOVERED INDEX " + indexTable + " ON " +
dataTable + " (UPDATED_TIME)"
+ + (salted ? " SALT_BUCKETS=4" : ""));
+ long insertTime = EnvironmentEdgeManager.currentTimeMillis() + 1;
+ long timestampBase = 1_700_000_000_000L;
+ int rows = 20;
+ int limit = 3;
+ clock.setValue(insertTime);
+ EnvironmentEdgeManager.injectEdge(clock);
+ upsertLimitRows(conn, dataTable, rows, timestampBase, false);
+ List<byte[]> staleKeys = new ArrayList<>();
+ try (Table index =
getUtility().getConnection().getTable(TableName.valueOf(indexTable))) {
+ // No Phoenix attributes: inspect the physical fixture without
verification or repair.
+ try (ResultScanner scanner = index.getScanner(new Scan())) {
+ for (Result row : scanner) {
+ staleKeys.add(row.getRow());
+ assertEquals(insertTime, row.rawCells()[0].getTimestamp());
+ }
+ }
+ assertEquals(rows, staleKeys.size());
+ clock.incrementValue(1000);
+ upsertLimitRows(conn, dataTable, rows, timestampBase, true);
+ for (byte[] key : staleKeys) {
+ assertFalse("The fixture must retain its stale index entries",
+ index.get(new Get(key)).isEmpty());
+ }
+ String sql = "SELECT /*+ INDEX(" + dataTable + " " + indexTable
+ + ") */ ID, VAL, UPDATED_TIME FROM " + dataTable + " ORDER BY
UPDATED_TIME ASC LIMIT "
+ + limit + (offset == 0 ? "" : " OFFSET " + offset);
+ assertPlan(conn, sql).table(indexTable).scanType("FULL SCAN");
+ try (ResultSet rs = stmt.executeQuery(sql)) {
+ for (int i = offset; i < offset + limit; i++) {
+ assertTrue("LIMIT must return valid rows after the stale
prefix", rs.next());
+ assertEquals("id-" + i, rs.getString(1));
+ assertEquals("value-" + i, rs.getString(2));
+ assertEquals(new Timestamp(timestampBase + i * 1000L),
rs.getTimestamp(3));
+ }
+ assertFalse(rs.next());
+ }
+ for (byte[] key : staleKeys) {
+ Result stale = index.get(new Get(key));
+ assertFalse("LIMIT must work while young stale entries remain",
stale.isEmpty());
+ assertEquals(insertTime, stale.rawCells()[0].getTimestamp());
+ }
+ }
+ }
+ } finally {
+ EnvironmentEdgeManager.injectEdge(originalClock);
+ originalThresholds.forEach((config, value) -> {
+ if (value == null) {
+
config.unset(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB);
+ } else {
+
config.set(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB,
value);
+ }
+ });
+ }
+ }
+
+ private void upsertLimitRows(Connection conn, String dataTable, int rows,
long timestampBase,
+ boolean nonNullTimestamp) throws SQLException {
+ try (PreparedStatement stmt = conn
+ .prepareStatement("UPSERT INTO " + dataTable + " (ID, UPDATED_TIME, VAL)
VALUES (?, ?, ?)")) {
+ for (int i = 0; i < rows; i++) {
+ stmt.setString(1, "id-" + i);
+ if (nonNullTimestamp) {
+ stmt.setTimestamp(2, new Timestamp(timestampBase + i * 1000L));
+ } else {
+ stmt.setNull(2, Types.TIMESTAMP);
+ }
+ stmt.setString(3, "value-" + i);
+ stmt.executeUpdate();
+ }
+ conn.commit();
+ }
+ }
+
/** PHOENIX-8016: an old index entry must be aged independently of its
recent base row. */
@Test
public void testOldStaleEntriesAreDeletedWhileDataRowsAreRecent() throws
Exception {
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/coprocessor/UncoveredGlobalIndexRegionScannerTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/coprocessor/UncoveredGlobalIndexRegionScannerTest.java
new file mode 100644
index 0000000000..1aa2f556df
--- /dev/null
+++
b/phoenix-core/src/test/java/org/apache/phoenix/coprocessor/UncoveredGlobalIndexRegionScannerTest.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.coprocessor;
+
+import static
org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants.PHYSICAL_DATA_TABLE_NAME;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hbase.DoNotRetryIOException;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.Connection;
+import org.apache.hadoop.hbase.client.RegionLocator;
+import org.apache.hadoop.hbase.client.Scan;
+import org.apache.hadoop.hbase.client.Table;
+import org.apache.hadoop.hbase.coprocessor.RegionCoprocessorEnvironment;
+import org.apache.hadoop.hbase.filter.PageFilter;
+import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
+import org.apache.hadoop.hbase.regionserver.Region;
+import org.apache.hadoop.hbase.regionserver.RegionScanner;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.apache.phoenix.index.IndexMaintainer;
+import org.apache.phoenix.util.ServerUtil.ConnectionFactory;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class UncoveredGlobalIndexRegionScannerTest {
+ private final RegionCoprocessorEnvironment env =
mock(RegionCoprocessorEnvironment.class);
+ private final Connection connection = mock(Connection.class);
+ private final Table table = mock(Table.class);
+ private final RegionLocator locator = mock(RegionLocator.class);
+ private final DelegateRegionScanner original =
mock(DelegateRegionScanner.class);
+ private final RegionScanner replacement = mock(RegionScanner.class);
+ private final IndexMaintainer maintainer = mock(IndexMaintainer.class);
+ private final Scan scan = new Scan().withStartRow(Bytes.toBytes("start"));
+
+ @Before
+ public void setUp() throws Exception {
+ ConnectionFactory.shutdown();
+ when(env.getConfiguration()).thenReturn(new Configuration(false));
+
when(env.createConnection(any(Configuration.class))).thenReturn(connection);
+ when(connection.getTable(any(TableName.class))).thenReturn(table);
+
when(connection.getRegionLocator(any(TableName.class))).thenReturn(locator);
+ when(table.getName()).thenReturn(TableName.valueOf("DATA"));
+
when(original.getNewRegionScanner(any(Scan.class))).thenReturn(replacement);
+ scan.setAttribute(PHYSICAL_DATA_TABLE_NAME, Bytes.toBytes("DATA"));
+ scan.setFilter(new PageFilter(1));
+ }
+
+ @After
+ public void tearDown() {
+ ConnectionFactory.shutdown();
+ }
+
+ @Test
+ public void testTableOpenFailureClosesReplacement() throws Exception {
+ IOException failure = new IOException("table open failed");
+ when(connection.getTable(any(TableName.class))).thenThrow(failure);
+ assertConstructionFails(original, failure);
+ verify(original).close();
+ verify(replacement).close();
+ verify(table, never()).close();
+ }
+
+ @Test
+ public void testRegionLookupFailureClosesReplacementAndTable() throws
Exception {
+ IOException failure = new IOException("region lookup failed");
+ when(locator.getEndKeys()).thenThrow(failure);
+ assertConstructionFails(original, failure);
+ verify(replacement).close();
+ verify(table).close();
+ }
+
+ @Test
+ public void testCleanupFailuresPreserveInitializationFailure() throws
Exception {
+ IOException failure = new IOException("region lookup failed");
+ IOException scannerCloseFailure = new IOException("scanner close failed");
+ IOException tableCloseFailure = new IOException("table close failed");
+ when(locator.getEndKeys()).thenThrow(failure);
+ doThrow(scannerCloseFailure).when(replacement).close();
+ doThrow(tableCloseFailure).when(table).close();
+ assertConstructionFails(original, failure);
+ assertEquals(2, failure.getSuppressed().length);
+ assertSame(scannerCloseFailure, failure.getSuppressed()[0]);
+ assertSame(tableCloseFailure, failure.getSuppressed()[1]);
+ verify(table).close();
+ }
+
+ @Test
+ public void testSuperclassFailureDoesNotReopenScanner() throws Exception {
+ RuntimeException failure = new IllegalArgumentException("configuration
failed");
+ when(env.getConfiguration()).thenThrow(failure);
+ try {
+ construct(original);
+ fail("Expected superclass initialization to fail");
+ } catch (RuntimeException actual) {
+ assertSame(failure, actual);
+ }
+ verify(original, never()).getNewRegionScanner(any(Scan.class));
+ verify(original, never()).close();
+ }
+
+ @Test
+ public void testFailureWithoutPageFilterLeavesOriginalOwnedByCaller() throws
Exception {
+ scan.setFilter(null);
+ IOException failure = new IOException("region lookup failed");
+ when(locator.getEndKeys()).thenThrow(failure);
+ assertConstructionFails(original, failure);
+ verify(original, never()).close();
+ verify(original, never()).getNewRegionScanner(any(Scan.class));
+ verify(table).close();
+ }
+
+ @Test
+ public void testUnsupportedScannerIsNotRetriedAndLeftOwnedByCaller() throws
Exception {
+ RegionScanner unsupported = mock(RegionScanner.class);
+ try {
+ construct(unsupported);
+ fail("Expected incompatible scanner to fail");
+ } catch (DoNotRetryIOException failure) {
+ assertTrue(failure.getCause() instanceof ClassCastException);
+ }
+ verify(unsupported, never()).close();
+ }
+
+ private void assertConstructionFails(RegionScanner scanner, IOException
expected)
+ throws Exception {
+ try {
+ construct(scanner);
+ fail("Expected scanner initialization to fail");
+ } catch (IOException actual) {
+ assertSame(expected, actual);
+ }
+ }
+
+ private void construct(RegionScanner scanner) throws IOException {
+ new UncoveredGlobalIndexRegionScanner(scanner, mock(Region.class), scan,
env, new Scan(), null,
+ maintainer, null, new ImmutableBytesWritable(), 60_000, -1, false);
+ }
+}