From aff9926b1cf839a6b978e04df86d341460474dce Mon Sep 17 00:00:00 2001 From: Palash Chauhan Date: Mon, 6 Apr 2026 15:31:28 -0700 Subject: [PATCH 01/10] PHOENIX-7795 : Tenant TTL using Global Views --- .../coprocessor/CompactionScanner.java | 9 + .../phoenix/end2end/TenantViewTTLIT.java | 576 ++++++++++++++++++ 2 files changed, 585 insertions(+) create mode 100644 phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantViewTTLIT.java diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java index f12dc77f724..703f296f1d8 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java @@ -1354,6 +1354,15 @@ public CompiledTTLExpression getTTLExpressionForRow(List result) throws IO // case multi-tenant, non-index tables, global space matchedType = GLOBAL_VIEWS; tableTTLInfo = tableRowKeyMatcher.match(rowKey, offset, GLOBAL_VIEWS); + if (tableTTLInfo == null) { + // Global views with WHERE on the tenant-id column produce a ROW_KEY_MATCHER + // that includes the tenant-id prefix. Retry from offset 0 (or past salt byte) + // to match these views. + int tenantInclusiveOffset = pkPositions.get(this.isSalted ? 1 : 0); + if (tenantInclusiveOffset != offset) { + tableTTLInfo = tableRowKeyMatcher.match(rowKey, tenantInclusiveOffset, GLOBAL_VIEWS); + } + } if (tableTTLInfo == null) { // search returned no results, determine the new pkPosition(offset) to use // Search using the new offset diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantViewTTLIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantViewTTLIT.java new file mode 100644 index 00000000000..dce02fa0c35 --- /dev/null +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantViewTTLIT.java @@ -0,0 +1,576 @@ +/* + * 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; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.hadoop.hbase.HConstants; +import org.apache.hadoop.hbase.TableName; +import org.apache.hadoop.hbase.client.Admin; +import org.apache.hadoop.hbase.client.ConnectionFactory; +import org.apache.hadoop.hbase.client.RegionInfo; +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.phoenix.coprocessorclient.BaseScannerRegionObserverConstants; +import org.apache.phoenix.query.BaseTest; +import org.apache.phoenix.query.QueryServices; +import org.apache.phoenix.util.EnvironmentEdgeManager; +import org.apache.phoenix.util.ManualEnvironmentEdge; +import org.apache.phoenix.util.ReadOnlyProps; +import org.apache.phoenix.util.SchemaUtil; +import org.apache.phoenix.util.TestUtil; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +/** + * Integration test verifying per-tenant TTL via global views on a multi-tenant table. + * + * The pattern under test: + * 1. Create a MULTI_TENANT=true base table (no TTL on the table itself). + * 2. Create global views with WHERE clause on the tenant-id PK column, each with a + * different TTL value. + * 3. Verify read-time masking: expired rows are not visible when querying through the view. + * 4. Verify compaction: expired rows are physically removed by major compaction. + * 5. Verify tenant isolation: a short-TTL tenant's data expires independently of a + * long-TTL tenant's data. + */ +@Category(NeedsOwnMiniClusterTest.class) +public class TenantViewTTLIT extends BaseTest { + + private ManualEnvironmentEdge injectEdge; + + @BeforeClass + public static void doSetup() throws Exception { + Map props = new HashMap<>(); + props.put(QueryServices.PHOENIX_VIEW_TTL_ENABLED, Boolean.toString(true)); + props.put(QueryServices.PHOENIX_COMPACTION_ENABLED, String.valueOf(true)); + props.put(BaseScannerRegionObserverConstants.PHOENIX_MAX_LOOKBACK_AGE_CONF_KEY, + Integer.toString(0)); + props.put(QueryServices.PHOENIX_VIEW_TTL_TENANT_VIEWS_PER_SCAN_LIMIT, String.valueOf(10)); + props.put("hbase.procedure.remote.dispatcher.delay.msec", "0"); + props.put(QueryServices.USE_STATS_FOR_PARALLELIZATION, Boolean.toString(false)); + setUpTestDriver(new ReadOnlyProps(props.entrySet().iterator())); + } + + @Before + public void beforeTest() { + EnvironmentEdgeManager.reset(); + injectEdge = new ManualEnvironmentEdge(); + injectEdge.setValue(EnvironmentEdgeManager.currentTimeMillis()); + } + + @After + public synchronized void afterTest() { + EnvironmentEdgeManager.reset(); + } + + /** + * Verifies that different tenants can have different TTLs via global views with WHERE + * clauses on the tenant-id column, and that read masking works correctly for each. + * + * Tenant org1 gets TTL = 10 seconds, org2 gets TTL = 100 seconds. + * After 20 seconds: org1's data should be masked, org2's data should still be visible. + */ + @Test + public void testReadMaskingWithDifferentTenantTTLs() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String view1Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String view2Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); + + int ttlOrg1 = 10; + int ttlOrg2 = 100; + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, " + + "ID1 VARCHAR NOT NULL, " + + "ID2 VARCHAR NOT NULL, " + + "COL1 VARCHAR, " + + "COL2 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", + view1Name, fullTableName, ttlOrg1)); + + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", + view2Name, fullTableName, ttlOrg2)); + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + upsertRow(conn, view1Name, "k1", "v1", "row1col1", "row1col2"); + upsertRow(conn, view1Name, "k2", "v2", "row2col1", "row2col2"); + upsertRow(conn, view2Name, "k1", "v1", "row3col1", "row3col2"); + upsertRow(conn, view2Name, "k2", "v2", "row4col1", "row4col2"); + + assertViewRowCount(conn, view1Name, 2, "org1 should have 2 rows before TTL expiry"); + assertViewRowCount(conn, view2Name, 2, "org2 should have 2 rows before TTL expiry"); + + injectEdge.incrementValue((ttlOrg1 * 2) * 1000L); + + assertViewRowCount(conn, view1Name, 0, + "org1 rows should be masked after org1 TTL expiry"); + assertViewRowCount(conn, view2Name, 2, + "org2 rows should still be visible (TTL not expired yet)"); + + injectEdge.incrementValue((ttlOrg2 * 2) * 1000L); + + assertViewRowCount(conn, view2Name, 0, + "org2 rows should be masked after org2 TTL expiry"); + } + } + + /** + * Verifies that major compaction physically removes expired rows per the view's TTL, + * while preserving rows belonging to views whose TTL has not yet expired. + */ + @Test + public void testCompactionWithDifferentTenantTTLs() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String view1Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String view2Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); + + int ttlOrg1 = 10; + int ttlOrg2 = 1000; + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, " + + "ID1 VARCHAR NOT NULL, " + + "ID2 VARCHAR NOT NULL, " + + "COL1 VARCHAR, " + + "COL2 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", + view1Name, fullTableName, ttlOrg1)); + + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", + view2Name, fullTableName, ttlOrg2)); + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + upsertRow(conn, view1Name, "k1", "v1", "c1", "c2"); + upsertRow(conn, view1Name, "k2", "v2", "c3", "c4"); + upsertRow(conn, view2Name, "k1", "v1", "c5", "c6"); + upsertRow(conn, view2Name, "k2", "v2", "c7", "c8"); + } + + long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 4, + "All 4 rows should exist in HBase before compaction"); + + injectEdge.setValue(afterInsertTime + (ttlOrg1 * 2 * 1000L)); + EnvironmentEdgeManager.injectEdge(injectEdge); + flushAndMajorCompact(schemaName, baseTableName); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 2, + "Only org2's 2 rows should remain after compacting past org1 TTL"); + + injectEdge.setValue(afterInsertTime + (ttlOrg2 * 2 * 1000L)); + flushAndMajorCompact(schemaName, baseTableName); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 0, + "All rows should be removed after compacting past org2 TTL"); + } + + /** + * Verifies that data not covered by any view (an "unassigned" tenant) is NOT expired + * by compaction, since no view TTL applies to it. + */ + @Test + public void testUnassignedTenantDataNotExpiredByCompaction() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String viewName = SchemaUtil.getTableName(schemaName, generateUniqueName()); + + int ttl = 10; + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, " + + "ID1 VARCHAR NOT NULL, " + + "COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", + viewName, fullTableName, ttl)); + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + upsertRowDirect(conn, fullTableName, "org1", "k1", "val1"); + upsertRowDirect(conn, fullTableName, "org2", "k1", "val2"); + } + + long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); + + injectEdge.setValue(afterInsertTime + (ttl * 2 * 1000L)); + EnvironmentEdgeManager.injectEdge(injectEdge); + flushAndMajorCompact(schemaName, baseTableName); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 1, + "org2's row (no view, no TTL) should survive compaction; org1's row should be removed"); + } + + /** + * Verifies read masking and compaction for three tenants with different TTLs to + * confirm the mechanism scales beyond two tenants. + */ + @Test + public void testThreeTenantsDifferentTTLs() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String viewA = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String viewB = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String viewC = SchemaUtil.getTableName(schemaName, generateUniqueName()); + + int ttlA = 10; + int ttlB = 50; + int ttlC = 200; + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, " + + "ID1 VARCHAR NOT NULL, " + + "COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenA' TTL = %d", + viewA, fullTableName, ttlA)); + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenB' TTL = %d", + viewB, fullTableName, ttlB)); + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenC' TTL = %d", + viewC, fullTableName, ttlC)); + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + upsertRowSimple(conn, viewA, "r1", "a1"); + upsertRowSimple(conn, viewB, "r1", "b1"); + upsertRowSimple(conn, viewC, "r1", "c1"); + + assertViewRowCount(conn, viewA, 1, "tenA visible before TTL"); + assertViewRowCount(conn, viewB, 1, "tenB visible before TTL"); + assertViewRowCount(conn, viewC, 1, "tenC visible before TTL"); + + injectEdge.incrementValue(ttlA * 2 * 1000L); + assertViewRowCount(conn, viewA, 0, "tenA masked after its TTL"); + assertViewRowCount(conn, viewB, 1, "tenB still visible"); + assertViewRowCount(conn, viewC, 1, "tenC still visible"); + + injectEdge.incrementValue(ttlB * 2 * 1000L); + assertViewRowCount(conn, viewB, 0, "tenB masked after its TTL"); + assertViewRowCount(conn, viewC, 1, "tenC still visible"); + } + + long afterInsertTime = injectEdge.currentTime(); + + injectEdge.setValue(afterInsertTime + (ttlC * 2 * 1000L)); + EnvironmentEdgeManager.injectEdge(injectEdge); + flushAndMajorCompact(schemaName, baseTableName); + + assertHBaseRowCount(schemaName, baseTableName, 0, 0, + "All rows should be compacted away after all TTLs expire"); + } + + /** + * Verifies that compaction works correctly with a pre-split table where each region + * has non-empty start/end keys. Four tenants span two regions; compaction should + * correctly expire data per view regardless of which region the data resides in. + */ + @Test + public void testCompactionWithRegionSplitPrunesGlobalViews() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String viewA = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String viewB = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String viewC = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String viewD = SchemaUtil.getTableName(schemaName, generateUniqueName()); + + int shortTTL = 10; + int longTTL = 1000; + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + + // Pre-split between orgB and orgC so we get 2 regions: + // Region 1: [empty, 'orgC') -> orgA, orgB + // Region 2: ['orgC', empty) -> orgC, orgD + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, " + + "ID1 VARCHAR NOT NULL, " + + "COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'" + + " SPLIT ON ('orgC')", + fullTableName)); + + TableName tn = TableName.valueOf(SchemaUtil.getTableName(schemaName, baseTableName)); + try (org.apache.hadoop.hbase.client.Connection hConn = + ConnectionFactory.createConnection(getUtility().getConfiguration())) { + List regions = hConn.getAdmin().getRegions(tn); + assertEquals("Expected 2 regions after SPLIT ON", 2, regions.size()); + } + + // orgA and orgB get short TTL, orgC gets short TTL, orgD gets long TTL + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgA' TTL = %d", + viewA, fullTableName, shortTTL)); + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgB' TTL = %d", + viewB, fullTableName, shortTTL)); + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgC' TTL = %d", + viewC, fullTableName, shortTTL)); + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgD' TTL = %d", + viewD, fullTableName, longTTL)); + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + upsertRowSimple(conn, viewA, "r1", "a1"); + upsertRowSimple(conn, viewB, "r1", "b1"); + upsertRowSimple(conn, viewC, "r1", "c1"); + upsertRowSimple(conn, viewD, "r1", "d1"); + } + + long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 4, + "All 4 rows should exist before compaction"); + + injectEdge.setValue(afterInsertTime + (shortTTL * 2 * 1000L)); + EnvironmentEdgeManager.injectEdge(injectEdge); + flushAndMajorCompact(schemaName, baseTableName); + + // orgA, orgB, orgC expired (shortTTL). orgD survives (longTTL). + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 1, + "Only orgD's row should survive; orgA/B/C expired across both regions"); + + // Now advance past longTTL and compact again + injectEdge.setValue(afterInsertTime + (longTTL * 2 * 1000L)); + flushAndMajorCompact(schemaName, baseTableName); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 0, + "All rows should be removed after all TTLs expire"); + } + + /** + * Verifies that compaction correctly applies per-tenant TTL on a SALTED multi-tenant + * table. Salting changes the row key layout to [salt_byte][tenant_id][pk_cols], which + * exercises different offset calculations in both getTTLExpressionForRow() (isSalted + * branch) and the GLOBAL_VIEWS post-filter (tenant-id extraction from salted region keys). + */ + @Test + public void testCompactionWithSaltedMultiTenantTable() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String view1Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String view2Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); + + int ttlOrg1 = 10; + int ttlOrg2 = 1000; + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, " + + "ID1 VARCHAR NOT NULL, " + + "COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, SALT_BUCKETS=4, COLUMN_ENCODED_BYTES=0," + + " DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", + view1Name, fullTableName, ttlOrg1)); + + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", + view2Name, fullTableName, ttlOrg2)); + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + upsertRowSimple(conn, view1Name, "k1", "c1"); + upsertRowSimple(conn, view1Name, "k2", "c2"); + upsertRowSimple(conn, view2Name, "k1", "c3"); + upsertRowSimple(conn, view2Name, "k2", "c4"); + } + + long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 4, + "All 4 rows should exist in salted table before compaction"); + + injectEdge.setValue(afterInsertTime + (ttlOrg1 * 2 * 1000L)); + EnvironmentEdgeManager.injectEdge(injectEdge); + flushAndMajorCompact(schemaName, baseTableName); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 2, + "Only org2's 2 rows should remain in salted table after org1 TTL"); + + injectEdge.setValue(afterInsertTime + (ttlOrg2 * 2 * 1000L)); + flushAndMajorCompact(schemaName, baseTableName); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 0, + "All rows should be removed from salted table after all TTLs expire"); + } + + // ---- Helper methods ---- + + private void upsertRow(Connection conn, String viewName, + String id1, String id2, String col1, String col2) throws SQLException { + PreparedStatement stmt = conn.prepareStatement(String.format( + "UPSERT INTO %s (ID1, ID2, COL1, COL2) VALUES (?, ?, ?, ?)", viewName)); + stmt.setString(1, id1); + stmt.setString(2, id2); + stmt.setString(3, col1); + stmt.setString(4, col2); + stmt.executeUpdate(); + conn.commit(); + } + + private void upsertRowDirect(Connection conn, String tableName, + String orgId, String id1, String col1) throws SQLException { + PreparedStatement stmt = conn.prepareStatement(String.format( + "UPSERT INTO %s (ORGID, ID1, COL1) VALUES (?, ?, ?)", tableName)); + stmt.setString(1, orgId); + stmt.setString(2, id1); + stmt.setString(3, col1); + stmt.executeUpdate(); + conn.commit(); + } + + private void upsertRowSimple(Connection conn, String viewName, + String id1, String col1) throws SQLException { + PreparedStatement stmt = conn.prepareStatement(String.format( + "UPSERT INTO %s (ID1, COL1) VALUES (?, ?)", viewName)); + stmt.setString(1, id1); + stmt.setString(2, col1); + stmt.executeUpdate(); + conn.commit(); + } + + private void assertViewRowCount(Connection conn, String viewName, + int expectedCount, String message) throws SQLException { + ResultSet rs = conn.createStatement().executeQuery( + "SELECT COUNT(*) FROM " + viewName); + assertTrue(message + " (query returned no rows)", rs.next()); + assertEquals(message, expectedCount, rs.getInt(1)); + assertFalse(rs.next()); + } + + private void assertHBaseRowCount(String schemaName, String tableName, + long minTimestamp, int expectedRows, String message) + throws IOException, SQLException { + byte[] hbaseTableNameBytes = SchemaUtil.getTableNameAsBytes(schemaName, tableName); + try (Table tbl = driver.getConnectionQueryServices(getUrl(), TestUtil.TEST_PROPERTIES) + .getTable(hbaseTableNameBytes)) { + Scan allRows = new Scan(); + allRows.setTimeRange(minTimestamp, HConstants.LATEST_TIMESTAMP); + ResultScanner scanner = tbl.getScanner(allRows); + int numRows = 0; + for (Result result = scanner.next(); result != null; result = scanner.next()) { + numRows++; + } + assertEquals(message, expectedRows, numRows); + } + } + + private void flushAndMajorCompact(String schemaName, String tableName) throws Exception { + String hbaseTableName = SchemaUtil.getTableName(schemaName, tableName); + TableName tn = TableName.valueOf(hbaseTableName); + try (org.apache.hadoop.hbase.client.Connection hConn = + ConnectionFactory.createConnection(getUtility().getConfiguration())) { + Admin admin = hConn.getAdmin(); + if (!admin.tableExists(tn)) { + return; + } + admin.flush(tn); + TestUtil.majorCompact(getUtility(), tn); + } + } + +} From b1289f2656fd4243786f32883a6848f26a05e32d Mon Sep 17 00:00:00 2001 From: Palash Chauhan Date: Mon, 6 Apr 2026 21:15:52 -0700 Subject: [PATCH 02/10] spotless fix and rename class --- ...{TenantViewTTLIT.java => TenantTTLIT.java} | 287 ++++++++---------- 1 file changed, 130 insertions(+), 157 deletions(-) rename phoenix-core/src/it/java/org/apache/phoenix/end2end/{TenantViewTTLIT.java => TenantTTLIT.java} (68%) diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantViewTTLIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java similarity index 68% rename from phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantViewTTLIT.java rename to phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java index dce02fa0c35..635138d8f76 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantViewTTLIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java @@ -30,7 +30,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; - import org.apache.hadoop.hbase.HConstants; import org.apache.hadoop.hbase.TableName; import org.apache.hadoop.hbase.client.Admin; @@ -53,20 +52,17 @@ import org.junit.BeforeClass; import org.junit.Test; import org.junit.experimental.categories.Category; + /** - * Integration test verifying per-tenant TTL via global views on a multi-tenant table. - * - * The pattern under test: - * 1. Create a MULTI_TENANT=true base table (no TTL on the table itself). - * 2. Create global views with WHERE clause on the tenant-id PK column, each with a - * different TTL value. - * 3. Verify read-time masking: expired rows are not visible when querying through the view. - * 4. Verify compaction: expired rows are physically removed by major compaction. - * 5. Verify tenant isolation: a short-TTL tenant's data expires independently of a - * long-TTL tenant's data. + * Integration test verifying per-tenant TTL via global views on a multi-tenant table. The pattern + * under test: 1. Create a MULTI_TENANT=true base table (no TTL on the table itself). 2. Create + * global views with WHERE clause on the tenant-id PK column, each with a different TTL value. 3. + * Verify read-time masking: expired rows are not visible when querying through the view. 4. Verify + * compaction: expired rows are physically removed by major compaction. 5. Verify tenant isolation: + * a short-TTL tenant's data expires independently of a long-TTL tenant's data. */ @Category(NeedsOwnMiniClusterTest.class) -public class TenantViewTTLIT extends BaseTest { +public class TenantTTLIT extends BaseTest { private ManualEnvironmentEdge injectEdge; @@ -96,11 +92,10 @@ public synchronized void afterTest() { } /** - * Verifies that different tenants can have different TTLs via global views with WHERE - * clauses on the tenant-id column, and that read masking works correctly for each. - * - * Tenant org1 gets TTL = 10 seconds, org2 gets TTL = 100 seconds. - * After 20 seconds: org1's data should be masked, org2's data should still be visible. + * Verifies that different tenants can have different TTLs via global views with WHERE clauses on + * the tenant-id column, and that read masking works correctly for each. Tenant org1 gets TTL = 10 + * seconds, org2 gets TTL = 100 seconds. After 20 seconds: org1's data should be masked, org2's + * data should still be visible. */ @Test public void testReadMaskingWithDifferentTenantTTLs() throws Exception { @@ -116,24 +111,21 @@ public void testReadMaskingWithDifferentTenantTTLs() throws Exception { try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" - + "ORGID VARCHAR NOT NULL, " - + "ID1 VARCHAR NOT NULL, " - + "ID2 VARCHAR NOT NULL, " - + "COL1 VARCHAR, " - + "COL2 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); - - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", - view1Name, fullTableName, ttlOrg1)); - - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", - view2Name, fullTableName, ttlOrg2)); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " + "ID1 VARCHAR NOT NULL, " + + "ID2 VARCHAR NOT NULL, " + "COL1 VARCHAR, " + "COL2 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", + view1Name, fullTableName, ttlOrg1)); + + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", + view2Name, fullTableName, ttlOrg2)); long startTime = EnvironmentEdgeManager.currentTimeMillis(); EnvironmentEdgeManager.injectEdge(injectEdge); @@ -149,21 +141,19 @@ public void testReadMaskingWithDifferentTenantTTLs() throws Exception { injectEdge.incrementValue((ttlOrg1 * 2) * 1000L); - assertViewRowCount(conn, view1Name, 0, - "org1 rows should be masked after org1 TTL expiry"); + assertViewRowCount(conn, view1Name, 0, "org1 rows should be masked after org1 TTL expiry"); assertViewRowCount(conn, view2Name, 2, "org2 rows should still be visible (TTL not expired yet)"); injectEdge.incrementValue((ttlOrg2 * 2) * 1000L); - assertViewRowCount(conn, view2Name, 0, - "org2 rows should be masked after org2 TTL expiry"); + assertViewRowCount(conn, view2Name, 0, "org2 rows should be masked after org2 TTL expiry"); } } /** - * Verifies that major compaction physically removes expired rows per the view's TTL, - * while preserving rows belonging to views whose TTL has not yet expired. + * Verifies that major compaction physically removes expired rows per the view's TTL, while + * preserving rows belonging to views whose TTL has not yet expired. */ @Test public void testCompactionWithDifferentTenantTTLs() throws Exception { @@ -179,24 +169,21 @@ public void testCompactionWithDifferentTenantTTLs() throws Exception { try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" - + "ORGID VARCHAR NOT NULL, " - + "ID1 VARCHAR NOT NULL, " - + "ID2 VARCHAR NOT NULL, " - + "COL1 VARCHAR, " - + "COL2 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); - - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", - view1Name, fullTableName, ttlOrg1)); - - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", - view2Name, fullTableName, ttlOrg2)); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " + "ID1 VARCHAR NOT NULL, " + + "ID2 VARCHAR NOT NULL, " + "COL1 VARCHAR, " + "COL2 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", + view1Name, fullTableName, ttlOrg1)); + + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", + view2Name, fullTableName, ttlOrg2)); long startTime = EnvironmentEdgeManager.currentTimeMillis(); EnvironmentEdgeManager.injectEdge(injectEdge); @@ -228,8 +215,8 @@ public void testCompactionWithDifferentTenantTTLs() throws Exception { } /** - * Verifies that data not covered by any view (an "unassigned" tenant) is NOT expired - * by compaction, since no view TTL applies to it. + * Verifies that data not covered by any view (an "unassigned" tenant) is NOT expired by + * compaction, since no view TTL applies to it. */ @Test public void testUnassignedTenantDataNotExpiredByCompaction() throws Exception { @@ -243,18 +230,16 @@ public void testUnassignedTenantDataNotExpiredByCompaction() throws Exception { try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" - + "ORGID VARCHAR NOT NULL, " - + "ID1 VARCHAR NOT NULL, " - + "COL1 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " + "ID1 VARCHAR NOT NULL, " + + "COL1 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", - viewName, fullTableName, ttl)); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", + viewName, fullTableName, ttl)); long startTime = EnvironmentEdgeManager.currentTimeMillis(); EnvironmentEdgeManager.injectEdge(injectEdge); @@ -275,8 +260,8 @@ public void testUnassignedTenantDataNotExpiredByCompaction() throws Exception { } /** - * Verifies read masking and compaction for three tenants with different TTLs to - * confirm the mechanism scales beyond two tenants. + * Verifies read masking and compaction for three tenants with different TTLs to confirm the + * mechanism scales beyond two tenants. */ @Test public void testThreeTenantsDifferentTTLs() throws Exception { @@ -294,24 +279,22 @@ public void testThreeTenantsDifferentTTLs() throws Exception { try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" - + "ORGID VARCHAR NOT NULL, " - + "ID1 VARCHAR NOT NULL, " - + "COL1 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); - - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenA' TTL = %d", - viewA, fullTableName, ttlA)); - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenB' TTL = %d", - viewB, fullTableName, ttlB)); - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenC' TTL = %d", - viewC, fullTableName, ttlC)); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " + "ID1 VARCHAR NOT NULL, " + + "COL1 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenA' TTL = %d", + viewA, fullTableName, ttlA)); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenB' TTL = %d", + viewB, fullTableName, ttlB)); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenC' TTL = %d", + viewC, fullTableName, ttlC)); long startTime = EnvironmentEdgeManager.currentTimeMillis(); EnvironmentEdgeManager.injectEdge(injectEdge); @@ -346,9 +329,9 @@ public void testThreeTenantsDifferentTTLs() throws Exception { } /** - * Verifies that compaction works correctly with a pre-split table where each region - * has non-empty start/end keys. Four tenants span two regions; compaction should - * correctly expire data per view regardless of which region the data resides in. + * Verifies that compaction works correctly with a pre-split table where each region has non-empty + * start/end keys. Four tenants span two regions; compaction should correctly expire data per view + * regardless of which region the data resides in. */ @Test public void testCompactionWithRegionSplitPrunesGlobalViews() throws Exception { @@ -367,38 +350,34 @@ public void testCompactionWithRegionSplitPrunesGlobalViews() throws Exception { conn.setAutoCommit(true); // Pre-split between orgB and orgC so we get 2 regions: - // Region 1: [empty, 'orgC') -> orgA, orgB - // Region 2: ['orgC', empty) -> orgC, orgD - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" - + "ORGID VARCHAR NOT NULL, " - + "ID1 VARCHAR NOT NULL, " - + "COL1 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + // Region 1: [empty, 'orgC') -> orgA, orgB + // Region 2: ['orgC', empty) -> orgC, orgD + conn.createStatement() + .execute(String.format("CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " + + "ID1 VARCHAR NOT NULL, " + "COL1 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'" - + " SPLIT ON ('orgC')", - fullTableName)); + + " SPLIT ON ('orgC')", fullTableName)); TableName tn = TableName.valueOf(SchemaUtil.getTableName(schemaName, baseTableName)); try (org.apache.hadoop.hbase.client.Connection hConn = - ConnectionFactory.createConnection(getUtility().getConfiguration())) { + ConnectionFactory.createConnection(getUtility().getConfiguration())) { List regions = hConn.getAdmin().getRegions(tn); assertEquals("Expected 2 regions after SPLIT ON", 2, regions.size()); } // orgA and orgB get short TTL, orgC gets short TTL, orgD gets long TTL - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgA' TTL = %d", - viewA, fullTableName, shortTTL)); - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgB' TTL = %d", - viewB, fullTableName, shortTTL)); - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgC' TTL = %d", - viewC, fullTableName, shortTTL)); - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgD' TTL = %d", - viewD, fullTableName, longTTL)); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgA' TTL = %d", + viewA, fullTableName, shortTTL)); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgB' TTL = %d", + viewB, fullTableName, shortTTL)); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgC' TTL = %d", + viewC, fullTableName, shortTTL)); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgD' TTL = %d", + viewD, fullTableName, longTTL)); long startTime = EnvironmentEdgeManager.currentTimeMillis(); EnvironmentEdgeManager.injectEdge(injectEdge); @@ -432,10 +411,10 @@ public void testCompactionWithRegionSplitPrunesGlobalViews() throws Exception { } /** - * Verifies that compaction correctly applies per-tenant TTL on a SALTED multi-tenant - * table. Salting changes the row key layout to [salt_byte][tenant_id][pk_cols], which - * exercises different offset calculations in both getTTLExpressionForRow() (isSalted - * branch) and the GLOBAL_VIEWS post-filter (tenant-id extraction from salted region keys). + * Verifies that compaction correctly applies per-tenant TTL on a SALTED multi-tenant table. + * Salting changes the row key layout to [salt_byte][tenant_id][pk_cols], which exercises + * different offset calculations in both getTTLExpressionForRow() (isSalted branch) and the + * GLOBAL_VIEWS post-filter (tenant-id extraction from salted region keys). */ @Test public void testCompactionWithSaltedMultiTenantTable() throws Exception { @@ -451,23 +430,19 @@ public void testCompactionWithSaltedMultiTenantTable() throws Exception { try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" - + "ORGID VARCHAR NOT NULL, " - + "ID1 VARCHAR NOT NULL, " - + "COL1 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + conn.createStatement() + .execute(String.format("CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " + + "ID1 VARCHAR NOT NULL, " + "COL1 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + ") MULTI_TENANT=true, SALT_BUCKETS=4, COLUMN_ENCODED_BYTES=0," - + " DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); + + " DEFAULT_COLUMN_FAMILY='0'", fullTableName)); - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", - view1Name, fullTableName, ttlOrg1)); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", + view1Name, fullTableName, ttlOrg1)); - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", - view2Name, fullTableName, ttlOrg2)); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", + view2Name, fullTableName, ttlOrg2)); long startTime = EnvironmentEdgeManager.currentTimeMillis(); EnvironmentEdgeManager.injectEdge(injectEdge); @@ -500,10 +475,10 @@ public void testCompactionWithSaltedMultiTenantTable() throws Exception { // ---- Helper methods ---- - private void upsertRow(Connection conn, String viewName, - String id1, String id2, String col1, String col2) throws SQLException { - PreparedStatement stmt = conn.prepareStatement(String.format( - "UPSERT INTO %s (ID1, ID2, COL1, COL2) VALUES (?, ?, ?, ?)", viewName)); + private void upsertRow(Connection conn, String viewName, String id1, String id2, String col1, + String col2) throws SQLException { + PreparedStatement stmt = conn.prepareStatement( + String.format("UPSERT INTO %s (ID1, ID2, COL1, COL2) VALUES (?, ?, ?, ?)", viewName)); stmt.setString(1, id1); stmt.setString(2, id2); stmt.setString(3, col1); @@ -512,10 +487,10 @@ private void upsertRow(Connection conn, String viewName, conn.commit(); } - private void upsertRowDirect(Connection conn, String tableName, - String orgId, String id1, String col1) throws SQLException { - PreparedStatement stmt = conn.prepareStatement(String.format( - "UPSERT INTO %s (ORGID, ID1, COL1) VALUES (?, ?, ?)", tableName)); + private void upsertRowDirect(Connection conn, String tableName, String orgId, String id1, + String col1) throws SQLException { + PreparedStatement stmt = conn.prepareStatement( + String.format("UPSERT INTO %s (ORGID, ID1, COL1) VALUES (?, ?, ?)", tableName)); stmt.setString(1, orgId); stmt.setString(2, id1); stmt.setString(3, col1); @@ -523,31 +498,29 @@ private void upsertRowDirect(Connection conn, String tableName, conn.commit(); } - private void upsertRowSimple(Connection conn, String viewName, - String id1, String col1) throws SQLException { - PreparedStatement stmt = conn.prepareStatement(String.format( - "UPSERT INTO %s (ID1, COL1) VALUES (?, ?)", viewName)); + private void upsertRowSimple(Connection conn, String viewName, String id1, String col1) + throws SQLException { + PreparedStatement stmt = + conn.prepareStatement(String.format("UPSERT INTO %s (ID1, COL1) VALUES (?, ?)", viewName)); stmt.setString(1, id1); stmt.setString(2, col1); stmt.executeUpdate(); conn.commit(); } - private void assertViewRowCount(Connection conn, String viewName, - int expectedCount, String message) throws SQLException { - ResultSet rs = conn.createStatement().executeQuery( - "SELECT COUNT(*) FROM " + viewName); + private void assertViewRowCount(Connection conn, String viewName, int expectedCount, + String message) throws SQLException { + ResultSet rs = conn.createStatement().executeQuery("SELECT COUNT(*) FROM " + viewName); assertTrue(message + " (query returned no rows)", rs.next()); assertEquals(message, expectedCount, rs.getInt(1)); assertFalse(rs.next()); } - private void assertHBaseRowCount(String schemaName, String tableName, - long minTimestamp, int expectedRows, String message) - throws IOException, SQLException { + private void assertHBaseRowCount(String schemaName, String tableName, long minTimestamp, + int expectedRows, String message) throws IOException, SQLException { byte[] hbaseTableNameBytes = SchemaUtil.getTableNameAsBytes(schemaName, tableName); try (Table tbl = driver.getConnectionQueryServices(getUrl(), TestUtil.TEST_PROPERTIES) - .getTable(hbaseTableNameBytes)) { + .getTable(hbaseTableNameBytes)) { Scan allRows = new Scan(); allRows.setTimeRange(minTimestamp, HConstants.LATEST_TIMESTAMP); ResultScanner scanner = tbl.getScanner(allRows); @@ -563,7 +536,7 @@ private void flushAndMajorCompact(String schemaName, String tableName) throws Ex String hbaseTableName = SchemaUtil.getTableName(schemaName, tableName); TableName tn = TableName.valueOf(hbaseTableName); try (org.apache.hadoop.hbase.client.Connection hConn = - ConnectionFactory.createConnection(getUtility().getConfiguration())) { + ConnectionFactory.createConnection(getUtility().getConfiguration())) { Admin admin = hConn.getAdmin(); if (!admin.tableExists(tn)) { return; From 7ddbad973340e79bbf0b35ac0d12a4fb25eeae97 Mon Sep 17 00:00:00 2001 From: Palash Chauhan Date: Wed, 8 Apr 2026 12:40:25 -0700 Subject: [PATCH 03/10] use tenant views --- .../phoenix/compile/CreateTableCompiler.java | 10 + .../coprocessor/CompactionScanner.java | 9 - .../apache/phoenix/end2end/TenantTTLIT.java | 350 ++++++++---------- 3 files changed, 160 insertions(+), 209 deletions(-) diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java index fbafe3613d7..aa13caa788c 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java @@ -74,6 +74,7 @@ import org.apache.phoenix.schema.PTable; import org.apache.phoenix.schema.PTable.ViewType; import org.apache.phoenix.schema.PTableType; +import org.apache.phoenix.schema.RowKeySchema; import org.apache.phoenix.schema.SortOrder; import org.apache.phoenix.schema.TableNotFoundException; import org.apache.phoenix.schema.TableRef; @@ -82,6 +83,7 @@ import org.apache.phoenix.util.ByteUtil; import org.apache.phoenix.util.MetaDataUtil; import org.apache.phoenix.util.QueryUtil; +import org.apache.phoenix.util.ScanUtil; import org.apache.phoenix.util.SchemaUtil; import org.apache.phoenix.util.ViewUtil; import org.slf4j.Logger; @@ -235,6 +237,14 @@ public MutationPlan compile(CreateTableStatement create) throws SQLException { rowKeyMatcher = WhereOptimizer.getRowKeyMatcher(context, create.getTableName(), parentToBe, where); } + if (rowKeyMatcher.length == 0 && parentToBe.isMultiTenant()) { + PName tenantId = connection.getTenantId(); + if (tenantId != null) { + RowKeySchema schema = parentToBe.getRowKeySchema(); + boolean isSalted = parentToBe.getBucketNum() != null; + rowKeyMatcher = ScanUtil.getTenantIdBytes(schema, isSalted, tenantId, true, false); + } + } verifyIfAnyParentHasIndexesAndViewExtendsPk(parentToBe, columnDefs, pkConstraint); } final ViewType viewType = viewTypeToBe; diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java index 703f296f1d8..f12dc77f724 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java @@ -1354,15 +1354,6 @@ public CompiledTTLExpression getTTLExpressionForRow(List result) throws IO // case multi-tenant, non-index tables, global space matchedType = GLOBAL_VIEWS; tableTTLInfo = tableRowKeyMatcher.match(rowKey, offset, GLOBAL_VIEWS); - if (tableTTLInfo == null) { - // Global views with WHERE on the tenant-id column produce a ROW_KEY_MATCHER - // that includes the tenant-id prefix. Retry from offset 0 (or past salt byte) - // to match these views. - int tenantInclusiveOffset = pkPositions.get(this.isSalted ? 1 : 0); - if (tenantInclusiveOffset != offset) { - tableTTLInfo = tableRowKeyMatcher.match(rowKey, tenantInclusiveOffset, GLOBAL_VIEWS); - } - } if (tableTTLInfo == null) { // search returned no results, determine the new pkPosition(offset) to use // Search using the new offset diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java index 635138d8f76..aba96e0ee56 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java @@ -17,6 +17,7 @@ */ package org.apache.phoenix.end2end; +import static org.apache.phoenix.util.PhoenixRuntime.TENANT_ID_ATTRIB; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -54,12 +55,7 @@ import org.junit.experimental.categories.Category; /** - * Integration test verifying per-tenant TTL via global views on a multi-tenant table. The pattern - * under test: 1. Create a MULTI_TENANT=true base table (no TTL on the table itself). 2. Create - * global views with WHERE clause on the tenant-id PK column, each with a different TTL value. 3. - * Verify read-time masking: expired rows are not visible when querying through the view. 4. Verify - * compaction: expired rows are physically removed by major compaction. 5. Verify tenant isolation: - * a short-TTL tenant's data expires independently of a long-TTL tenant's data. + * Integration test verifying per-tenant TTL via tenant views on a multi-tenant table. */ @Category(NeedsOwnMiniClusterTest.class) public class TenantTTLIT extends BaseTest { @@ -102,52 +98,52 @@ public void testReadMaskingWithDifferentTenantTTLs() throws Exception { String schemaName = generateUniqueName(); String baseTableName = generateUniqueName(); String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); - String view1Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); - String view2Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String view1Name = generateUniqueName(); + String view2Name = generateUniqueName(); int ttlOrg1 = 10; int ttlOrg2 = 100; try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, ID2 VARCHAR NOT NULL, " + + "COL1 VARCHAR, COL2 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + } - conn.createStatement() - .execute(String.format( - "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " + "ID1 VARCHAR NOT NULL, " - + "ID2 VARCHAR NOT NULL, " + "COL1 VARCHAR, " + "COL2 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); - - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", - view1Name, fullTableName, ttlOrg1)); - - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", - view2Name, fullTableName, ttlOrg2)); + createTenantViewWithTTL("org1", fullTableName, view1Name, ttlOrg1); + createTenantViewWithTTL("org2", fullTableName, view2Name, ttlOrg2); - long startTime = EnvironmentEdgeManager.currentTimeMillis(); - EnvironmentEdgeManager.injectEdge(injectEdge); - injectEdge.setValue(startTime); + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); - upsertRow(conn, view1Name, "k1", "v1", "row1col1", "row1col2"); - upsertRow(conn, view1Name, "k2", "v2", "row2col1", "row2col2"); - upsertRow(conn, view2Name, "k1", "v1", "row3col1", "row3col2"); - upsertRow(conn, view2Name, "k2", "v2", "row4col1", "row4col2"); + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + upsertRow(t1, view1Name, "k1", "v1", "row1col1", "row1col2"); + upsertRow(t1, view1Name, "k2", "v2", "row2col1", "row2col2"); + } + try (Connection t2 = getTenantConnection("org2")) { + t2.setAutoCommit(true); + upsertRow(t2, view2Name, "k1", "v1", "row3col1", "row3col2"); + upsertRow(t2, view2Name, "k2", "v2", "row4col1", "row4col2"); + } - assertViewRowCount(conn, view1Name, 2, "org1 should have 2 rows before TTL expiry"); - assertViewRowCount(conn, view2Name, 2, "org2 should have 2 rows before TTL expiry"); + try (Connection t1 = getTenantConnection("org1"); Connection t2 = getTenantConnection("org2")) { + assertViewRowCount(t1, view1Name, 2, "org1 should have 2 rows before TTL expiry"); + assertViewRowCount(t2, view2Name, 2, "org2 should have 2 rows before TTL expiry"); injectEdge.incrementValue((ttlOrg1 * 2) * 1000L); - assertViewRowCount(conn, view1Name, 0, "org1 rows should be masked after org1 TTL expiry"); - assertViewRowCount(conn, view2Name, 2, + assertViewRowCount(t1, view1Name, 0, "org1 rows should be masked after org1 TTL expiry"); + assertViewRowCount(t2, view2Name, 2, "org2 rows should still be visible (TTL not expired yet)"); injectEdge.incrementValue((ttlOrg2 * 2) * 1000L); - assertViewRowCount(conn, view2Name, 0, "org2 rows should be masked after org2 TTL expiry"); + assertViewRowCount(t2, view2Name, 0, "org2 rows should be masked after org2 TTL expiry"); } } @@ -160,39 +156,37 @@ public void testCompactionWithDifferentTenantTTLs() throws Exception { String schemaName = generateUniqueName(); String baseTableName = generateUniqueName(); String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); - String view1Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); - String view2Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String view1Name = generateUniqueName(); + String view2Name = generateUniqueName(); int ttlOrg1 = 10; int ttlOrg2 = 1000; try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, ID2 VARCHAR NOT NULL, " + + "COL1 VARCHAR, COL2 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + } - conn.createStatement() - .execute(String.format( - "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " + "ID1 VARCHAR NOT NULL, " - + "ID2 VARCHAR NOT NULL, " + "COL1 VARCHAR, " + "COL2 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); - - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", - view1Name, fullTableName, ttlOrg1)); - - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", - view2Name, fullTableName, ttlOrg2)); + createTenantViewWithTTL("org1", fullTableName, view1Name, ttlOrg1); + createTenantViewWithTTL("org2", fullTableName, view2Name, ttlOrg2); - long startTime = EnvironmentEdgeManager.currentTimeMillis(); - EnvironmentEdgeManager.injectEdge(injectEdge); - injectEdge.setValue(startTime); + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); - upsertRow(conn, view1Name, "k1", "v1", "c1", "c2"); - upsertRow(conn, view1Name, "k2", "v2", "c3", "c4"); - upsertRow(conn, view2Name, "k1", "v1", "c5", "c6"); - upsertRow(conn, view2Name, "k2", "v2", "c7", "c8"); + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + upsertRow(t1, view1Name, "k1", "v1", "c1", "c2"); + upsertRow(t1, view1Name, "k2", "v2", "c3", "c4"); + } + try (Connection t2 = getTenantConnection("org2")) { + t2.setAutoCommit(true); + upsertRow(t2, view2Name, "k1", "v1", "c5", "c6"); + upsertRow(t2, view2Name, "k2", "v2", "c7", "c8"); } long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); @@ -214,51 +208,6 @@ public void testCompactionWithDifferentTenantTTLs() throws Exception { "All rows should be removed after compacting past org2 TTL"); } - /** - * Verifies that data not covered by any view (an "unassigned" tenant) is NOT expired by - * compaction, since no view TTL applies to it. - */ - @Test - public void testUnassignedTenantDataNotExpiredByCompaction() throws Exception { - String schemaName = generateUniqueName(); - String baseTableName = generateUniqueName(); - String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); - String viewName = SchemaUtil.getTableName(schemaName, generateUniqueName()); - - int ttl = 10; - - try (Connection conn = DriverManager.getConnection(getUrl())) { - conn.setAutoCommit(true); - - conn.createStatement() - .execute(String.format( - "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " + "ID1 VARCHAR NOT NULL, " - + "COL1 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); - - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", - viewName, fullTableName, ttl)); - - long startTime = EnvironmentEdgeManager.currentTimeMillis(); - EnvironmentEdgeManager.injectEdge(injectEdge); - injectEdge.setValue(startTime); - - upsertRowDirect(conn, fullTableName, "org1", "k1", "val1"); - upsertRowDirect(conn, fullTableName, "org2", "k1", "val2"); - } - - long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); - - injectEdge.setValue(afterInsertTime + (ttl * 2 * 1000L)); - EnvironmentEdgeManager.injectEdge(injectEdge); - flushAndMajorCompact(schemaName, baseTableName); - - assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 1, - "org2's row (no view, no TTL) should survive compaction; org1's row should be removed"); - } - /** * Verifies read masking and compaction for three tenants with different TTLs to confirm the * mechanism scales beyond two tenants. @@ -268,9 +217,9 @@ public void testThreeTenantsDifferentTTLs() throws Exception { String schemaName = generateUniqueName(); String baseTableName = generateUniqueName(); String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); - String viewA = SchemaUtil.getTableName(schemaName, generateUniqueName()); - String viewB = SchemaUtil.getTableName(schemaName, generateUniqueName()); - String viewC = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String viewA = generateUniqueName(); + String viewB = generateUniqueName(); + String viewC = generateUniqueName(); int ttlA = 10; int ttlB = 50; @@ -281,41 +230,41 @@ public void testThreeTenantsDifferentTTLs() throws Exception { conn.createStatement() .execute(String.format( - "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " + "ID1 VARCHAR NOT NULL, " - + "COL1 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", fullTableName)); + } - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenA' TTL = %d", - viewA, fullTableName, ttlA)); - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenB' TTL = %d", - viewB, fullTableName, ttlB)); - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'tenC' TTL = %d", - viewC, fullTableName, ttlC)); + createTenantViewWithTTL("tenA", fullTableName, viewA, ttlA); + createTenantViewWithTTL("tenB", fullTableName, viewB, ttlB); + createTenantViewWithTTL("tenC", fullTableName, viewC, ttlC); - long startTime = EnvironmentEdgeManager.currentTimeMillis(); - EnvironmentEdgeManager.injectEdge(injectEdge); - injectEdge.setValue(startTime); + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); - upsertRowSimple(conn, viewA, "r1", "a1"); - upsertRowSimple(conn, viewB, "r1", "b1"); - upsertRowSimple(conn, viewC, "r1", "c1"); + try (Connection tA = getTenantConnection("tenA"); Connection tB = getTenantConnection("tenB"); + Connection tC = getTenantConnection("tenC")) { + tA.setAutoCommit(true); + tB.setAutoCommit(true); + tC.setAutoCommit(true); + upsertRowSimple(tA, viewA, "r1", "a1"); + upsertRowSimple(tB, viewB, "r1", "b1"); + upsertRowSimple(tC, viewC, "r1", "c1"); - assertViewRowCount(conn, viewA, 1, "tenA visible before TTL"); - assertViewRowCount(conn, viewB, 1, "tenB visible before TTL"); - assertViewRowCount(conn, viewC, 1, "tenC visible before TTL"); + assertViewRowCount(tA, viewA, 1, "tenA visible before TTL"); + assertViewRowCount(tB, viewB, 1, "tenB visible before TTL"); + assertViewRowCount(tC, viewC, 1, "tenC visible before TTL"); injectEdge.incrementValue(ttlA * 2 * 1000L); - assertViewRowCount(conn, viewA, 0, "tenA masked after its TTL"); - assertViewRowCount(conn, viewB, 1, "tenB still visible"); - assertViewRowCount(conn, viewC, 1, "tenC still visible"); + assertViewRowCount(tA, viewA, 0, "tenA masked after its TTL"); + assertViewRowCount(tB, viewB, 1, "tenB still visible"); + assertViewRowCount(tC, viewC, 1, "tenC still visible"); injectEdge.incrementValue(ttlB * 2 * 1000L); - assertViewRowCount(conn, viewB, 0, "tenB masked after its TTL"); - assertViewRowCount(conn, viewC, 1, "tenC still visible"); + assertViewRowCount(tB, viewB, 0, "tenB masked after its TTL"); + assertViewRowCount(tC, viewC, 1, "tenC still visible"); } long afterInsertTime = injectEdge.currentTime(); @@ -334,14 +283,14 @@ public void testThreeTenantsDifferentTTLs() throws Exception { * regardless of which region the data resides in. */ @Test - public void testCompactionWithRegionSplitPrunesGlobalViews() throws Exception { + public void testCompactionWithSplitRegions() throws Exception { String schemaName = generateUniqueName(); String baseTableName = generateUniqueName(); String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); - String viewA = SchemaUtil.getTableName(schemaName, generateUniqueName()); - String viewB = SchemaUtil.getTableName(schemaName, generateUniqueName()); - String viewC = SchemaUtil.getTableName(schemaName, generateUniqueName()); - String viewD = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String viewA = generateUniqueName(); + String viewB = generateUniqueName(); + String viewC = generateUniqueName(); + String viewD = generateUniqueName(); int shortTTL = 10; int longTTL = 1000; @@ -353,40 +302,37 @@ public void testCompactionWithRegionSplitPrunesGlobalViews() throws Exception { // Region 1: [empty, 'orgC') -> orgA, orgB // Region 2: ['orgC', empty) -> orgC, orgD conn.createStatement() - .execute(String.format("CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " - + "ID1 VARCHAR NOT NULL, " + "COL1 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'" - + " SPLIT ON ('orgC')", fullTableName)); - - TableName tn = TableName.valueOf(SchemaUtil.getTableName(schemaName, baseTableName)); - try (org.apache.hadoop.hbase.client.Connection hConn = - ConnectionFactory.createConnection(getUtility().getConfiguration())) { - List regions = hConn.getAdmin().getRegions(tn); - assertEquals("Expected 2 regions after SPLIT ON", 2, regions.size()); - } + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'" + + " SPLIT ON ('orgC')", + fullTableName)); + } - // orgA and orgB get short TTL, orgC gets short TTL, orgD gets long TTL - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgA' TTL = %d", - viewA, fullTableName, shortTTL)); - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgB' TTL = %d", - viewB, fullTableName, shortTTL)); - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgC' TTL = %d", - viewC, fullTableName, shortTTL)); - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'orgD' TTL = %d", - viewD, fullTableName, longTTL)); + TableName tn = TableName.valueOf(SchemaUtil.getTableName(schemaName, baseTableName)); + try (org.apache.hadoop.hbase.client.Connection hConn = + ConnectionFactory.createConnection(getUtility().getConfiguration())) { + List regions = hConn.getAdmin().getRegions(tn); + assertEquals("Expected 2 regions after SPLIT ON", 2, regions.size()); + } - long startTime = EnvironmentEdgeManager.currentTimeMillis(); - EnvironmentEdgeManager.injectEdge(injectEdge); - injectEdge.setValue(startTime); + createTenantViewWithTTL("orgA", fullTableName, viewA, shortTTL); + createTenantViewWithTTL("orgB", fullTableName, viewB, shortTTL); + createTenantViewWithTTL("orgC", fullTableName, viewC, shortTTL); + createTenantViewWithTTL("orgD", fullTableName, viewD, longTTL); - upsertRowSimple(conn, viewA, "r1", "a1"); - upsertRowSimple(conn, viewB, "r1", "b1"); - upsertRowSimple(conn, viewC, "r1", "c1"); - upsertRowSimple(conn, viewD, "r1", "d1"); + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + String[] tenants = { "orgA", "orgB", "orgC", "orgD" }; + String[] views = { viewA, viewB, viewC, viewD }; + for (int i = 0; i < tenants.length; i++) { + try (Connection tc = getTenantConnection(tenants[i])) { + tc.setAutoCommit(true); + upsertRowSimple(tc, views[i], "r1", tenants[i] + "_val"); + } } long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); @@ -421,8 +367,8 @@ public void testCompactionWithSaltedMultiTenantTable() throws Exception { String schemaName = generateUniqueName(); String baseTableName = generateUniqueName(); String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); - String view1Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); - String view2Name = SchemaUtil.getTableName(schemaName, generateUniqueName()); + String view1Name = generateUniqueName(); + String view2Name = generateUniqueName(); int ttlOrg1 = 10; int ttlOrg2 = 1000; @@ -431,27 +377,30 @@ public void testCompactionWithSaltedMultiTenantTable() throws Exception { conn.setAutoCommit(true); conn.createStatement() - .execute(String.format("CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, " - + "ID1 VARCHAR NOT NULL, " + "COL1 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" - + ") MULTI_TENANT=true, SALT_BUCKETS=4, COLUMN_ENCODED_BYTES=0," - + " DEFAULT_COLUMN_FAMILY='0'", fullTableName)); + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, SALT_BUCKETS=4, COLUMN_ENCODED_BYTES=0," + + " DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + } - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org1' TTL = %d", - view1Name, fullTableName, ttlOrg1)); + createTenantViewWithTTL("org1", fullTableName, view1Name, ttlOrg1); + createTenantViewWithTTL("org2", fullTableName, view2Name, ttlOrg2); - conn.createStatement() - .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ORGID = 'org2' TTL = %d", - view2Name, fullTableName, ttlOrg2)); - - long startTime = EnvironmentEdgeManager.currentTimeMillis(); - EnvironmentEdgeManager.injectEdge(injectEdge); - injectEdge.setValue(startTime); + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); - upsertRowSimple(conn, view1Name, "k1", "c1"); - upsertRowSimple(conn, view1Name, "k2", "c2"); - upsertRowSimple(conn, view2Name, "k1", "c3"); - upsertRowSimple(conn, view2Name, "k2", "c4"); + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + upsertRowSimple(t1, view1Name, "k1", "c1"); + upsertRowSimple(t1, view1Name, "k2", "c2"); + } + try (Connection t2 = getTenantConnection("org2")) { + t2.setAutoCommit(true); + upsertRowSimple(t2, view2Name, "k1", "c3"); + upsertRowSimple(t2, view2Name, "k2", "c4"); } long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); @@ -475,6 +424,19 @@ public void testCompactionWithSaltedMultiTenantTable() throws Exception { // ---- Helper methods ---- + private Connection getTenantConnection(String tenantId) throws SQLException { + return DriverManager.getConnection(getUrl() + ';' + TENANT_ID_ATTRIB + '=' + tenantId); + } + + private void createTenantViewWithTTL(String tenantId, String baseTable, String viewName, + int ttlSeconds) throws SQLException { + try (Connection conn = getTenantConnection(tenantId)) { + conn.setAutoCommit(true); + conn.createStatement().execute(String.format("CREATE VIEW %s AS SELECT * FROM %s TTL = %d", + viewName, baseTable, ttlSeconds)); + } + } + private void upsertRow(Connection conn, String viewName, String id1, String id2, String col1, String col2) throws SQLException { PreparedStatement stmt = conn.prepareStatement( @@ -487,17 +449,6 @@ private void upsertRow(Connection conn, String viewName, String id1, String id2, conn.commit(); } - private void upsertRowDirect(Connection conn, String tableName, String orgId, String id1, - String col1) throws SQLException { - PreparedStatement stmt = conn.prepareStatement( - String.format("UPSERT INTO %s (ORGID, ID1, COL1) VALUES (?, ?, ?)", tableName)); - stmt.setString(1, orgId); - stmt.setString(2, id1); - stmt.setString(3, col1); - stmt.executeUpdate(); - conn.commit(); - } - private void upsertRowSimple(Connection conn, String viewName, String id1, String col1) throws SQLException { PreparedStatement stmt = @@ -545,5 +496,4 @@ private void flushAndMajorCompact(String schemaName, String tableName) throws Ex TestUtil.majorCompact(getUtility(), tn); } } - } From f8988720bcfe90f19a14c8aaa639a9be0def5695 Mon Sep 17 00:00:00 2001 From: Palash Chauhan Date: Thu, 9 Apr 2026 13:35:45 -0700 Subject: [PATCH 04/10] create rowKeyMatcher during compaction --- .../phoenix/compile/CreateTableCompiler.java | 10 ---- .../coprocessor/CompactionScanner.java | 10 ++++ .../apache/phoenix/end2end/TenantTTLIT.java | 51 +++++++++++++------ 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java index aa13caa788c..fbafe3613d7 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java @@ -74,7 +74,6 @@ import org.apache.phoenix.schema.PTable; import org.apache.phoenix.schema.PTable.ViewType; import org.apache.phoenix.schema.PTableType; -import org.apache.phoenix.schema.RowKeySchema; import org.apache.phoenix.schema.SortOrder; import org.apache.phoenix.schema.TableNotFoundException; import org.apache.phoenix.schema.TableRef; @@ -83,7 +82,6 @@ import org.apache.phoenix.util.ByteUtil; import org.apache.phoenix.util.MetaDataUtil; import org.apache.phoenix.util.QueryUtil; -import org.apache.phoenix.util.ScanUtil; import org.apache.phoenix.util.SchemaUtil; import org.apache.phoenix.util.ViewUtil; import org.slf4j.Logger; @@ -237,14 +235,6 @@ public MutationPlan compile(CreateTableStatement create) throws SQLException { rowKeyMatcher = WhereOptimizer.getRowKeyMatcher(context, create.getTableName(), parentToBe, where); } - if (rowKeyMatcher.length == 0 && parentToBe.isMultiTenant()) { - PName tenantId = connection.getTenantId(); - if (tenantId != null) { - RowKeySchema schema = parentToBe.getRowKeySchema(); - boolean isSalted = parentToBe.getBucketNum() != null; - rowKeyMatcher = ScanUtil.getTenantIdBytes(schema, isSalted, tenantId, true, false); - } - } verifyIfAnyParentHasIndexesAndViewExtendsPk(parentToBe, columnDefs, pkConstraint); } final ViewType viewType = viewTypeToBe; diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java index f12dc77f724..63ad67d1727 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java @@ -91,6 +91,7 @@ import org.apache.phoenix.schema.PNameFactory; import org.apache.phoenix.schema.PTable; import org.apache.phoenix.schema.PTableType; +import org.apache.phoenix.schema.RowKeySchema; import org.apache.phoenix.schema.RowKeyValueAccessor; import org.apache.phoenix.schema.SortOrder; import org.apache.phoenix.schema.TTLExpression; @@ -1004,6 +1005,15 @@ private void getTTLInfo(String physicalTableName, Set viewSet, } else { compiledExpr = (LiteralTTLExpression) viewTTL; } + if ( + (rowKeyMatcher == null || rowKeyMatcher.length == 0) && tid != null + && !tid.isEmpty() && isMultiTenant + ) { + RowKeySchema schema = baseTable.getRowKeySchema(); + boolean salted = baseTable.getBucketNum() != null; + rowKeyMatcher = ScanUtil.getTenantIdBytes(schema, salted, + PNameFactory.newName(tid), true, false); + } tableTTLInfoList.add(new TableTTLInfo(physicalTableName.getBytes(), tenantIdBytes, fullTableName.getBytes(), rowKeyMatcher, compiledExpr)); } diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java index aba96e0ee56..40e378ab644 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java @@ -164,11 +164,12 @@ public void testCompactionWithDifferentTenantTTLs() throws Exception { try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, ID2 VARCHAR NOT NULL, " - + "COL1 VARCHAR, COL2 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 INTEGER NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); } createTenantViewWithTTL("org1", fullTableName, view1Name, ttlOrg1); @@ -180,13 +181,13 @@ public void testCompactionWithDifferentTenantTTLs() throws Exception { try (Connection t1 = getTenantConnection("org1")) { t1.setAutoCommit(true); - upsertRow(t1, view1Name, "k1", "v1", "c1", "c2"); - upsertRow(t1, view1Name, "k2", "v2", "c3", "c4"); + upsertRowInt(t1, view1Name, 1, "c1"); + upsertRowInt(t1, view1Name, 2, "c2"); } try (Connection t2 = getTenantConnection("org2")) { t2.setAutoCommit(true); - upsertRow(t2, view2Name, "k1", "v1", "c5", "c6"); - upsertRow(t2, view2Name, "k2", "v2", "c7", "c8"); + upsertRowInt(t2, view2Name, 1, "c3"); + upsertRowInt(t2, view2Name, 2, "c4"); } long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); @@ -227,11 +228,10 @@ public void testThreeTenantsDifferentTTLs() throws Exception { try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement() .execute(String.format( - "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, KP CHAR(3) NOT NULL, SEQ BIGINT NOT NULL, " + + "COL1 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, KP, SEQ)" + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", fullTableName)); } @@ -249,9 +249,9 @@ public void testThreeTenantsDifferentTTLs() throws Exception { tA.setAutoCommit(true); tB.setAutoCommit(true); tC.setAutoCommit(true); - upsertRowSimple(tA, viewA, "r1", "a1"); - upsertRowSimple(tB, viewB, "r1", "b1"); - upsertRowSimple(tC, viewC, "r1", "c1"); + upsertRowCharBigint(tA, viewA, "AAA", 1L, "a1"); + upsertRowCharBigint(tB, viewB, "BBB", 1L, "b1"); + upsertRowCharBigint(tC, viewC, "CCC", 1L, "c1"); assertViewRowCount(tA, viewA, 1, "tenA visible before TTL"); assertViewRowCount(tB, viewB, 1, "tenB visible before TTL"); @@ -449,6 +449,27 @@ private void upsertRow(Connection conn, String viewName, String id1, String id2, conn.commit(); } + private void upsertRowInt(Connection conn, String viewName, int id1, String col1) + throws SQLException { + PreparedStatement stmt = + conn.prepareStatement(String.format("UPSERT INTO %s (ID1, COL1) VALUES (?, ?)", viewName)); + stmt.setInt(1, id1); + stmt.setString(2, col1); + stmt.executeUpdate(); + conn.commit(); + } + + private void upsertRowCharBigint(Connection conn, String viewName, String kp, long seq, + String col1) throws SQLException { + PreparedStatement stmt = conn + .prepareStatement(String.format("UPSERT INTO %s (KP, SEQ, COL1) VALUES (?, ?, ?)", viewName)); + stmt.setString(1, kp); + stmt.setLong(2, seq); + stmt.setString(3, col1); + stmt.executeUpdate(); + conn.commit(); + } + private void upsertRowSimple(Connection conn, String viewName, String id1, String col1) throws SQLException { PreparedStatement stmt = From 4988a259eadfce12a533d865f35730020d57d0b7 Mon Sep 17 00:00:00 2001 From: Palash Chauhan Date: Fri, 10 Apr 2026 11:43:14 -0700 Subject: [PATCH 05/10] client side row key matcher generation --- .../phoenix/compile/CreateTableCompiler.java | 2 +- .../phoenix/compile/WhereOptimizer.java | 22 ++++++++++++++++--- .../coprocessor/CompactionScanner.java | 10 --------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java index fbafe3613d7..ed73cc8a471 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java @@ -231,7 +231,7 @@ public MutationPlan compile(CreateTableStatement create) throws SQLException { } if (viewTypeToBe == ViewType.MAPPED && parentToBe.getPKColumns().isEmpty()) { validateCreateViewCompilation(connection, parentToBe, columnDefs, pkConstraint); - } else if (where != null && viewTypeToBe == ViewType.UPDATABLE) { + } else if (viewTypeToBe == ViewType.UPDATABLE) { rowKeyMatcher = WhereOptimizer.getRowKeyMatcher(context, create.getTableName(), parentToBe, where); } diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/WhereOptimizer.java b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/WhereOptimizer.java index ecb71aad521..1e9fd2b9334 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/WhereOptimizer.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/WhereOptimizer.java @@ -498,12 +498,28 @@ public static byte[] getRowKeyMatcher(final StatementContext context, PName tenantId = context.getConnection().getTenantId(); boolean isMultiTenant = tenantId != null && parentTable.isMultiTenant(); - byte[] tenantIdBytes = tenantId == null - ? ByteUtil.EMPTY_BYTE_ARRAY - : ScanUtil.getTenantIdBytes(schema, isSalted, tenantId, isMultiTenant, false); + // Gracefully handle tenant-id encoding failures (e.g., tenant-id type mismatch) + // so that view creation is not blocked; the view will simply have no ROW_KEY_MATCHER. + byte[] tenantIdBytes; + try { + tenantIdBytes = tenantId == null + ? ByteUtil.EMPTY_BYTE_ARRAY + : ScanUtil.getTenantIdBytes(schema, isSalted, tenantId, isMultiTenant, false); + } catch (SQLException e) { + tenantIdBytes = ByteUtil.EMPTY_BYTE_ARRAY; + } if (tenantIdBytes.length != 0) { rowKeySlotRangesList.add(Arrays.asList(KeyRange.POINT.apply(tenantIdBytes))); } + // For tenant views without a WHERE clause, return the tenant-id bytes as the + // ROW_KEY_MATCHER so that CompactionScanner can match rows to this view. + if (viewWhereExpression == null) { + if (rowKeySlotRangesList.isEmpty()) { + return ByteUtil.EMPTY_BYTE_ARRAY; + } + ScanRanges scanRange = ScanRanges.createSingleSpan(schema, rowKeySlotRangesList, null, false); + return scanRange.getScanRange().getLowerRange(); + } KeyExpressionVisitor visitor = new KeyExpressionVisitor(context, parentTable); KeyExpressionVisitor.KeySlots keySlots = viewWhereExpression.accept(visitor); if (keySlots == null) { diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java index 63ad67d1727..f12dc77f724 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/CompactionScanner.java @@ -91,7 +91,6 @@ import org.apache.phoenix.schema.PNameFactory; import org.apache.phoenix.schema.PTable; import org.apache.phoenix.schema.PTableType; -import org.apache.phoenix.schema.RowKeySchema; import org.apache.phoenix.schema.RowKeyValueAccessor; import org.apache.phoenix.schema.SortOrder; import org.apache.phoenix.schema.TTLExpression; @@ -1005,15 +1004,6 @@ private void getTTLInfo(String physicalTableName, Set viewSet, } else { compiledExpr = (LiteralTTLExpression) viewTTL; } - if ( - (rowKeyMatcher == null || rowKeyMatcher.length == 0) && tid != null - && !tid.isEmpty() && isMultiTenant - ) { - RowKeySchema schema = baseTable.getRowKeySchema(); - boolean salted = baseTable.getBucketNum() != null; - rowKeyMatcher = ScanUtil.getTenantIdBytes(schema, salted, - PNameFactory.newName(tid), true, false); - } tableTTLInfoList.add(new TableTTLInfo(physicalTableName.getBytes(), tenantIdBytes, fullTableName.getBytes(), rowKeyMatcher, compiledExpr)); } From 849a4f125f446021d1a8fcc31b5cc0710831b7e7 Mon Sep 17 00:00:00 2001 From: Palash Chauhan Date: Tue, 21 Apr 2026 11:29:54 -0700 Subject: [PATCH 06/10] restrict only one tenant view with no where clause --- .../phoenix/compile/CreateTableCompiler.java | 49 +++++++++++++++++++ .../phoenix/exception/SQLExceptionCode.java | 3 ++ 2 files changed, 52 insertions(+) diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java index ed73cc8a471..4f6281868bd 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Arrays; import java.util.BitSet; import java.util.Collections; import java.util.HashSet; @@ -38,6 +39,7 @@ import org.apache.hadoop.hbase.client.Scan; import org.apache.hadoop.hbase.client.Table; import org.apache.hadoop.hbase.io.ImmutableBytesWritable; +import org.apache.hadoop.hbase.util.Bytes; import org.apache.phoenix.exception.SQLExceptionCode; import org.apache.phoenix.exception.SQLExceptionInfo; import org.apache.phoenix.execute.MutationState; @@ -79,10 +81,12 @@ import org.apache.phoenix.schema.TableRef; import org.apache.phoenix.schema.types.PDataType; import org.apache.phoenix.schema.types.PVarbinary; +import org.apache.phoenix.coprocessorclient.TableInfo; import org.apache.phoenix.util.ByteUtil; import org.apache.phoenix.util.MetaDataUtil; import org.apache.phoenix.util.QueryUtil; import org.apache.phoenix.util.SchemaUtil; +import org.apache.phoenix.util.TableViewFinderResult; import org.apache.phoenix.util.ViewUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -234,6 +238,9 @@ public MutationPlan compile(CreateTableStatement create) throws SQLException { } else if (viewTypeToBe == ViewType.UPDATABLE) { rowKeyMatcher = WhereOptimizer.getRowKeyMatcher(context, create.getTableName(), parentToBe, where); + if (where == null && parentToBe.isMultiTenant() && connection.getTenantId() != null) { + validateNoExistingTenantViewWithoutWhere(connection, parentToBe); + } } verifyIfAnyParentHasIndexesAndViewExtendsPk(parentToBe, columnDefs, pkConstraint); } @@ -435,6 +442,48 @@ private void verifyIfAnyParentHasIndexesAndViewExtendsPk(PTable parentToBe, } } + /** + * For a multi-tenant parent, ensure the current tenant has no existing view without a WHERE + * clause. Two such views would share the same ROW_KEY_MATCHER (the tenant-id bytes), causing + * a conflict in the compaction RowKeyMatcher trie. + */ + private void validateNoExistingTenantViewWithoutWhere(final PhoenixConnection connection, + final PTable parentToBe) throws SQLException { + PName myTenantId = connection.getTenantId(); + if (myTenantId == null) { + return; + } + byte[] myTenantIdBytes = myTenantId.getBytes(); + TableViewFinderResult childViews; + try { + childViews = ViewUtil.findChildViews(connection, + parentToBe.getTenantId() == null ? null : parentToBe.getTenantId().getString(), + parentToBe.getSchemaName() == null ? null : parentToBe.getSchemaName().getString(), + parentToBe.getTableName().getString()); + } catch (IOException e) { + throw new SQLException(e); + } + for (TableInfo info : childViews.getLinks()) { + if (!Arrays.equals(info.getTenantId(), myTenantIdBytes)) { + continue; + } + String childFullName = SchemaUtil.getTableName( + info.getSchemaName() == null ? null : Bytes.toString(info.getSchemaName()), + Bytes.toString(info.getTableName())); + try { + PTable existing = connection.getTable(childFullName); + String existingViewStmt = existing.getViewStatement(); + if (existingViewStmt == null || existingViewStmt.isEmpty()) { + throw new SQLExceptionInfo.Builder( + SQLExceptionCode.TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE).build() + .buildException(); + } + } catch (TableNotFoundException e) { + // Orphan child link, ignore. + } + } + } + /** * Validate View creation compilation. 1. If view creation syntax does not specify primary key, * the method throws SQLException with PRIMARY_KEY_MISSING code. 2. If parent table does not diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/exception/SQLExceptionCode.java b/phoenix-core-client/src/main/java/org/apache/phoenix/exception/SQLExceptionCode.java index 3eddf2278fe..fb98e0bbd30 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/exception/SQLExceptionCode.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/exception/SQLExceptionCode.java @@ -483,6 +483,9 @@ public SQLException newException(SQLExceptionInfo info) { "CDC on this table is either enabled or is in the process of being enabled."), CANNOT_SET_OR_ALTER_MAX_LOOKBACK_FOR_INDEX(10964, "44A46", "Cannot set or alter " + PHOENIX_MAX_LOOKBACK_AGE_CONF_KEY + " on an index"), + TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE(10965, "44A47", + "A tenant cannot create multiple views without a WHERE clause on the same " + + "multi-tenant parent table."), /** Sequence related */ SEQUENCE_ALREADY_EXIST(1200, "42Z00", "Sequence already exists.", new Factory() { From a731960bca4432ab9b3cc5f83ace3e214acc4b92 Mon Sep 17 00:00:00 2001 From: Palash Chauhan Date: Tue, 21 Apr 2026 11:30:03 -0700 Subject: [PATCH 07/10] tests --- .../apache/phoenix/end2end/TenantTTLIT.java | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java index 40e378ab644..170ab3de0c7 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java @@ -21,6 +21,7 @@ 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 java.io.IOException; import java.sql.Connection; @@ -31,6 +32,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Properties; import org.apache.hadoop.hbase.HConstants; import org.apache.hadoop.hbase.TableName; import org.apache.hadoop.hbase.client.Admin; @@ -41,6 +43,7 @@ import org.apache.hadoop.hbase.client.Scan; import org.apache.hadoop.hbase.client.Table; import org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants; +import org.apache.phoenix.exception.SQLExceptionCode; import org.apache.phoenix.query.BaseTest; import org.apache.phoenix.query.QueryServices; import org.apache.phoenix.util.EnvironmentEdgeManager; @@ -422,12 +425,245 @@ public void testCompactionWithSaltedMultiTenantTable() throws Exception { "All rows should be removed from salted table after all TTLs expire"); } + /** + * Verifies that tenant TTL compaction on the base table works correctly when a secondary + * index exists on the base table. + */ + @Test + public void testCompactionWithTenantTableIndex() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String indexName = generateUniqueName(); + String view1Name = generateUniqueName(); + String view2Name = generateUniqueName(); + + int ttlOrg1 = 10; + int ttlOrg2 = 1000; + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR, COL2 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + conn.createStatement().execute(String.format( + "CREATE INDEX %s ON %s(COL1) INCLUDE(COL2)", indexName, fullTableName)); + } + + createTenantViewWithTTL("org1", fullTableName, view1Name, ttlOrg1); + createTenantViewWithTTL("org2", fullTableName, view2Name, ttlOrg2); + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + upsertRowSimple(t1, view1Name, "k1", "v1"); + upsertRowSimple(t1, view1Name, "k2", "v2"); + } + try (Connection t2 = getTenantConnection("org2")) { + t2.setAutoCommit(true); + upsertRowSimple(t2, view2Name, "k1", "v3"); + upsertRowSimple(t2, view2Name, "k2", "v4"); + } + + long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 4, + "Base table should have 4 rows before compaction"); + + injectEdge.setValue(afterInsertTime + (ttlOrg1 * 2 * 1000L)); + EnvironmentEdgeManager.injectEdge(injectEdge); + flushAndMajorCompact(schemaName, baseTableName); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 2, + "Only org2's 2 rows should remain in base table after org1 TTL even with index present"); + + injectEdge.setValue(afterInsertTime + (ttlOrg2 * 2 * 1000L)); + flushAndMajorCompact(schemaName, baseTableName); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 0, + "All rows should be removed from base table after all TTLs expire"); + } + + /** + * Verifies that two different tenants can create tenant views with the SAME view name + * on the same base table, with independent TTLs that don't interfere with each other. + */ + @Test + public void testSameViewNameAcrossDifferentTenants() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String sharedViewName = generateUniqueName(); + + int ttlOrg1 = 10; + int ttlOrg2 = 1000; + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + } + + // Both tenants create a view with the same name; scoped by tenant-id in SYSTEM.CATALOG. + createTenantViewWithTTL("org1", fullTableName, sharedViewName, ttlOrg1); + createTenantViewWithTTL("org2", fullTableName, sharedViewName, ttlOrg2); + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + upsertRowSimple(t1, sharedViewName, "k1", "a1"); + upsertRowSimple(t1, sharedViewName, "k2", "a2"); + } + try (Connection t2 = getTenantConnection("org2")) { + t2.setAutoCommit(true); + upsertRowSimple(t2, sharedViewName, "k1", "b1"); + upsertRowSimple(t2, sharedViewName, "k2", "b2"); + } + + try (Connection t1 = getTenantConnection("org1"); + Connection t2 = getTenantConnection("org2")) { + assertViewRowCount(t1, sharedViewName, 2, "org1 should see 2 rows"); + assertViewRowCount(t2, sharedViewName, 2, "org2 should see 2 rows"); + + injectEdge.incrementValue((ttlOrg1 * 2) * 1000L); + assertViewRowCount(t1, sharedViewName, 0, "org1 rows should be masked after its TTL"); + assertViewRowCount(t2, sharedViewName, 2, "org2 rows should still be visible"); + } + + long afterInsertTime = injectEdge.currentTime(); + flushAndMajorCompact(schemaName, baseTableName); + assertHBaseRowCount(schemaName, baseTableName, 0, 2, + "Only org2's rows should remain after compacting past org1 TTL"); + } + + /** + * Verifies that a tenant cannot create two tenant views without WHERE clauses on the same + * multi-tenant parent, since both would produce the same ROW_KEY_MATCHER and conflict in + * the compaction trie. + */ + @Test + public void testCannotCreateMultipleNoWhereViewsSameTenant() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String view1Name = generateUniqueName(); + String view2Name = generateUniqueName(); + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + } + + createTenantViewWithTTL("org1", fullTableName, view1Name, 10); + + try (Connection conn = getTenantConnection("org1")) { + conn.setAutoCommit(true); + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s TTL = 20", view2Name, fullTableName)); + fail("Expected TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE"); + } catch (SQLException e) { + assertEquals(SQLExceptionCode.TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE.getErrorCode(), + e.getErrorCode()); + } + } + + /** + * Verifies that tenant TTL works correctly when the connection has + * PHOENIX_UPDATABLE_VIEW_RESTRICTION_ENABLED=true. + */ + @Test + public void testTenantTTLWithUpdatableViewRestrictionEnabled() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String view1Name = generateUniqueName(); + String view2Name = generateUniqueName(); + + int ttlOrg1 = 10; + int ttlOrg2 = 1000; + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + } + + Properties restrictProps = new Properties(); + restrictProps.setProperty( + QueryServices.PHOENIX_UPDATABLE_VIEW_RESTRICTION_ENABLED, "true"); + createTenantViewWithProps("org1", fullTableName, view1Name, ttlOrg1, restrictProps); + createTenantViewWithProps("org2", fullTableName, view2Name, ttlOrg2, restrictProps); + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + try (Connection t1 = getTenantConnectionWithProps("org1", restrictProps)) { + t1.setAutoCommit(true); + upsertRowSimple(t1, view1Name, "k1", "v1"); + upsertRowSimple(t1, view1Name, "k2", "v2"); + } + try (Connection t2 = getTenantConnectionWithProps("org2", restrictProps)) { + t2.setAutoCommit(true); + upsertRowSimple(t2, view2Name, "k1", "v3"); + upsertRowSimple(t2, view2Name, "k2", "v4"); + } + + long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); + + injectEdge.setValue(afterInsertTime + (ttlOrg1 * 2 * 1000L)); + EnvironmentEdgeManager.injectEdge(injectEdge); + flushAndMajorCompact(schemaName, baseTableName); + + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 2, + "Only org2's 2 rows should remain after org1 TTL, even with view restriction enabled"); + } + // ---- Helper methods ---- private Connection getTenantConnection(String tenantId) throws SQLException { return DriverManager.getConnection(getUrl() + ';' + TENANT_ID_ATTRIB + '=' + tenantId); } + private Connection getTenantConnectionWithProps(String tenantId, Properties props) + throws SQLException { + Properties merged = new Properties(); + merged.putAll(props); + merged.setProperty(TENANT_ID_ATTRIB, tenantId); + return DriverManager.getConnection(getUrl(), merged); + } + + private void createTenantViewWithProps(String tenantId, String baseTable, String viewName, + int ttlSeconds, Properties props) throws SQLException { + try (Connection conn = getTenantConnectionWithProps(tenantId, props)) { + conn.setAutoCommit(true); + conn.createStatement().execute(String.format("CREATE VIEW %s AS SELECT * FROM %s TTL = %d", + viewName, baseTable, ttlSeconds)); + } + } + private void createTenantViewWithTTL(String tenantId, String baseTable, String viewName, int ttlSeconds) throws SQLException { try (Connection conn = getTenantConnection(tenantId)) { From b17cf1f0008b88618fb321c7adb57d06ed8c1149 Mon Sep 17 00:00:00 2001 From: Palash Chauhan Date: Tue, 21 Apr 2026 14:23:12 -0700 Subject: [PATCH 08/10] spotless --- .../phoenix/compile/CreateTableCompiler.java | 9 +-- .../apache/phoenix/end2end/TenantTTLIT.java | 77 +++++++++---------- 2 files changed, 42 insertions(+), 44 deletions(-) diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java index 4f6281868bd..8e430f3d98d 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java @@ -40,6 +40,7 @@ import org.apache.hadoop.hbase.client.Table; import org.apache.hadoop.hbase.io.ImmutableBytesWritable; import org.apache.hadoop.hbase.util.Bytes; +import org.apache.phoenix.coprocessorclient.TableInfo; import org.apache.phoenix.exception.SQLExceptionCode; import org.apache.phoenix.exception.SQLExceptionInfo; import org.apache.phoenix.execute.MutationState; @@ -81,7 +82,6 @@ import org.apache.phoenix.schema.TableRef; import org.apache.phoenix.schema.types.PDataType; import org.apache.phoenix.schema.types.PVarbinary; -import org.apache.phoenix.coprocessorclient.TableInfo; import org.apache.phoenix.util.ByteUtil; import org.apache.phoenix.util.MetaDataUtil; import org.apache.phoenix.util.QueryUtil; @@ -444,8 +444,8 @@ private void verifyIfAnyParentHasIndexesAndViewExtendsPk(PTable parentToBe, /** * For a multi-tenant parent, ensure the current tenant has no existing view without a WHERE - * clause. Two such views would share the same ROW_KEY_MATCHER (the tenant-id bytes), causing - * a conflict in the compaction RowKeyMatcher trie. + * clause. Two such views would share the same ROW_KEY_MATCHER (the tenant-id bytes), causing a + * conflict in the compaction RowKeyMatcher trie. */ private void validateNoExistingTenantViewWithoutWhere(final PhoenixConnection connection, final PTable parentToBe) throws SQLException { @@ -475,8 +475,7 @@ private void validateNoExistingTenantViewWithoutWhere(final PhoenixConnection co String existingViewStmt = existing.getViewStatement(); if (existingViewStmt == null || existingViewStmt.isEmpty()) { throw new SQLExceptionInfo.Builder( - SQLExceptionCode.TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE).build() - .buildException(); + SQLExceptionCode.TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE).build().buildException(); } } catch (TableNotFoundException e) { // Orphan child link, ignore. diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java index 170ab3de0c7..ae3e0c4455d 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java @@ -426,8 +426,8 @@ public void testCompactionWithSaltedMultiTenantTable() throws Exception { } /** - * Verifies that tenant TTL compaction on the base table works correctly when a secondary - * index exists on the base table. + * Verifies that tenant TTL compaction on the base table works correctly when a secondary index + * exists on the base table. */ @Test public void testCompactionWithTenantTableIndex() throws Exception { @@ -443,14 +443,15 @@ public void testCompactionWithTenantTableIndex() throws Exception { try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" - + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR, COL2 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); - conn.createStatement().execute(String.format( - "CREATE INDEX %s ON %s(COL1) INCLUDE(COL2)", indexName, fullTableName)); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR, COL2 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + conn.createStatement().execute( + String.format("CREATE INDEX %s ON %s(COL1) INCLUDE(COL2)", indexName, fullTableName)); } createTenantViewWithTTL("org1", fullTableName, view1Name, ttlOrg1); @@ -491,8 +492,8 @@ public void testCompactionWithTenantTableIndex() throws Exception { } /** - * Verifies that two different tenants can create tenant views with the SAME view name - * on the same base table, with independent TTLs that don't interfere with each other. + * Verifies that two different tenants can create tenant views with the SAME view name on the same + * base table, with independent TTLs that don't interfere with each other. */ @Test public void testSameViewNameAcrossDifferentTenants() throws Exception { @@ -506,12 +507,12 @@ public void testSameViewNameAcrossDifferentTenants() throws Exception { try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" - + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); } // Both tenants create a view with the same name; scoped by tenant-id in SYSTEM.CATALOG. @@ -533,8 +534,7 @@ public void testSameViewNameAcrossDifferentTenants() throws Exception { upsertRowSimple(t2, sharedViewName, "k2", "b2"); } - try (Connection t1 = getTenantConnection("org1"); - Connection t2 = getTenantConnection("org2")) { + try (Connection t1 = getTenantConnection("org1"); Connection t2 = getTenantConnection("org2")) { assertViewRowCount(t1, sharedViewName, 2, "org1 should see 2 rows"); assertViewRowCount(t2, sharedViewName, 2, "org2 should see 2 rows"); @@ -551,8 +551,8 @@ public void testSameViewNameAcrossDifferentTenants() throws Exception { /** * Verifies that a tenant cannot create two tenant views without WHERE clauses on the same - * multi-tenant parent, since both would produce the same ROW_KEY_MATCHER and conflict in - * the compaction trie. + * multi-tenant parent, since both would produce the same ROW_KEY_MATCHER and conflict in the + * compaction trie. */ @Test public void testCannotCreateMultipleNoWhereViewsSameTenant() throws Exception { @@ -564,20 +564,20 @@ public void testCannotCreateMultipleNoWhereViewsSameTenant() throws Exception { try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" - + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); } createTenantViewWithTTL("org1", fullTableName, view1Name, 10); try (Connection conn = getTenantConnection("org1")) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE VIEW %s AS SELECT * FROM %s TTL = 20", view2Name, fullTableName)); + conn.createStatement().execute( + String.format("CREATE VIEW %s AS SELECT * FROM %s TTL = 20", view2Name, fullTableName)); fail("Expected TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE"); } catch (SQLException e) { assertEquals(SQLExceptionCode.TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE.getErrorCode(), @@ -602,17 +602,16 @@ public void testTenantTTLWithUpdatableViewRestrictionEnabled() throws Exception try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute(String.format( - "CREATE TABLE %s (" - + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " - + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" - + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", - fullTableName)); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); } Properties restrictProps = new Properties(); - restrictProps.setProperty( - QueryServices.PHOENIX_UPDATABLE_VIEW_RESTRICTION_ENABLED, "true"); + restrictProps.setProperty(QueryServices.PHOENIX_UPDATABLE_VIEW_RESTRICTION_ENABLED, "true"); createTenantViewWithProps("org1", fullTableName, view1Name, ttlOrg1, restrictProps); createTenantViewWithProps("org2", fullTableName, view2Name, ttlOrg2, restrictProps); @@ -648,7 +647,7 @@ private Connection getTenantConnection(String tenantId) throws SQLException { } private Connection getTenantConnectionWithProps(String tenantId, Properties props) - throws SQLException { + throws SQLException { Properties merged = new Properties(); merged.putAll(props); merged.setProperty(TENANT_ID_ATTRIB, tenantId); From 32210608125eb371f9c39c2e911828052c4ff07d Mon Sep 17 00:00:00 2001 From: Palash Chauhan Date: Wed, 22 Apr 2026 11:06:57 -0700 Subject: [PATCH 09/10] fix tests --- .../java/org/apache/phoenix/compile/CreateTableCompiler.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java index 8e430f3d98d..a704742bfc6 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java @@ -453,6 +453,9 @@ private void validateNoExistingTenantViewWithoutWhere(final PhoenixConnection co if (myTenantId == null) { return; } + if (connection.getQueryServices() instanceof ConnectionlessQueryServicesImpl) { + return; + } byte[] myTenantIdBytes = myTenantId.getBytes(); TableViewFinderResult childViews; try { From f205442020f3737797a499107373fee27fb0f8b0 Mon Sep 17 00:00:00 2001 From: Palash Chauhan Date: Thu, 23 Apr 2026 15:14:00 -0700 Subject: [PATCH 10/10] add ttl conflict check --- .../phoenix/compile/CreateTableCompiler.java | 61 ++-- .../phoenix/exception/SQLExceptionCode.java | 7 +- .../apache/phoenix/schema/MetaDataClient.java | 36 +++ .../org/apache/phoenix/util/ViewUtil.java | 83 ++++++ .../apache/phoenix/end2end/TenantTTLIT.java | 272 +++++++++++++++++- 5 files changed, 401 insertions(+), 58 deletions(-) diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java index a704742bfc6..039d6592659 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/CreateTableCompiler.java @@ -25,7 +25,6 @@ import java.io.IOException; import java.sql.SQLException; import java.util.ArrayList; -import java.util.Arrays; import java.util.BitSet; import java.util.Collections; import java.util.HashSet; @@ -39,8 +38,7 @@ import org.apache.hadoop.hbase.client.Scan; import org.apache.hadoop.hbase.client.Table; import org.apache.hadoop.hbase.io.ImmutableBytesWritable; -import org.apache.hadoop.hbase.util.Bytes; -import org.apache.phoenix.coprocessorclient.TableInfo; +import org.apache.hadoop.hbase.util.Pair; import org.apache.phoenix.exception.SQLExceptionCode; import org.apache.phoenix.exception.SQLExceptionInfo; import org.apache.phoenix.execute.MutationState; @@ -55,6 +53,7 @@ import org.apache.phoenix.expression.SingleCellColumnExpression; import org.apache.phoenix.expression.visitor.StatelessTraverseNoExpressionVisitor; import org.apache.phoenix.jdbc.PhoenixConnection; +import org.apache.phoenix.jdbc.PhoenixDatabaseMetaData; import org.apache.phoenix.jdbc.PhoenixStatement; import org.apache.phoenix.jdbc.PhoenixStatement.Operation; import org.apache.phoenix.parse.BindParseNode; @@ -86,7 +85,6 @@ import org.apache.phoenix.util.MetaDataUtil; import org.apache.phoenix.util.QueryUtil; import org.apache.phoenix.util.SchemaUtil; -import org.apache.phoenix.util.TableViewFinderResult; import org.apache.phoenix.util.ViewUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -238,9 +236,14 @@ public MutationPlan compile(CreateTableStatement create) throws SQLException { } else if (viewTypeToBe == ViewType.UPDATABLE) { rowKeyMatcher = WhereOptimizer.getRowKeyMatcher(context, create.getTableName(), parentToBe, where); - if (where == null && parentToBe.isMultiTenant() && connection.getTenantId() != null) { - validateNoExistingTenantViewWithoutWhere(connection, parentToBe); - } + // On a multi-tenant base table, for a given tenant we allow EITHER + // (a) any number of tenant views without TTL, OR + // (b) exactly one tenant view with TTL (WHERE or no-WHERE). + // A TTL view coexisting with any other view for the same tenant causes + // ROW_KEY_MATCHER prefix conflicts in the compaction RowKeyMatcher trie (the tenant-id + // bytes are a prefix of any scoped pattern). + ViewUtil.validateTenantTTLViewCoexistence(connection, parentToBe, hasTTLProperty(create), + null); } verifyIfAnyParentHasIndexesAndViewExtendsPk(parentToBe, columnDefs, pkConstraint); } @@ -443,47 +446,15 @@ private void verifyIfAnyParentHasIndexesAndViewExtendsPk(PTable parentToBe, } /** - * For a multi-tenant parent, ensure the current tenant has no existing view without a WHERE - * clause. Two such views would share the same ROW_KEY_MATCHER (the tenant-id bytes), causing a - * conflict in the compaction RowKeyMatcher trie. + * Returns true if the {@code CREATE VIEW} statement has a {@code TTL} property set. */ - private void validateNoExistingTenantViewWithoutWhere(final PhoenixConnection connection, - final PTable parentToBe) throws SQLException { - PName myTenantId = connection.getTenantId(); - if (myTenantId == null) { - return; - } - if (connection.getQueryServices() instanceof ConnectionlessQueryServicesImpl) { - return; - } - byte[] myTenantIdBytes = myTenantId.getBytes(); - TableViewFinderResult childViews; - try { - childViews = ViewUtil.findChildViews(connection, - parentToBe.getTenantId() == null ? null : parentToBe.getTenantId().getString(), - parentToBe.getSchemaName() == null ? null : parentToBe.getSchemaName().getString(), - parentToBe.getTableName().getString()); - } catch (IOException e) { - throw new SQLException(e); - } - for (TableInfo info : childViews.getLinks()) { - if (!Arrays.equals(info.getTenantId(), myTenantIdBytes)) { - continue; - } - String childFullName = SchemaUtil.getTableName( - info.getSchemaName() == null ? null : Bytes.toString(info.getSchemaName()), - Bytes.toString(info.getTableName())); - try { - PTable existing = connection.getTable(childFullName); - String existingViewStmt = existing.getViewStatement(); - if (existingViewStmt == null || existingViewStmt.isEmpty()) { - throw new SQLExceptionInfo.Builder( - SQLExceptionCode.TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE).build().buildException(); - } - } catch (TableNotFoundException e) { - // Orphan child link, ignore. + private boolean hasTTLProperty(CreateTableStatement create) { + for (Pair prop : create.getProps().values()) { + if (PhoenixDatabaseMetaData.TTL.equalsIgnoreCase(prop.getFirst())) { + return true; } } + return false; } /** diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/exception/SQLExceptionCode.java b/phoenix-core-client/src/main/java/org/apache/phoenix/exception/SQLExceptionCode.java index fb98e0bbd30..ccbc512e12d 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/exception/SQLExceptionCode.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/exception/SQLExceptionCode.java @@ -483,9 +483,10 @@ public SQLException newException(SQLExceptionInfo info) { "CDC on this table is either enabled or is in the process of being enabled."), CANNOT_SET_OR_ALTER_MAX_LOOKBACK_FOR_INDEX(10964, "44A46", "Cannot set or alter " + PHOENIX_MAX_LOOKBACK_AGE_CONF_KEY + " on an index"), - TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE(10965, "44A47", - "A tenant cannot create multiple views without a WHERE clause on the same " - + "multi-tenant parent table."), + TENANT_TTL_VIEW_CONFLICT(10965, "44A47", + "On a multi-tenant base table, a tenant's TTL-enabled view cannot coexist with any other " + + "view for the same tenant. Either all tenant views must be without TTL, or there must " + + "be exactly one tenant view with TTL."), /** Sequence related */ SEQUENCE_ALREADY_EXIST(1200, "42Z00", "Sequence already exists.", new Factory() { diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/schema/MetaDataClient.java b/phoenix-core-client/src/main/java/org/apache/phoenix/schema/MetaDataClient.java index 0da85f851dc..2c5af7932bf 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/schema/MetaDataClient.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/schema/MetaDataClient.java @@ -6548,6 +6548,21 @@ private boolean evaluateStmtProperties(MetaProperties metaProperties, boolean isStrictTTL = metaProperties.isStrictTTL() != null ? metaProperties.isStrictTTL : table.isStrictTTL(); newTTL.validateTTLOnAlter(connection, table, isStrictTTL); + // For a tenant view on a multi-tenant base table, prevent setting TTL when any sibling + // view exists for the same tenant (to avoid ROW_KEY_MATCHER prefix conflicts in the + // compaction trie). + if ( + !newTTL.equals(TTL_EXPRESSION_NOT_DEFINED) && table.getType() == PTableType.VIEW + && table.getTenantId() != null + ) { + PTable parent = resolveParentTable(table); + if (parent != null) { + String selfFullName = SchemaUtil.getTableName( + table.getSchemaName() == null ? null : table.getSchemaName().getString(), + table.getTableName().getString()); + ViewUtil.validateTenantTTLViewCoexistence(connection, parent, true, selfFullName); + } + } metaPropertiesEvaluated.setTTL(getCompatibleTTLExpression(metaProperties.getTTL(), table.getType(), table.getViewType(), table.getName().toString())); changingPhoenixTableProperty = true; @@ -6597,6 +6612,27 @@ private boolean evaluateStmtProperties(MetaProperties metaProperties, return changingPhoenixTableProperty; } + /** + * Resolves the immediate parent {@link PTable} of the given view, or {@code null} if it cannot be + * resolved. + */ + private PTable resolveParentTable(PTable view) { + PName parentName = view.getParentTableName(); + if (parentName == null) { + return null; + } + PName parentSchema = view.getParentSchemaName(); + String parentFullName = SchemaUtil + .getTableName(parentSchema == null ? null : parentSchema.getString(), parentName.getString()); + try { + return connection.getTable(parentFullName); + } catch (TableNotFoundException e) { + return null; + } catch (SQLException e) { + return null; + } + } + public static class MetaProperties { private Boolean isImmutableRowsProp = null; private Boolean multiTenantProp = null; diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/util/ViewUtil.java b/phoenix-core-client/src/main/java/org/apache/phoenix/util/ViewUtil.java index 5b32db5655c..ce68075fff6 100644 --- a/phoenix-core-client/src/main/java/org/apache/phoenix/util/ViewUtil.java +++ b/phoenix-core-client/src/main/java/org/apache/phoenix/util/ViewUtil.java @@ -27,6 +27,7 @@ import static org.apache.phoenix.jdbc.PhoenixDatabaseMetaData.TABLE_FAMILY_BYTES; import static org.apache.phoenix.jdbc.PhoenixDatabaseMetaData.TABLE_TYPE_BYTES; import static org.apache.phoenix.jdbc.PhoenixDatabaseMetaData.TTL_BYTES; +import static org.apache.phoenix.schema.LiteralTTLExpression.TTL_EXPRESSION_NOT_DEFINED; import static org.apache.phoenix.schema.PTableImpl.getColumnsToClone; import static org.apache.phoenix.util.PhoenixRuntime.CURRENT_SCN_ATTRIB; import static org.apache.phoenix.util.PhoenixRuntime.TENANT_ID_ATTRIB; @@ -67,11 +68,14 @@ import org.apache.phoenix.coprocessorclient.MetaDataProtocol; import org.apache.phoenix.coprocessorclient.TableInfo; import org.apache.phoenix.coprocessorclient.WhereConstantParser; +import org.apache.phoenix.exception.SQLExceptionCode; +import org.apache.phoenix.exception.SQLExceptionInfo; import org.apache.phoenix.index.IndexMaintainer; import org.apache.phoenix.jdbc.PhoenixConnection; import org.apache.phoenix.jdbc.PhoenixDatabaseMetaData; import org.apache.phoenix.parse.ParseNode; import org.apache.phoenix.parse.SQLParser; +import org.apache.phoenix.query.ConnectionlessQueryServicesImpl; import org.apache.phoenix.query.QueryConstants; import org.apache.phoenix.schema.ColumnNotFoundException; import org.apache.phoenix.schema.PColumn; @@ -432,6 +436,85 @@ public static TableViewFinderResult findChildViews(PhoenixConnection connection, return childViewsResult; } + /** + * Validates tenant TTL view coexistence rules on a multi-tenant base table. For a given tenant on + * such a base table we allow EITHER multiple tenant views without TTL, OR exactly one tenant view + * with TTL (WHERE or no-WHERE). A TTL view coexisting with any other view for the same tenant + * causes ROW_KEY_MATCHER prefix conflicts in the compaction RowKeyMatcher trie (the tenant-id + * bytes are a prefix of any WHERE-scoped pattern). + *

