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

lokiore pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/phoenix.git


The following commit(s) were added to refs/heads/master by this push:
     new 96c05a47cb PHOENIX-7996 Correctly maintain immutable global indexes on 
the server side for partial upserts (#2611)
96c05a47cb is described below

commit 96c05a47cbd32cbe81a8b324aa202131b57d3e91
Author: Lokesh Khurana <[email protected]>
AuthorDate: Thu Sep 17 10:41:00 2026 -0700

    PHOENIX-7996 Correctly maintain immutable global indexes on the server side 
for partial upserts (#2611)
    
    Co-authored-by: lokesh-khurana <[email protected]>
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 .../org/apache/phoenix/compile/DeleteCompiler.java |  19 +-
 .../org/apache/phoenix/compile/UpsertCompiler.java |   7 +-
 .../org/apache/phoenix/execute/MutationState.java  |   4 +-
 .../org/apache/phoenix/index/IndexMaintainer.java  |   8 +-
 .../phoenix/index/IndexMetaDataCacheClient.java    |   9 +-
 .../java/org/apache/phoenix/util/IndexUtil.java    |  23 +-
 .../phoenix/hbase/index/IndexRegionObserver.java   |  40 ++
 .../java/org/apache/phoenix/end2end/DeleteIT.java  |  10 +-
 .../org/apache/phoenix/end2end/IndexToolIT.java    |   9 +-
 .../end2end/index/BaseImmutableIndexIT.java        | 454 +++++++++++++++++++++
 .../apache/phoenix/end2end/index/BaseIndexIT.java  |  17 +-
 .../end2end/index/BaseIndexWithRegionMovesIT.java  |  14 +-
 .../end2end/index/GlobalIndexCheckerIT.java        |  71 ++++
 .../phoenix/monitoring/BasePhoenixMetricsIT.java   |   2 +
 .../monitoring/PhoenixTableLevelMetricsIT.java     |   2 +
 .../org/apache/phoenix/rpc/PhoenixClientRpcIT.java |   9 +-
 16 files changed, 659 insertions(+), 39 deletions(-)

diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/DeleteCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/DeleteCompiler.java
index 3d1e9ffdcd..08f9803eb6 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/DeleteCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/DeleteCompiler.java
@@ -18,8 +18,6 @@
 package org.apache.phoenix.compile;
 
 import static 
org.apache.phoenix.execute.MutationState.RowTimestampColInfo.NULL_ROWTIMESTAMP_INFO;
-import static 
org.apache.phoenix.query.QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB;
-import static 
org.apache.phoenix.query.QueryServicesOptions.DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED;
 import static org.apache.phoenix.util.NumberUtil.add;
 
 import edu.umd.cs.findbugs.annotations.NonNull;
@@ -214,7 +212,7 @@ public class DeleteCompiler {
           // The data table is always the last one in the list if it's
           // not chosen as the best of the possible plans.
           dataTable = otherTableRefs.get(otherTableRefs.size() - 1).getTable();
-          if (!isMaintainedOnClient(table, connection)) {
+          if (!isMaintainedOnClient(table, dataTable, connection)) {
             // dataTable is a projected table and may not include all the 
indexed columns and so we
             // need to get
             // the actual data table
@@ -259,7 +257,7 @@ public class DeleteCompiler {
         // row timestamp column, then the
         // row key will already have its value.
         // Check for otherTableRefs being empty required when deleting 
directly from the index
-        if (otherTableRefs.isEmpty() || isMaintainedOnClient(table, 
connection)) {
+        if (otherTableRefs.isEmpty() || isMaintainedOnClient(table, dataTable, 
connection)) {
           mutations.put(rowKeyPtr,
             new RowMutationState(PRow.DELETE_MARKER, 0,
               statement.getConnection().getStatementExecutionCounter(), 
NULL_ROWTIMESTAMP_INFO,
@@ -385,7 +383,7 @@ public class DeleteCompiler {
       for (PTable index : table.getIndexes()) {
         if (
           !index.getIndexState().isDisabled()
-            && isMaintainedOnClient(index, statement.getConnection())
+            && isMaintainedOnClient(index, table, statement.getConnection())
         ) {
           nonDisabledIndexes.add(index);
         }
@@ -569,7 +567,7 @@ public class DeleteCompiler {
     // mutations are generated on the client side. Indexed columns are needed 
to identify index rows
     // to be deleted
     for (PTable index : table.getIndexes()) {
-      if (isMaintainedOnClient(index, connection)) {
+      if (isMaintainedOnClient(index, table, connection)) {
         IndexMaintainer maintainer = index.getIndexMaintainer(table, 
connection);
         // Go through maintainer as it handles functional indexes correctly
         for (Pair<String, String> columnInfo : 
maintainer.getIndexedColumnInfo()) {
@@ -1135,15 +1133,16 @@ public class DeleteCompiler {
     }
   }
 
-  private static boolean isMaintainedOnClient(PTable table, PhoenixConnection 
connection) {
+  private static boolean isMaintainedOnClient(PTable table, PTable dataTable,
+    PhoenixConnection connection) {
     if (CDCUtil.isCDCIndex(table)) {
       return false;
     }
+    // The server-side-maintenance flag is read against the data table so a 
ROW_TIMESTAMP data
+    // table keeps its indexes client-maintained (see IndexUtil).
     if (
       !table.isTransactional() && table.getIndexType() != IndexType.LOCAL
-        && connection.getQueryServices().getConfiguration().getBoolean(
-          SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
-          DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED)
+        && IndexUtil.isServerSideImmutableIndexMaintenanceEnabled(dataTable, 
connection)
     ) {
       return false;
     }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UpsertCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UpsertCompiler.java
index 106f5071e3..b1cb9255ad 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UpsertCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UpsertCompiler.java
@@ -17,8 +17,6 @@
  */
 package org.apache.phoenix.compile;
 
-import static 
org.apache.phoenix.query.QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB;
-import static 
org.apache.phoenix.query.QueryServicesOptions.DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED;
 import static 
org.apache.phoenix.thirdparty.com.google.common.base.Preconditions.checkArgument;
 import static 
org.apache.phoenix.thirdparty.com.google.common.collect.Lists.newArrayListWithCapacity;
 
@@ -626,9 +624,8 @@ public class UpsertCompiler {
         // region space managed by region servers. So we bail out on executing 
on server side.
         // Disable running upsert select on server side if a table has global 
mutable secondary
         // indexes on it
-        boolean serverSideImmutableIndexes = 
connection.getQueryServices().getConfiguration()
-          .getBoolean(SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
-            DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED);
+        boolean serverSideImmutableIndexes =
+          IndexUtil.isServerSideImmutableIndexMaintenanceEnabled(table, 
connection);
         boolean hasGlobalServerSideIndexes = SchemaUtil.hasGlobalIndex(table)
           && (serverSideImmutableIndexes || !table.isImmutableRows());
         boolean hasWhereSubquery = select.getWhere() != null && 
select.getWhere().hasSubquery();
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/MutationState.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/MutationState.java
index b0bfac0b04..4931620a07 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/MutationState.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/MutationState.java
@@ -1927,7 +1927,9 @@ public class MutationState implements SQLCloseable {
       }
       PTable logicalTable = tableInfo.getPTable();
       if (
-        !this.serverSideImmutableIndexes && 
tableInfo.getOrigTableRef().getTable().isImmutableRows()
+        !IndexUtil.isServerSideImmutableIndexMaintenanceEnabled(
+          tableInfo.getOrigTableRef().getTable(), 
this.serverSideImmutableIndexes)
+          && tableInfo.getOrigTableRef().getTable().isImmutableRows()
           && (this.indexRegionObserverEnabledAllTables
             || IndexUtil.isGlobalIndexCheckerEnabled(connection, 
tableInfo.getHTableName()))
       ) {
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/index/IndexMaintainer.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/index/IndexMaintainer.java
index 70b83d1795..533ddadb10 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/index/IndexMaintainer.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/index/IndexMaintainer.java
@@ -17,8 +17,6 @@
  */
 package org.apache.phoenix.index;
 
-import static 
org.apache.phoenix.query.QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB;
-import static 
org.apache.phoenix.query.QueryServicesOptions.DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED;
 import static 
org.apache.phoenix.schema.PTable.QualifierEncodingScheme.NON_ENCODED_QUALIFIERS;
 
 import com.google.protobuf.InvalidProtocolBufferException;
@@ -198,11 +196,9 @@ public class IndexMaintainer implements Writable, 
Iterable<ColumnReference> {
     return Iterators.filter(indexes, new Predicate<PTable>() {
       @Override
       public boolean apply(PTable index) {
-        return sendIndexMaintainer(index) && ((index.getIndexType() == 
IndexType.GLOBAL
+        return sendIndexMaintainer(index) && ((IndexUtil.isGlobalIndex(index)
           && (dataTable.getImmutableStorageScheme() != 
index.getImmutableStorageScheme()
-            || connection.getQueryServices().getConfiguration().getBoolean(
-              SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
-              DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED)))
+            || 
IndexUtil.isServerSideImmutableIndexMaintenanceEnabled(dataTable, connection)))
           || index.getIndexType() == IndexType.LOCAL || 
CDCUtil.isCDCIndex(index));
       }
     });
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/index/IndexMetaDataCacheClient.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/index/IndexMetaDataCacheClient.java
index 53a833bbe0..7b1e83bc0d 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/index/IndexMetaDataCacheClient.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/index/IndexMetaDataCacheClient.java
@@ -40,6 +40,7 @@ import org.apache.phoenix.query.QueryServicesOptions;
 import org.apache.phoenix.schema.PTable;
 import org.apache.phoenix.schema.PTableType;
 import org.apache.phoenix.util.ByteUtil;
+import org.apache.phoenix.util.IndexUtil;
 import org.apache.phoenix.util.PhoenixRuntime;
 import org.apache.phoenix.util.ReadOnlyProps;
 import org.apache.phoenix.util.ScanUtil;
@@ -145,9 +146,11 @@ public class IndexMetaDataCacheClient {
         QueryServicesOptions.DEFAULT_INDEX_USE_SERVER_METADATA)
         && props.getBoolean(QueryServices.INDEX_REGION_OBSERVER_ENABLED_ATTRIB,
           QueryServicesOptions.DEFAULT_INDEX_REGION_OBSERVER_ENABLED);
-      boolean serverSideImmutableIndexes =
-        props.getBoolean(SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
-          DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED);
+      // Keep ROW_TIMESTAMP tables client-maintained so the send-metadata 
decision matches the
+      // client-vs-server maintenance decision made for the same table 
elsewhere.
+      boolean serverSideImmutableIndexes = 
IndexUtil.isServerSideImmutableIndexMaintenanceEnabled(
+        table, props.getBoolean(SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
+          DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED));
       boolean useServerCacheRpc =
         useIndexMetadataCache(connection, mutations, 
indexMetaDataPtr.getLength() + txState.length)
           && sendIndexMaintainers;
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/util/IndexUtil.java 
b/phoenix-core-client/src/main/java/org/apache/phoenix/util/IndexUtil.java
index 5262437af8..830c2d49c6 100644
--- a/phoenix-core-client/src/main/java/org/apache/phoenix/util/IndexUtil.java
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/util/IndexUtil.java
@@ -761,6 +761,25 @@ public class IndexUtil {
     }
   }
 
+  /**
+   * Whether server-side maintenance of an immutable data table's global 
indexes applies. A table
+   * with a ROW_TIMESTAMP column stays client-maintained even when the flag is 
set: server-side
+   * maintenance stamps every cell with the server batch timestamp, which 
would overwrite the
+   * user-supplied ROW_TIMESTAMP value and silently drop rows on ROW_TIMESTAMP 
range scans.
+   */
+  public static boolean isServerSideImmutableIndexMaintenanceEnabled(PTable 
dataTable,
+    boolean configured) {
+    return configured && dataTable.getRowTimestampColPos() == -1;
+  }
+
+  public static boolean isServerSideImmutableIndexMaintenanceEnabled(PTable 
dataTable,
+    PhoenixConnection connection) {
+    return isServerSideImmutableIndexMaintenanceEnabled(dataTable,
+      connection.getQueryServices().getConfiguration().getBoolean(
+        QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
+        QueryServicesOptions.DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED));
+  }
+
   public static List<PTable> getClientMaintainedIndexes(PTable table,
     boolean serverSideImmutableIndex) {
     Iterator<PTable> indexIterator = // Only maintain tables with immutable 
rows through this
@@ -768,7 +787,9 @@ public class IndexUtil {
       (table.isTransactional() && 
table.getTransactionProvider().getTransactionProvider()
         .isUnsupported(Feature.MAINTAIN_LOCAL_INDEX_ON_SERVER))
         ? IndexMaintainer.maintainedIndexes(table.getIndexes().iterator())
-        : (table.isImmutableRows() && !serverSideImmutableIndex || 
table.isTransactional())
+        : (table.isImmutableRows()
+          && !isServerSideImmutableIndexMaintenanceEnabled(table, 
serverSideImmutableIndex)
+          || table.isTransactional())
           // If the data table has a different storage scheme than index 
table, don't maintain
           // this on the client. For example, if the index is single cell but 
the data table is
           // one_cell, and there is a partial update on the data table, index 
can't be built
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
index b1145c721b..5125c783e4 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
@@ -1189,6 +1189,44 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
     return false;
   }
 
+  /**
+   * Determines whether any data table mutation in the batch omits an on-disk 
column that a global
+   * index materializes (an indexed, covered, or index-WHERE column). For an 
immutable table the
+   * region-side build skips the current-row read-back, so such a partial 
upsert would drop or
+   * misbuild that column in the index while it survives in the data table. 
Columns are resolved to
+   * their on-disk qualifiers per the data table storage scheme, so a 
single-cell table (whose
+   * upserts rewrite the whole cell) is never treated as partial. Both covered 
and uncovered global
+   * maintainers are considered; transform and local index maintainers are 
skipped.
+   */
+  private boolean isPartialGlobalIndexMutation(PhoenixIndexMetaData 
indexMetaData,
+    MiniBatchOperationInProgress<Mutation> miniBatchOp) {
+    Set<ColumnReference> columns = new HashSet<ColumnReference>();
+    for (IndexMaintainer indexMaintainer : 
indexMetaData.getIndexMaintainers()) {
+      if (indexMaintainer instanceof TransformMaintainer || 
indexMaintainer.isLocalIndex()) {
+        continue;
+      }
+      columns.addAll(indexMaintainer.getAllColumnsForDataTable());
+    }
+    if (columns.isEmpty()) {
+      return false;
+    }
+    for (int i = 0; i < miniBatchOp.size(); i++) {
+      if (isAtomicOperationComplete(miniBatchOp.getOperationStatus(i))) {
+        continue;
+      }
+      Mutation m = miniBatchOp.getOperation(i);
+      if (!this.builder.isEnabled(m)) {
+        continue;
+      }
+      for (ColumnReference column : columns) {
+        if (m.get(column.getFamily(), column.getQualifier()).isEmpty()) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
   /**
    * Retrieve the data row state either from memory or disk. The rows are 
locked by the caller.
    * <p>
@@ -1985,6 +2023,8 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
           || context.hasStrictConditionalTTL()
           || !context.immutableRows && context.hasUncoveredIndex
             && isPartialUncoveredIndexMutation(indexMetaData, miniBatchOp)
+          || context.immutableRows && (context.hasGlobalIndex || 
context.hasUncoveredIndex)
+            && isPartialGlobalIndexMutation(indexMetaData, miniBatchOp)
       ) {
         getCurrentRowStates(c, context, batchTimestamp);
       }
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/DeleteIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/DeleteIT.java
index c2a31842f9..cb7ffbfd27 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/DeleteIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/DeleteIT.java
@@ -60,6 +60,7 @@ import org.apache.phoenix.parse.DeleteStatement;
 import org.apache.phoenix.parse.SQLParser;
 import org.apache.phoenix.query.ConnectionQueryServices;
 import org.apache.phoenix.query.QueryServices;
+import org.apache.phoenix.query.QueryServicesOptions;
 import org.apache.phoenix.util.PropertiesUtil;
 import org.apache.phoenix.util.QueryUtil;
 import org.junit.Test;
@@ -505,7 +506,14 @@ public class DeleteIT extends ParallelStatsDisabledIT {
       psDelete.setString(3, "CC");
       psDelete.setDate(4, date);
       String explainPlan = QueryUtil.getExplainPlan(psDelete.executeQuery());
-      if (addNonPKIndex) {
+      // A non-PK index needs the row's non-PK values to maintain its entries. 
When immutable
+      // indexes are maintained client side the client must read the row 
first, so the delete is
+      // not a single-row plan. When they are maintained server side the 
region server reads and
+      // maintains the index, so the point delete stays a single-row plan.
+      boolean serverSideImmutableIndexes = 
con.unwrap(PhoenixConnection.class).getQueryServices()
+        
.getConfiguration().getBoolean(QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
+          QueryServicesOptions.DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED);
+      if (addNonPKIndex && !serverSideImmutableIndexes) {
         assertNotEquals("DELETE SINGLE ROW", explainPlan);
       } else {
         assertEquals("DELETE SINGLE ROW", explainPlan);
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/IndexToolIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/IndexToolIT.java
index 23f083f096..cd65cc45b8 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/IndexToolIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/IndexToolIT.java
@@ -888,8 +888,13 @@ public class IndexToolIT extends BaseTest {
         0, IndexTool.IndexVerifyType.ONLY, "-fi");
 
       CounterGroup mrJobCounters = getMRJobCounters(indexTool);
-      // Extra index rows should be detected
-      if (mutable) {
+      boolean serverSideImmutableIndexes = 
conn.unwrap(PhoenixConnection.class).getQueryServices()
+        
.getConfiguration().getBoolean(QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
+          QueryServicesOptions.DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED);
+      // Extra index rows should be detected. Immutable indexes maintained 
server-side adopt the
+      // same two-phase verified write protocol as mutable indexes, so the 
directly-written extra
+      // rows are counted as UNVERIFIED rather than VERIFIED.
+      if (mutable || serverSideImmutableIndexes) {
         assertEquals(3, mrJobCounters
           
.findCounter(BEFORE_REPAIR_EXTRA_UNVERIFIED_INDEX_ROW_COUNT.name()).getValue());
       } else {
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseImmutableIndexIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseImmutableIndexIT.java
index aaeeea6d07..dbf8487952 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseImmutableIndexIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseImmutableIndexIT.java
@@ -761,4 +761,458 @@ public abstract class BaseImmutableIndexIT extends 
BaseTest {
       conn.commit();
     }
   }
+
+  // 
---------------------------------------------------------------------------------------------
+  // Partial-upsert and delete coverage for server-side immutable index 
maintenance
+  // (SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB). These run under both 
storage schemes (the
+  // columnEncoded parameter selects ONE_CELL_PER_COLUMN vs 
SINGLE_CELL_ARRAY_WITH_OFFSETS) and
+  // under both flag states -- ServerSideImmutableIndexIT forces the flag on,
+  // ClientSideImmutableIndexIT
+  // forces it off -- so every assertion below must hold whether the index is 
maintained on the
+  // server or the client. Only non-transactional global indexes are affected 
by the flag, so each
+  // test bails out for local and transactional parameterizations.
+  // 
---------------------------------------------------------------------------------------------
+
+  /**
+   * Reads every row of a query into "col1|col2|..." strings (a null column 
renders as the literal
+   * "null"), so an index-served result can be compared for exact agreement 
with the data table
+   * under either storage scheme.
+   */
+  private static List<String> readRows(Connection conn, String sql) throws 
SQLException {
+    List<String> rows = new ArrayList<>();
+    try (ResultSet rs = conn.createStatement().executeQuery(sql)) {
+      int cols = rs.getMetaData().getColumnCount();
+      while (rs.next()) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 1; i <= cols; i++) {
+          if (i > 1) {
+            sb.append('|');
+          }
+          sb.append(rs.getString(i));
+        }
+        rows.add(sb.toString());
+      }
+    }
+    return rows;
+  }
+
+  /**
+   * A partial upsert on an immutable table with a covered global index must 
leave the index
+   * consistent with the data table under BOTH storage schemes. Under 
ONE_CELL_PER_COLUMN the
+   * omitted covered column keeps its earlier value; under 
SINGLE_CELL_ARRAY_WITH_OFFSETS the whole
+   * row is a single cell so the omitted column is overwritten to null. Either 
way the index the
+   * server maintains must read back exactly what the data table returns. The 
SINGLE_CELL branch
+   * (columnEncoded=true) is the load-bearing case the ONE_CELL-only 
GlobalIndexCheckerIT test
+   * skips.
+   */
+  @Test
+  public void testPartialUpsertForImmutableCoveredIndex() throws Exception {
+    if (localIndex || transactionProvider != null) {
+      return;
+    }
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.setAutoCommit(false);
+      String tableName = "TBL_" + generateUniqueName();
+      String indexName = "IND_" + generateUniqueName();
+      conn.createStatement()
+        .execute("CREATE TABLE " + tableName
+          + " (id VARCHAR NOT NULL PRIMARY KEY, val1 VARCHAR, val2 VARCHAR, 
val3 VARCHAR) "
+          + tableDDLOptions);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " VALUES ('a', 'ab', 'abc', 
'abcd')");
+      conn.commit();
+      conn.createStatement()
+        .execute("CREATE INDEX " + indexName + " ON " + tableName + " (val1) 
INCLUDE (val2, val3)");
+
+      String idxSql = "SELECT /*+ INDEX(" + tableName + " " + indexName
+        + ") */ id, val1, val2, val3 " + "FROM " + tableName + " WHERE val1 = 
'ab'";
+      String dataSql =
+        "SELECT /*+ NO_INDEX */ id, val1, val2, val3 FROM " + tableName + " 
WHERE val1 = 'ab'";
+      // Full upsert: the index is actually used (guards against a vacuous 
data-scan agreement) and
+      // returns the complete row.
+      assertExplainPlan(false,
+        QueryUtil.getExplainPlan(conn.createStatement().executeQuery("EXPLAIN 
" + idxSql)),
+        tableName, indexName);
+      assertEquals("[a|ab|abc|abcd]", readRows(conn, idxSql).toString());
+      assertEquals(readRows(conn, dataSql), readRows(conn, idxSql));
+
+      // Partial upsert re-supplies val1 (the index key) and val2 but omits 
the covered column val3.
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, val1, val2) VALUES ('a', 
'ab', 'abcc')");
+      conn.commit();
+      // The server-maintained index must still agree with the data table, 
whatever the storage
+      // scheme did to the omitted column.
+      assertEquals("index must agree with data after partial upsert", 
readRows(conn, dataSql),
+        readRows(conn, idxSql));
+    }
+  }
+
+  /**
+   * A partial upsert that omits the indexed column of an UNCOVERED global 
index must leave the
+   * index path resolving exactly the rows the data table does. Under 
ONE_CELL_PER_COLUMN the
+   * indexed column keeps its earlier value so both find the row under 'ab'; 
under
+   * SINGLE_CELL_ARRAY_WITH_OFFSETS the single-cell overwrite nulls the 
indexed column so both find
+   * nothing. The scan path agrees under both maintenance modes because it 
self-heals unverified
+   * index rows at read time. The COUNT (aggregate) path does not self-heal, 
so it only agrees when
+   * the index is maintained on the server: server-side maintenance reads the 
current row back and
+   * rewrites a verified entry for the retained key, whereas client-side 
maintenance cannot rebuild
+   * the omitted index key and leaves the COUNT path undercounting -- the 
pre-existing gap this PR's
+   * server-side path closes.
+   */
+  @Test
+  public void testPartialUpsertForImmutableUncoveredIndex() throws Exception {
+    if (localIndex || transactionProvider != null) {
+      return;
+    }
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.setAutoCommit(false);
+      String tableName = "TBL_" + generateUniqueName();
+      String indexName = "IND_" + generateUniqueName();
+      conn.createStatement().execute("CREATE TABLE " + tableName
+        + " (id VARCHAR NOT NULL PRIMARY KEY, val1 VARCHAR, val2 VARCHAR) " + 
tableDDLOptions);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, val1, val2) VALUES ('a', 
'ab', 'abc')");
+      conn.commit();
+      conn.createStatement()
+        .execute("CREATE UNCOVERED INDEX " + indexName + " ON " + tableName + 
" (val1)");
+
+      String idxSql = "SELECT /*+ INDEX(" + tableName + " " + indexName + ") 
*/ id FROM "
+        + tableName + " WHERE val1 = 'ab'";
+      String dataSql = "SELECT /*+ NO_INDEX */ id FROM " + tableName + " WHERE 
val1 = 'ab'";
+      String idxCount = "SELECT /*+ INDEX(" + tableName + " " + indexName + ") 
*/ COUNT(*) FROM "
+        + tableName + " WHERE val1 = 'ab'";
+      String dataCount = "SELECT /*+ NO_INDEX */ COUNT(*) FROM " + tableName + 
" WHERE val1 = 'ab'";
+      // Full upsert: the uncovered index is used and resolves the row.
+      assertExplainPlan(false,
+        QueryUtil.getExplainPlan(conn.createStatement().executeQuery("EXPLAIN 
" + idxSql)),
+        tableName, indexName);
+      assertEquals("[a]", readRows(conn, idxSql).toString());
+
+      // Partial upsert omits the indexed column val1.
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, val2) VALUES ('a', 
'xyz')");
+      conn.commit();
+      // The scan path self-heals unverified index rows against the data 
table, so it agrees under
+      // both maintenance modes.
+      assertEquals("uncovered index scan must agree with data after partial 
upsert",
+        readRows(conn, dataSql), readRows(conn, idxSql));
+      if (serverSideIndex) {
+        // The COUNT (aggregate) path does not self-heal. Only server-side 
maintenance rebuilds a
+        // verified uncovered-index entry from the read-back row, so only then 
does the COUNT path
+        // agree with the data table; the client-side path is the pre-existing 
gap this PR closes.
+        assertEquals("uncovered index COUNT must agree with data after partial 
upsert",
+          readRows(conn, dataCount), readRows(conn, idxCount));
+      }
+    }
+  }
+
+  /**
+   * A partial upsert on a table with more than one covered global index must 
leave every maintained
+   * index in agreement with the data table for the covered column it reads 
back.
+   */
+  @Test
+  public void testPartialUpsertForImmutableMultipleIndexes() throws Exception {
+    if (localIndex || transactionProvider != null) {
+      return;
+    }
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.setAutoCommit(false);
+      String tableName = "TBL_" + generateUniqueName();
+      String indexName1 = "IND_" + generateUniqueName();
+      String indexName2 = "IND_" + generateUniqueName();
+      conn.createStatement()
+        .execute("CREATE TABLE " + tableName
+          + " (id VARCHAR NOT NULL PRIMARY KEY, val1 VARCHAR, val2 VARCHAR, 
val3 VARCHAR) "
+          + tableDDLOptions);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " VALUES ('a', 'ab', 'abc', 
'abcd')");
+      conn.commit();
+      conn.createStatement()
+        .execute("CREATE INDEX " + indexName1 + " ON " + tableName + " (val1) 
INCLUDE (val3)");
+      conn.createStatement()
+        .execute("CREATE INDEX " + indexName2 + " ON " + tableName + " (val2) 
INCLUDE (val3)");
+      // Partial upsert re-supplies val1 and val2 (both index keys) and omits 
the covered column
+      // val3.
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, val1, val2) VALUES ('a', 
'ab', 'abc')");
+      conn.commit();
+
+      String viaIdx1 = "SELECT /*+ INDEX(" + tableName + " " + indexName1 + ") 
*/ val3 FROM "
+        + tableName + " WHERE val1 = 'ab'";
+      String viaIdx2 = "SELECT /*+ INDEX(" + tableName + " " + indexName2 + ") 
*/ val3 FROM "
+        + tableName + " WHERE val2 = 'abc'";
+      String data = "SELECT /*+ NO_INDEX */ val3 FROM " + tableName + " WHERE 
id = 'a'";
+      assertExplainPlan(false,
+        QueryUtil.getExplainPlan(conn.createStatement().executeQuery("EXPLAIN 
" + viaIdx1)),
+        tableName, indexName1);
+      assertExplainPlan(false,
+        QueryUtil.getExplainPlan(conn.createStatement().executeQuery("EXPLAIN 
" + viaIdx2)),
+        tableName, indexName2);
+      assertEquals("index1 must agree with data", readRows(conn, data), 
readRows(conn, viaIdx1));
+      assertEquals("index2 must agree with data", readRows(conn, data), 
readRows(conn, viaIdx2));
+    }
+  }
+
+  /**
+   * A partial upsert that touches only one column family of a 
multi-column-family immutable table
+   * must read back a covered column that lives in an untouched family. Family 
b is never written by
+   * the partial upsert, so its cell survives under BOTH storage schemes and 
val3 must remain 'abcd'
+   * on the data table -- and the server-maintained index must read back the 
same value.
+   */
+  @Test
+  public void testPartialUpsertForImmutableMultipleColumnFamilies() throws 
Exception {
+    if (localIndex || transactionProvider != null) {
+      return;
+    }
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.setAutoCommit(false);
+      String tableName = "TBL_" + generateUniqueName();
+      String indexName = "IND_" + generateUniqueName();
+      conn.createStatement()
+        .execute("CREATE TABLE " + tableName
+          + " (id VARCHAR NOT NULL PRIMARY KEY, a.val1 VARCHAR, a.val2 
VARCHAR, b.val3 VARCHAR) "
+          + tableDDLOptions);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " VALUES ('a', 'ab', 'abc', 
'abcd')");
+      conn.commit();
+      conn.createStatement()
+        .execute("CREATE INDEX " + indexName + " ON " + tableName + " (val1) 
INCLUDE (val2, val3)");
+      // Partial upsert touches only family a (val1, val2); family b's val3 is 
untouched.
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, val1, val2) VALUES ('a', 
'ab', 'abcc')");
+      conn.commit();
+
+      String idxSql = "SELECT /*+ INDEX(" + tableName + " " + indexName
+        + ") */ val1, val2, val3 FROM " + tableName + " WHERE val1 = 'ab'";
+      String dataSql =
+        "SELECT /*+ NO_INDEX */ val1, val2, val3 FROM " + tableName + " WHERE 
id = 'a'";
+      assertExplainPlan(false,
+        QueryUtil.getExplainPlan(conn.createStatement().executeQuery("EXPLAIN 
" + idxSql)),
+        tableName, indexName);
+      List<String> dataRows = readRows(conn, dataSql);
+      List<String> idxRows = readRows(conn, idxSql);
+      if (tableDDLOptions.contains("ONE_CELL_PER_COLUMN")) {
+        // ONE_CELL_PER_COLUMN keeps the untouched family's cell, so val3 must 
survive as 'abcd'.
+        // This
+        // anchors the cross-family read-back so an all-null agreement cannot 
pass vacuously.
+        // (SINGLE_CELL_ARRAY_WITH_OFFSETS does not retain the omitted column 
on a partial upsert;
+        // the
+        // agreement check below still verifies the index tracks the data 
table under that scheme.)
+        assertEquals("[ab|abcc|abcd]", dataRows.toString());
+      }
+      // The server-maintained index must read back identically to the data 
table across families.
+      assertEquals("index must agree with data across column families", 
dataRows, idxRows);
+    }
+  }
+
+  /**
+   * A partial upsert that omits the WHERE-clause column of a partial index 
(CREATE INDEX ... WHERE)
+   * must leave the partial index selecting exactly the rows the data-table 
predicate does. Under
+   * ONE_CELL_PER_COLUMN the WHERE column keeps its earlier value so the row 
stays indexed; under
+   * SINGLE_CELL_ARRAY_WITH_OFFSETS the single-cell overwrite nulls it so 
neither the index nor the
+   * data path matches val2 = 'keep'. Either way the index path and the data 
path must agree.
+   */
+  @Test
+  public void testPartialUpsertForImmutablePartialIndex() throws Exception {
+    if (localIndex || transactionProvider != null) {
+      return;
+    }
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.setAutoCommit(false);
+      String tableName = "TBL_" + generateUniqueName();
+      String indexName = "IND_" + generateUniqueName();
+      conn.createStatement()
+        .execute("CREATE TABLE " + tableName
+          + " (id VARCHAR NOT NULL PRIMARY KEY, val1 VARCHAR, val2 VARCHAR, 
val3 VARCHAR) "
+          + tableDDLOptions);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " VALUES ('a', 'ab', 'keep', 
'abcd')");
+      conn.commit();
+      // Only rows with val2 = 'keep' are indexed; val2 is the WHERE-clause 
column.
+      conn.createStatement().execute("CREATE INDEX " + indexName + " ON " + 
tableName
+        + " (val1) INCLUDE (val3) WHERE val2 = 'keep'");
+      // Partial upsert omits val2 (the partial-index predicate column) and 
updates val3.
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, val1, val3) VALUES ('a', 
'ab', 'updated')");
+      conn.commit();
+
+      // The query predicate implies the partial-index predicate, so the index 
is applicable; force
+      // it and compare against the data table for the same predicate.
+      String idxSql = "SELECT /*+ INDEX(" + tableName + " " + indexName + ") 
*/ val3 FROM "
+        + tableName + " WHERE val1 = 'ab' AND val2 = 'keep'";
+      String dataSql =
+        "SELECT /*+ NO_INDEX */ val3 FROM " + tableName + " WHERE val1 = 'ab' 
AND val2 = 'keep'";
+      assertEquals("partial index must agree with data after partial upsert",
+        readRows(conn, dataSql), readRows(conn, idxSql));
+    }
+  }
+
+  /**
+   * A partial upsert on an immutable table carrying BOTH a 
matching-storage-scheme index (inherits
+   * the base scheme) and a mismatched-storage-scheme index (explicit 
SINGLE_CELL_ARRAY_WITH_OFFSETS
+   * on a ONE_CELL_PER_COLUMN base) must leave every index in agreement with 
the data table. The
+   * mismatched index is always maintained on the server because its storage 
scheme differs from the
+   * data table's (IndexMaintainer), independent of the 
server-side-immutable-index flag; the
+   * matching index is maintained on the server only when the flag is on. So 
with the flag off the
+   * same upsert batch carries one server-maintained and one client-maintained 
index, exercising the
+   * single per-batch immutableRows classification -- both indexes must still 
read back the retained
+   * covered column. The mismatched pairing is only legal in the upgrade 
direction (a SINGLE_CELL
+   * base rejects a ONE_CELL index with 
INVALID_IMMUTABLE_STORAGE_SCHEME_CHANGE), so the mismatched
+   * index is created only under the ONE_CELL_PER_COLUMN base; under the 
SINGLE_CELL base only the
+   * matching index runs.
+   */
+  @Test
+  public void testPartialUpsertForImmutableMixedStorageSchemeIndexes() throws 
Exception {
+    if (localIndex || transactionProvider != null) {
+      return;
+    }
+    boolean oneCellBase = tableDDLOptions.contains("ONE_CELL_PER_COLUMN");
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.setAutoCommit(false);
+      String tableName = "TBL_" + generateUniqueName();
+      String matchingIndex = "IND_" + generateUniqueName();
+      String mismatchedIndex = "IND_" + generateUniqueName();
+      conn.createStatement()
+        .execute("CREATE TABLE " + tableName
+          + " (id VARCHAR NOT NULL PRIMARY KEY, val1 VARCHAR, val2 VARCHAR, 
val3 VARCHAR) "
+          + tableDDLOptions);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " VALUES ('a', 'ab', 'abc', 
'abcd')");
+      conn.commit();
+      // Matching-scheme index: inherits the base table's storage scheme.
+      conn.createStatement()
+        .execute("CREATE INDEX " + matchingIndex + " ON " + tableName + " 
(val1) INCLUDE (val3)");
+      // Mismatched-scheme index: explicit SINGLE_CELL on a ONE_CELL base (the 
only legal mismatch
+      // direction). Skipped under a SINGLE_CELL base, where a ONE_CELL index 
would be rejected.
+      if (oneCellBase) {
+        conn.createStatement()
+          .execute("CREATE INDEX " + mismatchedIndex + " ON " + tableName
+            + " (val2) INCLUDE (val3) 
IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS, "
+            + "COLUMN_ENCODED_BYTES=2");
+      }
+      // Partial upsert re-supplies both index keys (val1, val2) and omits the 
covered column val3.
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, val1, val2) VALUES ('a', 
'ab', 'abc')");
+      conn.commit();
+
+      String viaMatching = "SELECT /*+ INDEX(" + tableName + " " + 
matchingIndex
+        + ") */ id, val3 FROM " + tableName + " WHERE val1 = 'ab'";
+      String data = "SELECT /*+ NO_INDEX */ id, val3 FROM " + tableName + " 
WHERE id = 'a'";
+      assertExplainPlan(false,
+        QueryUtil.getExplainPlan(conn.createStatement().executeQuery("EXPLAIN 
" + viaMatching)),
+        tableName, matchingIndex);
+      List<String> dataRows = readRows(conn, data);
+      if (oneCellBase) {
+        // ONE_CELL base keeps the omitted covered column, so val3 must 
survive as 'abcd' (id
+        // anchors
+        // the row so an all-null agreement cannot pass vacuously).
+        assertEquals("[a|abcd]", dataRows.toString());
+      }
+      assertEquals("matching-scheme index must agree with data", dataRows,
+        readRows(conn, viaMatching));
+      if (oneCellBase) {
+        String viaMismatched = "SELECT /*+ INDEX(" + tableName + " " + 
mismatchedIndex + ") */ id, "
+          + "val3 FROM " + tableName + " WHERE val2 = 'abc'";
+        assertExplainPlan(false,
+          
QueryUtil.getExplainPlan(conn.createStatement().executeQuery("EXPLAIN " + 
viaMismatched)),
+          tableName, mismatchedIndex);
+        // The mismatched index is always server-maintained (schemes differ); 
it must read back the
+        // same retained covered column as the data table, even when the 
matching index in the same
+        // batch is maintained on the client (flag off).
+        assertEquals("mismatched-scheme index must agree with data", dataRows,
+          readRows(conn, viaMismatched));
+
+        // Aggregate (COUNT) path does not self-heal against the data table 
the way the point scan
+        // above does, so it is a strong guard that the SINGLE_CELL index 
actually retained the
+        // omitted covered column. The single-cell family is rewritten 
wholesale on the partial
+        // upsert, so without the always-on server read-back val3 would be 
lost and this COUNT would
+        // drop to 0. It must equal the data-table COUNT in BOTH flag modes 
because a mismatched
+        // storage scheme keeps the index server-maintained regardless of the 
flag.
+        String countByCovered = "COUNT(*) FROM " + tableName + " WHERE val3 = 
'abcd'";
+        String dataCount = "SELECT /*+ NO_INDEX */ " + countByCovered;
+        String mismatchedCount =
+          "SELECT /*+ INDEX(" + tableName + " " + mismatchedIndex + ") */ " + 
countByCovered;
+        // Filtering on a covered (non-key) column is a full index scan, not a 
range scan, so assert
+        // the index is used by name rather than via the range-scan-only 
assertExplainPlan helper.
+        String mismatchedCountPlan = QueryUtil
+          .getExplainPlan(conn.createStatement().executeQuery("EXPLAIN " + 
mismatchedCount));
+        assertTrue(
+          "COUNT must be served from the mismatched-scheme index; plan was: " 
+ mismatchedCountPlan,
+          mismatchedCountPlan.contains(mismatchedIndex));
+        assertEquals("mismatched-scheme index COUNT must agree with data",
+          readRows(conn, dataCount), readRows(conn, mismatchedCount));
+      }
+    }
+  }
+
+  /**
+   * Deleting from an immutable table that has a ROW_TIMESTAMP column and a 
secondary index. The
+   * ROW_TIMESTAMP carve-out must keep index maintenance on the client even 
when the server-side
+   * flag is on, so the region server does not re-stamp the cells with a 
server clock; otherwise a
+   * ROW_TIMESTAMP range scan would silently drop the surviving rows.
+   */
+  @Test
+  public void testDeleteFromImmutableRowTimestampTableWithIndex() throws 
Exception {
+    if (localIndex || transactionProvider != null) {
+      return;
+    }
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.setAutoCommit(true);
+      String tableName = "TBL_" + generateUniqueName();
+      String indexName = "IND_" + generateUniqueName();
+      conn.createStatement().execute(
+        "CREATE TABLE " + tableName + " (k1 BIGINT NOT NULL, k2 VARCHAR NOT 
NULL, val VARCHAR "
+          + "CONSTRAINT pk PRIMARY KEY (k1 ROW_TIMESTAMP, k2)) " + 
tableDDLOptions);
+      conn.createStatement().execute("CREATE INDEX " + indexName + " ON " + 
tableName + " (val)");
+      conn.createStatement().execute("UPSERT INTO " + tableName + " VALUES 
(100, 'a', 'v1')");
+      conn.createStatement().execute("UPSERT INTO " + tableName + " VALUES 
(200, 'b', 'v2')");
+      conn.createStatement().execute("UPSERT INTO " + tableName + " VALUES 
(300, 'c', 'v3')");
+
+      conn.createStatement().execute("DELETE FROM " + tableName + " WHERE k1 = 
200 AND k2 = 'b'");
+
+      // Data and index counts must agree after the delete.
+      try (ResultSet rs =
+        conn.createStatement().executeQuery("SELECT /*+ NO_INDEX */ COUNT(*) 
FROM " + tableName)) {
+        assertTrue(rs.next());
+        assertEquals(2, rs.getInt(1));
+      }
+      String countViaIndex = "SELECT COUNT(*) FROM " + tableName + " WHERE val 
IS NOT NULL";
+      assertExplainPlan(false,
+        QueryUtil.getExplainPlan(conn.createStatement().executeQuery("EXPLAIN 
" + countViaIndex)),
+        tableName, indexName);
+      try (ResultSet rs = conn.createStatement().executeQuery(countViaIndex)) {
+        assertTrue(rs.next());
+        assertEquals(2, rs.getInt(1));
+      }
+      // Each surviving row keeps its ROW_TIMESTAMP-derived cell timestamp: a 
scan bounded to a
+      // surviving row's exact ROW_TIMESTAMP band must still return it. This 
is the ROW_TIMESTAMP
+      // carve-out under test -- maintenance stays on the client so cells are 
not re-stamped with
+      // the
+      // server clock; a server-clock re-stamp would move the cell outside its 
ROW_TIMESTAMP band
+      // and
+      // this bounded scan would return 0. The band deliberately excludes the 
deleted k1=200: a
+      // DELETE
+      // marker is stamped at wall-clock time, outside any ROW_TIMESTAMP band, 
so a bounded scan
+      // cannot observe the delete -- an orthogonal ROW_TIMESTAMP semantics 
unrelated to this flag.
+      for (long k1 : new long[] { 100L, 300L }) {
+        try (
+          ResultSet rs = conn.createStatement().executeQuery("SELECT /*+ 
NO_INDEX */ COUNT(*) FROM "
+            + tableName + " WHERE k1 >= " + k1 + " AND k1 <= " + k1)) {
+          assertTrue(rs.next());
+          assertEquals("surviving ROW_TIMESTAMP row " + k1 + " must remain 
reachable in its band",
+            1, rs.getInt(1));
+        }
+      }
+      // Index read resolves a surviving row to its original ROW_TIMESTAMP key.
+      String viaIndex = "SELECT k1 FROM " + tableName + " WHERE val = 'v3'";
+      assertExplainPlan(false,
+        QueryUtil.getExplainPlan(conn.createStatement().executeQuery("EXPLAIN 
" + viaIndex)),
+        tableName, indexName);
+      try (ResultSet rs = conn.createStatement().executeQuery(viaIndex)) {
+        assertTrue(rs.next());
+        assertEquals(300L, rs.getLong(1));
+        assertFalse(rs.next());
+      }
+    }
+  }
 }
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseIndexIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseIndexIT.java
index 4dca35db98..9c70f18d6b 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseIndexIT.java
@@ -69,6 +69,7 @@ import org.apache.phoenix.parse.TableName;
 import org.apache.phoenix.query.BaseTest;
 import org.apache.phoenix.query.QueryConstants;
 import org.apache.phoenix.query.QueryServices;
+import org.apache.phoenix.query.QueryServicesOptions;
 import org.apache.phoenix.schema.PIndexState;
 import org.apache.phoenix.schema.PTable;
 import org.apache.phoenix.schema.PTableImpl;
@@ -271,15 +272,21 @@ public abstract class BaseIndexIT extends 
ParallelStatsDisabledIT {
     Iterator<Pair<byte[], List<Cell>>> iterator = 
PhoenixRuntime.getUncommittedDataIterator(conn);
     if (iterator.hasNext()) {
       byte[] tableName = iterator.next().getFirst(); // skip data table 
mutations
-      PTable table = 
conn.unwrap(PhoenixConnection.class).getTable(Bytes.toString(tableName));
-      boolean clientSideUpdate =
-        (!localIndex || (transactional && 
table.getTransactionProvider().getTransactionProvider()
-          .isUnsupported(Feature.MAINTAIN_LOCAL_INDEX_ON_SERVER))) && 
(!mutable || transactional);
+      PhoenixConnection pconn = conn.unwrap(PhoenixConnection.class);
+      PTable table = pconn.getTable(Bytes.toString(tableName));
+      // Immutable indexes are maintained server side unless client-side 
maintenance is enabled.
+      boolean serverSideImmutableIndexes = 
pconn.getQueryServices().getConfiguration().getBoolean(
+        QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
+        QueryServicesOptions.DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED);
+      boolean clientSideUpdate = (!localIndex || (transactional && 
table.getTransactionProvider()
+        
.getTransactionProvider().isUnsupported(Feature.MAINTAIN_LOCAL_INDEX_ON_SERVER)))
+        && ((!mutable && !serverSideImmutableIndexes) || transactional);
       if (!clientSideUpdate) {
         assertTrue(table.getType() == PTableType.TABLE); // should be data 
table
       }
       boolean hasIndexData = iterator.hasNext();
-      // global immutable and global transactional tables are processed client 
side
+      // global transactional (and immutable when maintained client side) 
tables are processed
+      // client side
       assertEquals(clientSideUpdate, hasIndexData);
     }
   }
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseIndexWithRegionMovesIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseIndexWithRegionMovesIT.java
index 329cb4df55..afff9ebeeb 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseIndexWithRegionMovesIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/BaseIndexWithRegionMovesIT.java
@@ -69,6 +69,7 @@ import org.apache.phoenix.parse.TableName;
 import org.apache.phoenix.query.BaseTest;
 import org.apache.phoenix.query.QueryConstants;
 import org.apache.phoenix.query.QueryServices;
+import org.apache.phoenix.query.QueryServicesOptions;
 import org.apache.phoenix.schema.PIndexState;
 import org.apache.phoenix.schema.PTable;
 import org.apache.phoenix.schema.PTableImpl;
@@ -300,14 +301,19 @@ public abstract class BaseIndexWithRegionMovesIT extends 
ParallelStatsDisabledWi
     if (iterator.hasNext()) {
       byte[] tableName = iterator.next().getFirst(); // skip data table 
mutations
       PTable table = PhoenixRuntime.getTable(conn, Bytes.toString(tableName));
-      boolean clientSideUpdate =
-        (!localIndex || (transactional && 
table.getTransactionProvider().getTransactionProvider()
-          .isUnsupported(Feature.MAINTAIN_LOCAL_INDEX_ON_SERVER))) && 
(!mutable || transactional);
+      // Immutable indexes are maintained server side unless client-side 
maintenance is enabled.
+      boolean serverSideImmutableIndexes = 
conn.unwrap(PhoenixConnection.class).getQueryServices()
+        
.getConfiguration().getBoolean(QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
+          QueryServicesOptions.DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED);
+      boolean clientSideUpdate = (!localIndex || (transactional && 
table.getTransactionProvider()
+        
.getTransactionProvider().isUnsupported(Feature.MAINTAIN_LOCAL_INDEX_ON_SERVER)))
+        && ((!mutable && !serverSideImmutableIndexes) || transactional);
       if (!clientSideUpdate) {
         assertTrue(table.getType() == PTableType.TABLE); // should be data 
table
       }
       boolean hasIndexData = iterator.hasNext();
-      // global immutable and global transactional tables are processed client 
side
+      // global transactional (and immutable when maintained client side) 
tables are processed
+      // client side
       assertEquals(clientSideUpdate, hasIndexData);
     }
   }
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
index 8d7d4c546a..927612f737 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
@@ -55,10 +55,12 @@ import org.apache.hadoop.mapreduce.CounterGroup;
 import org.apache.phoenix.end2end.IndexToolIT;
 import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest;
 import org.apache.phoenix.hbase.index.IndexRegionObserver;
+import org.apache.phoenix.jdbc.PhoenixConnection;
 import org.apache.phoenix.jdbc.PhoenixResultSet;
 import org.apache.phoenix.mapreduce.index.IndexTool;
 import org.apache.phoenix.query.BaseTest;
 import org.apache.phoenix.query.QueryServices;
+import org.apache.phoenix.query.QueryServicesOptions;
 import org.apache.phoenix.schema.PTableImpl;
 import org.apache.phoenix.util.EnvironmentEdgeManager;
 import org.apache.phoenix.util.PhoenixRuntime;
@@ -725,6 +727,7 @@ public class GlobalIndexCheckerIT extends BaseTest {
       conn.createStatement().execute("upsert into " + dataTableName + " (id, 
val1, val2, val3) "
         + "values ('a', 'ab', 'abcc', null)");
       conn.commit();
+      waitForEventualConsistency();
       String selectSql = "SELECT * from " + dataTableName + " WHERE val1  = 
'ab'";
       // Verify that we will read from the index table
       assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
@@ -753,6 +756,74 @@ public class GlobalIndexCheckerIT extends BaseTest {
     }
   }
 
+  /**
+   * Same partial-upsert scenario as testPartialRowUpdateForImmutable but for 
an UNCOVERED global
+   * index. A partial upsert that omits the indexed column (val1) must not 
create a spurious
+   * NULL-keyed index entry: for an immutable table val1 keeps its earlier 
value, so the index must
+   * still resolve the row under its original key and never under IS NULL. 
Verified via the index
+   * path (explain plan asserts the index table is used).
+   */
+  @Test
+  public void testPartialRowUpdateForImmutableUncovered() throws Exception {
+    if (async || encoded) {
+      // No need to run this test more than once
+      return;
+    }
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      String dataTableName = generateUniqueName();
+      conn.createStatement()
+        .execute("create table " + dataTableName
+          + " (id varchar(10) not null primary key, val1 varchar(10), val2 
varchar(10))"
+          + " IMMUTABLE_ROWS=true, IMMUTABLE_STORAGE_SCHEME="
+          + PTableImpl.ImmutableStorageScheme.ONE_CELL_PER_COLUMN);
+      // full upsert that sets the indexed column
+      conn.createStatement()
+        .execute("upsert into " + dataTableName + " (id, val1, val2) values 
('a', 'ab', 'abc')");
+      conn.commit();
+      String indexTableName = generateUniqueName();
+      conn.createStatement().execute("CREATE UNCOVERED INDEX " + 
indexTableName + " on "
+        + dataTableName + " (val1)" + this.indexDDLOptions);
+      // PARTIAL upsert that omits the indexed column val1. For immutable rows 
val1 stays 'ab'.
+      conn.createStatement()
+        .execute("upsert into " + dataTableName + " (id, val2) values ('a', 
'xyz')");
+      conn.commit();
+      waitForEventualConsistency();
+
+      // (a) index path must still resolve the row under its original key 'ab'
+      String selectAb = "SELECT id from " + dataTableName + " WHERE val1 = 
'ab'";
+      assertExplainPlan(conn, selectAb, dataTableName, indexTableName);
+      try (ResultSet rs = conn.createStatement().executeQuery(selectAb)) {
+        assertTrue(rs.next());
+        assertEquals("a", rs.getString(1));
+        assertFalse(rs.next());
+      }
+
+      // (b) count via the index must be exactly 1. The scan path (a)/(c) 
self-heals unverified
+      // index rows at read time, but the COUNT aggregate does not: only 
server-side maintenance
+      // reads the current row back and rewrites a verified entry for the 
retained key, so the
+      // COUNT path agrees only when server-side immutable-index maintenance 
is enabled.
+      boolean serverSideImmutableIndexes = 
conn.unwrap(PhoenixConnection.class).getQueryServices()
+        
.getConfiguration().getBoolean(QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB,
+          QueryServicesOptions.DEFAULT_SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED);
+      if (serverSideImmutableIndexes) {
+        String countAb = "SELECT COUNT(*) from " + dataTableName + " WHERE 
val1 = 'ab'";
+        assertExplainPlan(conn, countAb, dataTableName, indexTableName);
+        try (ResultSet rs = conn.createStatement().executeQuery(countAb)) {
+          assertTrue(rs.next());
+          assertEquals(1, rs.getInt(1));
+        }
+      }
+
+      // (c) a partial build lacking the read-back would emit a spurious 
NULL-keyed index entry
+      // pointing at id='a'; the index path for IS NULL must therefore return 
nothing
+      String selectNull = "SELECT id from " + dataTableName + " WHERE val1 IS 
NULL";
+      assertExplainPlan(conn, selectNull, dataTableName, indexTableName);
+      try (ResultSet rs = conn.createStatement().executeQuery(selectNull)) {
+        assertFalse(rs.next());
+      }
+    }
+  }
+
   @Test
   public void testFailPreIndexRowUpdate() throws Exception {
     String dataTableName = generateUniqueName();
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/monitoring/BasePhoenixMetricsIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/monitoring/BasePhoenixMetricsIT.java
index fe059f8451..e768d096b3 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/monitoring/BasePhoenixMetricsIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/monitoring/BasePhoenixMetricsIT.java
@@ -67,6 +67,8 @@ public abstract class BasePhoenixMetricsIT extends BaseTest {
     // disable renewing leases as this will force spooling to happen.
     props.put(QueryServices.RENEW_LEASE_ENABLED, String.valueOf(false));
     props.put(PHOENIX_INDEX_CDC_CONSUMER_ENABLED, Boolean.toString(false));
+    // Keep immutable-index maintenance client-side so mutation metrics 
account for index mutations.
+    props.put(QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB, 
Boolean.FALSE.toString());
     setUpTestDriver(new ReadOnlyProps(props.entrySet().iterator()));
     // need the non-test driver for some tests that check number of 
hconnections, etc.
     DriverManager.registerDriver(PhoenixDriver.INSTANCE);
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/monitoring/PhoenixTableLevelMetricsIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/monitoring/PhoenixTableLevelMetricsIT.java
index 4ca910964a..c8424ca00b 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/monitoring/PhoenixTableLevelMetricsIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/monitoring/PhoenixTableLevelMetricsIT.java
@@ -164,6 +164,8 @@ public class PhoenixTableLevelMetricsIT extends BaseTest {
     conf.set(QueryServices.TABLE_LEVEL_METRICS_ENABLED, String.valueOf(true));
     conf.set(QueryServices.METRIC_PUBLISHER_ENABLED, String.valueOf(true));
     conf.set(QueryServices.COLLECT_REQUEST_LEVEL_METRICS, 
String.valueOf(true));
+    // Keep immutable-index maintenance client-side so index-usage mutation 
metrics are recorded.
+    conf.set(QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB, 
String.valueOf(false));
 
     InstanceResolver.clearSingletons();
     // Override to get required config for static fields loaded that require 
HBase config
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/rpc/PhoenixClientRpcIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/rpc/PhoenixClientRpcIT.java
index ba4c06457b..fd0827a4a2 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/rpc/PhoenixClientRpcIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/rpc/PhoenixClientRpcIT.java
@@ -33,6 +33,7 @@ import org.apache.hadoop.hbase.ipc.CallRunner;
 import org.apache.hadoop.hbase.regionserver.RSRpcServices;
 import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest;
 import org.apache.phoenix.query.BaseTest;
+import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.util.PropertiesUtil;
 import org.apache.phoenix.util.QueryUtil;
 import org.apache.phoenix.util.ReadOnlyProps;
@@ -58,8 +59,14 @@ public class PhoenixClientRpcIT extends BaseTest {
       
Collections.singletonMap(RSRpcServices.REGION_SERVER_RPC_SCHEDULER_FACTORY_CLASS,
         TestPhoenixIndexRpcSchedulerFactory.class.getName());
     NUM_SLAVES_BASE = 2;
+    // Keep immutable-index maintenance client-side: this test verifies that 
client-originated
+    // index writes do not use the region-server index RPC queue. Under 
server-side maintenance
+    // that invariant no longer holds (the write is routed through the index 
handler pool only
+    // when the index region is not colocated with the data region), so pin 
the flag off here.
+    Map<String, String> clientProps = Collections.singletonMap(
+      QueryServices.SERVER_SIDE_IMMUTABLE_INDEXES_ENABLED_ATTRIB, 
Boolean.FALSE.toString());
     setUpTestDriver(new ReadOnlyProps(serverProps.entrySet().iterator()),
-      ReadOnlyProps.EMPTY_PROPS);
+      new ReadOnlyProps(clientProps.entrySet().iterator()));
   }
 
   @AfterClass

Reply via email to