Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -493,7 +494,16 @@ public boolean next(List<Cell> 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 = ServerUtil.deriveRowKeyFromScanOrRegionBoundaries(scan, region);
kv = new KeyValue(rowKey, QueryConstants.OFFSET_FAMILY,
QueryConstants.OFFSET_COLUMN, remainingOffset);
}
}
results.add(kv);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,4 +291,33 @@ public static <T> Throwable getExceptionFromFailedFuture(Future<T> 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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Naming convention for this method is similar to getScanStartRowKeyFromScanOrRegionBoundaries method in the ServerUtil.java

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we use getScanStartRowKeyFromScanOrRegionBoundaries() instead of this new method?

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;
Comment on lines +314 to +315
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if this generates correct rowkey: if scan start rowkey is not inclusive, we need to find the shortest possible next rowkey?
I think we should have this logic elsewhere, @TheNamesRai could you please check once?

} else {
rowKey = HConstants.EMPTY_END_ROW;
}
}

return rowKey;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,49 @@ 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()) {
// 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);

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
// 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 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);

// Should return no rows without throwing exception
assertFalse("Expected no rows when OFFSET exceeds available data", rs.next());
}
}

private String getSchemaName() {
return withSchemaName
? caseSensitiveNames
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,143 @@ 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] + "',"
Expand Down