From 2a7ba60fb4bc70f77ac289461254a17f27c170ee Mon Sep 17 00:00:00 2001 From: Saurabh Rai Date: Wed, 22 Oct 2025 22:00:27 +0530 Subject: [PATCH 1/4] PHOENIX-7524: Fix IndexOutOfBoundsException in queries with OFFSET when rows are exhausted --- .../NonAggregateRegionScannerFactory.java | 26 +++- .../apache/phoenix/end2end/CDCQueryIT.java | 36 +++++ .../phoenix/end2end/QueryWithOffsetIT.java | 143 ++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java b/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java index 65affd6e792..4f79523cf8b 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java @@ -493,7 +493,31 @@ public boolean next(List results, ScannerContext scannerContext) throws IO kv = new KeyValue(QueryConstants.OFFSET_ROW_KEY_BYTES, QueryConstants.OFFSET_FAMILY, QueryConstants.OFFSET_COLUMN, remainingOffset); } else { - kv = getOffsetKvWithLastScannedRowKey(remainingOffset, tuple); + // Check if tuple is empty before calling getOffsetKvWithLastScannedRowKey + // to avoid IndexOutOfBoundsException when accessing tuple.getKey() + if (tuple.size() > 0) { + kv = getOffsetKvWithLastScannedRowKey(remainingOffset, tuple); + } else { + // Use fallback logic when tuple is empty (PHOENIX-7524) + byte[] rowKey; + byte[] startKey = scan.getStartRow().length > 0 + ? scan.getStartRow() + : region.getRegionInfo().getStartKey(); + byte[] endKey = + scan.getStopRow().length > 0 ? scan.getStopRow() : region.getRegionInfo().getEndKey(); + rowKey = ByteUtil.getLargestPossibleRowKeyInRange(startKey, endKey); + if (rowKey == null) { + if (scan.includeStartRow()) { + rowKey = startKey; + } else if (scan.includeStopRow()) { + rowKey = endKey; + } else { + rowKey = HConstants.EMPTY_END_ROW; + } + } + kv = new KeyValue(rowKey, QueryConstants.OFFSET_FAMILY, QueryConstants.OFFSET_COLUMN, + remainingOffset); + } } results.add(kv); } else { diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java index 9a86354778d..90e91c38c91 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java @@ -987,6 +987,42 @@ public void testCDCIndexTTLEqualsToMaxLookbackAge() throws Exception { } } + /** + * Test for PHOENIX-7524: CDC Query with OFFSET can throw IndexOutOfBoundsException + * + * Scenario: CDC query with OFFSET that exceeds available rows + * Expected: Query should return empty result set + */ + @Test + public void testCDCQueryWithOffsetExceedingRows() throws Exception { + String schemaName = getSchemaName(); + String tableName = getTableOrViewName(schemaName); + String cdcName = getCDCName(); + + try (Connection conn = newConnection()) { + createTable(conn, + "CREATE TABLE " + tableName + " (k VARCHAR NOT NULL PRIMARY KEY, v1 INTEGER, v2 INTEGER)", + encodingScheme, multitenant, tableSaltBuckets, false, null); + createCDC(conn, "CREATE CDC " + cdcName + " ON " + tableName, encodingScheme); + + conn.createStatement().executeUpdate("UPSERT INTO " + tableName + " VALUES ('a', 1, 2)"); + conn.commit(); + + // This query should return empty result, not throw exception + // OFFSET 1 with only 1 row means we skip the only row + // IMPORTANT: Using PHOENIX_ROW_TIMESTAMP() > CURRENT_TIME() without subtraction + // This means the WHERE clause filters out ALL rows (no row has timestamp in the future) + // So we're trying to OFFSET past 0 rows + String query = + "SELECT * FROM " + cdcName + " WHERE PHOENIX_ROW_TIMESTAMP() > CURRENT_TIME() LIMIT 1 OFFSET 1"; + + ResultSet rs = conn.createStatement().executeQuery(query); + + // Should return no rows without throwing exception + assertFalse("Expected no rows when OFFSET exceeds available data", rs.next()); + } + } + private String getSchemaName() { return withSchemaName ? caseSensitiveNames diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithOffsetIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithOffsetIT.java index 65ec34ff08c..0ee27c578a3 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithOffsetIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithOffsetIT.java @@ -231,6 +231,149 @@ public void testMetaDataWithOffset() throws SQLException { assertEquals(5, md.getColumnCount()); } + /** + * Test for PHOENIX-7524: Query with WHERE clause that filters all rows + OFFSET + * + * Scenario: WHERE clause filters out all rows, then OFFSET tries to skip rows + * Expected: Query should return empty result set + */ + @Test + public void testOffsetWithWhereClauseFilteringAllRows() throws SQLException { + String testTableName = generateUniqueName(); + Connection conn = DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); + + conn.createStatement().execute( + "CREATE TABLE " + testTableName + " (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR)"); + + for (int i = 1; i <= 10; i++) { + conn.createStatement().executeUpdate( + "UPSERT INTO " + testTableName + " VALUES (" + i + ", 'name" + i + "')"); + } + conn.commit(); + + // WHERE clause that filters ALL rows (no row has id > 100) + String query = "SELECT * FROM " + testTableName + " WHERE id > 100 LIMIT 5 OFFSET 1"; + ResultSet rs = conn.createStatement().executeQuery(query); + + // Should return no rows without throwing exception + assertFalse("Expected no rows when WHERE filters all rows", rs.next()); + conn.close(); + } + + /** + * Test for PHOENIX-7524: Empty table with OFFSET + * + * Scenario: Table exists but has no rows + * Expected: Query should return empty result set + */ + @Test + public void testOffsetOnEmptyTable() throws SQLException { + String testTableName = generateUniqueName(); + Connection conn = DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); + + conn.createStatement().execute( + "CREATE TABLE " + testTableName + " (id INTEGER NOT NULL PRIMARY KEY, val VARCHAR)"); + // Don't insert any rows - table is empty + conn.commit(); + + // Query empty table with OFFSET + String query = "SELECT * FROM " + testTableName + " LIMIT 10 OFFSET 5"; + ResultSet rs = conn.createStatement().executeQuery(query); + + assertFalse("Expected no rows from empty table", rs.next()); + conn.close(); + } + + /** + * Test for PHOENIX-7524: OFFSET with LIKE pattern matching nothing + * + * Scenario: LIKE pattern that doesn't match any rows + * Expected: Query should return empty result set + */ + @Test + public void testOffsetWithLikePatternMatchingNothing() throws SQLException { + String testTableName = generateUniqueName(); + Connection conn = DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); + + conn.createStatement().execute( + "CREATE TABLE " + testTableName + " (name VARCHAR NOT NULL PRIMARY KEY, score INTEGER)"); + + conn.createStatement().executeUpdate("UPSERT INTO " + testTableName + " VALUES ('test1', 100)"); + conn.createStatement().executeUpdate("UPSERT INTO " + testTableName + " VALUES ('test2', 200)"); + conn.createStatement().executeUpdate("UPSERT INTO " + testTableName + " VALUES ('test3', 300)"); + conn.commit(); + + // LIKE pattern that doesn't match + String query = "SELECT * FROM " + testTableName + + " WHERE name LIKE 'prod%' LIMIT 10 OFFSET 2"; + ResultSet rs = conn.createStatement().executeQuery(query); + + assertFalse("Expected no rows with LIKE pattern matching nothing", rs.next()); + conn.close(); + } + + /** + * Test for PHOENIX-7524: OFFSET on table with splits but empty regions + * + * Scenario: Pre-split table with no data in certain regions + * Expected: Query should return empty result set + */ + @Test + public void testOffsetOnSplitTableWithEmptyRegions() throws SQLException { + String testTableName = generateUniqueName(); + Connection conn = DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); + + // Create pre-split table + conn.createStatement().execute( + "CREATE TABLE " + testTableName + + " (pk VARCHAR NOT NULL PRIMARY KEY, data INTEGER) SPLIT ON ('m', 'z')"); + + // Insert data only in first region (before 'm') + conn.createStatement().executeUpdate("UPSERT INTO " + testTableName + " VALUES ('a', 1)"); + conn.createStatement().executeUpdate("UPSERT INTO " + testTableName + " VALUES ('b', 2)"); + conn.commit(); + + // Query range 'n' to 'y' (in middle/last region with no data) + String query = "SELECT * FROM " + testTableName + + " WHERE pk >= 'n' AND pk < 'y' LIMIT 5 OFFSET 1"; + ResultSet rs = conn.createStatement().executeQuery(query); + + assertFalse("Expected no rows in empty region", rs.next()); + conn.close(); + } + + /** + * Test for PHOENIX-7524: OFFSET exceeds rows returned by WHERE clause + * + * Scenario: WHERE clause returns SOME rows (e.g., 5 rows), but OFFSET exceeds them (e.g., 10) + * Expected: Query should return empty result set + */ + @Test + public void testOffsetExceedsRowsReturnedByWhereClause() throws SQLException { + String testTableName = generateUniqueName(); + Connection conn = DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); + + conn.createStatement().execute( + "CREATE TABLE " + testTableName + " (id INTEGER NOT NULL PRIMARY KEY, category VARCHAR, val INTEGER)"); + + for (int i = 1; i <= 20; i++) { + conn.createStatement().executeUpdate( + "UPSERT INTO " + testTableName + " VALUES (" + i + ", 'cat" + (i % 3) + "', " + (i * 100) + ")"); + } + conn.commit(); + + // WHERE clause returns 7 rows (id <= 20 where id % 3 == 1: rows 1,4,7,10,13,16,19) + // But OFFSET is 10, which exceeds the 7 rows available + String query = "SELECT * FROM " + testTableName + + " WHERE category = 'cat1' LIMIT 5 OFFSET 10"; + ResultSet rs = conn.createStatement().executeQuery(query); + + // Should return no rows without throwing exception + assertFalse("Expected no rows when OFFSET exceeds filtered result count", rs.next()); + + conn.close(); + } + private void initTableValues(Connection conn) throws SQLException { for (int i = 0; i < 26; i++) { conn.createStatement().execute("UPSERT INTO " + tableName + " values('" + STRINGS[i] + "'," From 109f1d06107f7ac55d34d252e031eb1d713f21d1 Mon Sep 17 00:00:00 2001 From: Saurabh Rai Date: Thu, 23 Oct 2025 08:56:40 +0530 Subject: [PATCH 2/4] spotless checks --- .../NonAggregateRegionScannerFactory.java | 9 ++- .../apache/phoenix/end2end/CDCQueryIT.java | 10 +-- .../phoenix/end2end/QueryWithOffsetIT.java | 78 +++++++++---------- 3 files changed, 45 insertions(+), 52 deletions(-) diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java b/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java index 4f79523cf8b..763b1ba591f 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java @@ -503,8 +503,9 @@ public boolean next(List results, ScannerContext scannerContext) throws IO byte[] startKey = scan.getStartRow().length > 0 ? scan.getStartRow() : region.getRegionInfo().getStartKey(); - byte[] endKey = - scan.getStopRow().length > 0 ? scan.getStopRow() : region.getRegionInfo().getEndKey(); + byte[] endKey = scan.getStopRow().length > 0 + ? scan.getStopRow() + : region.getRegionInfo().getEndKey(); rowKey = ByteUtil.getLargestPossibleRowKeyInRange(startKey, endKey); if (rowKey == null) { if (scan.includeStartRow()) { @@ -515,8 +516,8 @@ public boolean next(List results, ScannerContext scannerContext) throws IO rowKey = HConstants.EMPTY_END_ROW; } } - kv = new KeyValue(rowKey, QueryConstants.OFFSET_FAMILY, QueryConstants.OFFSET_COLUMN, - remainingOffset); + kv = new KeyValue(rowKey, QueryConstants.OFFSET_FAMILY, + QueryConstants.OFFSET_COLUMN, remainingOffset); } } results.add(kv); diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java index 90e91c38c91..5ad77949587 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java @@ -988,10 +988,8 @@ public void testCDCIndexTTLEqualsToMaxLookbackAge() throws Exception { } /** - * Test for PHOENIX-7524: CDC Query with OFFSET can throw IndexOutOfBoundsException - * - * Scenario: CDC query with OFFSET that exceeds available rows - * Expected: Query should return empty result set + * Test for PHOENIX-7524: CDC Query with OFFSET can throw IndexOutOfBoundsException Scenario: CDC + * query with OFFSET that exceeds available rows Expected: Query should return empty result set */ @Test public void testCDCQueryWithOffsetExceedingRows() throws Exception { @@ -1013,8 +1011,8 @@ public void testCDCQueryWithOffsetExceedingRows() throws Exception { // IMPORTANT: Using PHOENIX_ROW_TIMESTAMP() > CURRENT_TIME() without subtraction // This means the WHERE clause filters out ALL rows (no row has timestamp in the future) // So we're trying to OFFSET past 0 rows - String query = - "SELECT * FROM " + cdcName + " WHERE PHOENIX_ROW_TIMESTAMP() > CURRENT_TIME() LIMIT 1 OFFSET 1"; + String query = "SELECT * FROM " + cdcName + + " WHERE PHOENIX_ROW_TIMESTAMP() > CURRENT_TIME() LIMIT 1 OFFSET 1"; ResultSet rs = conn.createStatement().executeQuery(query); diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithOffsetIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithOffsetIT.java index 0ee27c578a3..d0229623ba7 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithOffsetIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithOffsetIT.java @@ -232,22 +232,22 @@ public void testMetaDataWithOffset() throws SQLException { } /** - * Test for PHOENIX-7524: Query with WHERE clause that filters all rows + OFFSET - * - * Scenario: WHERE clause filters out all rows, then OFFSET tries to skip rows - * Expected: Query should return empty result set + * Test for PHOENIX-7524: Query with WHERE clause that filters all rows + OFFSET Scenario: WHERE + * clause filters out all rows, then OFFSET tries to skip rows Expected: Query should return empty + * result set */ @Test public void testOffsetWithWhereClauseFilteringAllRows() throws SQLException { String testTableName = generateUniqueName(); - Connection conn = DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); + Connection conn = + DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); conn.createStatement().execute( "CREATE TABLE " + testTableName + " (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR)"); for (int i = 1; i <= 10; i++) { - conn.createStatement().executeUpdate( - "UPSERT INTO " + testTableName + " VALUES (" + i + ", 'name" + i + "')"); + conn.createStatement() + .executeUpdate("UPSERT INTO " + testTableName + " VALUES (" + i + ", 'name" + i + "')"); } conn.commit(); @@ -261,18 +261,17 @@ public void testOffsetWithWhereClauseFilteringAllRows() throws SQLException { } /** - * Test for PHOENIX-7524: Empty table with OFFSET - * - * Scenario: Table exists but has no rows - * Expected: Query should return empty result set + * Test for PHOENIX-7524: Empty table with OFFSET Scenario: Table exists but has no rows Expected: + * Query should return empty result set */ @Test public void testOffsetOnEmptyTable() throws SQLException { String testTableName = generateUniqueName(); - Connection conn = DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); + Connection conn = + DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); - conn.createStatement().execute( - "CREATE TABLE " + testTableName + " (id INTEGER NOT NULL PRIMARY KEY, val VARCHAR)"); + conn.createStatement() + .execute("CREATE TABLE " + testTableName + " (id INTEGER NOT NULL PRIMARY KEY, val VARCHAR)"); // Don't insert any rows - table is empty conn.commit(); @@ -285,15 +284,14 @@ public void testOffsetOnEmptyTable() throws SQLException { } /** - * Test for PHOENIX-7524: OFFSET with LIKE pattern matching nothing - * - * Scenario: LIKE pattern that doesn't match any rows - * Expected: Query should return empty result set + * Test for PHOENIX-7524: OFFSET with LIKE pattern matching nothing Scenario: LIKE pattern that + * doesn't match any rows Expected: Query should return empty result set */ @Test public void testOffsetWithLikePatternMatchingNothing() throws SQLException { String testTableName = generateUniqueName(); - Connection conn = DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); + Connection conn = + DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); conn.createStatement().execute( "CREATE TABLE " + testTableName + " (name VARCHAR NOT NULL PRIMARY KEY, score INTEGER)"); @@ -304,8 +302,7 @@ public void testOffsetWithLikePatternMatchingNothing() throws SQLException { conn.commit(); // LIKE pattern that doesn't match - String query = "SELECT * FROM " + testTableName + - " WHERE name LIKE 'prod%' LIMIT 10 OFFSET 2"; + String query = "SELECT * FROM " + testTableName + " WHERE name LIKE 'prod%' LIMIT 10 OFFSET 2"; ResultSet rs = conn.createStatement().executeQuery(query); assertFalse("Expected no rows with LIKE pattern matching nothing", rs.next()); @@ -313,20 +310,18 @@ public void testOffsetWithLikePatternMatchingNothing() throws SQLException { } /** - * Test for PHOENIX-7524: OFFSET on table with splits but empty regions - * - * Scenario: Pre-split table with no data in certain regions - * Expected: Query should return empty result set + * Test for PHOENIX-7524: OFFSET on table with splits but empty regions Scenario: Pre-split table + * with no data in certain regions Expected: Query should return empty result set */ @Test public void testOffsetOnSplitTableWithEmptyRegions() throws SQLException { String testTableName = generateUniqueName(); - Connection conn = DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); + Connection conn = + DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); // Create pre-split table - conn.createStatement().execute( - "CREATE TABLE " + testTableName + - " (pk VARCHAR NOT NULL PRIMARY KEY, data INTEGER) SPLIT ON ('m', 'z')"); + conn.createStatement().execute("CREATE TABLE " + testTableName + + " (pk VARCHAR NOT NULL PRIMARY KEY, data INTEGER) SPLIT ON ('m', 'z')"); // Insert data only in first region (before 'm') conn.createStatement().executeUpdate("UPSERT INTO " + testTableName + " VALUES ('a', 1)"); @@ -334,8 +329,8 @@ public void testOffsetOnSplitTableWithEmptyRegions() throws SQLException { conn.commit(); // Query range 'n' to 'y' (in middle/last region with no data) - String query = "SELECT * FROM " + testTableName + - " WHERE pk >= 'n' AND pk < 'y' LIMIT 5 OFFSET 1"; + String query = + "SELECT * FROM " + testTableName + " WHERE pk >= 'n' AND pk < 'y' LIMIT 5 OFFSET 1"; ResultSet rs = conn.createStatement().executeQuery(query); assertFalse("Expected no rows in empty region", rs.next()); @@ -343,29 +338,28 @@ public void testOffsetOnSplitTableWithEmptyRegions() throws SQLException { } /** - * Test for PHOENIX-7524: OFFSET exceeds rows returned by WHERE clause - * - * Scenario: WHERE clause returns SOME rows (e.g., 5 rows), but OFFSET exceeds them (e.g., 10) - * Expected: Query should return empty result set + * Test for PHOENIX-7524: OFFSET exceeds rows returned by WHERE clause Scenario: WHERE clause + * returns SOME rows (e.g., 5 rows), but OFFSET exceeds them (e.g., 10) Expected: Query should + * return empty result set */ @Test public void testOffsetExceedsRowsReturnedByWhereClause() throws SQLException { String testTableName = generateUniqueName(); - Connection conn = DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); + Connection conn = + DriverManager.getConnection(getUrl(), PropertiesUtil.deepCopy(TEST_PROPERTIES)); - conn.createStatement().execute( - "CREATE TABLE " + testTableName + " (id INTEGER NOT NULL PRIMARY KEY, category VARCHAR, val INTEGER)"); + conn.createStatement().execute("CREATE TABLE " + testTableName + + " (id INTEGER NOT NULL PRIMARY KEY, category VARCHAR, val INTEGER)"); for (int i = 1; i <= 20; i++) { - conn.createStatement().executeUpdate( - "UPSERT INTO " + testTableName + " VALUES (" + i + ", 'cat" + (i % 3) + "', " + (i * 100) + ")"); + conn.createStatement().executeUpdate("UPSERT INTO " + testTableName + " VALUES (" + i + + ", 'cat" + (i % 3) + "', " + (i * 100) + ")"); } conn.commit(); // WHERE clause returns 7 rows (id <= 20 where id % 3 == 1: rows 1,4,7,10,13,16,19) // But OFFSET is 10, which exceeds the 7 rows available - String query = "SELECT * FROM " + testTableName + - " WHERE category = 'cat1' LIMIT 5 OFFSET 10"; + String query = "SELECT * FROM " + testTableName + " WHERE category = 'cat1' LIMIT 5 OFFSET 10"; ResultSet rs = conn.createStatement().executeQuery(query); // Should return no rows without throwing exception From 28de02e77759431274b288f91a46ada512c9b74a Mon Sep 17 00:00:00 2001 From: Saurabh Rai Date: Thu, 23 Oct 2025 15:45:28 +0530 Subject: [PATCH 3/4] update CDCQueryIT for multitenant table and with schema table --- .../apache/phoenix/end2end/CDCQueryIT.java | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java index 5ad77949587..782e79a27a5 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CDCQueryIT.java @@ -998,12 +998,20 @@ public void testCDCQueryWithOffsetExceedingRows() throws Exception { String cdcName = getCDCName(); try (Connection conn = newConnection()) { - createTable(conn, - "CREATE TABLE " + tableName + " (k VARCHAR NOT NULL PRIMARY KEY, v1 INTEGER, v2 INTEGER)", - encodingScheme, multitenant, tableSaltBuckets, false, null); + // Multi-tenant tables require at least 2 PK columns (tenant_id, other_pk) + String createTableDDL = multitenant + ? "CREATE TABLE " + tableName + + " (tenant_id VARCHAR NOT NULL, k VARCHAR NOT NULL, v1 INTEGER, v2 INTEGER " + + "CONSTRAINT pk PRIMARY KEY (tenant_id, k))" + : "CREATE TABLE " + tableName + " (k VARCHAR NOT NULL PRIMARY KEY, v1 INTEGER, v2 INTEGER)"; + + createTable(conn, createTableDDL, encodingScheme, multitenant, tableSaltBuckets, false, null); createCDC(conn, "CREATE CDC " + cdcName + " ON " + tableName, encodingScheme); - conn.createStatement().executeUpdate("UPSERT INTO " + tableName + " VALUES ('a', 1, 2)"); + String upsertSQL = multitenant + ? "UPSERT INTO " + tableName + " VALUES ('tenant1', 'a', 1, 2)" + : "UPSERT INTO " + tableName + " VALUES ('a', 1, 2)"; + conn.createStatement().executeUpdate(upsertSQL); conn.commit(); // This query should return empty result, not throw exception @@ -1011,7 +1019,8 @@ public void testCDCQueryWithOffsetExceedingRows() throws Exception { // IMPORTANT: Using PHOENIX_ROW_TIMESTAMP() > CURRENT_TIME() without subtraction // This means the WHERE clause filters out ALL rows (no row has timestamp in the future) // So we're trying to OFFSET past 0 rows - String query = "SELECT * FROM " + cdcName + String cdcFullName = SchemaUtil.getTableName(schemaName, cdcName); + String query = "SELECT * FROM " + cdcFullName + " WHERE PHOENIX_ROW_TIMESTAMP() > CURRENT_TIME() LIMIT 1 OFFSET 1"; ResultSet rs = conn.createStatement().executeQuery(query); From d2aef568c36babb61de31c130f6a2dffdb309d58 Mon Sep 17 00:00:00 2001 From: Saurabh Rai Date: Tue, 28 Oct 2025 14:56:53 +0530 Subject: [PATCH 4/4] Make a util method - deriveRowKeyFromScanOrRegionBoundaries --- .../NonAggregateRegionScannerFactory.java | 19 ++---------- .../org/apache/phoenix/util/ServerUtil.java | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java b/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java index 763b1ba591f..3e7fcb0e3b6 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/iterate/NonAggregateRegionScannerFactory.java @@ -83,6 +83,7 @@ import org.apache.phoenix.util.EncodedColumnsUtil; import org.apache.phoenix.util.IndexUtil; import org.apache.phoenix.util.ScanUtil; +import org.apache.phoenix.util.ServerUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -499,23 +500,7 @@ public boolean next(List results, ScannerContext scannerContext) throws IO kv = getOffsetKvWithLastScannedRowKey(remainingOffset, tuple); } else { // Use fallback logic when tuple is empty (PHOENIX-7524) - byte[] rowKey; - byte[] startKey = scan.getStartRow().length > 0 - ? scan.getStartRow() - : region.getRegionInfo().getStartKey(); - byte[] endKey = scan.getStopRow().length > 0 - ? scan.getStopRow() - : region.getRegionInfo().getEndKey(); - rowKey = ByteUtil.getLargestPossibleRowKeyInRange(startKey, endKey); - if (rowKey == null) { - if (scan.includeStartRow()) { - rowKey = startKey; - } else if (scan.includeStopRow()) { - rowKey = endKey; - } else { - rowKey = HConstants.EMPTY_END_ROW; - } - } + byte[] rowKey = ServerUtil.deriveRowKeyFromScanOrRegionBoundaries(scan, region); kv = new KeyValue(rowKey, QueryConstants.OFFSET_FAMILY, QueryConstants.OFFSET_COLUMN, remainingOffset); } diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/util/ServerUtil.java b/phoenix-core-server/src/main/java/org/apache/phoenix/util/ServerUtil.java index 4a409ac69e5..ed0cd2ee4b3 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/util/ServerUtil.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/util/ServerUtil.java @@ -291,4 +291,33 @@ public static Throwable getExceptionFromFailedFuture(Future f) { } return t; } + + /** + * Derives a safe row key for empty result sets based on scan or region boundaries. Used when + * constructing KeyValues for aggregate results or OFFSET responses when no actual data rows were + * scanned. + * @param scan The scan being executed + * @param region The region being scanned + * @return A valid row key derived from scan or region boundaries + */ + public static byte[] deriveRowKeyFromScanOrRegionBoundaries(Scan scan, Region region) { + byte[] startKey = + scan.getStartRow().length > 0 ? scan.getStartRow() : region.getRegionInfo().getStartKey(); + byte[] endKey = + scan.getStopRow().length > 0 ? scan.getStopRow() : region.getRegionInfo().getEndKey(); + + byte[] rowKey = ByteUtil.getLargestPossibleRowKeyInRange(startKey, endKey); + + if (rowKey == null) { + if (scan.includeStartRow()) { + rowKey = startKey; + } else if (scan.includeStopRow()) { + rowKey = endKey; + } else { + rowKey = HConstants.EMPTY_END_ROW; + } + } + + return rowKey; + } }