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:
+ *
+ * - Non-TTL view as first view for a tenant: allowed.
+ * - Another non-TTL view for the same tenant: allowed (both without TTL).
+ * - TTL view added to a tenant that already has non-TTL views: rejected (new has TTL, existing
+ * siblings exist).
+ * - Different tenant on the same base creates a TTL view: allowed (per-tenant scope).
+ * - Same tenant tries to add a second TTL view: rejected (existing has TTL).
+ * - Same tenant tries to add a non-TTL view when a TTL view already exists: rejected (existing
+ * has TTL).
+ * - Tenant view with WHERE clause is allowed when only non-TTL views exist.
+ *
*/
@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();
+ }
}
| |