+ * This is a no-op when: + *

    + *
  • the connection has no tenant id,
  • + *
  • query services are connectionless (unit test mode),
  • + *
  • the parent is not a multi-tenant base {@link PTableType#TABLE}.
  • + *
+ * @param connection phoenix connection (must be a tenant connection for the check to + * fire) + * @param parent the parent table the view is being created/altered against + * @param newViewHasTTL true if the view being created or altered will have TTL + * @param viewToExcludeFullName full name of the view to skip from sibling iteration (used on the + * ALTER path to exclude the view being altered itself); pass + * {@code null} on the CREATE path + * @throws SQLException {@link SQLExceptionCode#TENANT_TTL_VIEW_CONFLICT} if the operation would + * create a TTL / non-TTL coexistence conflict + */ + public static void validateTenantTTLViewCoexistence(PhoenixConnection connection, PTable parent, + boolean newViewHasTTL, String viewToExcludeFullName) throws SQLException { + if (connection.getTenantId() == null) { + return; + } + if (connection.getQueryServices() instanceof ConnectionlessQueryServicesImpl) { + return; + } + if (parent.getType() != PTableType.TABLE || !parent.isMultiTenant()) { + return; + } + byte[] myTenantIdBytes = connection.getTenantId().getBytes(); + TableViewFinderResult childViews; + try { + childViews = findChildViews(connection, + parent.getTenantId() == null ? null : parent.getTenantId().getString(), + parent.getSchemaName() == null ? null : parent.getSchemaName().getString(), + parent.getTableName().getString()); + } catch (IOException e) { + // CHILD_LINK may be unavailable (namespace mapping edge cases, partial setup, etc.). + // Skip validation rather than fail the DDL. + return; + } + boolean foundAnyExisting = false; + for (TableInfo info : childViews.getLinks()) { + if (!Arrays.equals(info.getTenantId(), myTenantIdBytes)) { + continue; + } + String childFullName = SchemaUtil.getTableName( + info.getSchemaName() == null ? null : Bytes.toString(info.getSchemaName()), + Bytes.toString(info.getTableName())); + if (childFullName.equals(viewToExcludeFullName)) { + continue; + } + try { + PTable existing = connection.getTable(childFullName); + foundAnyExisting = true; + boolean existingHasTTL = existing.getTTLExpression() != null + && !existing.getTTLExpression().equals(TTL_EXPRESSION_NOT_DEFINED); + if (existingHasTTL) { + // An existing TTL view blocks any additional view (TTL or not). + throw new SQLExceptionInfo.Builder(SQLExceptionCode.TENANT_TTL_VIEW_CONFLICT).build() + .buildException(); + } + } catch (TableNotFoundException e) { + // Orphan child link, ignore. + } + } + if (newViewHasTTL && foundAnyExisting) { + // The new / altered view has TTL but sibling views already exist; the TTL view's trie + // entry would silently apply to the existing views' rows. + throw new SQLExceptionInfo.Builder(SQLExceptionCode.TENANT_TTL_VIEW_CONFLICT).build() + .buildException(); + } + } + /** * Check metadata to find if a given table/view has any immediate child views. Note that this is * not resilient to orphan {@code parent->child } links. diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java index ae3e0c4455d..c7f135804f3 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantTTLIT.java @@ -550,12 +550,81 @@ public void testSameViewNameAcrossDifferentTenants() throws Exception { } /** - * Verifies that a tenant cannot create two tenant views without WHERE clauses on the same - * multi-tenant parent, since both would produce the same ROW_KEY_MATCHER and conflict in the - * compaction trie. + * Exercises every branch of the tenant-view-conflict check in CreateTableCompiler: + *
    + *
  1. Non-TTL view as first view for a tenant: allowed.
  2. + *
  3. Another non-TTL view for the same tenant: allowed (both without TTL).
  4. + *
  5. TTL view added to a tenant that already has non-TTL views: rejected (new has TTL, existing + * siblings exist).
  6. + *
  7. Different tenant on the same base creates a TTL view: allowed (per-tenant scope).
  8. + *
  9. Same tenant tries to add a second TTL view: rejected (existing has TTL).
  10. + *
  11. Same tenant tries to add a non-TTL view when a TTL view already exists: rejected (existing + * has TTL).
  12. + *
  13. Tenant view with WHERE clause is allowed when only non-TTL views exist.
  14. + *
*/ @Test - public void testCannotCreateMultipleNoWhereViewsSameTenant() throws Exception { + public void testTenantTTLViewCoexistenceRules() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + } + + // Case 1: First non-TTL view for org1 -> allowed. + String org1View1 = generateUniqueName(); + createTenantViewNoTTL("org1", fullTableName, org1View1); + + // Case 2: Another non-TTL view for org1 -> allowed (pattern (a): all non-TTL). + String org1View2 = generateUniqueName(); + createTenantViewNoTTL("org1", fullTableName, org1View2); + + // Case 3: TTL view for org1 while non-TTL siblings exist -> rejected. + String org1TTLView = generateUniqueName(); + assertCreateTenantViewFails("org1", fullTableName, org1TTLView, 10); + + // Case 4: org2 (different tenant) creates a TTL view on the same base -> allowed. + String org2TTLView = generateUniqueName(); + createTenantViewWithTTL("org2", fullTableName, org2TTLView, 10); + + // Case 5: org2 tries a second TTL view -> rejected (existing has TTL). + String org2TTLView2 = generateUniqueName(); + assertCreateTenantViewFails("org2", fullTableName, org2TTLView2, 20); + + // Case 6: org2 tries a non-TTL view -> rejected (existing has TTL). + String org2NoTTLView = generateUniqueName(); + try (Connection conn = getTenantConnection("org2")) { + conn.setAutoCommit(true); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s", org2NoTTLView, fullTableName)); + fail("Expected TENANT_TTL_VIEW_CONFLICT when adding non-TTL view to tenant with TTL view"); + } catch (SQLException e) { + assertEquals(SQLExceptionCode.TENANT_TTL_VIEW_CONFLICT.getErrorCode(), e.getErrorCode()); + } + + // Case 7: org1 with only non-TTL siblings can still create a WHERE-scoped non-TTL view. + String org1WhereView = generateUniqueName(); + try (Connection conn = getTenantConnection("org1")) { + conn.setAutoCommit(true); + conn.createStatement().execute(String.format( + "CREATE VIEW %s AS SELECT * FROM %s WHERE ID1 = 'x'", org1WhereView, fullTableName)); + } + } + + /** + * Verifies the ALTER-path tenant TTL conflict check: altering a tenant view on a multi-tenant + * base table to SET TTL is rejected if any sibling view exists for the same tenant. + */ + @Test + public void testAlterTTLOnTenantViewWithSiblingIsRejected() throws Exception { String schemaName = generateUniqueName(); String baseTableName = generateUniqueName(); String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); @@ -572,17 +641,169 @@ public void testCannotCreateMultipleNoWhereViewsSameTenant() throws Exception { fullTableName)); } - createTenantViewWithTTL("org1", fullTableName, view1Name, 10); + // Two non-TTL sibling views for org1 - allowed at create time. + createTenantViewNoTTL("org1", fullTableName, view1Name); + createTenantViewNoTTL("org1", fullTableName, view2Name); - try (Connection conn = getTenantConnection("org1")) { + // Altering view1 to add TTL while view2 exists should be rejected. + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + t1.createStatement().execute(String.format("ALTER VIEW %s SET TTL = 10", view1Name)); + fail("Expected TENANT_TTL_VIEW_CONFLICT when altering TTL while sibling view exists"); + } catch (SQLException e) { + assertEquals(SQLExceptionCode.TENANT_TTL_VIEW_CONFLICT.getErrorCode(), e.getErrorCode()); + } + + // Drop sibling then alter succeeds. + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + t1.createStatement().execute(String.format("DROP VIEW %s", view2Name)); + t1.createStatement().execute(String.format("ALTER VIEW %s SET TTL = 10", view1Name)); + } + } + + /** + * Creates a tenant view without TTL, inserts rows, then alters the view to add TTL. Verifies read + * masking and compaction honor the new TTL after the ALTER. + */ + @Test + public void testAlterTenantViewToAddTTL() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String viewName = generateUniqueName(); + + int ttl = 10; + + try (Connection conn = DriverManager.getConnection(getUrl())) { conn.setAutoCommit(true); - conn.createStatement().execute( - String.format("CREATE VIEW %s AS SELECT * FROM %s TTL = 20", view2Name, fullTableName)); - fail("Expected TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE"); + conn.createStatement() + .execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, COL1 VARCHAR " + + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + } + + createTenantViewNoTTL("org1", fullTableName, viewName); + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + upsertRowSimple(t1, viewName, "k1", "v1"); + upsertRowSimple(t1, viewName, "k2", "v2"); + assertViewRowCount(t1, viewName, 2, "rows visible before TTL is set"); + } + + // Apply TTL after data was inserted. + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + t1.createStatement().execute(String.format("ALTER VIEW %s SET TTL = %d", viewName, ttl)); + } + + long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); + injectEdge.setValue(afterInsertTime + (ttl * 2 * 1000L)); + EnvironmentEdgeManager.injectEdge(injectEdge); + + // Read masking after ALTER: rows should be masked. + try (Connection t1 = getTenantConnection("org1")) { + assertViewRowCount(t1, viewName, 0, "rows should be masked after ALTER TTL expiry"); + } + + // Compaction after ALTER: rows should be physically removed. + flushAndMajorCompact(schemaName, baseTableName); + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 0, + "rows should be removed by compaction after ALTER TTL expiry"); + } + + /** + * Creates a tenant view (no WHERE) with TTL on a multi-tenant base table, then creates child + * views on the tenant view using WHERE on the next PK column. Verifies: + *
    + *
  • Setting TTL on the child view is rejected with TTL_ALREADY_DEFINED_IN_HIERARCHY.
  • + *
  • Child views without TTL are accepted and inherit the parent tenant view's TTL for both read + * masking and compaction.
  • + *
+ */ + @Test + public void testChildViewsOfTenantTTLViewInheritTTL() throws Exception { + String schemaName = generateUniqueName(); + String baseTableName = generateUniqueName(); + String fullTableName = SchemaUtil.getTableName(schemaName, baseTableName); + String tenantViewName = generateUniqueName(); + String childView1 = generateUniqueName(); + String childView2 = generateUniqueName(); + String childViewConflict = generateUniqueName(); + + int parentTTL = 10; + + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(true); + conn.createStatement().execute(String.format( + "CREATE TABLE %s (" + "ORGID VARCHAR NOT NULL, ID1 VARCHAR NOT NULL, ID2 VARCHAR NOT NULL, " + + "COL1 VARCHAR " + "CONSTRAINT PK PRIMARY KEY (ORGID, ID1, ID2)" + + ") MULTI_TENANT=true, COLUMN_ENCODED_BYTES=0, DEFAULT_COLUMN_FAMILY='0'", + fullTableName)); + } + + // Parent tenant view (no WHERE) with TTL. + createTenantViewWithTTL("org1", fullTableName, tenantViewName, parentTTL); + + // Setting TTL on a child view should fail with hierarchy error. + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + t1.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s WHERE ID1 = 'a' TTL = 5", + childViewConflict, tenantViewName)); + fail("Expected TTL_ALREADY_DEFINED_IN_HIERARCHY"); } catch (SQLException e) { - assertEquals(SQLExceptionCode.TENANT_ALREADY_HAS_VIEW_WITHOUT_WHERE_CLAUSE.getErrorCode(), + assertEquals(SQLExceptionCode.TTL_ALREADY_DEFINED_IN_HIERARCHY.getErrorCode(), e.getErrorCode()); } + + // Create two child views on the tenant view without TTL. + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + t1.createStatement().execute(String + .format("CREATE VIEW %s AS SELECT * FROM %s WHERE ID1 = 'a'", childView1, tenantViewName)); + t1.createStatement().execute(String + .format("CREATE VIEW %s AS SELECT * FROM %s WHERE ID1 = 'b'", childView2, tenantViewName)); + } + + long startTime = EnvironmentEdgeManager.currentTimeMillis(); + EnvironmentEdgeManager.injectEdge(injectEdge); + injectEdge.setValue(startTime); + + try (Connection t1 = getTenantConnection("org1")) { + t1.setAutoCommit(true); + upsertRowChildView(t1, childView1, "z1", "v1"); + upsertRowChildView(t1, childView1, "z2", "v2"); + upsertRowChildView(t1, childView2, "z1", "v3"); + upsertRowChildView(t1, childView2, "z2", "v4"); + assertViewRowCount(t1, childView1, 2, "childView1 visible before parent TTL"); + assertViewRowCount(t1, childView2, 2, "childView2 visible before parent TTL"); + } + + long afterInsertTime = EnvironmentEdgeManager.currentTimeMillis(); + + // Advance past parent's TTL: child views should be masked (inherited TTL). + injectEdge.setValue(afterInsertTime + (parentTTL * 2 * 1000L)); + EnvironmentEdgeManager.injectEdge(injectEdge); + + try (Connection t1 = getTenantConnection("org1")) { + assertViewRowCount(t1, childView1, 0, + "childView1 should be masked using inherited parent TTL"); + assertViewRowCount(t1, childView2, 0, + "childView2 should be masked using inherited parent TTL"); + } + + // Compaction should physically remove all rows (inherited TTL). + flushAndMajorCompact(schemaName, baseTableName); + assertHBaseRowCount(schemaName, baseTableName, afterInsertTime, 0, + "all rows should be removed from base table after parent TTL expiry"); } /** @@ -752,4 +973,35 @@ private void flushAndMajorCompact(String schemaName, String tableName) throws Ex TestUtil.majorCompact(getUtility(), tn); } } + + private void createTenantViewNoTTL(String tenantId, String baseTable, String viewName) + throws SQLException { + try (Connection conn = getTenantConnection(tenantId)) { + conn.setAutoCommit(true); + conn.createStatement() + .execute(String.format("CREATE VIEW %s AS SELECT * FROM %s", viewName, baseTable)); + } + } + + private void assertCreateTenantViewFails(String tenantId, String baseTable, String viewName, + int ttlSeconds) throws SQLException { + try (Connection conn = getTenantConnection(tenantId)) { + conn.setAutoCommit(true); + conn.createStatement().execute(String.format("CREATE VIEW %s AS SELECT * FROM %s TTL = %d", + viewName, baseTable, ttlSeconds)); + fail("Expected TENANT_TTL_VIEW_CONFLICT for tenant=" + tenantId + ", view=" + viewName); + } catch (SQLException e) { + assertEquals(SQLExceptionCode.TENANT_TTL_VIEW_CONFLICT.getErrorCode(), e.getErrorCode()); + } + } + + private void upsertRowChildView(Connection conn, String viewName, String id2, String col1) + throws SQLException { + PreparedStatement stmt = + conn.prepareStatement(String.format("UPSERT INTO %s (ID2, COL1) VALUES (?, ?)", viewName)); + stmt.setString(1, id2); + stmt.setString(2, col1); + stmt.executeUpdate(); + conn.commit(); + } }