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 2c8dbe2f35 PHOENIX-8016 Use index timestamp for uncovered stale-entry
cleanup (#2630)
2c8dbe2f35 is described below
commit 2c8dbe2f35652a7fbf011212da504a2c9ab22982
Author: Jing Yu <[email protected]>
AuthorDate: Thu Oct 15 15:10:40 2026 -0700
PHOENIX-8016 Use index timestamp for uncovered stale-entry cleanup (#2630)
---
.../coprocessor/UncoveredIndexRegionScanner.java | 11 +-
.../index/UncoveredGlobalIndexRegionScannerIT.java | 220 +++++++++++++++++++++
2 files changed, 226 insertions(+), 5 deletions(-)
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/UncoveredIndexRegionScanner.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/UncoveredIndexRegionScanner.java
index 5d65754109..b7c84db002 100644
---
a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/UncoveredIndexRegionScanner.java
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/UncoveredIndexRegionScanner.java
@@ -350,13 +350,14 @@ public abstract class UncoveredIndexRegionScanner extends
BaseRegionScanner {
}
return true;
}
- // This is not a valid index row
+ // This is not a valid index row. Age it by its own timestamp, but delete
it at the data row
+ // timestamp (when the row stopped mapping to this index key) or later, so
that the index row
+ // history stays consistent with the data row history.
if (
- indexMaintainer.isAgedEnough(IndexUtil.getMaxTimestamp(put),
ageThreshold)
- && !indexMaintainer.isCDCIndex()
+ indexMaintainer.isAgedEnough(indexTimestamp, ageThreshold) &&
!indexMaintainer.isCDCIndex()
) {
- region
- .delete(indexMaintainer.createDelete(indexRowKey,
IndexUtil.getMaxTimestamp(put), false));
+ long deleteTimestamp = Math.max(IndexUtil.getMaxTimestamp(put),
indexTimestamp);
+ region.delete(indexMaintainer.createDelete(indexRowKey, deleteTimestamp,
false));
}
return false;
}
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 641fcaa2ea..fb8f6f9de5 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
@@ -23,20 +23,33 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
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.Arrays;
import java.util.Calendar;
import java.util.Collection;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hbase.Cell;
+import org.apache.hadoop.hbase.CellUtil;
+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;
@@ -47,7 +60,9 @@ import org.apache.phoenix.hbase.index.IndexRegionObserver;
import org.apache.phoenix.query.BaseTest;
import org.apache.phoenix.query.KeyRange;
import org.apache.phoenix.query.QueryServices;
+import org.apache.phoenix.util.EnvironmentEdge;
import org.apache.phoenix.util.EnvironmentEdgeManager;
+import org.apache.phoenix.util.ManualEnvironmentEdge;
import org.apache.phoenix.util.QueryUtil;
import org.apache.phoenix.util.ReadOnlyProps;
import org.apache.phoenix.util.SchemaUtil;
@@ -65,6 +80,11 @@ import
org.apache.phoenix.thirdparty.com.google.common.collect.Maps;
@Category(NeedsOwnMiniClusterTest.class)
@RunWith(Parameterized.class)
public class UncoveredGlobalIndexRegionScannerIT 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 long UPDATED_TIME_BASE = 1_700_000_000_000L;
private final boolean uncovered;
private final boolean salted;
@@ -890,6 +910,206 @@ public class UncoveredGlobalIndexRegionScannerIT extends
BaseTest {
}
}
+ /** PHOENIX-8016: an old index entry must be aged independently of its
recent base row. */
+ @Test
+ public void testOldStaleEntriesAreDeletedWhileDataRowsAreRecent() throws
Exception {
+ assumeTrue(uncovered);
+ verifyStaleEntryRepair(true);
+ }
+
+ @Test
+ public void testYoungStaleEntriesAreNotDeleted() throws Exception {
+ assumeTrue(uncovered);
+ verifyStaleEntryRepair(false);
+ }
+
+ private void verifyStaleEntryRepair(boolean oldEntries) 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 the class-wide zero threshold for existing tests; use a positive
age only here.
+ for (Configuration config : originalThresholds.keySet()) {
+
config.setLong(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB,
+ AGE_THRESHOLD_MS);
+ assertEquals(AGE_THRESHOLD_MS,
+
config.getLong(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB,
-1));
+ }
+ try (Connection conn = DriverManager.getConnection(getUrl())) {
+ Fixture fixture = createFixture(conn, clock, oldEntries ?
AGE_THRESHOLD_MS * 2 : 1000);
+ // No LIMIT: isolate cleanup from premature scan termination in
PHOENIX-8015.
+ String query = fixture.indexQuery("");
+ assertPlan(conn, query).table(fixture.indexTable).scanType("FULL
SCAN");
+ assertRows(conn, query, ROW_COUNT);
+ if (oldEntries) {
+ try (Table index =
+
getUtility().getConnection().getTable(TableName.valueOf(fixture.indexTable))) {
+ for (Result stale : fixture.staleRows) {
+ assertTrue("Old stale index entry survived read repair",
+ index.get(new Get(stale.getRow())).isEmpty());
+ assertStaleEntryDeleteTimestamp(index, stale,
fixture.dataTimestamp);
+ }
+ }
+ assertEquals("Current index entries must survive cleanup", ROW_COUNT,
+ readPhysicalRows(fixture.indexTable).size());
+ } else {
+ assertStaleEntriesPresent(fixture);
+ }
+ assertRows(conn, "SELECT /*+ NO_INDEX */ ID, PAYLOAD, UPDATED_TIME
FROM "
+ + fixture.dataTable + " ORDER BY UPDATED_TIME ASC", ROW_COUNT);
+ }
+ } 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 Fixture createFixture(Connection conn, ManualEnvironmentEdge clock,
long updateDelayMs)
+ 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)" + " TTL="
+ + TTL_SECONDS + ", IS_STRICT_TTL=true" + (salted ? ", SALT_BUCKETS=4"
: ""));
+ stmt.execute("CREATE UNCOVERED INDEX " + indexTable + " ON " + dataTable
+ " (UPDATED_TIME)"
+ + (salted ? " SALT_BUCKETS=4" : ""));
+ }
+ clock.setValue(EnvironmentEdgeManager.currentTimeMillis() + 1);
+ EnvironmentEdgeManager.injectEdge(clock);
+ long insertTime = clock.currentTime();
+ upsertRows(conn, dataTable, false);
+ List<Result> staleRows = readPhysicalRows(indexTable);
+ assertEquals("Initial NULL-keyed index entries", ROW_COUNT,
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);
+ Fixture fixture = new Fixture(dataTable, indexTable, staleRows,
clock.currentTime());
+ assertEquals("Both stale and current index entries must physically exist",
ROW_COUNT * 2,
+ readPhysicalRows(indexTable).size());
+ assertStaleEntriesPresent(fixture);
+ for (Result data : readPhysicalRows(dataTable)) {
+ assertEquals("Base rows must have the recent update timestamp",
fixture.dataTimestamp,
+ data.rawCells()[0].getTimestamp());
+ }
+ return fixture;
+ }
+
+ private void upsertRows(Connection conn, String dataTable, boolean
nonNullTimestamp)
+ 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) {
+ 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 void assertStaleEntryDeleteTimestamp(Table index, Result stale, long
dataTimestamp)
+ throws Exception {
+ // The stale entry is aged by its own timestamp but must be deleted at the
data row timestamp,
+ // when the row stopped mapping to this key, so IndexTool sees consistent
row histories.
+ Scan scan = new
Scan().withStartRow(stale.getRow()).withStopRow(stale.getRow(), true);
+ scan.setRaw(true);
+ scan.readAllVersions();
+ boolean foundDelete = false;
+ try (ResultScanner scanner = index.getScanner(scan)) {
+ for (Result result : scanner) {
+ for (Cell cell : result.rawCells()) {
+ if (CellUtil.isDeleteFamily(cell)) {
+ foundDelete = true;
+ assertEquals("Stale-entry deletion must use the data row
timestamp", dataTimestamp,
+ cell.getTimestamp());
+ }
+ }
+ }
+ }
+ assertTrue("Stale-entry cleanup must leave a family delete marker",
foundDelete);
+ }
+
+ 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 {
+ try (Statement stmt = conn.createStatement(); ResultSet rs =
stmt.executeQuery(query)) {
+ int count = 0;
+ while (rs.next()) {
+ assertEquals("tenant-" + count, rs.getString(1));
+ assertEquals("payload-" + count, rs.getString(2));
+ assertEquals(new Timestamp(UPDATED_TIME_BASE + count * 1000L),
rs.getTimestamp(3));
+ count++;
+ }
+ assertEquals("Valid rows returned by: " + query, expectedRows, count);
+ }
+ }
+
+ private static class Fixture {
+ final String dataTable;
+ final String indexTable;
+ final List<Result> staleRows;
+ final long dataTimestamp;
+
+ Fixture(String dataTable, String indexTable, List<Result> staleRows, long
dataTimestamp) {
+ this.dataTable = dataTable;
+ this.indexTable = indexTable;
+ this.staleRows = staleRows;
+ this.dataTimestamp = dataTimestamp;
+ }
+
+ String indexQuery(String predicate) {
+ return "SELECT /*+ INDEX(" + dataTable + " " + indexTable
+ + ") */ ID, PAYLOAD, UPDATED_TIME FROM " + dataTable + predicate
+ + " ORDER BY UPDATED_TIME ASC";
+ }
+ }
+
public static class ScanFilterRegionObserver extends SimpleRegionObserver {
public static final AtomicInteger count = new AtomicInteger(0);