PHOENIX-7961 Secondary index diverges from data table after TTL expiry on partial-touch upserts (#2574)
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 52517a8..b0bfac0 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
@@ -1528,6 +1528,10 @@
         // no-op if table doesn't have Conditional TTL
         ScanUtil.annotateMutationWithConditionalTTL(connection, tableInfo.getPTable(),
           mutationList);
+        // no-op unless table/view has a literal TTL; threads the empty-column CF/CQ (plus a view's
+        // literal TTL and any non-strict flag) so the internal current-row scan masks like a client
+        // read
+        ScanUtil.annotateMutationWithLiteralTTL(connection, tableInfo.getPTable(), mutationList);
         // If we haven't retried yet, retry for this case only, as it's possible that
         // a split will occur after we send the index metadata cache to all known
         // region servers.
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 22833e2..e70aba7 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
@@ -4923,9 +4923,9 @@
           /**
            * To check if TTL is defined at any of the child below we are checking it at
            * {@link org.apache.phoenix.coprocessor.MetaDataEndpointImpl#mutateColumn(List, ColumnMutator, int, PTable, PTable, boolean)}
-           * level where in function
-           * {@link org.apache.phoenix.coprocessor.MetaDataEndpointImpl# validateIfMutationAllowedOnParent(PTable, List, PTableType, long, byte[], byte[], byte[], List, int)}
-           * we are already traversing through allDescendantViews.
+           * level where in function {@link org.apache.phoenix.coprocessor.MetaDataEndpointImpl#
+           * validateIfMutationAllowedOnParent(PTable, List, PTableType, long, byte[], byte[],
+           * byte[], List, int)} we are already traversing through allDescendantViews.
            */
         }
 
diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/util/ScanUtil.java b/phoenix-core-client/src/main/java/org/apache/phoenix/util/ScanUtil.java
index 1dfc5b1..0be1157 100644
--- a/phoenix-core-client/src/main/java/org/apache/phoenix/util/ScanUtil.java
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/util/ScanUtil.java
@@ -92,6 +92,7 @@
 import org.apache.phoenix.query.QueryServicesOptions;
 import org.apache.phoenix.schema.CompiledTTLExpression;
 import org.apache.phoenix.schema.IllegalDataException;
+import org.apache.phoenix.schema.LiteralTTLExpression;
 import org.apache.phoenix.schema.PColumn;
 import org.apache.phoenix.schema.PName;
 import org.apache.phoenix.schema.PTable;
@@ -1862,6 +1863,104 @@
     }
   }
 
