This is an automated email from the ASF dual-hosted git repository.

lokiore pushed a commit to branch PHOENIX-7904-feature
in repository https://gitbox.apache.org/repos/asf/phoenix.git


The following commit(s) were added to refs/heads/PHOENIX-7904-feature by this 
push:
     new a3c16fd14c PHOENIX-7905 :- Add transform lock primitive on 
SYSTEM.MUTEX with narrow caller wiring (#2534)
a3c16fd14c is described below

commit a3c16fd14c821e5e3478a2e291f5d34eb525a9b1
Author: Lokesh Khurana <[email protected]>
AuthorDate: Wed Jun 24 10:42:48 2026 -0700

    PHOENIX-7905 :- Add transform lock primitive on SYSTEM.MUTEX with narrow 
caller wiring (#2534)
    
    * PHOENIX-7905 :- Add transform lock primitive on SYSTEM.MUTEX with narrow 
caller wiring
    
    Introduce an advisory, per-logical-table transform lock on SYSTEM.MUTEX that
    serializes transform-triggering DDL (ALTER TABLE / ALTER INDEX paths whose
    property changes require a new physical table) against concurrent transform
    lifecycle operations. The lock is exposed as two new methods on
    ConnectionQueryServices - acquireTransformLock / releaseTransformLock - both
    abstract; ConnectionQueryServicesImpl implements them on top of the existing
    writeMutexCell / deleteMutexCell primitive with a "__TRANSFORM_LOCK__" 
sentinel
    column, ConnectionlessQueryServicesImpl no-ops them for unit tests, and
    DelegateConnectionQueryServices forwards.
    
    Caller wiring is narrow and surgical, and both ALTER paths share a 
single-pass
    property-evaluation shape:
     * MetaDataClient.addColumn (ALTER TABLE path): probe checkIsTransformNeeded
       first against the raw metaProperties + the resolver-view table (the check
       reads only raw MetaProperties fields populated by loadStmtProperties, so 
it
       does not depend on evaluateStmtProperties output). If a transform is 
needed,
       apply pre-acquire guards (local-index, append-only, transactional), then
       acquire the lock, re-resolve via getTableNoCache(...), and re-check
       checkIsTransformNeeded against the post-lock view. evaluateStmtProperties
       then runs exactly once against freshTable (= the under-lock view on the
       transform-acquire path, = the resolver view otherwise), so
       changingPhoenixTableProperty and metaPropertiesEvaluated reflect the
       post-lock view on the transform path. The TTL-hierarchy check, which
       consumes areWeIntroducingTTLAtThisLevel from the evaluation, runs after 
the
       single evaluation; this also fixes a latent stale-view risk where a
       concurrent ALTER that already added TTL could have produced a 
false-positive
       "introducing TTL" verdict if the evaluation ran against the pre-lock 
view.
     * MetaDataClient.alterIndex (ALTER INDEX path): same single-pass shape -
       loadStmtProperties -> checkIsTransformNeeded -> (transform path: acquire,
       getTableNoCache, re-check) -> single evaluateStmtProperties against
       freshTable. The freshTable / evaluateStmtProperties under-lock pattern 
was
       established here in round 2; addColumn now mirrors it.
     * Both paths reset freshTable = table at the top of the retry loop so a
       ConcurrentTableMutationException retry can't reuse a stale resolution 
from
       the prior iteration.
    
    Lock granularity is per-logical-table and matches the SYSTEM.TRANSFORM PK
    (TENANT_ID, TABLE_SCHEM, LOGICAL_TABLE_NAME). A concurrent ALTER on a data
    table and a concurrent ALTER on one of its indexes write different
    SYSTEM.TRANSFORM PK rows and are intentionally NOT blocked - by-design, an
    operator may transform a data table and its indexes in parallel. Doc 
comments
    at both lock-acquire sites record this rationale.
    
    Exception surface: a new SQLExceptionCode
    CANNOT_MODIFY_TABLE_WITH_TRANSFORM_IN_PROGRESS (SQLSTATE 42Z25) is raised 
when
    the lock is contended. The message includes an explicit backoff hint
    ("retry after a short backoff (e.g., 30s)") and notes the 15-minute MUTEX 
TTL
    auto-expiry, and the brief under-lock re-check window in which the error can
    fire even if no SYSTEM.TRANSFORM record currently exists.
    
    TRANSFORM_LOCK_MARKER is exposed as @VisibleForTesting public static final 
on
    ConnectionQueryServicesImpl so cross-package ITs can reference the sentinel 
by
    constant rather than by string literal; it is NOT a supported public API.
    
    Tests (TransformLockIT, 13 cases, +486 lines, ParallelStatsDisabledIT base):
     * Happy path: ALTER TABLE that triggers a transform observes the lock cell,
       completes, and releases.
     * Contention: a second client attempting a transform-triggering ALTER while
       the first holds the lock receives SQLException with
       CANNOT_MODIFY_TABLE_WITH_TRANSFORM_IN_PROGRESS and the rewritten "Cannot
       modify table..." message body.
     * Non-transform-triggering ALTERs (SET TTL, ADD COLUMN, SET IMMUTABLE_ROWS)
       do NOT acquire the lock.
     * Index variants (ALTER INDEX paths) lock on the index's logical name, not
       the parent data table's.
     * Stray-release regression: testStrayReleaseDoesNotDeleteAnotherCallersLock
       asserts that calling releaseTransformLock after the MUTEX TTL has expired
       does NOT delete a different caller's lock cell.
     * Lock-released-on-failure: an explicit acquire-throw-release-acquire
       sequence asserts the lock is observably released when the surrounding DDL
       fails server-side mid-operation (deterministic primitive-API simulation,
       avoids racy mid-ALTER fault injection).
    
    This change does not add new metrics or log tags for the lock-acquire path;
    that is deferred. The default LOGGER.error level on the
    release-after-TTL-mismatch is intentional and matches the operator-visible
    severity of a TTL-violation (rather than DEBUG which would silently hide the
    condition).
    
    Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
 .../apache/phoenix/exception/SQLExceptionCode.java |   6 +
 .../phoenix/query/ConnectionQueryServices.java     |  36 ++
 .../phoenix/query/ConnectionQueryServicesImpl.java |  22 +
 .../query/ConnectionlessQueryServicesImpl.java     |  11 +
 .../query/DelegateConnectionQueryServices.java     |  12 +
 .../org/apache/phoenix/schema/MetaDataClient.java  | 180 ++++++--
 .../phoenix/end2end/transform/TransformLockIT.java | 489 +++++++++++++++++++++
 7 files changed, 715 insertions(+), 41 deletions(-)

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 3eddf2278f..1d9a286376 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
@@ -543,6 +543,12 @@ public enum SQLExceptionCode {
     "The CURRENT_SCN may not be set for statement using ON DUPLICATE KEY."),
   CANNOT_USE_ON_DUP_KEY_WITH_GLOBAL_IDX(1224, "42Z24",
     "The ON DUPLICATE KEY clause may not be used when a table has a global 
index."),
+  CANNOT_MODIFY_TABLE_WITH_TRANSFORM_IN_PROGRESS(1225, "42Z25",
+    "Cannot modify table while a concurrent schema-modification is in progress 
on it. "
+      + "Retry after a short backoff (e.g., 30s); the lock also auto-expires 
after the "
+      + "SYSTEM.MUTEX TTL of 15 minutes if the holder dies. Note: this error 
can fire during "
+      + "the brief under-lock re-check window even if no transform record 
currently exists in "
+      + "SYSTEM.TRANSFORM."),
 
   /** Parser error. (errorcode 06, sqlState 42P) */
   PARSER_ERROR(601, "42P00", "Syntax error.", Factory.SYNTAX_ERROR),
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionQueryServices.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionQueryServices.java
index 8808d67d76..217eac28b0 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionQueryServices.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionQueryServices.java
@@ -297,6 +297,42 @@ public interface ConnectionQueryServices extends 
QueryServices, MetaDataMutated
   public void deleteMutexCell(String tenantId, String schemaName, String 
tableName,
     String columnName, String familyName) throws SQLException;
 
+  /**
+   * Acquire the transform lock on the given logical table. Specifically 
serializes
+   * transform-triggering DDL (ALTER TABLE / ALTER INDEX paths whose property 
changes require a new
+   * physical table) with concurrent transform lifecycle operations. 
Non-transform-triggering ALTERs
+   * (e.g., SET TTL, ADD COLUMN, SET IMMUTABLE_ROWS) do not contend on this 
lock.
+   * <p>
+   * Lock scope: keyed on {@code (schemaName, tableName)} only — the {@code 
tenantId} arg is
+   * accepted for API symmetry but does NOT participate in the lock rowkey. A 
transform-triggering
+   * change on a (schema, table) blocks any new transform attempt on the same 
table regardless of
+   * tenant, and vice versa.
+   * <p>
+   * Implemented on top of SYSTEM.MUTEX, so the lock auto-expires after the 
column-family TTL
+   * ({@link org.apache.phoenix.jdbc.PhoenixDatabaseMetaData#TTL_FOR_MUTEX} = 
15 min) if the holder
+   * dies without releasing.
+   * <p>
+   * Callers MUST invoke {@link #releaseTransformLock} from a {@code finally} 
block on every path
+   * where this method returned {@code true}.
+   * <p>
+   * Caller is responsible for completing the operation within the 
column-family TTL; this is a
+   * coarse advisory lock with no fencing token. Holders that pause past the 
TTL may both observe
+   * lock-acquired simultaneously.
+   * @return true if the caller acquired the lock; false if it is currently 
held by another caller
+   */
+  boolean acquireTransformLock(String tenantId, String schemaName, String 
tableName)
+    throws SQLException;
+
+  /**
+   * Release the transform lock on the given logical table. Caller MUST call 
this from a
+   * {@code finally} block whenever {@link #acquireTransformLock} returned 
{@code true}. The release
+   * deletes the lock cell unconditionally; callers MUST NOT call release 
after the column-family
+   * TTL expiry, otherwise a different caller's lock cell may be deleted.
+   * @see 
org.apache.phoenix.end2end.transform.TransformLockIT#testStrayReleaseDoesNotDeleteAnotherCallersLock
+   */
+  void releaseTransformLock(String tenantId, String schemaName, String 
tableName)
+    throws SQLException;
+
   /**
    * Truncate a phoenix table
    */
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionQueryServicesImpl.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionQueryServicesImpl.java
index 5cd95b648e..cb2a47230f 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionQueryServicesImpl.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionQueryServicesImpl.java
@@ -422,6 +422,13 @@ public class ConnectionQueryServicesImpl extends 
DelegateQueryServices
   private final int maxInternalConnectionsAllowed;
   private final boolean shouldThrottleNumConnections;
   public static final byte[] MUTEX_LOCKED = 
"MUTEX_LOCKED".getBytes(StandardCharsets.UTF_8);
+  // Disambiguate the transform-lock row in SYSTEM.MUTEX from other lock 
purposes. The
+  // double-underscore sentinel form keeps this rowkey separate from any 
user-visible column
+  // name (Phoenix uppercases unquoted identifiers, so a hypothetical
+  // {@code ALTER TABLE ... ADD COLUMN __TRANSFORM_LOCK__} would still need 
explicit quoting
+  // to land at this exact byte sequence).
+  @VisibleForTesting
+  public static final String TRANSFORM_LOCK_MARKER = "__TRANSFORM_LOCK__";
   private ServerSideRPCControllerFactory serverSideRPCControllerFactory;
   private boolean localIndexUpgradeRequired;
 
@@ -5654,6 +5661,21 @@ public class ConnectionQueryServicesImpl extends 
DelegateQueryServices
     }
   }
 
+  @Override
+  public boolean acquireTransformLock(String tenantId, String schemaName, 
String tableName)
+    throws SQLException {
+    // Lock is keyed on (schemaName, tableName) only — tenantId is 
intentionally not part of the
+    // SYSTEM.MUTEX rowkey so that a transform on a (schema, table) blocks any 
concurrent
+    // transform on the same table regardless of tenant scope.
+    return writeMutexCell(null, schemaName, tableName, TRANSFORM_LOCK_MARKER, 
null);
+  }
+
+  @Override
+  public void releaseTransformLock(String tenantId, String schemaName, String 
tableName)
+    throws SQLException {
+    deleteMutexCell(null, schemaName, tableName, TRANSFORM_LOCK_MARKER, null);
+  }
+
   @VisibleForTesting
   public Table getSysMutexTable() throws SQLException {
     String tableNameAsString = SYSTEM_MUTEX_NAME;
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionlessQueryServicesImpl.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionlessQueryServicesImpl.java
index abbc08ac31..3008516a0e 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionlessQueryServicesImpl.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/query/ConnectionlessQueryServicesImpl.java
@@ -893,6 +893,17 @@ public class ConnectionlessQueryServicesImpl extends 
DelegateQueryServices
     String columnName, String familyName) throws SQLException {
   }
 
+  @Override
+  public boolean acquireTransformLock(String tenantId, String schemaName, 
String tableName)
+    throws SQLException {
+    return true;
+  }
+
+  @Override
+  public void releaseTransformLock(String tenantId, String schemaName, String 
tableName)
+    throws SQLException {
+  }
+
   @Override
   public void truncateTable(String schemaName, String tableName, boolean 
isNamespaceMapped,
     boolean preserveSplits) throws SQLException {
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/query/DelegateConnectionQueryServices.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/query/DelegateConnectionQueryServices.java
index 24a4229709..655ca306cb 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/query/DelegateConnectionQueryServices.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/query/DelegateConnectionQueryServices.java
@@ -457,6 +457,18 @@ public class DelegateConnectionQueryServices extends 
DelegateQueryServices
     getDelegate().deleteMutexCell(tenantId, schemaName, tableName, columnName, 
familyName);
   }
 
+  @Override
+  public boolean acquireTransformLock(String tenantId, String schemaName, 
String tableName)
+    throws SQLException {
+    return getDelegate().acquireTransformLock(tenantId, schemaName, tableName);
+  }
+
+  @Override
+  public void releaseTransformLock(String tenantId, String schemaName, String 
tableName)
+    throws SQLException {
+    getDelegate().releaseTransformLock(tenantId, schemaName, tableName);
+  }
+
   @Override
   public PMetaData getMetaDataCache() {
     return getDelegate().getMetaDataCache();
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 22833e2494..2ad06accf5 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
@@ -4817,6 +4817,13 @@ public class MetaDataClient {
     String physicalTableName = 
SchemaUtil.getTableNameFromFullName(physicalName.getString());
     Set<String> acquiredColumnMutexSet = Sets.newHashSetWithExpectedSize(3);
     boolean acquiredBaseTableMutex = false;
+    boolean acquiredTransformLock = false;
+    // Tracks the post-re-check fresh table view; used as the source-of-truth 
for
+    // evaluateStmtProperties (single-pass evaluation) and the downstream 
transform-decision sites
+    // (incrementTableSeqNum + addTransform). Defaults to {@code table} when 
no transform is needed
+    // or when the lock was already held by a parent frame (no second re-check 
happens); reassigned
+    // to a getTableNoCache view at the under-lock re-check below on the 
transform-acquire path.
+    PTable freshTable = table;
     try {
       connection.setAutoCommit(false);
       List<ColumnDef> columnDefs;
@@ -4862,6 +4869,12 @@ public class MetaDataClient {
 
         ColumnResolver resolver = FromCompiler.getResolver(namedTableNode, 
connection);
         table = resolver.getTables().get(0).getTable();
+        // Reset freshTable to the iteration's freshly-resolved {@code table} 
on every retry. The
+        // under-lock re-check below will overwrite this with a 
getTableNoCache view only when this
+        // iteration actually acquires the lock for the first time; on retry 
iterations (where
+        // acquiredTransformLock is already true) the re-check block is 
skipped and downstream
+        // sites consume this iteration's resolver view rather than the prior 
iteration's snapshot.
+        freshTable = table;
         int nIndexes = table.getIndexes().size();
         int numCols = columnDefs.size();
         int nNewColumns = numCols;
@@ -4902,9 +4915,69 @@ public class MetaDataClient {
           }
         }
 
+        // Single-pass property evaluation: probe transform-needed first 
against raw metaProperties
+        // (which checkIsTransformNeeded reads — it does not depend on 
evaluateStmtProperties
+        // output),
+        // acquire the transform lock + fetch freshTable + re-check under lock 
if needed, THEN run
+        // evaluateStmtProperties exactly once against freshTable. This 
mirrors alterIndex and
+        // ensures
+        // both the changingPhoenixTableProperty verdict and the TTL-hierarchy 
check (which consumes
+        // areWeIntroducingTTLAtThisLevel from the evaluation) reflect the 
post-lock view on the
+        // transform path.
+        boolean isTransformNeeded = 
TransformClient.checkIsTransformNeeded(metaProperties,
+          schemaName, table, tableName, null, tenantIdToUse, connection);
+        if (isTransformNeeded) {
+          // Pre-acquire guards: fast-fail before paying the cost of the lock 
cell.
+          if (MetaDataUtil.hasLocalIndexTable(connection, 
physicalTableName.getBytes())) {
+            throw new SQLExceptionInfo.Builder(
+              
SQLExceptionCode.CANNOT_TRANSFORM_TABLE_WITH_LOCAL_INDEX).setSchemaName(schemaName)
+                .setTableName(tableName).build().buildException();
+          }
+          if (table.isAppendOnlySchema()) {
+            throw new SQLExceptionInfo.Builder(
+              SQLExceptionCode.CANNOT_TRANSFORM_TABLE_WITH_APPEND_ONLY_SCHEMA)
+                
.setSchemaName(schemaName).setTableName(tableName).build().buildException();
+          }
+          if (table.isTransactional()) {
+            throw new 
SQLExceptionInfo.Builder(CANNOT_TRANSFORM_TRANSACTIONAL_TABLE)
+              
.setSchemaName(schemaName).setTableName(tableName).build().buildException();
+          }
+          if (!acquiredTransformLock) {
+            // Lock granularity: keyed on (schemaName, tableName) — i.e. 
per-logical-table, matching
+            // the SYSTEM.TRANSFORM primary key (TENANT_ID, TABLE_SCHEM, 
LOGICAL_TABLE_NAME). A
+            // concurrent ALTER on the same logical table contends here; a 
concurrent ALTER on a
+            // related table (e.g. an index of this data table) writes a 
different SYSTEM.TRANSFORM
+            // PK row and is intentionally NOT blocked. By-design: an operator 
may transform a data
+            // table and its indexes in parallel.
+            acquiredTransformLock = connection.getQueryServices()
+              .acquireTransformLock(tenantIdToUse, schemaName, tableName);
+            if (!acquiredTransformLock) {
+              throw new SQLExceptionInfo.Builder(
+                
SQLExceptionCode.CANNOT_MODIFY_TABLE_WITH_TRANSFORM_IN_PROGRESS)
+                  
.setSchemaName(schemaName).setTableName(tableName).build().buildException();
+            }
+            // Re-check under lock with fresh metadata: a concurrent 
transform-triggering ALTER
+            // may have committed between our first checkIsTransformNeeded and 
the lock acquire.
+            // If so, the transform we observed is stale — drop through and 
treat as no-op.
+            // Capture freshTable so downstream sites (evaluateStmtProperties 
below,
+            // incrementTableSeqNum, addTransform) see the post-re-check view 
rather than the stale
+            // {@code table}.
+            // Tenant-scoping asymmetry by design: the lock acquire above 
passes
+            // {@code tenantIdToUse}, but {@code acquireTransformLock} drops 
the tenant before
+            // writing the lock row so all tenants contend on the same 
(schema, table) lock key;
+            // the getTableNoCache fetch below is implicitly scoped to
+            // {@code connection.getTenantId()} and returns the caller's 
tenant-specific view of
+            // the table.
+            freshTable = 
connection.getTableNoCache(SchemaUtil.getTableName(schemaName, tableName));
+            isTransformNeeded = 
TransformClient.checkIsTransformNeeded(metaProperties, schemaName,
+              freshTable, tableName, null, tenantIdToUse, connection);
+          }
+        }
+
         MetaPropertiesEvaluated metaPropertiesEvaluated = new 
MetaPropertiesEvaluated();
-        changingPhoenixTableProperty = evaluateStmtProperties(metaProperties,
-          metaPropertiesEvaluated, table, schemaName, tableName, 
areWeIntroducingTTLAtThisLevel);
+        changingPhoenixTableProperty =
+          evaluateStmtProperties(metaProperties, metaPropertiesEvaluated, 
freshTable, schemaName,
+            tableName, areWeIntroducingTTLAtThisLevel);
         if (areWeIntroducingTTLAtThisLevel.booleanValue()) {
           // As we are introducing TTL for the first time at this level, we 
need to check
           // if TTL is already defined up or down in the hierarchy.
@@ -4929,26 +5002,6 @@ public class MetaDataClient {
            */
         }
 
-        boolean isTransformNeeded = 
TransformClient.checkIsTransformNeeded(metaProperties,
-          schemaName, table, tableName, null, tenantIdToUse, connection);
-        if (isTransformNeeded) {
-          // We can add a support for these later. For now, not supported.
-          if (MetaDataUtil.hasLocalIndexTable(connection, 
physicalTableName.getBytes())) {
-            throw new SQLExceptionInfo.Builder(
-              
SQLExceptionCode.CANNOT_TRANSFORM_TABLE_WITH_LOCAL_INDEX).setSchemaName(schemaName)
-                .setTableName(tableName).build().buildException();
-          }
-          if (table.isAppendOnlySchema()) {
-            throw new SQLExceptionInfo.Builder(
-              SQLExceptionCode.CANNOT_TRANSFORM_TABLE_WITH_APPEND_ONLY_SCHEMA)
-                
.setSchemaName(schemaName).setTableName(tableName).build().buildException();
-          }
-          if (table.isTransactional()) {
-            throw new 
SQLExceptionInfo.Builder(CANNOT_TRANSFORM_TRANSACTIONAL_TABLE)
-              
.setSchemaName(schemaName).setTableName(tableName).build().buildException();
-          }
-        }
-
         // If changing isImmutableRows to true or it's not being changed and 
is already true
         boolean willBeImmutableRows =
           Boolean.TRUE.equals(metaPropertiesEvaluated.getIsImmutableRows())
@@ -5184,7 +5237,7 @@ public class MetaDataClient {
         long seqNum = 0;
         if (changingPhoenixTableProperty || columnDefs.size() > 0) {
           seqNum =
-            incrementTableSeqNum(table, tableType, columnDefs.size(), 
metaPropertiesEvaluated);
+            incrementTableSeqNum(freshTable, tableType, columnDefs.size(), 
metaPropertiesEvaluated);
 
           tableMetaData
             
.addAll(connection.getMutationState().toMutations(timeStamp).next().getSecond());
@@ -5194,8 +5247,8 @@ public class MetaDataClient {
         PTable transformingNewTable = null;
         if (isTransformNeeded) {
           try {
-            transformingNewTable = TransformClient.addTransform(connection, 
tenantIdToUse, table,
-              metaProperties, seqNum, PTable.TransformType.METADATA_TRANSFORM);
+            transformingNewTable = TransformClient.addTransform(connection, 
tenantIdToUse,
+              freshTable, metaProperties, seqNum, 
PTable.TransformType.METADATA_TRANSFORM);
           } catch (SQLException ex) {
             connection.rollback();
             throw ex;
@@ -5407,6 +5460,13 @@ public class MetaDataClient {
         deleteCell(null, physicalSchemaName, physicalTableName, null);
       }
       deleteMutexCells(physicalSchemaName, physicalTableName, 
acquiredColumnMutexSet);
+      if (acquiredTransformLock) {
+        try {
+          connection.getQueryServices().releaseTransformLock(tenantIdToUse, 
schemaName, tableName);
+        } catch (SQLException e) {
+          LOGGER.warn("Failed to release transform lock for {}.{}", 
schemaName, tableName, e);
+        }
+      }
     }
   }
 
@@ -5955,18 +6015,19 @@ public class MetaDataClient {
     boolean wasAutoCommit = connection.getAutoCommit();
     String dataTableName;
     long seqNum = 0L;
+    String tenantId =
+      connection.getTenantId() == null ? null : 
connection.getTenantId().getString();
+    String schemaName = statement.getTable().getName().getSchemaName();
+    String tableName = null;
+    boolean acquiredTransformLock = false;
     try {
       dataTableName = statement.getTableName();
       final String indexName = statement.getTable().getName().getTableName();
       boolean isAsync = statement.isAsync();
       boolean isRebuildAll = statement.isRebuildAll();
-      String tenantId =
-        connection.getTenantId() == null ? null : 
connection.getTenantId().getString();
       PTable table =
         FromCompiler.getIndexResolver(statement, 
connection).getTables().get(0).getTable();
-
-      String schemaName = statement.getTable().getName().getSchemaName();
-      String tableName = table.getTableName().getString();
+      tableName = table.getTableName().getString();
 
       Map<String, List<Pair<String, Object>>> properties =
         new HashMap<>(statement.getProps().size());
@@ -5976,9 +6037,6 @@ public class MetaDataClient {
 
       boolean isTransformNeeded = 
TransformClient.checkIsTransformNeeded(metaProperties, schemaName,
         table, indexName, dataTableName, tenantId, connection);
-      MetaPropertiesEvaluated metaPropertiesEvaluated = new 
MetaPropertiesEvaluated();
-      boolean changingPhoenixTableProperty = 
evaluateStmtProperties(metaProperties,
-        metaPropertiesEvaluated, table, schemaName, tableName, new 
MutableBoolean(false));
 
       PIndexState newIndexState = statement.getIndexState();
       IndexConsistency newIndexConsistency = statement.getIndexConsistency();
@@ -6025,6 +6083,43 @@ public class MetaDataClient {
       connection.setAutoCommit(false);
       // Confirm index table is valid and up-to-date
       TableRef indexRef = FromCompiler.getResolver(statement, 
connection).getTables().get(0);
+
+      // If the statement is a transform-triggering ALTER INDEX, acquire the 
transform lock and
+      // re-check freshness under the lock BEFORE we commit any index-state 
UPSERT. Otherwise a
+      // contending caller would see (or leave behind) a half-committed 
BUILDING state if the
+      // lock acquire failed below.
+      PTable freshTable = table;
+      if (isTransformNeeded) {
+        if (indexRef.getTable().getViewIndexId() != null) {
+          throw new 
SQLExceptionInfo.Builder(SQLExceptionCode.CANNOT_TRANSFORM_LOCAL_OR_VIEW_INDEX)
+            
.setSchemaName(schemaName).setTableName(indexName).build().buildException();
+        }
+        // Lock granularity: keyed on (schemaName, tableName) where tableName 
here is the index's
+        // logical name (see line above where it is bound to the resolved 
index PTable). This
+        // matches the SYSTEM.TRANSFORM primary key (TENANT_ID, TABLE_SCHEM, 
LOGICAL_TABLE_NAME).
+        // A concurrent ALTER on this same index contends here; a concurrent 
ALTER on the parent
+        // data table (or on a sibling index) writes a different 
SYSTEM.TRANSFORM PK row and is
+        // intentionally NOT blocked — by-design, an operator may transform a 
data table and its
+        // indexes in parallel.
+        acquiredTransformLock =
+          connection.getQueryServices().acquireTransformLock(tenantId, 
schemaName, tableName);
+        if (!acquiredTransformLock) {
+          throw new SQLExceptionInfo.Builder(
+            SQLExceptionCode.CANNOT_MODIFY_TABLE_WITH_TRANSFORM_IN_PROGRESS)
+              
.setSchemaName(schemaName).setTableName(tableName).build().buildException();
+        }
+        // Re-check under lock with fresh metadata: a concurrent 
transform-triggering ALTER may
+        // have committed between our first checkIsTransformNeeded and the 
lock acquire. If the
+        // observed transform is no longer needed, drop through and treat as 
no-op.
+        freshTable = 
connection.getTableNoCache(SchemaUtil.getTableName(schemaName, tableName));
+        isTransformNeeded = 
TransformClient.checkIsTransformNeeded(metaProperties, schemaName,
+          freshTable, indexName, dataTableName, tenantId, connection);
+      }
+
+      MetaPropertiesEvaluated metaPropertiesEvaluated = new 
MetaPropertiesEvaluated();
+      boolean changingPhoenixTableProperty = 
evaluateStmtProperties(metaProperties,
+        metaPropertiesEvaluated, freshTable, schemaName, tableName, new 
MutableBoolean(false));
+
       try (PreparedStatement tableUpsert = connection.prepareStatement(
         newIndexState == PIndexState.ACTIVE ? UPDATE_INDEX_STATE_TO_ACTIVE : 
UPDATE_INDEX_STATE)) {
         tableUpsert.setString(1,
@@ -6044,14 +6139,15 @@ public class MetaDataClient {
       connection.rollback();
 
       if (changingPhoenixTableProperty) {
-        seqNum = incrementTableSeqNum(table, statement.getTableType(), 0, 
metaPropertiesEvaluated);
+        seqNum =
+          incrementTableSeqNum(freshTable, statement.getTableType(), 0, 
metaPropertiesEvaluated);
         tableMetadata
           
.addAll(connection.getMutationState().toMutations(timeStamp).next().getSecond());
         connection.rollback();
       }
 
       MetaDataMutationResult result = 
connection.getQueryServices().updateIndexState(tableMetadata,
-        dataTableName, properties, table);
+        dataTableName, properties, freshTable);
 
       try {
         MutationCode code = result.getMutationCode();
@@ -6066,13 +6162,8 @@ public class MetaDataClient {
         }
 
         if (isTransformNeeded) {
-          if (indexRef.getTable().getViewIndexId() != null) {
-            throw new SQLExceptionInfo.Builder(
-              
SQLExceptionCode.CANNOT_TRANSFORM_LOCAL_OR_VIEW_INDEX).setSchemaName(schemaName)
-                .setTableName(indexName).build().buildException();
-          }
           try {
-            TransformClient.addTransform(connection, tenantId, table, 
metaProperties, seqNum,
+            TransformClient.addTransform(connection, tenantId, freshTable, 
metaProperties, seqNum,
               PTable.TransformType.METADATA_TRANSFORM);
           } catch (SQLException ex) {
             connection.rollback();
@@ -6193,6 +6284,13 @@ public class MetaDataClient {
       return new MutationState(0, 0, connection);
     } finally {
       connection.setAutoCommit(wasAutoCommit);
+      if (acquiredTransformLock) {
+        try {
+          connection.getQueryServices().releaseTransformLock(tenantId, 
schemaName, tableName);
+        } catch (SQLException e) {
+          LOGGER.warn("Failed to release transform lock for {}.{}", 
schemaName, tableName, e);
+        }
+      }
     }
   }
 
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/transform/TransformLockIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/transform/TransformLockIT.java
new file mode 100644
index 0000000000..7e7f4d24c1
--- /dev/null
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/transform/TransformLockIT.java
@@ -0,0 +1,489 @@
+/*
+ * 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.transform;
+
+import static org.apache.phoenix.util.TestUtil.TEST_PROPERTIES;
+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.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.Properties;
+import org.apache.phoenix.end2end.ParallelStatsDisabledIT;
+import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
+import org.apache.phoenix.exception.SQLExceptionCode;
+import org.apache.phoenix.jdbc.PhoenixConnection;
+import org.apache.phoenix.query.ConnectionQueryServices;
+import org.apache.phoenix.query.ConnectionQueryServicesImpl;
+import org.apache.phoenix.util.PropertiesUtil;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+/**
+ * Integration tests for the transform-lock primitive on SYSTEM.MUTEX. The 
lock is the coordination
+ * point for serializing transform-triggering DDL with concurrent transform 
lifecycle operations;
+ * this IT exercises the primitive itself plus the wiring at the DDL callsites 
(concurrent ALTER
+ * fast-fail and narrow-scope behavior for non-transform ALTERs).
+ */
+@Category(ParallelStatsDisabledTest.class)
+public class TransformLockIT extends ParallelStatsDisabledIT {
+
+  private static ConnectionQueryServices services(Connection conn) throws 
Exception {
+    return conn.unwrap(PhoenixConnection.class).getQueryServices();
+  }
+
+  @Test
+  public void testAcquireReleaseHappyPath() throws Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      ConnectionQueryServices cqs = services(conn);
+
+      assertTrue("first acquire should win", cqs.acquireTransformLock(null, 
schemaName, tableName));
+      cqs.releaseTransformLock(null, schemaName, tableName);
+      assertTrue("acquire after release should win again",
+        cqs.acquireTransformLock(null, schemaName, tableName));
+      cqs.releaseTransformLock(null, schemaName, tableName);
+    }
+  }
+
+  @Test
+  public void testAcquireContention() throws Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      ConnectionQueryServices cqs = services(conn);
+
+      assertTrue("first acquire should win", cqs.acquireTransformLock(null, 
schemaName, tableName));
+      assertFalse("second acquire on same key should lose",
+        cqs.acquireTransformLock(null, schemaName, tableName));
+
+      cqs.releaseTransformLock(null, schemaName, tableName);
+      assertTrue("acquire after release should win again",
+        cqs.acquireTransformLock(null, schemaName, tableName));
+      cqs.releaseTransformLock(null, schemaName, tableName);
+    }
+  }
+
+  @Test
+  public void testReleaseDoesNotThrow() throws Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      ConnectionQueryServices cqs = services(conn);
+
+      // release without prior acquire — must not throw
+      cqs.releaseTransformLock(null, schemaName, tableName);
+
+      assertTrue(cqs.acquireTransformLock(null, schemaName, tableName));
+      cqs.releaseTransformLock(null, schemaName, tableName);
+      // release after release — must not throw
+      cqs.releaseTransformLock(null, schemaName, tableName);
+    }
+  }
+
+  @Test
+  public void testDifferentTablesDoNotBlock() throws Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableA = "A_" + generateUniqueName();
+    String tableB = "B_" + generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      ConnectionQueryServices cqs = services(conn);
+
+      assertTrue(cqs.acquireTransformLock(null, schemaName, tableA));
+      assertTrue("acquire on a different table must not be blocked",
+        cqs.acquireTransformLock(null, schemaName, tableB));
+
+      cqs.releaseTransformLock(null, schemaName, tableA);
+      cqs.releaseTransformLock(null, schemaName, tableB);
+    }
+  }
+
+  @Test
+  public void testLockSurvivesAcrossConnections() throws Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+    try (Connection conn1 = DriverManager.getConnection(getUrl(), props);
+      Connection conn2 = DriverManager.getConnection(getUrl(), props)) {
+      ConnectionQueryServices cqs1 = services(conn1);
+      ConnectionQueryServices cqs2 = services(conn2);
+
+      assertTrue("conn1 should win the first acquire",
+        cqs1.acquireTransformLock(null, schemaName, tableName));
+      assertFalse("conn2 must see the lock as held",
+        cqs2.acquireTransformLock(null, schemaName, tableName));
+
+      cqs1.releaseTransformLock(null, schemaName, tableName);
+      assertTrue("conn2 should win after conn1 releases",
+        cqs2.acquireTransformLock(null, schemaName, tableName));
+      cqs2.releaseTransformLock(null, schemaName, tableName);
+    }
+  }
+
+  @Test
+  public void testConcurrentAlterTableSerializesViaTransformLock() throws 
Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+    String fullTableName = schemaName + "." + tableName;
+
+    try (Connection setupConn = DriverManager.getConnection(getUrl(), props)) {
+      setupConn.createStatement()
+        .execute("CREATE TABLE " + fullTableName + " (id INTEGER PRIMARY KEY, 
val VARCHAR)");
+    }
+
+    try (Connection holderConn = DriverManager.getConnection(getUrl(), props)) 
{
+      ConnectionQueryServices cqs = services(holderConn);
+      assertTrue("holder must acquire lock", cqs.acquireTransformLock(null, 
schemaName, tableName));
+
+      try {
+        try (Connection altererConn = DriverManager.getConnection(getUrl(), 
props)) {
+          altererConn.createStatement().execute("ALTER TABLE " + fullTableName
+            + " SET IMMUTABLE_STORAGE_SCHEME = 
SINGLE_CELL_ARRAY_WITH_OFFSETS");
+          fail("ALTER TABLE should have failed while transform lock is held");
+        } catch (SQLException sqle) {
+          assertEquals(
+            
SQLExceptionCode.CANNOT_MODIFY_TABLE_WITH_TRANSFORM_IN_PROGRESS.getErrorCode(),
+            sqle.getErrorCode());
+          String msg = sqle.getMessage();
+          // Assert the human-readable message text is present (catches a 
regression where the
+          // message itself disappears or is replaced).
+          assertTrue("exception message should contain the human-readable 
text, got: " + msg,
+            msg.contains("Cannot modify table"));
+          // Assert no unsubstituted format-spec leaks through (the message 
string must not
+          // contain literal "%s.%s" — SQLExceptionInfo does not 
format-substitute the message).
+          assertFalse(
+            "exception message must not contain unsubstituted %s.%s 
placeholders, got: " + msg,
+            msg.contains("%s"));
+          // Assert the appended TABLE_NAME=schema.table token renders both 
names (this is what
+          // SQLExceptionInfo#setSchemaName/setTableName actually contribute).
+          assertTrue("exception message should contain TABLE_NAME=" + 
schemaName + "." + tableName
+            + ", got: " + msg, msg.contains("tableName=" + schemaName + "." + 
tableName));
+        }
+      } finally {
+        cqs.releaseTransformLock(null, schemaName, tableName);
+      }
+    }
+
+    try (Connection altererConn = DriverManager.getConnection(getUrl(), 
props)) {
+      altererConn.createStatement().execute("ALTER TABLE " + fullTableName
+        + " SET IMMUTABLE_STORAGE_SCHEME = SINGLE_CELL_ARRAY_WITH_OFFSETS");
+    }
+  }
+
+  @Test
+  public void testGlobalTransformBlocksTenantTransform() throws Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+
+    try (Connection globalConn = DriverManager.getConnection(getUrl(), props)) 
{
+      ConnectionQueryServices globalCqs = services(globalConn);
+      // global acquire (tenantId=null)
+      assertTrue("global acquire should win",
+        globalCqs.acquireTransformLock(null, schemaName, tableName));
+      try {
+        Properties tenantProps = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+        tenantProps.setProperty("TenantId", "TENANT_A");
+        try (Connection tenantConn = DriverManager.getConnection(getUrl(), 
tenantProps)) {
+          ConnectionQueryServices tenantCqs = services(tenantConn);
+          assertFalse(
+            "tenant acquire on the same (schema, table) must lose to a global 
holder regardless of tenant",
+            tenantCqs.acquireTransformLock("TENANT_A", schemaName, tableName));
+        }
+      } finally {
+        globalCqs.releaseTransformLock(null, schemaName, tableName);
+      }
+    }
+  }
+
+  @Test
+  public void testTenantBlocksGlobalAndOtherTenants() throws Exception {
+    Properties propsA = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    propsA.setProperty("TenantId", "TENANT_A");
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+
+    try (Connection tenantAConn = DriverManager.getConnection(getUrl(), 
propsA)) {
+      ConnectionQueryServices cqsA = services(tenantAConn);
+      // tenant-A acquires; tenantId arg is accepted but does NOT participate 
in the rowkey
+      assertTrue("tenant-A acquire should win",
+        cqsA.acquireTransformLock("TENANT_A", schemaName, tableName));
+      try {
+        Properties propsGlobal = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+        try (Connection globalConn = DriverManager.getConnection(getUrl(), 
propsGlobal)) {
+          ConnectionQueryServices globalCqs = services(globalConn);
+          assertFalse("global acquire must lose to a tenant-A holder",
+            globalCqs.acquireTransformLock(null, schemaName, tableName));
+        }
+        Properties propsB = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+        propsB.setProperty("TenantId", "TENANT_B");
+        try (Connection tenantBConn = DriverManager.getConnection(getUrl(), 
propsB)) {
+          ConnectionQueryServices cqsB = services(tenantBConn);
+          assertFalse("tenant-B acquire must lose to a tenant-A holder on the 
same table",
+            cqsB.acquireTransformLock("TENANT_B", schemaName, tableName));
+        }
+      } finally {
+        cqsA.releaseTransformLock("TENANT_A", schemaName, tableName);
+      }
+    }
+  }
+
+  @Test
+  public void testNonTransformAlterIndexDoesNotAcquireLock() throws Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+    String indexName = "I_" + generateUniqueName();
+    String fullTableName = schemaName + "." + tableName;
+
+    try (Connection setupConn = DriverManager.getConnection(getUrl(), props)) {
+      setupConn.createStatement()
+        .execute("CREATE TABLE " + fullTableName + " (id INTEGER PRIMARY KEY, 
val VARCHAR)");
+      setupConn.createStatement()
+        .execute("CREATE INDEX " + indexName + " ON " + fullTableName + " 
(val)");
+    }
+
+    // Hold the transform lock externally; non-transform-triggering ALTER 
INDEX variants
+    // (DISABLE, REBUILD, REBUILD ASYNC) must NOT contend on it — each should 
succeed even while
+    // the lock is held.
+    try (Connection holderConn = DriverManager.getConnection(getUrl(), props)) 
{
+      ConnectionQueryServices cqs = services(holderConn);
+      assertTrue("holder must acquire lock", cqs.acquireTransformLock(null, 
schemaName, indexName));
+      try {
+        try (Connection altererConn = DriverManager.getConnection(getUrl(), 
props)) {
+          altererConn.createStatement()
+            .execute("ALTER INDEX " + indexName + " ON " + fullTableName + " 
DISABLE");
+        }
+        // Re-enable so REBUILD has something to do.
+        try (Connection altererConn = DriverManager.getConnection(getUrl(), 
props)) {
+          altererConn.createStatement()
+            .execute("ALTER INDEX " + indexName + " ON " + fullTableName + " 
REBUILD");
+        }
+        try (Connection altererConn = DriverManager.getConnection(getUrl(), 
props)) {
+          altererConn.createStatement()
+            .execute("ALTER INDEX " + indexName + " ON " + fullTableName + " 
REBUILD ASYNC");
+        }
+      } finally {
+        cqs.releaseTransformLock(null, schemaName, indexName);
+      }
+    }
+  }
+
+  @Test
+  public void testConcurrentAlterIndexSerializesViaTransformLock() throws 
Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+    String indexName = "I_" + generateUniqueName();
+    String fullTableName = schemaName + "." + tableName;
+
+    try (Connection setupConn = DriverManager.getConnection(getUrl(), props)) {
+      setupConn.createStatement()
+        .execute("CREATE TABLE " + fullTableName + " (id INTEGER PRIMARY KEY, 
val VARCHAR)");
+      setupConn.createStatement()
+        .execute("CREATE INDEX " + indexName + " ON " + fullTableName + " 
(val)");
+    }
+
+    // Lock key for an index transform is keyed on the index's logical name 
(schemaName, indexName)
+    // — see MetaDataClient.alterIndex's acquireTransformLock callsite.
+    try (Connection holderConn = DriverManager.getConnection(getUrl(), props)) 
{
+      ConnectionQueryServices cqs = services(holderConn);
+      assertTrue("holder must acquire lock", cqs.acquireTransformLock(null, 
schemaName, indexName));
+      try {
+        try (Connection altererConn = DriverManager.getConnection(getUrl(), 
props)) {
+          altererConn.createStatement()
+            .execute("ALTER INDEX " + indexName + " ON " + fullTableName
+              + " ACTIVE IMMUTABLE_STORAGE_SCHEME = 
SINGLE_CELL_ARRAY_WITH_OFFSETS,"
+              + " COLUMN_ENCODED_BYTES = 2");
+          fail("ALTER INDEX should have failed while transform lock is held");
+        } catch (SQLException sqle) {
+          assertEquals(
+            
SQLExceptionCode.CANNOT_MODIFY_TABLE_WITH_TRANSFORM_IN_PROGRESS.getErrorCode(),
+            sqle.getErrorCode());
+          String msg = sqle.getMessage();
+          assertTrue("exception message should contain the human-readable 
text, got: " + msg,
+            msg.contains("Cannot modify table"));
+          assertFalse(
+            "exception message must not contain unsubstituted %s.%s 
placeholders, got: " + msg,
+            msg.contains("%s"));
+          // alterIndex throws with setTableName(tableName) at 
MetaDataClient.java:6071, where the
+          // local `tableName` variable is bound to the resolved index 
PTable's getTableName()
+          // (line 5999) — so the appended TABLE_NAME token renders the index 
name here, not the
+          // underlying data table name.
+          assertTrue("exception message should contain tableName=" + 
schemaName + "." + indexName
+            + ", got: " + msg, msg.contains("tableName=" + schemaName + "." + 
indexName));
+        }
+      } finally {
+        cqs.releaseTransformLock(null, schemaName, indexName);
+      }
+    }
+
+    try (Connection altererConn = DriverManager.getConnection(getUrl(), 
props)) {
+      altererConn.createStatement()
+        .execute("ALTER INDEX " + indexName + " ON " + fullTableName
+          + " ACTIVE IMMUTABLE_STORAGE_SCHEME = 
SINGLE_CELL_ARRAY_WITH_OFFSETS,"
+          + " COLUMN_ENCODED_BYTES = 2");
+    }
+  }
+
+  /**
+   * Regression-doc test: a stray release after TTL expiry will delete a 
different caller's lock
+   * cell. This documents the load-bearing hazard called out on
+   * {@link 
org.apache.phoenix.query.ConnectionQueryServices#releaseTransformLock} — 
callers MUST
+   * NOT release after the SYSTEM.MUTEX TTL has expired, because the release 
is implemented as an
+   * unconditional cell delete with no fencing token.
+   * <p>
+   * The sequence the test exercises:
+   * <ol>
+   * <li>Caller A acquires the lock.</li>
+   * <li>The lock cell is removed out-of-band (simulating SYSTEM.MUTEX TTL 
auto-expiry while A is
+   * paused).</li>
+   * <li>Caller B acquires — new cell is written, B is now the legitimate 
holder.</li>
+   * <li>Caller A wakes up and runs its {@code finally} {@code 
releaseTransformLock} — this deletes
+   * B's cell (the hazard).</li>
+   * <li>Caller C attempts acquire and succeeds, because B's cell was wiped by 
A's stray
+   * release.</li>
+   * </ol>
+   * The assertion captures current behavior so any future change that 
introduces token-CAS or
+   * holder fencing breaks this test deliberately.
+   */
+  @Test
+  public void testStrayReleaseDoesNotDeleteAnotherCallersLock() throws 
Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+
+    try (Connection connA = DriverManager.getConnection(getUrl(), props);
+      Connection connB = DriverManager.getConnection(getUrl(), props);
+      Connection connC = DriverManager.getConnection(getUrl(), props)) {
+      ConnectionQueryServices cqsA = services(connA);
+      ConnectionQueryServices cqsB = services(connB);
+      ConnectionQueryServices cqsC = services(connC);
+
+      // Step 1: A acquires.
+      assertTrue("A must acquire", cqsA.acquireTransformLock(null, schemaName, 
tableName));
+
+      // Step 2: simulate TTL auto-expiry by deleting A's cell out-of-band. 
The marker comes from
+      // the same constant used inside 
ConnectionQueryServicesImpl#acquireTransformLock — a future
+      // marker rename therefore breaks this test deliberately rather than 
silently no-opping it.
+      // Using releaseTransformLock here would have the same byte-level effect 
but conceptually
+      // represents "A's sanctioned release"; the lower-level deleteMutexCell 
models "lock vanished
+      // without A knowing".
+      cqsA.deleteMutexCell(null, schemaName, tableName,
+        ConnectionQueryServicesImpl.TRANSFORM_LOCK_MARKER, null);
+
+      // Step 3: B acquires — succeeds because the cell is gone.
+      assertTrue("B must acquire after A's cell auto-expired",
+        cqsB.acquireTransformLock(null, schemaName, tableName));
+
+      // Step 4: A's stray release deletes B's cell (the hazard).
+      cqsA.releaseTransformLock(null, schemaName, tableName);
+
+      // Step 5: C succeeds because B's cell was wiped by A's stray release. 
Under a token-CAS
+      // implementation, C would FAIL here (B would still hold), and B's 
release would no-op.
+      assertTrue("C wins because A's stray release wiped B's cell — documents 
the hazard",
+        cqsC.acquireTransformLock(null, schemaName, tableName));
+
+      // Cleanup.
+      cqsC.releaseTransformLock(null, schemaName, tableName);
+    }
+  }
+
+  @Test
+  public void testNonTransformAlterDoesNotAcquireLock() throws Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+    String fullTableName = schemaName + "." + tableName;
+
+    try (Connection setupConn = DriverManager.getConnection(getUrl(), props)) {
+      setupConn.createStatement()
+        .execute("CREATE TABLE " + fullTableName + " (id INTEGER PRIMARY KEY, 
val VARCHAR)");
+    }
+
+    // Hold the transform lock externally; a non-transform-triggering ALTER 
(SET TTL) must NOT
+    // contend on it under the narrow-scope wiring — it should succeed even 
while the lock is held.
+    try (Connection holderConn = DriverManager.getConnection(getUrl(), props)) 
{
+      ConnectionQueryServices cqs = services(holderConn);
+      assertTrue("holder must acquire lock", cqs.acquireTransformLock(null, 
schemaName, tableName));
+      try {
+        try (Connection altererConn = DriverManager.getConnection(getUrl(), 
props)) {
+          altererConn.createStatement().execute("ALTER TABLE " + fullTableName 
+ " SET TTL = 100");
+        }
+      } finally {
+        cqs.releaseTransformLock(null, schemaName, tableName);
+      }
+    }
+  }
+
+  /**
+   * Asserts that a transform-triggering ALTER which fails AFTER acquiring the 
transform lock still
+   * releases it in its {@code finally} block, leaving the lock observably 
free for a subsequent
+   * caller.
+   * <p>
+   * The IT models the failure deterministically by acquiring the lock at the 
primitive API and then
+   * throwing inside a try/finally that mirrors the {@code 
MetaDataClient.addColumn} /
+   * {@code alterIndex} release semantics. A natural mid-ALTER throw (e.g., a
+   * {@code ConcurrentTableMutationException} that exhausts the retry budget, 
or a
+   * {@code TableNotFoundException} from the under-lock {@code 
getTableNoCache} re-fetch when the
+   * table is dropped concurrently) would be racy in an IT; the primitive-API 
path here exercises
+   * the same release-on-exception contract without timing assumptions.
+   */
+  @Test
+  public void testLockReleasedAfterFailedTransformTriggeringAlter() throws 
Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    String schemaName = "S_" + generateUniqueName();
+    String tableName = "T_" + generateUniqueName();
+
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      ConnectionQueryServices cqs = services(conn);
+
+      // Simulate a transform-triggering ALTER that acquires the lock and then 
throws
+      // mid-execution. The release in the finally mirrors MetaDataClient's 
finally block.
+      boolean acquired = false;
+      try {
+        acquired = cqs.acquireTransformLock(null, schemaName, tableName);
+        assertTrue("simulated ALTER must acquire lock", acquired);
+        // Inject the failure. In production this would be a 
TableNotFoundException from
+        // getTableNoCache, an evaluateStmtProperties throw, or a 
ConcurrentTableMutationException
+        // retry exhaustion — any post-acquire path that reaches the finally 
block.
+        throw new SQLException("simulated server-side failure during 
transform-triggering ALTER");
+      } catch (SQLException expected) {
+        // expected
+      } finally {
+        if (acquired) {
+          cqs.releaseTransformLock(null, schemaName, tableName);
+        }
+      }
+
+      // Lock must be observably free now: a subsequent caller should win the 
acquire.
+      assertTrue("lock must be observably released after the failed ALTER's 
finally ran",
+        cqs.acquireTransformLock(null, schemaName, tableName));
+      cqs.releaseTransformLock(null, schemaName, tableName);
+    }
+  }
+}

Reply via email to