+  /**
+   * Annotates mutations for a table/view with a literal TTL so the server-side internal current-row
+   * scan (IndexRegionObserver.getCurrentRowStates) can mask expired rows exactly like a client
+   * read. This is the literal-TTL sibling of {@link #annotateMutationWithConditionalTTL}; the two
+   * are disjoint (one is guarded on a literal expression, the other on a conditional expression) so
+   * at most one fires for a given table.
+   * <p>
+   * The guiding principle is to thread precisely what the read path
+   * ({@link #setScanAttributesForPhoenixTTL}) would set as the {@code _TTL} scan attribute:
+   * <ul>
+   * <li>For a base <b>TABLE</b>, the numeric literal TTL lives on the HBase CF descriptor, so the
+   * server's {@link org.apache.phoenix.coprocessor.TTLRegionScanner} CF-descriptor fallback derives
+   * it. We set no {@code _TTL} attribute (matching the read path for base tables).</li>
+   * <li>For a <b>VIEW</b>, the view-level TTL is stored in SYSTEM.CATALOG and not on the shared CF
+   * descriptor (which carries the base table's TTL), so the view's compiled literal expression must
+   * be threaded per-mutation as {@code _TTL}, but only when {@code serialize()} is non-null — the
+   * exact non-null filter the read path uses. {@code serialize()} returns null only for NONE, which
+   * correctly threads nothing (matching the read path). This reuses the single {@code _TTL}
+   * attribute for both literal and conditional TTL, exactly as the scan path does; the server
+   * disambiguates by the deserialized expression type
+   * ({@link org.apache.phoenix.hbase.index.IndexRegionObserver#extractLiteralTTLForInternalScan}).</li>
+   * <li>For either type, {@code IS_STRICT_TTL=false} is set only when the table/view is non-strict,
+   * so absence defaults to strict, matching the read-path convention and avoiding over-masking of a
+   * non-strict table.</li>
+   * <li>For either type, the empty-column CF/CQ are threaded <b>unconditionally</b> (for any
+   * mutable literal-TTL table/view, regardless of the TTL value or the view-TTL flag), exactly as
+   * the client read path does: {@link #setScanAttributesForClient} sets the empty column on every
+   * non-analyze scan. They merely identify the table's empty column and only <i>enable</i> masking;
+   * {@link org.apache.phoenix.coprocessor.TTLRegionScanner} still independently requires an
+   * effective, non-FOREVER, strict TTL to actually mask, so setting them whenever a current-row
+   * read may happen makes the internal scan mask <i>identically</i> to a client read rather than
+   * diverging from it. They are also the only source of these values for the no-index current-row
+   * read (an atomic / ON DUPLICATE KEY / {@code returnResult} / row-delete on a TTL table), which
+   * has no {@code IndexMaintainer} on the server. Derived exactly as the read path derives them via
+   * {@link SchemaUtil#getEmptyColumnFamily(PTable)} /
+   * {@link SchemaUtil#getEmptyColumnQualifier}.</li>
+   * </ul>
+   */
+  public static void annotateMutationWithLiteralTTL(PhoenixConnection connection, PTable table,
+    List<? extends Mutation> mutations) throws SQLException {
+
+    if (!(table.getTTLExpression() instanceof LiteralTTLExpression)) {
+      return;
+    }
+    // NOTE: unlike annotateMutationWithConditionalTTL, we do NOT skip immutable tables here. For
+    // conditional TTL the server reads the current row only when context.hasConditionalTTL, which
+    // is
+    // itself derived from the _TTL mutation attribute this annotation would set - so skipping
+    // immutable tables is self-consistent (no attribute => no conditional read). For a LITERAL TTL
+    // the server-side current-row read in IndexRegionObserver.getCurrentRowStates is triggered by
+    // table/index structure and mutation type, NOT by any attribute set here: a global index whose
+    // data/index storage schemes differ leaves context.immutableRows false (see
+    // identifyIndexMaintainerTypes) and reads the row to rebuild the full index entry, and the
+    // atomic / returnResult / row-delete paths read it regardless of immutability. If we skipped
+    // immutable tables, that read would be unmasked and could rebuild the index from TTL-expired
+    // cells - the very divergence this masking exists to prevent. The threaded attributes are inert
+    // (they only enable masking on a scan TTLRegionScanner independently gates) when no current-row
+    // read happens, so annotating immutable tables is safe.
+
+    // For a view, honor the view-TTL feature flag exactly as setScanAttributesForPhoenixTTL does.
+    boolean isView = table.getType() == PTableType.VIEW;
+    boolean viewTTLEnabled = !isView || connection.getQueryServices().getConfiguration().getBoolean(
+      QueryServices.PHOENIX_VIEW_TTL_ENABLED,
+      QueryServicesOptions.DEFAULT_PHOENIX_VIEW_TTL_ENABLED);
+
+    // The view's literal TTL is threaded as _TTL only for a view with view-TTL enabled, since it is
+    // not on the shared CF descriptor. A view with view-TTL disabled sets no _TTL on the read path
+    // (it returns after only IS_STRICT_TTL), so neither do we; a base table's literal TTL is on the
+    // CF descriptor, so the server's TTLRegionScanner fallback derives it and we thread no _TTL.
+    // The server disambiguates a mutation's _TTL by deserialized type, so reusing the same
+    // attribute for both literal and conditional TTL (as the scan path does) never confuses the
+    // conditional path.
+    byte[] ttlForScan = null;
+    if (isView && viewTTLEnabled) {
+      // serialize() is non-null for FOREVER and finite literals and null only for NONE, the exact
+      // non-null filter the read path uses at setScanAttributesForPhoenixTTL. FOREVER must be
+      // threaded, not skipped.
+      ttlForScan = table.getCompiledTTLExpression(connection).serialize();
+    }
+
+    byte[] emptyCF = SchemaUtil.getEmptyColumnFamily(table);
+    byte[] emptyCQ = SchemaUtil.getEmptyColumnQualifier(table);
+
+    byte[] isStrictTTL =
+      table.isStrictTTL() ? null : PBoolean.INSTANCE.toBytes(table.isStrictTTL());
+    for (Mutation mutation : mutations) {
+      if (ttlForScan != null) {
+        mutation.setAttribute(BaseScannerRegionObserverConstants.TTL, ttlForScan);
+      }
+      if (isStrictTTL != null) {
+        mutation.setAttribute(BaseScannerRegionObserverConstants.IS_STRICT_TTL, isStrictTTL);
+      }
+      mutation.setAttribute(BaseScannerRegionObserverConstants.EMPTY_COLUMN_FAMILY_NAME, emptyCF);
+      mutation.setAttribute(BaseScannerRegionObserverConstants.EMPTY_COLUMN_QUALIFIER_NAME,
+        emptyCQ);
+    }
+  }
+
   public static PageFilter removePageFilterFromFilterList(FilterList filterList) {
     Iterator<Filter> filterIterator = filterList.getFilters().iterator();
     while (filterIterator.hasNext()) {
diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/ServerScanUtil.java b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/ServerScanUtil.java
new file mode 100644
index 0000000..ecd4d9e
--- /dev/null
+++ b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/ServerScanUtil.java
@@ -0,0 +1,123 @@
+/*
+ * 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.coprocessor;
+
+import java.io.IOException;
+import org.apache.hadoop.hbase.client.Scan;
+import org.apache.hadoop.hbase.coprocessor.RegionCoprocessorEnvironment;
+import org.apache.hadoop.hbase.regionserver.Region;
+import org.apache.hadoop.hbase.regionserver.RegionScanner;
+import org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants;
+import org.apache.phoenix.schema.types.PBoolean;
+
+/**
+ * Utilities for internal server-side region scans that must honor Phoenix TTL exactly like a client
+ * read. The client normally sets the empty-column and TTL scan attributes
+ * ({@link org.apache.phoenix.util.ScanUtil#setScanAttributesForPhoenixTTL}) and the coprocessor
+ * hook {@code BaseScannerRegionObserver.postScannerOpen} wraps the scan in a
+ * {@link TTLRegionScanner}. Internal scans opened directly via {@code region.getScanner(scan)}
+ * bypass that hook, so they set no attributes and are never TTL-masked. These helpers reproduce the
+ * TTL-masking step for server-side callers (e.g. {@code IndexRegionObserver} current-row reads) so
+ * an internal scan masks identically to a client scan.
+ * <p>
+ * Note: server paging is intentionally <b>not</b> reproduced here. Paging exists to bound the work
+ * a single RPC handler thread does before yielding; these internal scans are region-local (opened
+ * directly on the {@link Region}, not through the RPC scan path), so they consume no handler thread
+ * and there is nothing for paging to protect.
+ */
+public class ServerScanUtil {
+
+  private ServerScanUtil() {
+  }
+
+  /**
+   * Sets the Phoenix TTL scan attributes on an internal data-table scan so it masks exactly like a
+   * client read.
+   * <p>
+   * TTL masking attributes ({@link TTLRegionScanner} reads these):
+   * <ul>
+   * <li>the empty-column CF/CQ, supplied by the caller from the bytes the client threaded on the
+   * mutation ({@link org.apache.phoenix.util.ScanUtil#annotateMutationWithLiteralTTL}) — the single
+   * source for every path, secondary-index and no-index (atomic / ON DUPLICATE KEY /
+   * {@code returnResult} / row-delete) alike;</li>
+   * <li>{@code IS_STRICT_TTL=false} when {@code isStrictTTL == false}, so a non-strict table is not
+   * masked (absence of the attribute defaults to strict, matching the read path);</li>
+   * <li>the view's literal TTL as the standard {@code _TTL} scan attribute when
+   * {@code literalTTLForScan != null}. A base table's literal TTL is left unset so
+   * {@link TTLRegionScanner}'s CF-descriptor fallback derives it.</li>
+   * </ul>
+   */
+  public static void setInternalScanAttributes(Scan scan, byte[] emptyCF, byte[] emptyCQ,
+    byte[] literalTTLForScan, boolean isStrictTTL) {
+    scan.setAttribute(BaseScannerRegionObserverConstants.EMPTY_COLUMN_FAMILY_NAME, emptyCF);
+    scan.setAttribute(BaseScannerRegionObserverConstants.EMPTY_COLUMN_QUALIFIER_NAME, emptyCQ);
+    if (!isStrictTTL) {
+      // Absence of the attribute defaults to strict-true (ScanUtil.isStrictTTL), so only set it
+      // when the table/view is non-strict, mirroring setScanAttributesForPhoenixTTL.
+      scan.setAttribute(BaseScannerRegionObserverConstants.IS_STRICT_TTL,
+        PBoolean.INSTANCE.toBytes(false));
+    }
+    if (literalTTLForScan != null) {
+      // Only views carry a literal TTL here; a base table relies on the CF-descriptor fallback.
+      scan.setAttribute(BaseScannerRegionObserverConstants.TTL, literalTTLForScan);
+    }
+  }
+
+  /**
+   * Opens a region scanner wrapped in a {@link TTLRegionScanner} exactly as
+   * {@code BaseScannerRegionObserver.postScannerOpen} wraps a client scan, so TTL masking is
+   * applied. This is always safe: {@link TTLRegionScanner#isMaskingEnabled} no-ops the masking when
+   * Phoenix compaction is disabled, the empty-column attributes are absent, the TTL is FOREVER, or
+   * the scan is non-strict — so wrapping a non-TTL scan changes no behavior.
+   * <p>
+   * The raw HBase {@link RegionScanner} is wrapped in a {@link NonPagingRegionScanner} rather than
+   * passed to {@link TTLRegionScanner} directly: {@link TTLRegionScanner} requires its delegate to
+   * be a {@link DelegateRegionScanner} — its gap-analysis and re-scan paths cast the delegate and
+   * call {@code getNewRegionScanner} — so a raw HBase scanner would fail those casts. On the client
+   * read path {@code PagingRegionScanner} fills this role; here we use a non-paging equivalent
+   * because the scan is region-local (opened directly on the {@link Region}, off the RPC path), so
+   * it holds no handler thread and has nothing for paging to protect.
+   */
+  public static RegionScanner openRegionScanner(RegionCoprocessorEnvironment env, Region region,
+    Scan scan) throws IOException {
+    return new TTLRegionScanner(env, scan,
+      new NonPagingRegionScanner(region, region.getScanner(scan)));
+  }
+
+  /**
+   * A minimal {@link DelegateRegionScanner} over a raw HBase {@link RegionScanner} that can re-open
+   * a fresh scanner from the {@link Region} via {@link #getNewRegionScanner(Scan)} but adds no
+   * paging. {@link TTLRegionScanner} needs a {@link DelegateRegionScanner} delegate (it casts and
+   * calls {@code getNewRegionScanner} during gap analysis and re-scans); this fills that role for
+   * internal region-local scans without reproducing the paging machinery that
+   * {@code PagingRegionScanner} adds for the RPC read path.
+   */
+  private static final class NonPagingRegionScanner extends DelegateRegionScanner {
+    private final Region region;
+
+    private NonPagingRegionScanner(Region region, RegionScanner scanner) {
+      super(scanner);
+      this.region = region;
+    }
+
+    @Override
+    public RegionScanner getNewRegionScanner(Scan scan) throws IOException {
+      return new NonPagingRegionScanner(region, region.getScanner(scan));
+    }
+  }
+}
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 9763388..b1145c7 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
@@ -32,6 +32,7 @@
 import java.io.DataInputStream;
 import java.io.EOFException;
 import java.io.IOException;
+import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -85,6 +86,7 @@
 import org.apache.htrace.TraceScope;
 import org.apache.phoenix.compile.ScanRanges;
 import org.apache.phoenix.coprocessor.DelegateRegionCoprocessorEnvironment;
+import org.apache.phoenix.coprocessor.ServerScanUtil;
 import org.apache.phoenix.coprocessor.generated.IndexMutationsProtos;
 import org.apache.phoenix.coprocessor.generated.PTableProtos;
 import org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants;
@@ -119,6 +121,8 @@
 import org.apache.phoenix.query.QueryConstants;
 import org.apache.phoenix.query.QueryServicesOptions;
 import org.apache.phoenix.schema.CompiledConditionalTTLExpression;
+import org.apache.phoenix.schema.CompiledTTLExpression;
+import org.apache.phoenix.schema.LiteralTTLExpression;
 import org.apache.phoenix.schema.PColumn;
 import org.apache.phoenix.schema.PRow;
 import org.apache.phoenix.schema.PTable;
@@ -378,8 +382,40 @@
     private boolean hasTransform;
     private boolean returnResult;
     private boolean returnOldRow;
-    private boolean hasConditionalTTL; // table has Conditional TTL
     private boolean immutableRows;
+    // The deserialized CompiledTTLExpression for this batch, or null when no _TTL attribute was
+    // set. LiteralTTLExpression for a view with literal TTL; CompiledConditionalTTLExpression for
+    // a conditional TTL table. Populated once by extractTTLKindForInternalScan before
+    // identifyMutationTypes runs. Consumers gate on the concrete instance type via the
+    // hasLiteralTTL() / hasConditionalTTL() methods.
+    private CompiledTTLExpression ttlExpressionForBatch;
+    // The empty-column CF/CQ threaded by the client (ScanUtil.annotateMutationWithLiteralTTL) for
+    // any table/view with an effective literal TTL — the single source that lets the internal
+    // current-row scan mask, for the secondary-index and no-index (atomic / ON DUPLICATE KEY /
+    // returnResult / row-delete) paths alike. Null when no literal TTL applies. Unlike the _TTL
+    // attribute these are left on the mutations (they are inert on the write path).
+    private byte[] emptyCFForInternalScan;
+    private byte[] emptyCQForInternalScan;
+    // Whether the table/view uses strict TTL, captured (via isStrictTTLEnabled) alongside the other
+    // internal-scan attributes so getCurrentRowStates masks exactly like a client read: a
+    // non-strict table is never masked. Defaults to strict, matching isStrictTTLEnabled's default.
+    private boolean hasStrictTTL = PTable.DEFAULT_IS_STRICT_TTL;
+
+    public boolean hasLiteralTTL() {
+      return ttlExpressionForBatch instanceof LiteralTTLExpression;
+    }
+
+    public boolean hasConditionalTTL() {
+      return ttlExpressionForBatch instanceof CompiledConditionalTTLExpression;
+    }
+
+    public boolean hasStrictTTL() {
+      return hasStrictTTL;
+    }
+
+    public boolean hasStrictConditionalTTL() {
+      return hasConditionalTTL() && hasStrictTTL();
+    }
 
     public BatchMutateContext() {
       this.clientVersion = 0;
@@ -679,7 +715,7 @@
       Mutation m = miniBatchOp.getOperation(i);
       if (
         this.builder.isAtomicOp(m) || context.returnResult || this.builder.isEnabled(m)
-          || (this.builder.hasConditionalTTL(m) && isStrictTTLEnabled(miniBatchOp))
+          || context.hasStrictConditionalTTL()
       ) {
         ImmutableBytesPtr row = new ImmutableBytesPtr(m.getRow());
         context.rowsToLock.add(row);
@@ -775,18 +811,16 @@
    */
   private void updateMutationsForConditionalTTL(MiniBatchOperationInProgress<Mutation> miniBatchOp,
     BatchMutateContext context) throws IOException {
-    // If TTL is not strict, skip conditional TTL processing
-    if (!isStrictTTLEnabled(miniBatchOp)) {
+    if (!context.hasConditionalTTL()) {
       return;
     }
+    CompiledConditionalTTLExpression ttlExpr =
+      (CompiledConditionalTTLExpression) context.ttlExpressionForBatch;
     // mapping from row key to indices in mini batch
     Map<ImmutableBytesPtr, List<Integer>> expiredVersions = Maps.newHashMap();
     Set<ImmutableBytesPtr> notExpiredVersions = Sets.newHashSet();
     for (int i = 0; i < miniBatchOp.size(); i++) {
       Mutation m = miniBatchOp.getOperation(i);
-      if (!builder.hasConditionalTTL(m)) {
-        continue;
-      }
       if (IndexUtil.isDeleteFamily(m)) {
         // no need to fix DeleteFamily mutation
         continue;
@@ -808,9 +842,6 @@
         positions.add(i);
         continue;
       }
-      byte[] ttl = m.getAttribute(BaseScannerRegionObserverConstants.TTL);
-      CompiledConditionalTTLExpression ttlExpr =
-        (CompiledConditionalTTLExpression) TTLExpressionFactory.create(ttl);
       List<Cell> currentRow = flattenCells(currentVersion);
       // isRaw is false because we are looking at a Put mutation
       if (ttlExpr.isExpired(currentRow, false)) {
@@ -913,7 +944,7 @@
   }
 
   public static void setTimestamps(MiniBatchOperationInProgress<Mutation> miniBatchOp,
-    IndexBuildManager builder, long ts, boolean isTTLStrict) throws IOException {
+    IndexBuildManager builder, long ts, boolean hasStrictConditionalTTL) throws IOException {
     for (Integer i = 0; i < miniBatchOp.size(); i++) {
       if (isAtomicOperationComplete(miniBatchOp.getOperationStatus(i))) {
         continue;
@@ -922,9 +953,8 @@
       // skip this mutation if we aren't enabling indexing or Conditional TTL
       // or not an atomic op or if it is an atomic op
       // and its timestamp is already set(not LATEST)
-      // Also, skip conditional TTL if TTL is not strict
       if (
-        !builder.isEnabled(m) && (!builder.hasConditionalTTL(m) || !isTTLStrict)
+        !builder.isEnabled(m) && !hasStrictConditionalTTL
           && !((builder.isAtomicOp(m) || builder.returnResult(m))
             && IndexUtil.getMaxTimestamp(m) == HConstants.LATEST_TIMESTAMP)
       ) {
@@ -1161,9 +1191,24 @@
 
   /**
    * Retrieve the data row state either from memory or disk. The rows are locked by the caller.
+   * <p>
+   * The disk read is opened through {@link ServerScanUtil} so it is TTL-masked exactly like a
+   * client read: the internal scan otherwise bypasses the {@code postScannerOpen} coprocessor hook
+   * (the only place a scan is wrapped in {@code TTLRegionScanner}) and would rebuild the index from
+   * logically-expired-but-physically-present cells. The empty-column CF/CQ that enable masking are
+   * the bytes the client threaded on the mutation
+   * ({@link org.apache.phoenix.util.ScanUtil#annotateMutationWithLiteralTTL}) and captured into the
+   * batch context — the single source for every path that reaches here, index or no-index alike.
+   * When the batch has a literal TTL ({@link BatchMutateContext#hasLiteralTTL()}), the TTL
+   * expression is re-serialized and set on the scan so {@link TTLRegionScanner} masks with the
+   * view's TTL (null for base tables, which use the CF-descriptor fallback).
+   * {@link BatchMutateContext#hasStrictTTL()} avoids over-masking a non-strict table. Masking is a
+   * strict no-op when Phoenix compaction is disabled. Unlike a client read the scan is not paged:
+   * it is region-local (off the RPC path), so it holds no handler thread and there is nothing for
+   * paging to protect.
    */
   private void getCurrentRowStates(ObserverContext<RegionCoprocessorEnvironment> c,
-    BatchMutateContext context) throws IOException {
+    BatchMutateContext context, long batchTimestamp) throws IOException {
     Set<KeyRange> keys = new HashSet<KeyRange>(context.rowsToLock.size());
     for (ImmutableBytesPtr rowKeyPtr : context.rowsToLock) {
       PendingRow pendingRow = new PendingRow(rowKeyPtr, context);
@@ -1212,6 +1257,19 @@
       return;
     }
 
+    byte[] emptyCF = context.emptyCFForInternalScan;
+    byte[] emptyCQ = context.emptyCQForInternalScan;
+    // Re-serialize the literal TTL expression for the scan attribute. Only views carry a literal
+    // TTL here; a base table relies on the CF-descriptor fallback (null is correct for them).
+    byte[] literalTTLForScan = null;
+    try {
+      literalTTLForScan =
+        context.hasLiteralTTL() ? context.ttlExpressionForBatch.serialize() : null;
+    } catch (SQLException e) {
+      throw new IOException("Failed to serialize literal TTL expression for internal scan", e);
+    }
+    boolean isStrictTTL = context.hasStrictTTL();
+
     if (this.useBloomFilter) {
       for (KeyRange key : keys) {
         // Scan.java usage alters scan instances, safer to create scan instance per usage
@@ -1220,6 +1278,16 @@
         // for bloom filters scan should be a get
         scan.withStartRow(key.getLowerRange(), true);
         scan.withStopRow(key.getLowerRange(), true);
+        // Anchor TTLRegionScanner's masking clock at batchTimestamp. The range upper bound is
+        // batchTimestamp + 1 (not batchTimestamp) because HBase scan time ranges are half-open
+        // [min, max): a bound of batchTimestamp would exclude any committed cell sitting at exactly
+        // batchTimestamp. That collision cannot happen in production (getBatchTimestamp forces a
+        // distinct timestamp for same-row batches), but a frozen test clock can produce it, and the
+        // read must see those cells to build a correct current-row state. The masking clock is then
+        // batchTimestamp + 1, trimming expired cells as of the timestamp the index is built at.
+        scan.setTimeRange(0, batchTimestamp + 1);
+        ServerScanUtil.setInternalScanAttributes(scan, emptyCF, emptyCQ, literalTTLForScan,
+          isStrictTTL);
         readDataTableRows(c, context, scan);
       }
     } else {
@@ -1228,13 +1296,27 @@
       scanRanges.initializeScan(scan);
       SkipScanFilter skipScanFilter = scanRanges.getSkipScanFilter();
       scan.setFilter(skipScanFilter);
+      // Anchor TTLRegionScanner's masking clock at batchTimestamp. The range upper bound is
+      // batchTimestamp + 1 (not batchTimestamp) because HBase scan time ranges are half-open
+      // [min, max): a bound of batchTimestamp would exclude any committed cell sitting at exactly
+      // batchTimestamp. That collision cannot happen in production (getBatchTimestamp forces a
+      // distinct timestamp for same-row batches), but a frozen test clock can produce it, and the
+      // read must see those cells to build a correct current-row state. The masking clock is then
+      // batchTimestamp + 1, trimming expired cells as of the timestamp the index is built at.
+      scan.setTimeRange(0, batchTimestamp + 1);
+      ServerScanUtil.setInternalScanAttributes(scan, emptyCF, emptyCQ, literalTTLForScan,
+        isStrictTTL);
       readDataTableRows(c, context, scan);
     }
   }
 
   private void readDataTableRows(ObserverContext<RegionCoprocessorEnvironment> c,
     BatchMutateContext context, Scan scan) throws IOException {
-    try (RegionScanner scanner = c.getEnvironment().getRegion().getScanner(scan)) {
+    // Open through ServerScanUtil so the scan is wrapped in TTLRegionScanner (mirroring
+    // postScannerOpen) and masks TTL-expired rows exactly like a client read. Masking no-ops when
+    // Phoenix compaction is off or the empty-column attributes are absent.
+    try (RegionScanner scanner =
+      ServerScanUtil.openRegionScanner(c.getEnvironment(), c.getEnvironment().getRegion(), scan)) {
       boolean more = true;
       while (more) {
         List<Cell> cells = new ArrayList<Cell>();
@@ -1664,6 +1746,42 @@
     }
   }
 
+  /**
+   * Captures the TTL-related internal-scan attributes the client threads off the representative
+   * mutation and deserializes the {@code _TTL} attribute into
+   * {@link BatchMutateContext#ttlExpressionForBatch}.
+   * <p>
+   * Two things are captured:
+   * <ul>
+   * <li>The <b>empty-column CF/CQ</b> — threaded whenever an effective (non-NONE) literal TTL
+   * applies, for both base tables and views. They are inert on the write path, so they are left on
+   * the mutations.</li>
+   * <li>The <b>{@code _TTL}</b> — deserialized once into {@code context.ttlExpressionForBatch}.
+   * When needed for an internal scan, the expression is re-serialized from this field.</li>
+   * </ul>
+   * Must run before {@link #identifyMutationTypes} so that {@code context.hasStrictTTL()} is
+   * available. A table has exactly one TTL kind and a batch never mixes views, so the deserialized
+   * expression is uniform across the batch.
+   */
+  private void extractTTLKindForInternalScan(MiniBatchOperationInProgress<Mutation> miniBatchOp,
+    BatchMutateContext context) throws IOException {
+    if (miniBatchOp.size() == 0) {
+      return;
+    }
+    Mutation representative = miniBatchOp.getOperation(0);
+    context.emptyCFForInternalScan =
+      representative.getAttribute(BaseScannerRegionObserverConstants.EMPTY_COLUMN_FAMILY_NAME);
+    context.emptyCQForInternalScan =
+      representative.getAttribute(BaseScannerRegionObserverConstants.EMPTY_COLUMN_QUALIFIER_NAME);
+    context.hasStrictTTL = isStrictTTLEnabled(miniBatchOp);
+
+    byte[] ttlBytes = representative.getAttribute(BaseScannerRegionObserverConstants.TTL);
+    if (ttlBytes == null) {
+      return;
+    }
+    context.ttlExpressionForBatch = TTLExpressionFactory.create(ttlBytes);
+  }
+
   private void identifyMutationTypes(MiniBatchOperationInProgress<Mutation> miniBatchOp,
     BatchMutateContext context) throws IOException {
     for (int i = 0; i < miniBatchOp.size(); i++) {
@@ -1678,9 +1796,7 @@
           context.returnOldRow = true;
         }
       }
-      if (this.builder.hasConditionalTTL(m) && isStrictTTLEnabled(miniBatchOp)) {
-        context.hasConditionalTTL = true;
-      }
+
       if (this.builder.isAtomicOp(m) || this.builder.returnResult(m)) {
         context.hasAtomic = true;
         if (context.hasRowDelete) {
@@ -1781,13 +1897,19 @@
         // The timestamp for this batch will be different from the last batch processed.
         lastTimestamp = ts;
         batchesWithLastTimestamp.clear();
-        batchesWithLastTimestamp.add(context.rowsToLock);
+        // Register a snapshot, not the live rowsToLock: this now runs before
+        // releaseLocksForOnDupIgnoreMutations may structurally mutate rowsToLock (remove) outside
+        // synchronized(this), which would corrupt a concurrent shouldSleep iteration under
+        // synchronized(this). The snapshot is a superset of every later state (rowsToLock only
+        // shrinks after lockRows), so shouldSleep stays conservative: at most an extra sleep, never
+        // a missed one.
+        batchesWithLastTimestamp.add(new HashSet<>(context.rowsToLock));
         return ts;
       } else {
         if (!shouldSleep(context)) {
           // There is no need to sleep as the last batches with the same timestamp
           // do not have a common row this batch
-          batchesWithLastTimestamp.add(context.rowsToLock);
+          batchesWithLastTimestamp.add(new HashSet<>(context.rowsToLock));
           return ts;
         }
       }
@@ -1806,7 +1928,7 @@
       // We do not have to check again if we need to sleep again since we got the next
       // timestamp while holding the row locks. This mean there cannot be a new
       // mutation with the same row attempting get the same timestamp
-      batchesWithLastTimestamp.add(context.rowsToLock);
+      batchesWithLastTimestamp.add(new HashSet<>(context.rowsToLock));
       return ts;
     }
   }
@@ -1817,6 +1939,7 @@
     BatchMutateContext context = new BatchMutateContext(indexMetaData.getClientVersion());
     setBatchMutateContext(c, context);
     identifyIndexMaintainerTypes(indexMetaData, context);
+    extractTTLKindForInternalScan(miniBatchOp, context);
     identifyMutationTypes(miniBatchOp, context);
     context.populateOriginalMutations(miniBatchOp);
 
@@ -1838,9 +1961,18 @@
     context.currentPhase = BatchMutatePhase.PRE;
     long onDupCheckTime = 0;
 
+    // Compute the batch timestamp now, while holding the row locks and before the current-row read,
+    // so the internal current-row scan can anchor its TTL masking clock at exactly the timestamp
+    // the
+    // index is built at (see getCurrentRowStates). getBatchTimestamp only needs the locked rows,
+    // which are already populated, and the "got the next timestamp while holding the row locks"
+    // invariant still holds since the locks are held through the rest of this method.
+    TableName table = c.getEnvironment().getRegion().getRegionInfo().getTable();
+    long batchTimestamp = getBatchTimestamp(context, table);
+
     if (
       context.hasAtomic || context.returnResult || context.hasGlobalIndex
-        || context.hasUncoveredIndex || context.hasTransform || context.hasConditionalTTL
+        || context.hasUncoveredIndex || context.hasTransform || context.hasStrictConditionalTTL()
     ) {
       // Retrieve the current row states from the data table while holding the lock.
       // This is needed for both atomic mutations and global indexes
@@ -1850,16 +1982,16 @@
       if (
         !context.immutableRows && context.hasGlobalIndex || context.hasTransform
           || context.hasAtomic || context.returnResult || context.hasRowDelete
-          || context.hasConditionalTTL
+          || context.hasStrictConditionalTTL()
           || !context.immutableRows && context.hasUncoveredIndex
             && isPartialUncoveredIndexMutation(indexMetaData, miniBatchOp)
       ) {
-        getCurrentRowStates(c, context);
+        getCurrentRowStates(c, context, batchTimestamp);
       }
       onDupCheckTime += (EnvironmentEdgeManager.currentTimeMillis() - start);
     }
 
-    if (context.hasConditionalTTL) {
+    if (context.hasStrictConditionalTTL()) {
       // If the table has conditional TTL, then before making any update to a row
       // we need to evaluate the ttl expression to check if the current row version has
       // expired. If the current row version has expired then the incoming mutation has to
@@ -1885,11 +2017,9 @@
       }
     }
 
-    TableName table = c.getEnvironment().getRegion().getRegionInfo().getTable();
-    long batchTimestamp = getBatchTimestamp(context, table);
     // Update the timestamps of the data table mutations to prevent overlapping timestamps
     // (which prevents index inconsistencies as this case is not handled).
-    setTimestamps(miniBatchOp, builder, batchTimestamp, isStrictTTLEnabled(miniBatchOp));
+    setTimestamps(miniBatchOp, builder, batchTimestamp, context.hasStrictConditionalTTL());
     if (context.hasGlobalIndex || context.hasUncoveredIndex || context.hasTransform) {
       // Prepare next data rows states for pending mutations (for global indexes)
       prepareDataRowStates(c, miniBatchOp, context, batchTimestamp);
@@ -2448,7 +2578,6 @@
         delete.add(cell);
       }
     }
-
     if (!put.isEmpty() || !delete.isEmpty()) {
       PTable table = operations.get(0).getFirst();
       addEmptyKVCellToPut(put, tuple, table);
diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/BaseIndexBuilder.java b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/BaseIndexBuilder.java
index 768a751..e5d6fef 100644
--- a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/BaseIndexBuilder.java
+++ b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/BaseIndexBuilder.java
@@ -146,11 +146,6 @@
     return false;
   }
 
-  @Override
-  public boolean hasConditionalTTL(Mutation m) {
-    return false;
-  }
-
   public RegionCoprocessorEnvironment getEnv() {
     return this.env;
   }
diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuildManager.java b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuildManager.java
index 53a8644..1d45dfd 100644
--- a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuildManager.java
+++ b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuildManager.java
@@ -185,7 +185,4 @@
     return delegate.returnResult(m);
   }
 
-  public boolean hasConditionalTTL(Mutation m) {
-    return delegate.hasConditionalTTL(m);
-  }
 }
diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuilder.java b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuilder.java
index 1d13448..6db7ed3 100644
--- a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuilder.java
+++ b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuilder.java
@@ -156,11 +156,4 @@
    */
   boolean returnResult(Mutation m);
 
-  /**
-   * True if mutation has a Conditional TTL expression
-   * @param m Mutation object
-   * @return True if mutation has a Conditional TTL expression, False otherwise
-   */
-  boolean hasConditionalTTL(Mutation m);
-
 }
diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/index/PhoenixIndexBuilder.java b/phoenix-core-server/src/main/java/org/apache/phoenix/index/PhoenixIndexBuilder.java
index 490b0ad..1532480 100644
--- a/phoenix-core-server/src/main/java/org/apache/phoenix/index/PhoenixIndexBuilder.java
+++ b/phoenix-core-server/src/main/java/org/apache/phoenix/index/PhoenixIndexBuilder.java
@@ -44,7 +44,6 @@
 import org.apache.hadoop.hbase.util.Pair;
 import org.apache.hadoop.io.WritableUtils;
 import org.apache.phoenix.coprocessor.generated.PTableProtos;
-import org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants;
 import org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants.ReplayWrite;
 import org.apache.phoenix.exception.DataExceedsCapacityException;
 import org.apache.phoenix.expression.Expression;
@@ -291,9 +290,4 @@
       && (Arrays.equals(returnResult, PhoenixIndexBuilderHelper.RETURN_RESULT_ROW)
         || Arrays.equals(returnResult, PhoenixIndexBuilderHelper.RETURN_RESULT_OLD_ROW));
   }
-
-  @Override
-  public boolean hasConditionalTTL(Mutation m) {
-    return m.getAttribute(BaseScannerRegionObserverConstants.TTL) != null;
-  }
 }
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexDataTableSyncIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexDataTableSyncIT.java
new file mode 100644
index 0000000..4412193
--- /dev/null
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexDataTableSyncIT.java
@@ -0,0 +1,772 @@
+/*
+ * 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.index;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.Admin;
+import org.apache.hadoop.hbase.client.Mutation;
+import org.apache.hadoop.hbase.coprocessor.ObserverContext;
+import org.apache.hadoop.hbase.coprocessor.RegionCoprocessorEnvironment;
+import org.apache.hadoop.hbase.coprocessor.SimpleRegionObserver;
+import org.apache.hadoop.hbase.regionserver.MiniBatchOperationInProgress;
+import org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants;
+import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest;
+import org.apache.phoenix.jdbc.PhoenixConnection;
+import org.apache.phoenix.query.BaseTest;
+import org.apache.phoenix.query.QueryServices;
+import org.apache.phoenix.schema.PTable;
+import org.apache.phoenix.util.EnvironmentEdgeManager;
+import org.apache.phoenix.util.ManualEnvironmentEdge;
+import org.apache.phoenix.util.ReadOnlyProps;
+import org.apache.phoenix.util.TestUtil;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import org.apache.phoenix.thirdparty.com.google.common.collect.Maps;
+
+/**
+ * Verifies that internal server-side index-maintenance current-row reads honor Phoenix TTL exactly
+ * like a client read, and that the read is anchored at the mutation timestamp so the data table and
+ * a global index stay aligned under compaction/TTL masking.
+ * <p>
+ * The scenario is the divergence being fixed: a covered column (covcol) is written once, then never
+ * re-written by later partial "touch" upserts. On the index side covcol is rebuilt at every touch's
+ * mutationTimestamp from the masked current-row read; on the data side covcol keeps its original
+ * timestamp. The fix anchors that internal read's masking clock at mutationTimestamp (the same
+ * instant the index is built at) via scan.setTimeRange(0, mutationTimestamp), so both sides always
+ * agree on whether covcol is still alive: whatever the read masks the index rebuild omits, and
+ * whatever the read keeps a later major compaction keeps on the data side too. The row and its
+ * index then expire as a unit rather than covcol being dropped on only one side.
+ * {@link #testNoIndexAtomicUpsertMasksExpiredRow} covers the no-index masking path: an atomic ON
+ * DUPLICATE KEY UPDATE on a TTL table with no secondary index still triggers an internal
+ * current-row read. That scan is masked using the empty-column CF/CQ the client threads on the
+ * mutation — the same single source used for the secondary-index case — so an expired row is
+ * treated as absent rather than resurrected.
+ */
+@Category(NeedsOwnMiniClusterTest.class)
+@RunWith(Parameterized.class)
+public class IndexDataTableSyncIT extends BaseTest {
+  static final int MAX_LOOKBACK_AGE = 10;
+  static final int TTL = 60;
+  static final String COVCOL_VALUE = "cov-original";
+  static final String IDXCOL_VALUE = "idx-original";
+
+  private final boolean columnEncoded;
+  private ManualEnvironmentEdge injectEdge;
+
+  public IndexDataTableSyncIT(boolean columnEncoded) {
+    this.columnEncoded = columnEncoded;
+  }
+
+  @Parameterized.Parameters(name = "columnEncoded={0}")
+  public static synchronized Collection<Object[]> data() {
+    return Arrays.asList(new Object[][] { { false }, { true } });
+  }
+
+  @BeforeClass
+  public static synchronized void doSetup() throws Exception {
+    setUpTestDriver(new ReadOnlyProps(baseServerProps().entrySet().iterator()));
+  }
+
+  /**
+   * Common server props for the TTL-sync IT: max-lookback and immediate global-index row aging so a
+   * major compaction deterministically exercises the covered-column timestamp-skew divergence.
+   */
+  static Map<String, String> baseServerProps() {
+    Map<String, String> props = Maps.newHashMapWithExpectedSize(4);
+    props.put(QueryServices.GLOBAL_INDEX_ROW_AGE_THRESHOLD_TO_DELETE_MS_ATTRIB, Long.toString(0));
+    props.put(BaseScannerRegionObserverConstants.PHOENIX_MAX_LOOKBACK_AGE_CONF_KEY,
+      Integer.toString(MAX_LOOKBACK_AGE));
+    props.put("hbase.procedure.remote.dispatcher.delay.msec", "0");
+    // The view case threads the view's literal TTL as the per-mutation _TTL attribute.
+    props.put(QueryServices.PHOENIX_VIEW_TTL_ENABLED, Boolean.toString(true));
+    return props;
+  }
+
+  @Before
+  public void beforeTest() {
+    EnvironmentEdgeManager.reset();
+    injectEdge = new ManualEnvironmentEdge();
+    injectEdge.setValue(EnvironmentEdgeManager.currentTimeMillis());
+  }
+
+  @After
+  public synchronized void afterTest() throws Exception {
+    boolean refCountLeaked = isAnyStoreRefCountLeaked();
+    EnvironmentEdgeManager.reset();
+    Assert.assertFalse("refCount leaked", refCountLeaked);
+  }
+
+  /**
+   * Builds the comma-separated table-property clause. When ttlSeconds >= 0 a TTL is emitted as the
+   * first property; COLUMN_ENCODED_BYTES always follows, and IS_STRICT_TTL=false is appended for a
+   * non-strict table.
+   */
+  private String withClause(int ttlSeconds, boolean strict) {
+    StringBuilder sb = new StringBuilder(" ");
+    if (ttlSeconds >= 0) {
+      sb.append("TTL=").append(ttlSeconds).append(", ");
+    }
+    sb.append("COLUMN_ENCODED_BYTES=").append(columnEncoded ? 2 : 0);
+    if (!strict) {
+      sb.append(", IS_STRICT_TTL=false");
+    }
+    return sb.toString();
+  }
+
+  /**
+   * Base table with a literal TTL and a covered global index. After a partial touch that never
+   * writes covcol, the data-side covcol keeps its original timestamp; the masked internal read
+   * anchored at mutationTimestamp keeps the data and index agreeing on covcol while the row is
+   * alive, and a full expiry after that shows the data row and the index expiring as a unit.
+   */
+  @Test
+  public void testBaseTableCoveredColumnResync() throws Exception {
+    String tableName = generateUniqueName();
+    String indexName = generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.createStatement()
+        .execute("CREATE TABLE " + tableName + " (id VARCHAR NOT NULL PRIMARY KEY, "
+          + "idxcol VARCHAR, covcol VARCHAR, touchcol VARCHAR)" + withClause(TTL, true));
+      conn.createStatement()
+        .execute("CREATE INDEX " + indexName + " ON " + tableName + " (idxcol) INCLUDE (covcol)");
+      conn.commit();
+
+      EnvironmentEdgeManager.injectEdge(injectEdge);
+
+      // Full initial upsert: covcol is written exactly once here and never again.
+      conn.createStatement().execute("UPSERT INTO " + tableName
+        + " (id, idxcol, covcol, touchcol) VALUES ('r1', 'k1', '" + COVCOL_VALUE + "', 'x0')");
+      conn.commit();
+
+      // Partial touch that does not write covcol, well within the TTL so the row is alive.
+      injectEdge.incrementValue(1000);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, touchcol) VALUES ('r1', 'x1')");
+      conn.commit();
+      assertTouchColConsistent(conn, tableName, indexName, "r1", "x1");
+      // Data and index agree on covcol
+      assertCovColConsistent(conn, tableName, indexName, "r1", "k1", COVCOL_VALUE);
+
+      // Advancing past TTL expires the row in data table for internal current row read.
+      injectEdge.incrementValue((TTL + 1) * 1000L);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, touchcol) VALUES ('r1', 'x2')");
+      conn.commit();
+      assertTouchColConsistent(conn, tableName, indexName, "r1", "x2");
+      // Data and index agree on covcol
+      assertCovColAbsent(conn, tableName, indexName, "r1");
+    }
+  }
+
+  /**
+   * Uncovered global index on a base table with a literal TTL. An uncovered index stores its
+   * indexed column (idxcol) in the index row key, rebuilt at every touch's mutationTimestamp, while
+   * a partial touch that omits idxcol would otherwise leave the data-side idxcol cell at its
+   * original timestamp.
+   */
+  @Test
+  public void testUncoveredIndexIndexedColumnResync() throws Exception {
+    String tableName = generateUniqueName();
+    String indexName = generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.createStatement()
+        .execute("CREATE TABLE " + tableName + " (id VARCHAR NOT NULL PRIMARY KEY, "
+          + "idxcol VARCHAR, touchcol VARCHAR)" + withClause(TTL, true));
+      conn.createStatement()
+        .execute("CREATE UNCOVERED INDEX " + indexName + " ON " + tableName + " (idxcol)");
+      conn.commit();
+
+      EnvironmentEdgeManager.injectEdge(injectEdge);
+
+      // Full initial upsert: idxcol is written exactly once here and never again.
+      conn.createStatement().execute("UPSERT INTO " + tableName
+        + " (id, idxcol, touchcol) VALUES ('r1', '" + IDXCOL_VALUE + "', 'x0')");
+      conn.commit();
+
+      // Partial touch that does not write idxcol, well within the TTL so the row is alive and the
+      // internal scan anchored at batchTimestamp still returns idxcol for the index rebuild.
+      injectEdge.incrementValue(1000);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, touchcol) VALUES ('r1', 'x1')");
+      conn.commit();
+
+      // An uncovered index carries no data columns, so touchcol is only readable data-side; the
+      // index side agrees on idxcol below. The row is alive well within the TTL.
+      assertDataTouchColAlive(conn, tableName, "r1", "x1");
+      assertUncoveredIdxColConsistent(conn, tableName, indexName, "r1", IDXCOL_VALUE);
+
+      // Advancing past TTL expires the row in data table for internal current row read.
+      injectEdge.incrementValue((TTL + 1) * 1000L);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, touchcol) VALUES ('r1', 'x2')");
+      conn.commit();
+      // Data side: row still alive by PK (touchcol keeps it); the masked idxcol@T0 is trimmed, so
+      // the uncovered index no longer carries a usable entry keyed by idxcol (asserted below).
+      assertDataTouchColAlive(conn, tableName, "r1", "x2");
+      assertUncoveredIdxColAbsent(conn, tableName, indexName, IDXCOL_VALUE);
+    }
+  }
+
+  /**
+   * View with a view-level literal TTL (not on the shared CF descriptor) and a covered index on the
+   * view. Exercises the literal-_TTL per-mutation threading: the internal scan masks with the
+   * view's TTL, anchored at mutationTimestamp, and covcol keeps its original data-side timestamp
+   * just like the base-table case while the data and index reads stay consistent. A full expiry
+   * after that shows the view's data row and its index expiring as a unit.
+   */
+  @Test
+  public void testViewCoveredColumnResync() throws Exception {
+    String baseTableName = generateUniqueName();
+    String viewName = generateUniqueName();
+    String indexName = generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      // Base table carries NO TTL; the TTL lives only on the view.
+      conn.createStatement()
+        .execute("CREATE TABLE " + baseTableName + " (id VARCHAR NOT NULL PRIMARY KEY, "
+          + "idxcol VARCHAR, covcol VARCHAR, touchcol VARCHAR)" + withClause(-1, true));
+      conn.createStatement()
+        .execute("CREATE VIEW " + viewName + " AS SELECT * FROM " + baseTableName + " TTL=" + TTL);
+      conn.createStatement()
+        .execute("CREATE INDEX " + indexName + " ON " + viewName + " (idxcol) INCLUDE (covcol)");
+      conn.commit();
+
+      EnvironmentEdgeManager.injectEdge(injectEdge);
+
+      conn.createStatement().execute("UPSERT INTO " + viewName
+        + " (id, idxcol, covcol, touchcol) VALUES ('r1', 'k1', '" + COVCOL_VALUE + "', 'x0')");
+      conn.commit();
+
+      injectEdge.incrementValue(1000);
+      conn.createStatement()
+        .execute("UPSERT INTO " + viewName + " (id, touchcol) VALUES ('r1', 'x1')");
+      conn.commit();
+      assertTouchColConsistent(conn, viewName, indexName, "r1", "x1");
+      // View and index agree on covcol
+      assertCovColConsistent(conn, viewName, indexName, "r1", "k1", COVCOL_VALUE);
+
+      // Advancing past the view TTL expires the row in data table for internal current row read.
+      injectEdge.incrementValue((TTL + 1) * 1000L);
+      conn.createStatement()
+        .execute("UPSERT INTO " + viewName + " (id, touchcol) VALUES ('r1', 'x2')");
+      conn.commit();
+      assertTouchColConsistent(conn, viewName, indexName, "r1", "x2");
+      assertCovColAbsent(conn, viewName, indexName, "r1");
+    }
+  }
+
+  /**
+   * A table with IS_STRICT_TTL=false must not be masked away: the internal scan anchored at
+   * mutationTimestamp must not mask covcol away.
+   */
+  @Test
+  public void testNonStrictTableNotOverMasked() throws Exception {
+    String tableName = generateUniqueName();
+    String indexName = generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.createStatement()
+        .execute("CREATE TABLE " + tableName + " (id VARCHAR NOT NULL PRIMARY KEY, "
+          + "idxcol VARCHAR, covcol VARCHAR, touchcol VARCHAR)" + withClause(TTL, false));
+      conn.createStatement()
+        .execute("CREATE INDEX " + indexName + " ON " + tableName + " (idxcol) INCLUDE (covcol)");
+      conn.commit();
+
+      EnvironmentEdgeManager.injectEdge(injectEdge);
+
+      conn.createStatement().execute("UPSERT INTO " + tableName
+        + " (id, idxcol, covcol, touchcol) VALUES ('r1', 'k1', '" + COVCOL_VALUE + "', 'x0')");
+      conn.commit();
+
+      // Advance beyond TTL, then touch. A strict table would mask covcol at the internal scan;
+      // a non-strict table must not.
+      injectEdge.incrementValue((TTL + 5) * 1000L);
+      conn.createStatement()
+        .execute("UPSERT INTO " + tableName + " (id, touchcol) VALUES ('r1', 'x1')");
+      conn.commit();
+      assertTouchColConsistent(conn, tableName, indexName, "r1", "x1");
+      // The decisive check: a non-strict internal read is not masked away, so the index rebuilds
+      // with covcol and both the data read and the index read still return it.
+      assertCovColConsistent(conn, tableName, indexName, "r1", "k1", COVCOL_VALUE);
+
+      injectEdge.incrementValue(MAX_LOOKBACK_AGE * 1000L);
+      flushAndMajorCompact(conn, tableName);
+      flushAndMajorCompact(conn, indexName);
+
+      // Data side: the row is still alive (touchcol keeps it) but the stale covcol was physically
+      // collected by compaction, so a forced data-table read returns covcol = NULL.
+      try (ResultSet rs = conn.createStatement()
+        .executeQuery("SELECT /*+ NO_INDEX */ covcol FROM " + tableName + " WHERE id = 'r1'")) {
+        assertTrue("data-table row should still be alive (touchcol keeps it)", rs.next());
+        assertNull("stale data-side covcol should be collected by compaction", rs.getString(1));
+        assertFalse("exactly one data-table row", rs.next());
+      }
+
+      // Index side: the covered index copy survives compaction, so the index read still returns it.
+      try (ResultSet rs = conn.createStatement()
+        .executeQuery("SELECT covcol FROM " + tableName + " WHERE idxcol = 'k1'")) {
+        assertTrue("index-visible row should still exist", rs.next());
+        assertEquals("index covcol should survive compaction", COVCOL_VALUE, rs.getString(1));
+        assertFalse("exactly one index-visible row", rs.next());
+      }
+    }
+  }
+
+  /**
+   * The no-index masking path: an atomic ON DUPLICATE KEY UPDATE on a base table with a literal TTL
+   * and NO secondary index.
+   */
+  @Test
+  public void testNoIndexAtomicUpsertMasksExpiredRow() throws Exception {
+    String tableName = generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.createStatement().execute("CREATE TABLE " + tableName
+        + " (id VARCHAR NOT NULL PRIMARY KEY, counter INTEGER)" + withClause(TTL, true));
+      conn.commit();
+
+      EnvironmentEdgeManager.injectEdge(injectEdge);
+
+      // First atomic upsert: the row does not exist, so the ON DUPLICATE clause is skipped and the
+      // UPSERT VALUES insert counter = 0.
+      conn.createStatement().execute("UPSERT INTO " + tableName
+        + " (id, counter) VALUES ('r1', 0) ON DUPLICATE KEY UPDATE counter = counter + 1");
+      conn.commit();
+      assertEquals("fresh insert of a new row", 0, atomicCounter(conn, tableName));
+
+      // Advance past the TTL. No flush/compact: the row's cells are still physically present, so
+      // only read masking - not compaction - can hide the now-expired row from the internal scan.
+      injectEdge.incrementValue((TTL + 1) * 1000L);
+
+      // Second atomic upsert on the now-expired row. Post-fix the masked internal scan returns no
+      // current row, so this inserts counter = 0 again; pre-fix the unmasked scan resurrects the
+      // row and the ON DUPLICATE clause increments counter to 1.
+      conn.createStatement().execute("UPSERT INTO " + tableName
+        + " (id, counter) VALUES ('r1', 0) ON DUPLICATE KEY UPDATE counter = counter + 1");
+      conn.commit();
+
+      assertEquals("expired row must be treated as absent, not resurrected and incremented", 0,
+        atomicCounter(conn, tableName));
+    }
+  }
+
+  @Test
+  public void testUpsertSelectTouchNearTtlBoundaryKeepsDataAndIndexConsistent() throws Exception {
+    runTtlBoundaryScenario(true);
+  }
+
+  @Test
+  public void testUpsertValuesTouchNearTtlBoundaryKeepsDataAndIndexConsistent() throws Exception {
+    runTtlBoundaryScenario(false);
+  }
+
+  @Test
+  public void testImmutableDifferentStorageSchemeIndexKeepsDataAndIndexConsistent()
+    throws Exception {
+    String tableName = generateUniqueName();
+    String indexName = generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.setAutoCommit(false);
+      // The scheme mismatch (independent of the class columnEncoded parameter, which this test does
+      // not use) is the point: it forces server-side index maintenance and the masked current-row
+      // read. Direction ONE_CELL data -> SINGLE_CELL index is the only allowed one.
+      conn.createStatement().execute("CREATE IMMUTABLE TABLE " + tableName
+        + " (id VARCHAR NOT NULL PRIMARY KEY, idxcol VARCHAR, covcol VARCHAR, touchcol VARCHAR) "
+        + "TTL=" + TTL + ", IMMUTABLE_STORAGE_SCHEME=ONE_CELL_PER_COLUMN, COLUMN_ENCODED_BYTES=0");
+      conn.createStatement()
+        .execute("CREATE INDEX " + indexName + " ON " + tableName + " (idxcol) INCLUDE (covcol) "
+          + "IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS, COLUMN_ENCODED_BYTES=2");
+      conn.commit();
+
+      // Confirm the two sides really do use different storage schemes.
+      assertMetadata(conn, PTable.ImmutableStorageScheme.ONE_CELL_PER_COLUMN,
+        PTable.QualifierEncodingScheme.NON_ENCODED_QUALIFIERS, tableName);
+      assertMetadata(conn, PTable.ImmutableStorageScheme.SINGLE_CELL_ARRAY_WITH_OFFSETS,
+        PTable.QualifierEncodingScheme.TWO_BYTE_QUALIFIERS, indexName);
+
+      // Freeze the clock at t0; every cell of the full initial row lands at t0.
+      long t0 = EnvironmentEdgeManager.currentTimeMillis();
+      injectEdge.setValue(t0);
+      EnvironmentEdgeManager.injectEdge(injectEdge);
+
+      conn.createStatement().execute("UPSERT INTO " + tableName
+        + " (id, idxcol, covcol, touchcol) VALUES ('r1', 'k1', '" + COVCOL_VALUE + "', 'x0')");
+      conn.commit();
+
+      // Push the t0 cells onto an HFile so the later major compaction merges two files.
+      flushTable(conn, tableName);
+      flushTable(conn, indexName);
+
+      // Phase 1: a partial touch that omits covcol, committed WITHIN the TTL so the row is alive.
+      // The masked current-row read still returns covcol@t0, so the index is rebuilt WITH covcol
+      // and
+      // both sides agree covcol is present (non-null).
+      injectEdge.incrementValue(1000);
+      conn.createStatement()
+        .executeUpdate("UPSERT INTO " + tableName + " (id, touchcol) VALUES ('r1', 'x1')");
+      conn.commit();
+      assertCovColConsistent(conn, tableName, indexName, "r1", "k1", COVCOL_VALUE);
+
+      // Phase 2: a second partial touch that also omits covcol, committed past covcol@t0's TTL
+      // boundary. With autoCommit off the UPSERT VALUES only buffers on the client; the server-side
+      // masked current-row read and the mutationTimestamp are both established at commit, by which
+      // point the clock is past covcol@t0's expiry. So the masked read drops covcol@t0 and the
+      // index is rebuilt WITHOUT covcol.
+      injectEdge.incrementValue(TTL * 1000L + 1);
+      conn.createStatement()
+        .executeUpdate("UPSERT INTO " + tableName + " (id, touchcol) VALUES ('r1', 'x2')");
+      conn.commit();
+
+      assertDataTouchColAlive(conn, tableName, "r1", "x2");
+      // Past the TTL boundary covcol reads NULL on both the data table and the index - agreement
+      // AND
+      // the specific expired value: the index was rebuilt without the stale covcol.
+      assertCovColAbsent(conn, tableName, indexName, "r1");
+    }
+  }
+
+  @Test
+  public void testConcurrentMajorCompactionDuringIndexWriteKeepsDataAndIndexConsistent()
+    throws Exception {
+    String tableName = generateUniqueName();
+    String indexName = generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.setAutoCommit(false);
+      conn.createStatement()
+        .execute("CREATE TABLE " + tableName + " (id VARCHAR NOT NULL PRIMARY KEY, "
+          + "idxcol VARCHAR, covcol VARCHAR, touchcol VARCHAR)" + withClause(TTL, true));
+      conn.createStatement()
+        .execute("CREATE INDEX " + indexName + " ON " + tableName + " (idxcol) INCLUDE (covcol)");
+      conn.commit();
+
+      // Freeze the clock at t0; every cell of the full initial row lands at t0.
+      long t0 = EnvironmentEdgeManager.currentTimeMillis();
+      injectEdge.setValue(t0);
+      EnvironmentEdgeManager.injectEdge(injectEdge);
+
+      // Full initial row: covcol is written once here and never again. Flush so covcol@t0 is on an
+      // HFile and the later major compaction has a file to rewrite.
+      conn.createStatement().execute("UPSERT INTO " + tableName
+        + " (id, idxcol, covcol, touchcol) VALUES ('r1', 'k1', '" + COVCOL_VALUE + "', 'x0')");
+      conn.commit();
+      flushTable(conn, tableName);
+      flushTable(conn, indexName);
+
+      // Arm the index-table observer so the NEXT index write (the touch's doPre) parks mid-flight,
+      // then advance to a time still comfortably within the TTL: the touch's current-row read sees
+      // covcol@t0 as alive and rebuilds the index with covcol.
+      CountDownLatch indexWriteReached = new CountDownLatch(1);
+      CountDownLatch indexWriteProceed = new CountDownLatch(1);
+      BlockingIndexWriteObserver.arm(indexName, indexWriteReached, indexWriteProceed);
+      TestUtil.addCoprocessor(conn, indexName, BlockingIndexWriteObserver.class);
+      injectEdge.setValue(t0 + 50_000L);
+
+      // Run the touch on its own thread/connection: it parks inside the index region's
+      // preBatchMutate (i.e. inside doPre) after the data-table read and after the data locks were
+      // released, letting us drive the compaction while it is parked.
+      AtomicReference<Exception> touchError = new AtomicReference<>();
+      Thread toucher = new Thread(() -> {
+        try (Connection tc = DriverManager.getConnection(getUrl())) {
+          tc.setAutoCommit(false);
+          tc.createStatement()
+            .executeUpdate("UPSERT INTO " + tableName + " (id, touchcol) VALUES ('r1', 'x1')");
+          tc.commit();
+        } catch (Exception e) {
+          touchError.set(e);
+        }
+      }, "ttl-sync-toucher");
+      toucher.start();
+
+      // Wait until the touch's index write has parked (read done, doPre not yet complete).
+      assertTrue("index write did not reach the observer in time",
+        indexWriteReached.await(60, TimeUnit.SECONDS));
+
+      // The row is now past the TTL (61s) but not past TTL + max-lookback (70s), so the concurrent
+      // major compaction on the data table must NOT physically collect covcol@t0.
+      injectEdge.setValue(t0 + 61_000L);
+      TestUtil.majorCompact(getUtility(), TableName.valueOf(tableName));
+
+      // Release the parked index write and let the touch finish its data-side persist.
+      indexWriteProceed.countDown();
+      toucher.join(TimeUnit.SECONDS.toMillis(60));
+      assertFalse("toucher thread did not finish", toucher.isAlive());
+      if (touchError.get() != null) {
+        throw touchError.get();
+      }
+
+      // The touch's fresh heartbeat (@t0+50s) keeps the row alive at the current clock (t0+61s, gap
+      // 11s < TTL). covcol@t0 survived the concurrent compaction, so the data table and the index
+      // still agree covcol is present.
+      assertCovColConsistent(conn, tableName, indexName, "r1", "k1", COVCOL_VALUE);
+    }
+  }
+
+  private void runTtlBoundaryScenario(boolean useUpsertSelect) throws Exception {
+    String tableName = generateUniqueName();
+    String indexName = generateUniqueName();
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      conn.setAutoCommit(false);
+      conn.createStatement()
+        .execute("CREATE TABLE " + tableName + " (id VARCHAR NOT NULL PRIMARY KEY, "
+          + "idxcol VARCHAR, covcol VARCHAR, touchcol VARCHAR)" + withClause(TTL, true));
+      conn.createStatement()
+        .execute("CREATE INDEX " + indexName + " ON " + tableName + " (idxcol) INCLUDE (covcol)");
+      conn.commit();
+
+      // Freeze the clock at t0; every cell of the full initial row lands at t0.
+      long t0 = EnvironmentEdgeManager.currentTimeMillis();
+      injectEdge.setValue(t0);
+      EnvironmentEdgeManager.injectEdge(injectEdge);
+
+      conn.createStatement().execute("UPSERT INTO " + tableName
+        + " (id, idxcol, covcol, touchcol) VALUES ('r1', 'k1', '" + COVCOL_VALUE + "', 'x0')");
+      conn.commit();
+
+      // Push the t0 cells onto an HFile so the later major compaction merges two files.
+      flushTable(conn, tableName);
+      flushTable(conn, indexName);
+
+      // Read the partial touch while the row is still alive (one tick before the boundary) but do
+      // NOT commit yet. For the UPSERT SELECT variant the inner SELECT reads the live row here.
+      injectEdge.incrementValue(TTL * 1000L - 1);
+      String expectedTouch;
+      if (useUpsertSelect) {
+        int affected = conn.createStatement().executeUpdate("UPSERT INTO " + tableName
+          + " (id, touchcol) SELECT id, touchcol FROM " + tableName + " WHERE id = 'r1'");
+        assertEquals("UPSERT SELECT must read the still-alive row and buffer one touch", 1,
+          affected);
+        expectedTouch = "x0";
+      } else {
+        conn.createStatement()
+          .executeUpdate("UPSERT INTO " + tableName + " (id, touchcol) VALUES ('r1', 'x1')");
+        expectedTouch = "x1";
+      }
+
+      // Advance two ticks past the boundary and commit: the touch's mutationTimestamp is now
+      // t0 + TTL + 1 (gap > TTL). idxcol@t0 / covcol@t0 are masked at the internal read anchored at
+      // mutationTimestamp, so the index is rebuilt without them - matching the data side.
+      injectEdge.incrementValue(2);
+      conn.commit();
+
+      // Settle each side's physical state; the major compaction merges the t0 HFile with this one.
+      flushAndMajorCompact(conn, tableName);
+      flushAndMajorCompact(conn, indexName);
+
+      // Data path (forced data-table read): the row is alive by PK, covcol read past the boundary.
+      String dataCov;
+      String dataTouch;
+      try (ResultSet rs = conn.createStatement().executeQuery(
+        "SELECT /*+ NO_INDEX */ covcol, touchcol FROM " + tableName + " WHERE id = 'r1'")) {
+        assertTrue("data-table row must still exist (touchcol keeps it alive)", rs.next());
+        dataCov = rs.getString(1);
+        dataTouch = rs.getString(2);
+        assertFalse("exactly one data-table row", rs.next());
+      }
+
+      // Index path (forced index read by PK): idxcol@t0 is masked past the boundary, so the row is
+      // reachable through the covered index only by its PK suffix, not by idxcol='k1'.
+      String indexCov;
+      try (ResultSet rs = conn.createStatement().executeQuery("SELECT /*+ INDEX(" + tableName + " "
+        + indexName + ") */ covcol FROM " + tableName + " WHERE id = 'r1'")) {
+        assertTrue("index-visible row must exist", rs.next());
+        indexCov = rs.getString(1);
+        assertFalse("exactly one index-visible row", rs.next());
+      }
+
+      assertEquals("touchcol must survive (row alive)", expectedTouch, dataTouch);
+      assertEquals("covcol must agree between data and index", dataCov, indexCov);
+    }
+  }
+
+  // ---- helpers ----
+
+  /**
+   * Reads the single {@code counter} value for row {@code r1}, asserting exactly one live row. Used
+   * by {@link #testNoIndexAtomicUpsertMasksExpiredRow} to observe the atomic upsert's result.
+   */
+  private static int atomicCounter(Connection conn, String tableName) throws SQLException {
+    try (ResultSet rs = conn.createStatement()
+      .executeQuery("SELECT counter FROM " + tableName + " WHERE id = 'r1'")) {
+      assertTrue("row should be present", rs.next());
+      int value = rs.getInt(1);
+      assertFalse("expected exactly one row", rs.next());
+      return value;
+    }
+  }
+
+  private void assertCovColConsistent(Connection conn, String queryTableName, String indexName,
+    String id, String idxColValue, String expectedCovVal) throws SQLException {
+    // Force the data-table read.
+    try (ResultSet rs = conn.createStatement().executeQuery(
+      "SELECT /*+ NO_INDEX */ covcol FROM " + queryTableName + " WHERE id = '" + id + "'")) {
+      assertTrue("data-table row should exist", rs.next());
+      assertEquals("data-table covcol", expectedCovVal, rs.getString(1));
+      assertFalse(rs.next());
+    }
+    // Read via the index (idxcol is the leading index column).
+    try (ResultSet rs =
+      conn.createStatement().executeQuery("SELECT /*+ INDEX(" + queryTableName + " " + indexName
+        + ") */ id, covcol FROM " + queryTableName + " WHERE idxcol = '" + idxColValue + "'")) {
+      assertTrue("index-visible row should exist", rs.next());
+      assertEquals("index id", id, rs.getString(1));
+      assertEquals("index covcol", expectedCovVal, rs.getString(2));
+      assertFalse("exactly one index-visible row", rs.next());
+    }
+  }
+
+  private void assertCovColAbsent(Connection conn, String queryTableName, String indexName,
+    String id) throws SQLException {
+    try (ResultSet rs = conn.createStatement().executeQuery(
+      "SELECT /*+ NO_INDEX */ covcol FROM " + queryTableName + " WHERE id = '" + id + "'")) {
+      assertTrue("data-table row should exist", rs.next());
+      assertNull("data-table row should not have covcol", rs.getString(1));
+      assertFalse("exactly one data-table row", rs.next());
+    }
+    try (ResultSet rs = conn.createStatement().executeQuery("SELECT /*+ INDEX(" + queryTableName
+      + " " + indexName + ") */ covcol FROM " + queryTableName + " WHERE id = '" + id + "'")) {
+      assertTrue("index-visible row should exist", rs.next());
+      assertNull("index-visible row should not have covcol", rs.getString(1));
+      assertFalse("exactly one index-visible row", rs.next());
+    }
+  }
+
+  private void assertTouchColConsistent(Connection conn, String queryTableName, String indexName,
+    String id, String expectedTouchVal) throws SQLException {
+    try (ResultSet rs = conn.createStatement().executeQuery(
+      "SELECT /*+ NO_INDEX */ touchcol FROM " + queryTableName + " WHERE id = '" + id + "'")) {
+      assertTrue("data-table row should exist", rs.next());
+      assertEquals("data-table touchcol", expectedTouchVal, rs.getString(1));
+      assertFalse(rs.next());
+    }
+    try (ResultSet rs = conn.createStatement().executeQuery("SELECT /*+ INDEX(" + queryTableName
+      + " " + indexName + ") */ touchcol FROM " + queryTableName + " WHERE id = '" + id + "'")) {
+      assertTrue("index-visible row should exist", rs.next());
+      assertEquals("index touchcol", expectedTouchVal, rs.getString(1));
+      assertFalse("exactly one index-visible row", rs.next());
+    }
+  }
+
+  /**
+   * Data-side-only liveness check for the uncovered-index case, where touchcol cannot be read
+   * through the index (an uncovered index stores no data columns and is reachable only by its
+   * indexed column). Asserts the data row is alive and carries the expected touchcol.
+   */
+  private void assertDataTouchColAlive(Connection conn, String queryTableName, String id,
+    String expectedTouchVal) throws SQLException {
+    try (ResultSet rs = conn.createStatement().executeQuery(
+      "SELECT /*+ NO_INDEX */ touchcol FROM " + queryTableName + " WHERE id = '" + id + "'")) {
+      assertTrue("data-table row should exist", rs.next());
+      assertEquals("data-table touchcol", expectedTouchVal, rs.getString(1));
+      assertFalse(rs.next());
+    }
+  }
+
+  private void assertUncoveredIdxColConsistent(Connection conn, String queryTableName,
+    String indexName, String id, String idxColValue) throws SQLException {
+    try (ResultSet rs =
+      conn.createStatement().executeQuery("SELECT /*+ INDEX(" + queryTableName + " " + indexName
+        + ") */ id, idxcol FROM " + queryTableName + " WHERE idxcol = '" + idxColValue + "'")) {
+      assertTrue("index-visible row should exist", rs.next());
+      assertEquals("index id", id, rs.getString(1));
+      assertEquals("index idxcol", idxColValue, rs.getString(2));
+      assertFalse("exactly one index-visible row", rs.next());
+    }
+  }
+
+  private void assertUncoveredIdxColAbsent(Connection conn, String queryTableName, String indexName,
+    String idxColValue) throws SQLException {
+    try (ResultSet rs =
+      conn.createStatement().executeQuery("SELECT /*+ INDEX(" + queryTableName + " " + indexName
+        + ") */ idxcol FROM " + queryTableName + " WHERE idxcol = '" + idxColValue + "'")) {
+      assertFalse("no index-visible row should exist", rs.next());
+    }
+  }
+
+  static void flushTable(Connection conn, String tableName) throws Exception {
+    try (Admin admin = conn.unwrap(PhoenixConnection.class).getQueryServices().getAdmin()) {
+      admin.flush(TableName.valueOf(tableName));
+    }
+  }
+
+  static void flushAndMajorCompact(Connection conn, String tableName) throws Exception {
+    TableName tn = TableName.valueOf(tableName);
+    try (Admin admin = conn.unwrap(PhoenixConnection.class).getQueryServices().getAdmin()) {
+      admin.flush(tn);
+    }
+    TestUtil.majorCompact(getUtility(), tn);
+  }
+
+  /**
+   * A region observer installed on the index table that parks the first write to a named index in
+   * {@code preBatchMutate}, letting a test run a concurrent major compaction on the data table
+   * while the secondary-index write is in flight. This is the "slow index write" seam: the index
+   * write is driven by {@code IndexRegionObserver.doPre -> table.batch(...)}, which reaches the
+   * index region's {@code preBatchMutate} strictly after the data-table current-row read and
+   * strictly before doPre returns. It is a one-shot per arming so only the touch's write blocks,
+   * not later maintenance.
+   */
+  public static class BlockingIndexWriteObserver extends SimpleRegionObserver {
+    private static volatile String targetIndexName;
+    private static volatile CountDownLatch reached;
+    private static volatile CountDownLatch proceed;
+    private static final AtomicBoolean fired = new AtomicBoolean(false);
+
+    static void arm(String indexName, CountDownLatch reachedLatch, CountDownLatch proceedLatch) {
+      targetIndexName = indexName;
+      reached = reachedLatch;
+      proceed = proceedLatch;
+      fired.set(false);
+    }
+
+    @Override
+    public void preBatchMutate(ObserverContext<RegionCoprocessorEnvironment> c,
+      MiniBatchOperationInProgress<Mutation> miniBatchOp) throws IOException {
+      String tableName = c.getEnvironment().getRegionInfo().getTable().getNameAsString();
+      if (tableName.equals(targetIndexName) && fired.compareAndSet(false, true)) {
+        reached.countDown();
+        try {
+          proceed.await(60, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+          Thread.currentThread().interrupt();
+          throw new IOException("interrupted while parking index write", e);
+        }
+      }
+    }
+  }
+}