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

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


The following commit(s) were added to refs/heads/master by this push:
     new 1112c37baf68 feat(metrics): report record index lookup counters to the 
metrics reporter (#19575)
1112c37baf68 is described below

commit 1112c37baf68fbe48409046ad9b6ca003a846844
Author: Rahil C <[email protected]>
AuthorDate: Fri Aug 28 09:06:38 2026 -0700

    feat(metrics): report record index lookup counters to the metrics reporter 
(#19575)
    
    There was no way to tell how much work a record index lookup did: how many
    keys a commit looked up, what fraction were already in the table, or how 
many
    index file groups were read. Each task computes those numbers and returns
    only the matches, and a miss produces no output row, so the driver cannot
    recover them afterwards. HoodieMetadataMetrics has declared
    lookup_record_index_key_count and lookup_record_index_key_hit_count for a
    long time with nothing referencing them (see HUDI-9544). Part of #19063.
    
    Every Spark write that tags records now reports, through the configured
    metrics reporter and nothing else:
    
      <table>.rli.lookup.lookup_record_index_key_count
      <table>.rli.lookup.lookup_record_index_key_hit_count
      <table>.rli.lookup.lookup_record_index_key_miss_count
      <table>.rli.lookup.lookup_record_index_shards_read
      <table>.rli.lookup.lookup_record_index_time
    
    Counting is per record, not per distinct key, so hits + misses == key_count
    holds exactly. Lookup time is summed across shards (they are read in
    parallel), so it measures per-commit read effort; divide by shards_read for
    a mean. Nothing is written to the timeline.
    
    How it works:
    - Counters are collected on executors into an AccumulatorV2-backed
      DistributedRegistry. The driver resolves the registry
      (RecordIndexLookupMetrics.resolveRegistry) and the lookup closure captures
      it as a field, on both the global and partitioned record index paths;
      nothing resolves a registry by name on the executor.
    - Registries are owned by HoodieSparkEngineContext, keyed by a normalized
      base path (authority + path, so Spark SQL's file:///data/t and the
      DataSource's /data/t resolve to one entry while s3://a/t and s3://b/t stay
      distinct). They are never published into Registry.REGISTRY_MAP; an entry
      exists only between the lookup that created it and the commit that drains
      it, so its presence is the record that this write looked something up.
    - SparkRDDWriteClient.postCommit publishes and releases the registry once
      the commit has landed, covering DataSource, Spark SQL DML and StreamSync
      without a new hook on the base write client. A commit that never lands
      publishes nothing, each commit reports only its own lookups (an abandoned
      attempt's counters are discarded when the next write resolves the
      registry), a commit that collected nothing zeroes the names it previously
      published instead of re-reporting them, and publishing is wrapped in a
      catch-all so a reporting problem cannot fail a completed write.
    - INSERT drop-duplicates dedup now runs on the committing client's engine
      context and config instead of a throwaway HoodieSparkEngineContext
      (DataSourceUtils.resolveDuplicates is removed), so the lookup it performs
      is attributed to the commit rather than stranded.
    - DistributedRegistry ignores executor-side set/release instead of failing
      the job, and failed task attempts are not merged, so a retry does not
      inflate the counts. Duplicate successful attempts under speculation still
      can; exactness is tracked in #19759.
    
    Gated by the new hoodie.metrics.rli.lookup.enable (default false, not
    inferred from hoodie.metrics.on, which it also requires). Off, nothing is
    collected and no accumulator is registered. Spark only; counters aggregate
    per table per JVM. hudi-io and BaseHoodieWriteClient are untouched;
    hudi-client-common gains one config accessor.
    
    Tests: unit coverage for the registry (including under task retry), the
    config gate and RecordIndexLookupMetrics; functional coverage on the Spark
    DataSource, Spark SQL, across a failed commit, and on the DeltaStreamer
    path, all reading the counters off a capturing reporter. The accumulator
    merge itself was certified with spark-submit on local-cluster[2,2,2560]
    (two forked executor JVMs): 10000 / 5000 / 5000 across 10 shards on both of
    two upsert rounds, the second reading 10000 rather than 20000.
---
 .../org/apache/hudi/config/HoodieWriteConfig.java  |   4 +
 .../apache/hudi/client/SparkRDDWriteClient.java    |  18 +-
 .../client/common/HoodieSparkEngineContext.java    |  32 +++
 ...titionedRecordIndexFileGroupLookupFunction.java |  19 ++
 .../SparkMetadataTableGlobalRecordLevelIndex.java  |  25 ++-
 .../index/SparkMetadataTableRecordLevelIndex.java  |   9 +-
 .../apache/hudi/metrics/DistributedRegistry.java   |  43 ++++
 .../hudi/metrics/RecordIndexLookupMetrics.java     | 218 +++++++++++++++++++++
 .../hudi/metrics/TestDistributedRegistry.java      | 102 ++++++++++
 .../TestDistributedRegistryUnderTaskRetry.java     | 148 ++++++++++++++
 .../hudi/metrics/TestRecordIndexLookupMetrics.java | 215 ++++++++++++++++++++
 .../common/config/metrics/HoodieMetricsConfig.java |  24 +++
 .../config/metrics/TestHoodieMetricsConfig.java    |  13 ++
 .../main/java/org/apache/hudi/DataSourceUtils.java |  24 ---
 .../org/apache/hudi/HoodieSparkSqlWriter.scala     |  15 +-
 .../hudi/PartitionedRecordLevelIndexSupport.scala  |   1 +
 .../java/org/apache/hudi/TestDataSourceUtils.java  |   7 +-
 .../hudi/testutils/CapturingMetricsReporter.java   |  79 ++++++++
 .../hudi/functional/RliLookupMetricsTestBase.scala | 136 +++++++++++++
 .../TestRliLookupMetricsAcrossFailedCommit.scala   | 125 ++++++++++++
 .../TestRliLookupMetricsOnDataSource.scala         | 154 +++++++++++++++
 .../TestRliLookupMetricsOnSparkSql.scala           | 138 +++++++++++++
 .../TestRliMetricsOnStreamerPath.java              | 128 ++++++++++++
 .../testutils/CapturingMetricsReporter.java        |  73 +++++++
 24 files changed, 1708 insertions(+), 42 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java
index c12acdbe2964..7bf46d9e9c33 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java
@@ -2614,6 +2614,10 @@ public class HoodieWriteConfig extends HoodieConfig {
     return metricsConfig.isLockingMetricsEnabled();
   }
 
+  public boolean isRecordIndexLookupMetricsEnabled() {
+    return metricsConfig.isRecordIndexLookupMetricsEnabled();
+  }
+
   public MetricsReporterType getMetricsReporterType() {
     return metricsConfig.getMetricsReporterType();
   }
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java
index ab331bdc7160..35c9abc76719 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java
@@ -45,6 +45,7 @@ import org.apache.hudi.metadata.SparkMetadataWriterFactory;
 import org.apache.hudi.metadata.StreamingMetadataWriteHandler;
 import org.apache.hudi.metrics.DistributedRegistryUtil;
 import org.apache.hudi.metrics.HoodieMetrics;
+import org.apache.hudi.metrics.RecordIndexLookupMetrics;
 import org.apache.hudi.table.BulkInsertPartitioner;
 import org.apache.hudi.table.HoodieSparkTable;
 import org.apache.hudi.table.HoodieTable;
@@ -146,8 +147,8 @@ public class SparkRDDWriteClient<T> extends
       // when streaming writes are enabled, writeStatuses is a mix of data 
table write status and mdt write status
       List<HoodieWriteStat> dataTableHoodieWriteStats = 
slimWriteStatsList.stream().filter(entry -> 
!entry.isMetadataTable()).map(SlimWriteStats::getWriteStat).collect(Collectors.toList());
       List<HoodieWriteStat> partialMetadataTableWriteStats = 
slimWriteStatsList.stream().filter(entry -> 
entry.isMetadataTable).map(SlimWriteStats::getWriteStat).collect(Collectors.toList());
-      return commitStats(instantTime, new 
TableWriteStats(dataTableHoodieWriteStats, partialMetadataTableWriteStats), 
extraMetadata, commitActionType, partitionToReplacedFileIds, extraPreCommitFunc,
-          false, Option.of(table));
+      return commitStats(instantTime, new 
TableWriteStats(dataTableHoodieWriteStats, partialMetadataTableWriteStats), 
extraMetadata, commitActionType, partitionToReplacedFileIds,
+          extraPreCommitFunc, false, Option.of(table));
     } else {
       log.error("Exiting early due to errors with write operation ");
       return false;
@@ -169,6 +170,19 @@ public class SparkRDDWriteClient<T> extends
     }
   }
 
+  /**
+   * The commit has landed, so the counters the executors collected for it can 
be reported. A write that
+   * looked nothing up owns no registry and publishes nothing; a commit that 
never lands never gets here.
+   */
+  @Override
+  protected void postCommit(HoodieTable table, HoodieCommitMetadata metadata, 
String instantTime,
+                            String commitActionType, Option<Map<String, 
String>> extraMetadata) {
+    super.postCommit(table, metadata, instantTime, commitActionType, 
extraMetadata);
+    if (config.isRecordIndexLookupMetricsEnabled()) {
+      RecordIndexLookupMetrics.publishAndRelease(context, config, metrics);
+    }
+  }
+
   @Override
   protected HoodieTable createTable(HoodieWriteConfig config) {
     return createTableAndValidate(config, HoodieSparkTable::create);
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java
index 091961bb4ca7..97317b61e490 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java
@@ -288,6 +288,38 @@ public class HoodieSparkEngineContext extends 
HoodieEngineContext {
     });
   }
 
+  /**
+   * Accumulator-backed registries owned by this context rather than by the 
process, keyed by table base
+   * path. Unlike {@link #getMetricRegistry}, these are never published into 
{@code Registry.REGISTRY_MAP}:
+   * the code that collects into them holds the registry by closure capture 
and never resolves it by name,
+   * so a process-wide index buys nothing and costs a shared lifetime.
+   *
+   * <p>Ownership is what makes the counters attributable. The entry exists 
only between the lookup that
+   * created it and the commit that drains it, so its presence is the record 
that this write looked
+   * something up.
+   */
+  private final Map<String, DistributedRegistry> ownedRegistries = new 
ConcurrentHashMap<>();
+
+  /**
+   * The registry a write collects into, created and registered with this 
context's {@code SparkContext}
+   * on first use. Callers drain it with {@link #removeOwnedRegistry}.
+   */
+  public DistributedRegistry getOrCreateOwnedRegistry(String key, String 
registryName) {
+    return ownedRegistries.computeIfAbsent(key, k -> {
+      DistributedRegistry registry = new DistributedRegistry(registryName);
+      registry.register(javaSparkContext);
+      return registry;
+    });
+  }
+
+  /**
+   * Removes and returns the registry for a key, or empty when this context 
never created one. Empty is
+   * how a write that performed no lookup is distinguished from one that did.
+   */
+  public Option<DistributedRegistry> removeOwnedRegistry(String key) {
+    return Option.ofNullable(ownedRegistries.remove(key));
+  }
+
   /**
    * Register the distributed registries on Spark executors.
    * This is called within Spark operations to make the registries available 
on executors.
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/PartitionedRecordIndexFileGroupLookupFunction.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/PartitionedRecordIndexFileGroupLookupFunction.java
index c32dd7706e95..d9096655f962 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/PartitionedRecordIndexFileGroupLookupFunction.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/PartitionedRecordIndexFileGroupLookupFunction.java
@@ -21,10 +21,13 @@ package org.apache.hudi.index;
 
 import org.apache.hudi.common.data.HoodieListData;
 import org.apache.hudi.common.data.HoodiePairData;
+import org.apache.hudi.common.metrics.Registry;
 import org.apache.hudi.common.model.HoodieRecordGlobalLocation;
+import org.apache.hudi.common.util.HoodieTimer;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.metadata.HoodieTableMetadata;
+import org.apache.hudi.metrics.RecordIndexLookupMetrics;
 
 import org.apache.spark.api.java.function.PairFlatMapFunction;
 
@@ -44,9 +47,18 @@ public class PartitionedRecordIndexFileGroupLookupFunction
     implements PairFlatMapFunction<Iterator<Pair<String, String>>, String, 
HoodieRecordGlobalLocation> {
 
   private final HoodieTableMetadata metadataTable;
+  /** Empty when no counters should be collected; see 
RecordIndexLookupMetrics#resolveRegistry. */
+  private final Option<Registry> lookupMetrics;
 
+  /** Uninstrumented, for the query-side read path. */
   public PartitionedRecordIndexFileGroupLookupFunction(HoodieTableMetadata 
metadataTable) {
+    this(metadataTable, Option.empty());
+  }
+
+  public PartitionedRecordIndexFileGroupLookupFunction(HoodieTableMetadata 
metadataTable,
+                                                       Option<Registry> 
lookupMetrics) {
     this.metadataTable = metadataTable;
+    this.lookupMetrics = lookupMetrics;
   }
 
   @Override
@@ -65,11 +77,18 @@ public class PartitionedRecordIndexFileGroupLookupFunction
       return Collections.emptyIterator();
     }
 
+    // Started only when collecting: an unused timer is an allocation per 
shard on the disabled path.
+    HoodieTimer shardTimer = lookupMetrics.isPresent() ? HoodieTimer.start() : 
null;
     HoodiePairData<String, HoodieRecordGlobalLocation> recordIndexData =
         
metadataTable.readRecordIndexLocationsWithKeys(HoodieListData.eager(keysToLookup),
 Option.of(partitionName));
     try {
       Map<String, HoodieRecordGlobalLocation> recordIndexInfo = 
recordIndexData.collectAsList().stream()
           .collect(HashMap::new, (map, pair) -> map.put(pair.getKey(), 
pair.getValue()), HashMap::putAll);
+      // recordIndexInfo is keyed by record key, so its key set is the found 
set with no extra allocation.
+      if (lookupMetrics.isPresent()) {
+        RecordIndexLookupMetrics.recordShardLookup(lookupMetrics.get(), 
keysToLookup,
+            recordIndexInfo.keySet(), shardTimer.endTimer());
+      }
       return recordIndexInfo.entrySet().stream()
           .map(e -> new Tuple2<>(e.getKey(), e.getValue())).iterator();
     } finally {
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableGlobalRecordLevelIndex.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableGlobalRecordLevelIndex.java
index 31150948b54f..88499c69fb4b 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableGlobalRecordLevelIndex.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableGlobalRecordLevelIndex.java
@@ -24,10 +24,13 @@ import org.apache.hudi.common.data.HoodieListData;
 import org.apache.hudi.common.data.HoodiePairData;
 import org.apache.hudi.common.engine.HoodieEngineContext;
 import org.apache.hudi.common.function.SerializableBiFunction;
+import org.apache.hudi.common.metrics.Registry;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieRecordGlobalLocation;
 import org.apache.hudi.common.util.Either;
 import org.apache.hudi.common.util.HoodieDataUtils;
+import org.apache.hudi.common.util.HoodieTimer;
+import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.ValidationUtils;
 import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.config.HoodieIndexConfig;
@@ -38,6 +41,7 @@ import org.apache.hudi.exception.HoodieIndexException;
 import org.apache.hudi.exception.TableNotFoundException;
 import org.apache.hudi.metadata.HoodieIndexVersion;
 import org.apache.hudi.metadata.MetadataPartitionType;
+import org.apache.hudi.metrics.RecordIndexLookupMetrics;
 import org.apache.hudi.table.HoodieTable;
 
 import lombok.extern.slf4j.Slf4j;
@@ -49,6 +53,7 @@ import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 import scala.Tuple2;
 
@@ -128,9 +133,10 @@ public class SparkMetadataTableGlobalRecordLevelIndex 
extends HoodieIndex<Object
     // keyToLocationPairRDD and records RDD.
     ValidationUtils.checkState(partitionedKeyRDD.getNumPartitions() <= 
numFileGroups);
 
-    // Lookup the keys in the record index
-
-    return HoodieJavaPairRDD.of(partitionedKeyRDD.mapPartitionsToPair(new 
RecordIndexFileGroupLookupFunction(hoodieTable)));
+    // Resolved on the driver so the closure carries it to executors.
+    Option<Registry> lookupMetrics = 
RecordIndexLookupMetrics.resolveRegistry(context, hoodieTable.getConfig());
+    return HoodieJavaPairRDD.of(partitionedKeyRDD.mapPartitionsToPair(
+        new RecordIndexFileGroupLookupFunction(hoodieTable, lookupMetrics)));
   }
 
   protected Either<Integer, Map<String, Integer>> 
fetchFileGroupSize(HoodieTable hoodieTable) {
@@ -181,9 +187,12 @@ public class SparkMetadataTableGlobalRecordLevelIndex 
extends HoodieIndex<Object
    */
   private static class RecordIndexFileGroupLookupFunction implements 
PairFlatMapFunction<Iterator<String>, String, HoodieRecordGlobalLocation> {
     private final HoodieTable hoodieTable;
+    /** Empty when no counters should be collected; see 
RecordIndexLookupMetrics#resolveRegistry. */
+    private final Option<Registry> lookupMetrics;
 
-    public RecordIndexFileGroupLookupFunction(HoodieTable hoodieTable) {
+    public RecordIndexFileGroupLookupFunction(HoodieTable hoodieTable, 
Option<Registry> lookupMetrics) {
       this.hoodieTable = hoodieTable;
+      this.lookupMetrics = lookupMetrics;
     }
 
     @Override
@@ -191,11 +200,19 @@ public class SparkMetadataTableGlobalRecordLevelIndex 
extends HoodieIndex<Object
       List<String> keysToLookup = new ArrayList<>();
       recordKeyIterator.forEachRemaining(keysToLookup::add);
 
+      // Started only when collecting: an unused timer is an allocation per 
shard on the disabled path.
+      HoodieTimer shardTimer = lookupMetrics.isPresent() ? HoodieTimer.start() 
: null;
       // recordIndexInfo object only contains records that are present in 
record_index.
       HoodiePairData<String, HoodieRecordGlobalLocation> recordIndexData =
           
hoodieTable.getTableMetadata().readRecordIndexLocationsWithKeys(HoodieListData.eager(keysToLookup));
       try {
         List<Pair<String, HoodieRecordGlobalLocation>> recordIndexInfo = 
HoodieDataUtils.dedupeAndCollectAsList(recordIndexData);
+        // Guarded rather than checked inside the helper: the found set is 
O(hits) and Java evaluates it
+        // as an argument first, so an unguarded call would cost every shard 
that on the disabled path.
+        if (lookupMetrics.isPresent()) {
+          RecordIndexLookupMetrics.recordShardLookup(lookupMetrics.get(), 
keysToLookup,
+              
recordIndexInfo.stream().map(Pair::getKey).collect(Collectors.toSet()), 
shardTimer.endTimer());
+        }
         return recordIndexInfo.stream()
             .map(e -> new Tuple2<>(e.getKey(), e.getValue())).iterator();
       } finally {
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableRecordLevelIndex.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableRecordLevelIndex.java
index 6a12dc3c67a5..d98c724ea96f 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableRecordLevelIndex.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkMetadataTableRecordLevelIndex.java
@@ -22,10 +22,12 @@ package org.apache.hudi.index;
 import org.apache.hudi.common.data.HoodieData;
 import org.apache.hudi.common.data.HoodiePairData;
 import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.metrics.Registry;
 import org.apache.hudi.common.model.FileSlice;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieRecordGlobalLocation;
 import org.apache.hudi.common.util.Either;
+import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.ValidationUtils;
 import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.config.HoodieWriteConfig;
@@ -34,6 +36,7 @@ import org.apache.hudi.data.HoodieJavaRDD;
 import org.apache.hudi.metadata.BucketizedMetadataTableFileGroupIndexParser;
 import org.apache.hudi.metadata.HoodieTableMetadataUtil;
 import org.apache.hudi.metadata.MetadataPartitionType;
+import org.apache.hudi.metrics.RecordIndexLookupMetrics;
 import org.apache.hudi.table.HoodieTable;
 
 import org.apache.spark.api.java.JavaRDD;
@@ -77,8 +80,10 @@ public class SparkMetadataTableRecordLevelIndex extends 
SparkMetadataTableGlobal
         .partitionBy(new PartitionIdPassthrough(numFileGroups))
         .map(t -> t._2);
     ValidationUtils.checkState(partitionedKeyRDD.getNumPartitions() <= 
numFileGroups);
-    // Lookup the keys in the record index
-    return HoodieJavaPairRDD.of(partitionedKeyRDD.mapPartitionsToPair(new 
PartitionedRecordIndexFileGroupLookupFunction(hoodieTable.getTableMetadata())));
+    // Lookup the keys in the record index. Resolved on the driver so the 
closure carries it to executors.
+    Option<Registry> lookupMetrics = 
RecordIndexLookupMetrics.resolveRegistry(context, hoodieTable.getConfig());
+    return HoodieJavaPairRDD.of(partitionedKeyRDD.mapPartitionsToPair(
+        new 
PartitionedRecordIndexFileGroupLookupFunction(hoodieTable.getTableMetadata(), 
lookupMetrics)));
   }
 
   @Override
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/DistributedRegistry.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/DistributedRegistry.java
index 4da400d8869a..c8b9fdde6458 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/DistributedRegistry.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/DistributedRegistry.java
@@ -20,8 +20,11 @@ package org.apache.hudi.metrics;
 
 import org.apache.hudi.common.metrics.Registry;
 
+import org.apache.spark.TaskContext;
 import org.apache.spark.api.java.JavaSparkContext;
 import org.apache.spark.util.AccumulatorV2;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.Serializable;
 import java.util.HashMap;
@@ -33,8 +36,13 @@ import java.util.concurrent.ConcurrentHashMap;
  */
 public class DistributedRegistry extends AccumulatorV2<Map<String, Long>, 
Map<String, Long>>
     implements Registry, Serializable {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(DistributedRegistry.class);
+
   private final String name;
   ConcurrentHashMap<String, Long> counters = new ConcurrentHashMap<>();
+  /** Driver-only, to detect a SparkContext restart in the same JVM (shells, 
notebooks, Spark Connect). */
+  private transient String registeredAppId;
 
   public DistributedRegistry(String name) {
     this.name = name;
@@ -48,9 +56,17 @@ public class DistributedRegistry extends 
AccumulatorV2<Map<String, Long>, Map<St
   public void register(JavaSparkContext jsc) {
     if (!isRegistered()) {
       jsc.sc().register(this);
+      // Only when this call actually registers: stamping unconditionally 
would re-brand an accumulator
+      // bound to a dead context and mask the staleness this field exists to 
detect.
+      this.registeredAppId = jsc.sc().applicationId();
     }
   }
 
+  /** False when bound to a different (typically stopped) context, meaning it 
must be recreated. */
+  public boolean isRegisteredWith(JavaSparkContext jsc) {
+    return isRegistered() && jsc.sc().applicationId().equals(registeredAppId);
+  }
+
   @Override
   public void clear() {
     counters.clear();
@@ -68,9 +84,36 @@ public class DistributedRegistry extends 
AccumulatorV2<Map<String, Long>, Map<St
 
   @Override
   public void set(String name, long value) {
+    // Last-writer-wins is neither commutative nor associative, and the driver 
merges executor copies in
+    // an unspecified order. Driver only; executors use increment()/add().
+    if (TaskContext.get() != null) {
+      // Warn rather than throw: this runs inside a task, and a metrics 
problem must not fail a write.
+      LOG.warn("DistributedRegistry.set() called from a Spark executor and 
ignored: it is non-commutative "
+          + "under accumulator merges and would produce non-deterministic 
values. Use increment()/add().");
+      return;
+    }
     counters.merge(name,  value, (oldValue, newValue) -> newValue);
   }
 
+  /**
+   * Subtracts rather than clearing, so a concurrent merge either lands before 
the subtraction and is
+   * released with it, or after and survives. Clamped at zero because {@code 
Metrics.shutdown()} can empty
+   * the registry underneath a release; counters reaching zero are removed so 
a table that performed no
+   * lookup is distinguishable from one that missed everything.
+   */
+  public void release(Map<String, Long> counts) {
+    // Driver-only for the same reason as set(): clamping and eviction are 
order-dependent under merges.
+    if (TaskContext.get() != null) {
+      LOG.warn("DistributedRegistry.release() called from a Spark executor and 
ignored: clamping and eviction "
+          + "are order-dependent under accumulator merges. Release at the 
commit boundary on the driver.");
+      return;
+    }
+    counts.forEach((name, released) -> counters.compute(name, (key, current) 
-> {
+      long remaining = (current == null ? 0L : current) - released;
+      return remaining > 0L ? remaining : null;
+    }));
+  }
+
   /**
    * Get all Counter type metrics.
    */
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/RecordIndexLookupMetrics.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/RecordIndexLookupMetrics.java
new file mode 100644
index 000000000000..e901c51a2554
--- /dev/null
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/RecordIndexLookupMetrics.java
@@ -0,0 +1,218 @@
+/*
+ * 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.hudi.metrics;
+
+import org.apache.hudi.client.common.HoodieSparkEngineContext;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.metrics.Registry;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.storage.StoragePath;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * The record index lookup counters, end to end: what they are called, where 
they are collected, and how
+ * they reach the reporter once a commit lands.
+ *
+ * <p>The numbers are only knowable on executors, because a lookup returns its 
hits and a miss produces
+ * no output row at all. So the driver resolves an accumulator-backed {@link 
DistributedRegistry}, the
+ * lookup closure captures it, tasks add into their own copy, and Spark merges 
those copies home.
+ */
+public class RecordIndexLookupMetrics {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(RecordIndexLookupMetrics.class);
+
+  /** The registry name, as reported. Keying is done by {@link #registryKey}. 
*/
+  public static final String REGISTRY_NAME = "HoodieRecordIndexLookup";
+
+  /** Reporter naming, as passed to {@code HoodieMetrics.getMetricsName}. */
+  public static final String METRIC_ACTION = "rli";
+  public static final String METRIC_QUALIFIER = "lookup";
+
+  /** Counts records, not distinct keys: a batch repeating a key contributes 
once per record, which is
+   * what keeps {@code hits + misses == key_count} exact. */
+  public static final String KEY_COUNT = "lookup_record_index_key_count";
+  public static final String KEY_HIT_COUNT = 
"lookup_record_index_key_hit_count";
+  public static final String KEY_MISS_COUNT = 
"lookup_record_index_key_miss_count";
+  public static final String SHARDS_READ = "lookup_record_index_shards_read";
+  /**
+   * Wall-clock spent in the shard read, summed across shards rather than 
averaged because shards are read
+   * in parallel: the value is per-commit read effort, and dividing by {@link 
#SHARDS_READ} gives a mean.
+   *
+   * <p>Distinct from {@code index.lookup.duration} published by {@code 
HoodieMetrics.updateIndexMetrics},
+   * which is driver wall-clock for the whole {@code tagLocation} including 
scheduling. Comparing the two
+   * shows how much of a lookup was actually spent reading the index.
+   */
+  public static final String LOOKUP_TIME = "lookup_record_index_time";
+
+  private RecordIndexLookupMetrics() {
+  }
+
+  /**
+   * The registry a lookup task collects into, or null when nothing should be 
collected. Captured in the
+   * lookup closure and passed to {@link #recordShardLookup}, so delivery is 
by closure capture rather
+   * than by name.
+   *
+   * <p>Requires the reporter to be on as well: with {@code hoodie.metrics.on} 
off there is nowhere to
+   * publish, and collecting would register an accumulator and scan every 
shard for nothing.
+   */
+  public static Option<Registry> resolveRegistry(HoodieEngineContext context, 
HoodieWriteConfig config) {
+    if (!config.isMetricsOn() || !config.isRecordIndexLookupMetricsEnabled()
+        || !(context instanceof HoodieSparkEngineContext)) {
+      return Option.empty();
+    }
+    DistributedRegistry registry = ((HoodieSparkEngineContext) context)
+        .getOrCreateOwnedRegistry(registryKey(config.getBasePath()), 
REGISTRY_NAME);
+    // Anything held predates this write: the drain removes the entry as it 
publishes, so a surviving
+    // one belongs to an attempt that never committed.
+    registry.clear();
+    return Option.of(registry);
+  }
+
+  /**
+   * Records one shard's lookup outcome. Counts records rather than distinct 
keys, so
+   * {@code hits + misses == records_looked_up} holds when a batch repeats a 
key. Membership is tested
+   * against the found set, bounded by the hit count, not the asked-about set, 
bounded by shard size.
+   *
+   * <p>Callers must skip the call entirely when the registry is null. 
Building the found set is
+   * O(hits), and an argument is evaluated before this method can take a null 
fast path, so guarding
+   * here would not keep that cost off the disabled path.
+   *
+   * @param registry     where to collect, never null
+   * @param keysLookedUp every record key routed to this shard
+   * @param foundKeys    the subset present in the index
+   * @param elapsedMs    wall-clock spent reading this shard
+   */
+  public static void recordShardLookup(Registry registry, Collection<String> 
keysLookedUp,
+                                       Collection<String> foundKeys, long 
elapsedMs) {
+    if (keysLookedUp.isEmpty()) {
+      return;
+    }
+    Set<String> found = foundKeys instanceof Set ? (Set<String>) foundKeys : 
new HashSet<>(foundKeys);
+    long records = keysLookedUp.size();
+    long hits = found.isEmpty() ? 0L : 
keysLookedUp.stream().filter(found::contains).count();
+    registry.add(KEY_COUNT, records);
+    registry.add(KEY_HIT_COUNT, hits);
+    registry.add(KEY_MISS_COUNT, records - hits);
+    registry.increment(SHARDS_READ);
+    registry.add(LOOKUP_TIME, elapsedMs);
+  }
+
+  /**
+   * Reports the counters and releases them, so the next commit reports only 
its own work. Called once
+   * the commit has landed, so a commit that never lands publishes nothing.
+   *
+   * <p>Release subtracts what was reported rather than clearing, so a 
straggler task whose update lands
+   * mid-publish carries into the next commit instead of being dropped. An 
all-zero registry is skipped.
+   */
+  public static void publishAndRelease(HoodieEngineContext context, 
HoodieWriteConfig config,
+                                       HoodieMetrics hoodieMetrics) {
+    try {
+      publish(context, config, hoodieMetrics);
+    } catch (Exception e) {
+      // This runs after the commit has landed. Reporting is not worth failing 
a completed write over.
+      LOG.warn("Failed to publish record index lookup metrics; the commit is 
unaffected.", e);
+    }
+  }
+
+  private static void publish(HoodieEngineContext context, HoodieWriteConfig 
config,
+                              HoodieMetrics hoodieMetrics) {
+    if (!config.isRecordIndexLookupMetricsEnabled()) {
+      // Callers gate before reaching here, so this is a wiring mistake rather 
than normal flow.
+      LOG.warn("Record index lookup metrics drain reached with the feature 
disabled; nothing published.");
+      return;
+    }
+    if (!(context instanceof HoodieSparkEngineContext)) {
+      return;
+    }
+    // Removing is what makes attribution work. An entry exists only because a 
lookup on this write
+    // created it, so its absence means this write looked nothing up and has 
nothing of its own to
+    // report -- which is the case an operation type cannot tell apart, since 
an insert that drops
+    // duplicates tags through SparkRDDReadClient without requiring tagging.
+    Option<DistributedRegistry> taken =
+        ((HoodieSparkEngineContext) 
context).removeOwnedRegistry(registryKey(config.getBasePath()));
+    if (!taken.isPresent()) {
+      zeroPreviouslyReported(hoodieMetrics);
+      return;
+    }
+    DistributedRegistry registry = taken.get();
+    Map<String, Long> counts = new HashMap<>(registry.getAllCounts(false));
+    if (counts.values().stream().allMatch(value -> value == 0L)) {
+      // Gauges hold their last value until overwritten, so leaving them alone 
would have a reporter
+      // re-emit the previous commit's numbers for this one. Zero them instead.
+      zeroPreviouslyReported(hoodieMetrics);
+      return;
+    }
+    publishToReporter(counts, hoodieMetrics);
+    registry.release(counts);
+  }
+
+  /**
+   * Resets the gauges to zero, for a commit that collected nothing. Only 
names already published are
+   * touched, so a table that has never emitted stays absent rather than 
reporting a row of zeros.
+   */
+  private static void zeroPreviouslyReported(HoodieMetrics hoodieMetrics) {
+    if (hoodieMetrics == null || hoodieMetrics.getMetrics() == null) {
+      return;
+    }
+    String prefix = hoodieMetrics.getMetricsName(METRIC_ACTION, 
METRIC_QUALIFIER);
+    if (prefix == null) {
+      return;
+    }
+    Map<String, Long> zeroed = new HashMap<>();
+    hoodieMetrics.getMetrics().getRegistry().getGauges().keySet().stream()
+        .filter(name -> name.startsWith(prefix + "."))
+        .forEach(name -> zeroed.put(name.substring(prefix.length() + 1), 0L));
+    if (!zeroed.isEmpty()) {
+      publishToReporter(zeroed, hoodieMetrics);
+    }
+  }
+
+  /** Gauges, so each commit overwrites the previous value rather than 
accumulating. */
+  private static void publishToReporter(Map<String, Long> counts, 
HoodieMetrics hoodieMetrics) {
+    if (hoodieMetrics == null || hoodieMetrics.getMetrics() == null) {
+      return;
+    }
+    String prefix = hoodieMetrics.getMetricsName(METRIC_ACTION, 
METRIC_QUALIFIER);
+    hoodieMetrics.getMetrics().registerGauges(counts, 
Option.ofNullable(prefix));
+  }
+
+  /**
+   * Key for this table's registry inside the owning context. The base path is 
normalized rather than used
+   * raw because one table is spelled more than one way: Spark SQL builds its 
config from the catalog
+   * location ({@code file:///data/t}) while the DataSource passes the bare 
path ({@code /data/t}), and
+   * the two must resolve to one entry. The authority is kept so {@code 
s3://a/t} and {@code s3://b/t}
+   * stay distinct.
+   *
+   * <p>No digest: this key never leaves the context and never becomes part of 
a metric name.
+   */
+  static String registryKey(String basePath) {
+    StoragePath path = new StoragePath(basePath);
+    String authority = path.toUri().getAuthority();
+    return (authority == null ? "" : authority) + 
path.getPathWithoutSchemeAndAuthority();
+  }
+}
diff --git 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistry.java
 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistry.java
index c64e11a27059..6058eeef3522 100644
--- 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistry.java
+++ 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistry.java
@@ -29,6 +29,7 @@ import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
@@ -184,6 +185,107 @@ public class TestDistributedRegistry {
     Assertions.assertEquals(finalExpectedSum, metricCounts.get(METRIC_1));
   }
 
+  @Test
+  public void testSetOnExecutorIsIgnoredWithoutFailingTheJob() {
+    // Given: a registry registered to the spark context
+    String registryName = REGISTRY_NAME + "_testSetOnExecutor";
+    Registry registry = engineContext.getMetricRegistry("", registryName);
+
+    List<Integer> data = new ArrayList<>();
+    data.add(1);
+
+    // When: set() is invoked from an executor. It is non-commutative under 
accumulator merges, so the
+    // value must not be recorded -- but the guard runs inside a task, where 
failing would take the write
+    // down with it, so it is ignored rather than thrown.
+    engineContext.map(data, value -> {
+      registry.set(METRIC_1, value);
+      return null;
+    }, 1);
+
+    // Then: the job succeeded and nothing was recorded.
+    Assertions.assertFalse(registry.getAllCounts().containsKey(METRIC_1),
+        "set() from an executor must not record a value: " + 
registry.getAllCounts());
+  }
+
+  @Test
+  public void testReleaseOnExecutorIsIgnoredWithoutFailingTheJob() {
+    // Given: a registry holding a known count
+    String registryName = REGISTRY_NAME + "_testReleaseOnExecutor";
+    DistributedRegistry registry = (DistributedRegistry) 
engineContext.getMetricRegistry("", registryName);
+    registry.add(METRIC_1, 5L);
+
+    List<Integer> data = new ArrayList<>();
+    data.add(1);
+
+    // When: release() is invoked from an executor. Clamping and eviction are 
order-dependent under
+    // accumulator merges, and this runs after the commit has landed, so it is 
ignored rather than thrown.
+    engineContext.map(data, value -> {
+      registry.release(Collections.singletonMap(METRIC_1, (long) value));
+      return null;
+    }, 1);
+
+    // Then: the job succeeded and the count was left alone.
+    Assertions.assertEquals(5L, registry.getAllCounts().get(METRIC_1),
+        "release() from an executor must not mutate the counters");
+  }
+
+  @Test
+  public void testSetOnDriverSucceeds() {
+    // set() on the driver (no TaskContext) remains supported.
+    DistributedRegistry registry = new DistributedRegistry(REGISTRY_NAME + 
"_testSetOnDriver");
+    registry.set(METRIC_1, 42);
+    Assertions.assertEquals(42, registry.getAllCounts().get(METRIC_1));
+  }
+
+  @Test
+  public void testReleaseEvictsCountersThatReachZero() {
+    // Given: a registry drained of exactly what it holds, as the 
commit-boundary drain does
+    DistributedRegistry registry = new DistributedRegistry(REGISTRY_NAME + 
"_testRelease");
+    registry.add(METRIC_1, 10);
+    registry.add(METRIC_2, 20);
+
+    // When: the full contents are released
+    registry.release(registry.getAllCounts(false));
+
+    // Then: the counters are gone, not left sitting at zero. A zero-valued 
entry survives
+    // ConcurrentHashMap.merge() and would be republished by every later drain.
+    Assertions.assertTrue(registry.getAllCounts().isEmpty(),
+        "released counters must be evicted, found " + registry.getAllCounts());
+    Assertions.assertTrue(registry.isZero());
+  }
+
+  @Test
+  public void testReleaseKeepsWhatArrivedAfterTheDrain() {
+    // Given: a snapshot taken, and a straggler update folded in before the 
release lands
+    DistributedRegistry registry = new DistributedRegistry(REGISTRY_NAME + 
"_testReleaseStraggler");
+    registry.add(METRIC_1, 10);
+    Map<String, Long> drained = registry.getAllCounts(false);
+    registry.add(METRIC_1, 4);
+
+    // When: the drained counts are released
+    registry.release(drained);
+
+    // Then: only the drained amount is subtracted - the straggler belongs to 
the next drain
+    Assertions.assertEquals(4L, registry.getAllCounts().get(METRIC_1));
+  }
+
+  @Test
+  public void testReleaseClampsAtZero() {
+    // Given: a registry emptied between the drain and the release. 
Metrics.shutdown() scrapes every
+    // registry in the process with flush=true, so an unrelated table's write 
finishing does exactly this.
+    DistributedRegistry registry = new DistributedRegistry(REGISTRY_NAME + 
"_testReleaseClamp");
+    registry.add(METRIC_1, 10);
+    Map<String, Long> drained = registry.getAllCounts(false);
+    registry.clear();
+
+    // When: the release lands on a registry that no longer holds those counts
+    registry.release(drained);
+
+    // Then: nothing negative is left behind - a negative counter would be 
stamped into commit metadata
+    Assertions.assertTrue(registry.getAllCounts().isEmpty(),
+        "an unbounded subtraction would have left " + METRIC_1 + " negative, 
got " + registry.getAllCounts());
+  }
+
   @Test
   public void testClear() {
     // Given: distributed registry with some metrics
diff --git 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistryUnderTaskRetry.java
 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistryUnderTaskRetry.java
new file mode 100644
index 000000000000..895c07bdeaae
--- /dev/null
+++ 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistryUnderTaskRetry.java
@@ -0,0 +1,148 @@
+/*
+ * 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.hudi.metrics;
+
+import org.apache.hudi.client.common.HoodieSparkEngineContext;
+import org.apache.hudi.common.metrics.Registry;
+
+import org.apache.spark.SparkConf;
+import org.apache.spark.TaskContext;
+import org.apache.spark.api.java.JavaSparkContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/** What task retries and stage recomputation do to counters collected in a 
{@link DistributedRegistry}. */
+public class TestDistributedRegistryUnderTaskRetry {
+
+  private static final String COUNTER = "keys";
+
+  private JavaSparkContext jsc;
+
+  @AfterEach
+  public void tearDown() {
+    if (jsc != null) {
+      jsc.stop();
+      jsc = null;
+    }
+  }
+
+  /** {@code local[n, maxFailures]} -- the second number is what makes Spark 
re-attempt a failed task. */
+  private JavaSparkContext retryingContext(String appName) {
+    SparkConf conf = new SparkConf()
+        .setAppName(appName)
+        .setMaster("local[2, 4]")
+        .set("spark.ui.enabled", "false")
+        .set("spark.sql.shuffle.partitions", "4");
+    return new JavaSparkContext(conf);
+  }
+
+  private static List<Integer> partitionedInput(int numPartitions, int 
perPartition) {
+    List<Integer> data = new ArrayList<>();
+    for (int i = 0; i < numPartitions * perPartition; i++) {
+      data.add(i);
+    }
+    return data;
+  }
+
+  /**
+   * One task fails its first attempt and succeeds on the retry. The count 
must reflect one pass over
+   * the data, not two -- Spark discards the failed attempt's accumulator 
updates.
+   */
+  @Test
+  public void testFailedAttemptDoesNotInflateCounts() {
+    jsc = retryingContext("rli-retry-failed-attempt");
+    HoodieSparkEngineContext context = new HoodieSparkEngineContext(jsc);
+    Registry registry = context.getOrCreateOwnedRegistry("retryRegistry", 
"retryRegistry");
+
+    int numPartitions = 4;
+    int perPartition = 25;
+
+    long collected = jsc.parallelize(partitionedInput(numPartitions, 
perPartition), numPartitions)
+        .mapPartitions(it -> {
+          List<Integer> batch = new ArrayList<>();
+          it.forEachRemaining(batch::add);
+          registry.add(COUNTER, batch.size());
+          // Partition 0 blows up the first time it is tried. maxFailures=4 
lets the retry through.
+          if (TaskContext.getPartitionId() == 0 && 
TaskContext.get().attemptNumber() == 0) {
+            throw new RuntimeException("induced failure on first attempt of 
partition 0");
+          }
+          return Collections.singletonList((long) batch.size()).iterator();
+        })
+        .reduce(Long::sum);
+
+    long counted = registry.getAllCounts(false).get(COUNTER);
+    long expected = (long) numPartitions * perPartition;
+
+    System.out.println("\n===== failed attempt =====");
+    System.out.println("  rows actually processed  " + collected);
+    System.out.println("  registry counted         " + counted);
+    System.out.println("  expected                 " + expected);
+    System.out.println("==========================\n");
+
+    assertEquals(expected, collected, "sanity: the retry must have succeeded 
and produced every row");
+    assertEquals(expected, counted,
+        "a failed attempt must contribute nothing: Spark ships accumulator 
updates home only from "
+            + "attempts that succeed, which is what makes plain task retries 
safe for these counters");
+  }
+
+  /**
+   * The other half, asserted so the caveat is grounded rather than asserted 
in prose: evaluating the same uncached transformation twice counts twice.
+   */
+  @Test
+  public void testRepeatedEvaluationDoubleCounts() {
+    jsc = retryingContext("rli-retry-recompute");
+    HoodieSparkEngineContext context = new HoodieSparkEngineContext(jsc);
+    Registry registry = context.getOrCreateOwnedRegistry("recomputeRegistry", 
"recomputeRegistry");
+
+    int numPartitions = 4;
+    int perPartition = 25;
+    long onePass = (long) numPartitions * perPartition;
+
+    org.apache.spark.api.java.JavaRDD<Long> counted =
+        jsc.parallelize(partitionedInput(numPartitions, perPartition), 
numPartitions)
+            .mapPartitions(it -> {
+              List<Integer> batch = new ArrayList<>();
+              it.forEachRemaining(batch::add);
+              registry.add(COUNTER, batch.size());
+              return Collections.singletonList((long) batch.size()).iterator();
+            });
+
+    // Two actions over an uncached RDD: the transformation runs twice.
+    counted.reduce(Long::sum);
+    counted.reduce(Long::sum);
+
+    long total = registry.getAllCounts(false).get(COUNTER);
+
+    System.out.println("\n===== repeated evaluation =====");
+    System.out.println("  one pass would be        " + onePass);
+    System.out.println("  registry counted         " + total);
+    System.out.println("===============================\n");
+
+    assertEquals(onePass * 2, total,
+        "counters incremented inside a transformation are at-least-once: a 
second evaluation of the "
+            + "same uncached RDD counts again. Documented behaviour, pinned 
here so a change in either "
+            + "direction is visible");
+  }
+}
diff --git 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestRecordIndexLookupMetrics.java
 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestRecordIndexLookupMetrics.java
new file mode 100644
index 000000000000..35214f4aef48
--- /dev/null
+++ 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestRecordIndexLookupMetrics.java
@@ -0,0 +1,215 @@
+/*
+ * 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.hudi.metrics;
+
+import org.apache.hudi.client.common.HoodieSparkEngineContext;
+import org.apache.hudi.common.config.metrics.HoodieMetricsConfig;
+import org.apache.hudi.common.metrics.Registry;
+import org.apache.hudi.common.testutils.HoodieTestUtils;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.testutils.HoodieClientTestUtils;
+
+import org.apache.spark.api.java.JavaSparkContext;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.apache.hudi.metrics.RecordIndexLookupMetrics.KEY_COUNT;
+import static org.apache.hudi.metrics.RecordIndexLookupMetrics.KEY_HIT_COUNT;
+import static org.apache.hudi.metrics.RecordIndexLookupMetrics.KEY_MISS_COUNT;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Drain semantics for the record index lookup counters, at the level where 
they are decided. */
+public class TestRecordIndexLookupMetrics {
+
+  private static final String TABLE = "drain_test_table";
+  private static final String BASE_PATH = "file:/tmp/drain_test_table";
+  private static final String OTHER_BASE_PATH = "file:/tmp/somewhere_else";
+
+  private static final String LOOKED_UP_KEY = KEY_COUNT;
+
+  private HoodieMetrics hoodieMetrics;
+  private static JavaSparkContext jsc;
+  private static HoodieSparkEngineContext context;
+
+  @BeforeAll
+  static void startContext() {
+    jsc = new 
JavaSparkContext(HoodieClientTestUtils.getSparkConfForTest("drain-test"));
+    context = new HoodieSparkEngineContext(jsc);
+  }
+
+  @AfterAll
+  static void stopContext() {
+    if (jsc != null) {
+      jsc.stop();
+      jsc = null;
+    }
+  }
+
+  @AfterEach
+  void clearOwnedRegistries() {
+    // Registries live on the context now, so draining what a test left is all 
the cleanup there is.
+    
context.removeOwnedRegistry(RecordIndexLookupMetrics.registryKey(BASE_PATH));
+    
context.removeOwnedRegistry(RecordIndexLookupMetrics.registryKey(OTHER_BASE_PATH));
+    if (hoodieMetrics != null && hoodieMetrics.getMetrics() != null) {
+      hoodieMetrics.getMetrics().shutdown();
+      hoodieMetrics = null;
+    }
+  }
+
+  private static HoodieWriteConfig config(String basePath, boolean gateOn) {
+    return HoodieWriteConfig.newBuilder()
+        .withPath(basePath)
+        .forTable(TABLE)
+        .withMetricsConfig(HoodieMetricsConfig.newBuilder()
+            .on(true)
+            .withReporterType(MetricsReporterType.INMEMORY.name())
+            .withRecordIndexLookupMetrics(gateOn)
+            .build())
+        .build();
+  }
+
+  /** The reporter this drain publishes into, for the table under test. */
+  private HoodieMetrics metricsFor(HoodieWriteConfig config) {
+    hoodieMetrics = new HoodieMetrics(config, 
HoodieTestUtils.getDefaultStorage());
+    return hoodieMetrics;
+  }
+
+  /** Creates the entry the way a lookup does, then fills it as the executors 
would. */
+  private static Registry seedRegistry(HoodieWriteConfig config, long 
lookedUp, long hits, long misses) {
+    Registry registry = context.getOrCreateOwnedRegistry(
+        RecordIndexLookupMetrics.registryKey(config.getBasePath()), 
RecordIndexLookupMetrics.REGISTRY_NAME);
+    registry.add(LOOKED_UP_KEY, lookedUp);
+    registry.add(KEY_HIT_COUNT, hits);
+    registry.add(KEY_MISS_COUNT, misses);
+    return registry;
+  }
+
+  /** Gauge name the reporter publishes a counter under. */
+  private String gaugeName(HoodieMetrics metrics, String metric) {
+    return metrics.getMetricsName(
+        RecordIndexLookupMetrics.METRIC_ACTION,
+        RecordIndexLookupMetrics.METRIC_QUALIFIER)
+        + "." + metric;
+  }
+
+  private Long gauge(HoodieMetrics metrics, String metric) {
+    Map<String, com.codahale.metrics.Gauge> gauges = 
metrics.getMetrics().getRegistry().getGauges();
+    com.codahale.metrics.Gauge<?> g = gauges.get(gaugeName(metrics, metric));
+    return g == null ? null : (Long) g.getValue();
+  }
+
+  @Test
+  void publishReportsWhatTheRegistryHolds() {
+    HoodieWriteConfig config = config(BASE_PATH, true);
+    seedRegistry(config, 10L, 7L, 3L);
+    HoodieMetrics metrics = metricsFor(config);
+
+    RecordIndexLookupMetrics.publishAndRelease(context, config, metrics);
+
+    assertEquals(10L, gauge(metrics, KEY_COUNT));
+    assertEquals(7L, gauge(metrics, KEY_HIT_COUNT));
+    assertEquals(3L, gauge(metrics, KEY_MISS_COUNT));
+  }
+
+  /**
+   * A released counter must disappear rather than sit at zero, or a table 
that performed no lookup at all
+   * would keep reporting the previous commit's values.
+   */
+  @Test
+  void releasingLeavesNoZeroValuedResidue() {
+    HoodieWriteConfig config = config(BASE_PATH, true);
+    Registry registry = seedRegistry(config, 10L, 7L, 3L);
+    HoodieMetrics metrics = metricsFor(config);
+
+    RecordIndexLookupMetrics.publishAndRelease(context, config, metrics);
+
+    assertTrue(registry.getAllCounts(false).isEmpty(),
+        "released counters must not linger, even at zero; got " + 
registry.getAllCounts(false));
+  }
+
+  /**
+   * {@code Metrics.shutdown()} scrapes every registry in the process with 
{@code flush=true}, so another
+   * table's write finishing can clear this registry mid-publish. The release 
must clamp rather than
+   * subtract into negative numbers that every later commit would report.
+   */
+  @Test
+  void registryClearedBeforeReleaseDoesNotGoNegative() {
+    HoodieWriteConfig config = config(BASE_PATH, true);
+    Registry registry = seedRegistry(config, 10L, 7L, 3L);
+    HoodieMetrics metrics = metricsFor(config);
+
+    registry.clear();
+    RecordIndexLookupMetrics.publishAndRelease(context, config, metrics);
+
+    registry.getAllCounts(false).forEach((name, value) ->
+        assertTrue(value >= 0L, "release must clamp at zero, found " + name + 
"=" + value));
+  }
+
+  /** Counts that arrive mid-publish belong to the next commit, and must 
survive the release. */
+  @Test
+  void countsArrivingDuringThePublishSurviveTheRelease() {
+    HoodieWriteConfig config = config(BASE_PATH, true);
+    Registry registry = seedRegistry(config, 10L, 7L, 3L);
+    HoodieMetrics metrics = metricsFor(config);
+
+    // A straggler task's accumulator update, folded in before the drain reads.
+    registry.add(LOOKED_UP_KEY, 4L);
+    RecordIndexLookupMetrics.publishAndRelease(context, config, metrics);
+
+    assertTrue(registry.getAllCounts(false).isEmpty(),
+        "a drain that reads and releases the same values leaves nothing 
behind");
+    assertEquals(14L, gauge(metrics, KEY_COUNT),
+        "the straggler's count is included in what was reported");
+  }
+
+  /** Two tables can share a name, so the registry key folds in the base path. 
*/
+  @Test
+  void countersAreScopedByBasePathNotOnlyByTableName() {
+    HoodieWriteConfig config = config(BASE_PATH, true);
+    HoodieWriteConfig otherTable = config(OTHER_BASE_PATH, true);
+    seedRegistry(config, 10L, 7L, 3L);
+    HoodieMetrics metrics = metricsFor(otherTable);
+
+    RecordIndexLookupMetrics.publishAndRelease(context, otherTable, metrics);
+
+    assertNull(gauge(metrics, KEY_COUNT),
+        "a table at a different base path must not report another table's 
lookups");
+  }
+
+  /** With the gate off, nothing is read and nothing is reported. */
+  @Test
+  void theGateSuppressesTheDrainEntirely() {
+    HoodieWriteConfig gatedOff = config(BASE_PATH, false);
+    Registry registry = seedRegistry(gatedOff, 10L, 7L, 3L);
+    HoodieMetrics metrics = metricsFor(gatedOff);
+
+    RecordIndexLookupMetrics.publishAndRelease(context, gatedOff, metrics);
+
+    assertNull(gauge(metrics, KEY_COUNT), "gate off: nothing reaches the 
reporter");
+    assertFalse(registry.getAllCounts(false).isEmpty(),
+        "gate off: the registry is not consumed either");
+  }
+}
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/config/metrics/HoodieMetricsConfig.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/config/metrics/HoodieMetricsConfig.java
index e76017f128df..ea2beb8405fc 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/config/metrics/HoodieMetricsConfig.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/config/metrics/HoodieMetricsConfig.java
@@ -109,6 +109,21 @@ public class HoodieMetricsConfig extends HoodieConfig {
       .sinceVersion("0.13.0")
       .withDocumentation("Enable metrics for locking infra. Useful when 
operating in multiwriter mode");
 
+  public static final ConfigProperty<Boolean> RLI_LOOKUP_METRICS_ENABLE = 
ConfigProperty
+      .key(METRIC_PREFIX + ".rli.lookup.enable")
+      .defaultValue(false)
+      .markAdvanced()
+      .sinceVersion("1.3.0")
+      .withDocumentation("Collect counters for the record level index lookup 
phase (records looked up, "
+          + "hits, misses, shards read and time spent) and report them at each 
commit through the "
+          + "configured metrics reporter. Off unless set explicitly, including 
when " + TURN_METRICS_ON.key()
+          + " is on: collection registers a Spark accumulator per table and 
runs on every shard read, so "
+          + "it is opted into by name rather than inherited on upgrade. 
Requires " + TURN_METRICS_ON.key()
+          + " as well, since the reporter is the only destination. Spark only: 
the record level index "
+          + "lookup is a Spark write-path concern, and other engines produce 
no counters. Counters are "
+          + "aggregated per table per JVM rather than per writer, so two write 
clients on one table in "
+          + "one process share them.");
+
   public static final ConfigProperty<String> 
METRICS_REPORTER_FILE_BASED_CONFIGS_PATH = ConfigProperty
       .key(METRIC_PREFIX + ".configs.properties")
       .defaultValue("")
@@ -202,6 +217,10 @@ public class HoodieMetricsConfig extends HoodieConfig {
     return getBoolean(HoodieMetricsConfig.LOCK_METRICS_ENABLE);
   }
 
+  public boolean isRecordIndexLookupMetricsEnabled() {
+    return getBoolean(HoodieMetricsConfig.RLI_LOOKUP_METRICS_ENABLE);
+  }
+
   public MetricsReporterType getMetricsReporterType() {
     return 
MetricsReporterType.valueOf(getString(HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE));
   }
@@ -411,6 +430,11 @@ public class HoodieMetricsConfig extends HoodieConfig {
       return this;
     }
 
+    public Builder withRecordIndexLookupMetrics(boolean enable) {
+      hoodieMetricsConfig.setValue(RLI_LOOKUP_METRICS_ENABLE, 
String.valueOf(enable));
+      return this;
+    }
+
     public Builder withLockingMetrics(boolean enable) {
       hoodieMetricsConfig.setValue(LOCK_METRICS_ENABLE, 
String.valueOf(enable));
       return this;
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/config/metrics/TestHoodieMetricsConfig.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/config/metrics/TestHoodieMetricsConfig.java
index f8a451f24923..daffe49c500c 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/config/metrics/TestHoodieMetricsConfig.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/config/metrics/TestHoodieMetricsConfig.java
@@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test;
 
 import static 
org.apache.hudi.common.config.HoodieCommonConfig.META_SYNC_BASE_PATH_KEY;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 
 class TestHoodieMetricsConfig {
@@ -40,6 +41,18 @@ class TestHoodieMetricsConfig {
     assertEquals("base/path/set/during/sync", config.getBasePath());
   }
 
+  /**
+   * Turning metrics on is consent to report, not consent to start collecting 
a new class of metric.
+   * Record index lookup collection registers a Spark accumulator per table 
and runs per shard read, so
+   * an operator has to ask for it by name rather than inherit it on upgrade.
+   */
+  @Test
+  void recordIndexLookupMetricsStayOffUnlessAskedForByName() {
+    HoodieMetricsConfig config = 
HoodieMetricsConfig.newBuilder().on(true).build();
+    
assertFalse(config.getBoolean(HoodieMetricsConfig.RLI_LOOKUP_METRICS_ENABLE),
+        "enabling metrics must not silently enrol a table in record index 
lookup collection");
+  }
+
   @Test
   void testReturnsNullWhenNeitherBasePathNorMetaSyncIsSet() {
     HoodieMetricsConfig config = HoodieMetricsConfig.newBuilder().build();
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/DataSourceUtils.java
 
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/DataSourceUtils.java
index 728f6dd6efc9..75bfd11277a9 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/DataSourceUtils.java
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/DataSourceUtils.java
@@ -274,30 +274,6 @@ public class DataSourceUtils {
     return false;
   }
 
-  /**
-   * Resolves duplicate records in the provided {@code incomingHoodieRecords}.
-   *
-   * <p>If {@code failOnDuplicates} is {@code false}, duplicate records 
already present in the dataset
-   * are dropped. Otherwise, a {@link HoodieDuplicateKeyException} is thrown 
if duplicates are found.</p>
-   *
-   * @param jssc the Spark context used for executing the deduplication
-   * @param incomingHoodieRecords the input {@link JavaRDD} of {@link 
HoodieRecord} objects to process
-   * @param parameters a map of configuration parameters, including the 
dataset path under the key {@code "path"}
-   * @param failOnDuplicates a flag indicating whether to fail when duplicates 
are found
-   * @return a {@link JavaRDD} of deduplicated {@link HoodieRecord} objects
-   */
-  @SuppressWarnings("unchecked")
-  public static JavaRDD<HoodieRecord> resolveDuplicates(JavaSparkContext jssc,
-                                                        JavaRDD<HoodieRecord> 
incomingHoodieRecords,
-                                                        Map<String, String> 
parameters,
-                                                        boolean 
failOnDuplicates) {
-    HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder()
-        .withPath(parameters.get("path"))
-        .withProps(parameters).build();
-    return handleDuplicates(
-        new HoodieSparkEngineContext(jssc), incomingHoodieRecords, 
writeConfig, failOnDuplicates);
-  }
-
   /**
    * Spark data source WriteStatus validator.
    *
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala
index 042af12a2954..46601c240493 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala
@@ -550,7 +550,7 @@ class HoodieSparkSqlWriterInternal {
             }
 
             // Remove duplicates from incoming records based on existing keys 
from storage.
-            val dedupedHoodieRecords = handleInsertDuplicates(hoodieRecords, 
hoodieConfig, operation, jsc, parameters)
+            val dedupedHoodieRecords = handleInsertDuplicates(hoodieRecords, 
hoodieConfig, operation, client)
             try {
               val writeResult = DataSourceUtils.doWriteOperation(client, 
dedupedHoodieRecords, instantTime, operation,
                 preppedSparkSqlWrites || preppedWriteOperation)
@@ -1234,15 +1234,18 @@ object HoodieSparkSqlWriterInternal {
   def handleInsertDuplicates(incomingRecords: JavaRDD[HoodieRecord[_]],
                              hoodieConfig: HoodieConfig,
                              operation: WriteOperationType,
-                             jsc: JavaSparkContext,
-                             parameters: Map[String, String]): 
JavaRDD[HoodieRecord[_]] = {
+                             client: SparkRDDWriteClient[_]): 
JavaRDD[HoodieRecord[_]] = {
     // If no deduplication is needed, return the incoming records as is
     if (!isDeduplicationRequired(hoodieConfig) || 
!isDeduplicationNeeded(operation)) {
       incomingRecords
     } else {
-      // Perform deduplication
-      DataSourceUtils.resolveDuplicates(
-        jsc, incomingRecords, parameters.asJava, 
shouldFailWhenDuplicatesFound(hoodieConfig))
+      // Resolve duplicates on the committing client's engine context and 
config rather than a throwaway
+      // context. The record index lookup this triggers collects its counters 
into a registry owned by the
+      // context that ran the lookup, and postCommit drains the client's 
context; a separate context would
+      // strand those counters and the INSERT would publish none. See 
RecordIndexLookupMetrics.
+      DataSourceUtils.handleDuplicates(
+        client.getEngineContext.asInstanceOf[HoodieSparkEngineContext], 
incomingRecords,
+        client.getConfig, shouldFailWhenDuplicatesFound(hoodieConfig))
     }
   }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionedRecordLevelIndexSupport.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionedRecordLevelIndexSupport.scala
index d2fe4589c095..cf75a09953ba 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionedRecordLevelIndexSupport.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/PartitionedRecordLevelIndexSupport.scala
@@ -103,6 +103,7 @@ class PartitionedRecordLevelIndexSupport(spark: 
SparkSession,
         .map(_._2)
         .toJavaRDD()
       ValidationUtils.checkState(partitionedKeyRDD.getNumPartitions <= 
numFileGroups)
+      // Read path: no write-side registry to report into, so lookups here are 
not instrumented.
       val fileIdToPartitionMap = partitionedKeyRDD.mapPartitionsToPair(new 
PartitionedRecordIndexFileGroupLookupFunction(metadataTable))
         .collect()
         .asScala
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/TestDataSourceUtils.java
 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/TestDataSourceUtils.java
index 8755120bb641..d8c90da5e7d0 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/TestDataSourceUtils.java
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/TestDataSourceUtils.java
@@ -66,7 +66,6 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -341,7 +340,6 @@ public class TestDataSourceUtils extends 
HoodieClientTestBase {
   void testDeduplicationAgainstRecordsAlreadyInTable() throws IOException {
     initResources();
     HoodieWriteConfig config = getConfig();
-    config.getProps().setProperty("path", config.getBasePath());
     try (SparkRDDWriteClient writeClient = getHoodieWriteClient(config)) {
       String newCommitTime = writeClient.startCommit();
       List<HoodieRecord> records = dataGen.generateInserts(newCommitTime, 100);
@@ -350,10 +348,11 @@ public class TestDataSourceUtils extends 
HoodieClientTestBase {
       writeClient.commit(newCommitTime, jsc.parallelize(statusList), 
Option.empty(), COMMIT_ACTION, Collections.emptyMap(), Option.empty());
       assertNoWriteErrors(statusList);
 
-      Map<String, String> parameters = 
config.getProps().entrySet().stream().collect(Collectors.toMap(entry -> 
entry.getKey().toString(), entry -> entry.getValue().toString()));
       List<HoodieRecord> newRecords = dataGen.generateInserts(newCommitTime, 
10);
       List<HoodieRecord> inputRecords = Stream.concat(records.subList(0, 
10).stream(), newRecords.stream()).collect(Collectors.toList());
-      List<HoodieRecord> output = DataSourceUtils.resolveDuplicates(jsc, 
jsc.parallelize(inputRecords, 1), parameters, false).collect();
+      // Deduplicate against the committing client's engine context and 
config, the same wiring the
+      // Spark SQL writer uses so the record index lookup registry is owned by 
the draining context.
+      List<HoodieRecord> output = DataSourceUtils.handleDuplicates(context, 
jsc.parallelize(inputRecords, 1), config, false).collect();
       Set<String> expectedRecordKeys = 
newRecords.stream().map(HoodieRecord::getRecordKey).collect(Collectors.toSet());
       assertEquals(expectedRecordKeys, 
output.stream().map(HoodieRecord::getRecordKey).collect(Collectors.toSet()));
     }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/testutils/CapturingMetricsReporter.java
 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/testutils/CapturingMetricsReporter.java
new file mode 100644
index 000000000000..bb10617d27fb
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/testutils/CapturingMetricsReporter.java
@@ -0,0 +1,79 @@
+/*
+ * 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.hudi.testutils;
+
+import org.apache.hudi.metrics.custom.CustomizableMetricsReporter;
+
+import com.codahale.metrics.MetricRegistry;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * Records every gauge value handed to a reporter, so a test can read them 
after the write.
+ *
+ * <p>Two write paths behave differently and both have to be readable. The 
Spark DataSource shuts metrics
+ * down at the end of every write ({@code DefaultSource}), which reports once 
and then discards the
+ * registry, so the value has to be captured as it is reported or it is gone. 
Spark SQL does not shut
+ * down, so nothing triggers a report at all and the value sits in a 
still-live registry. {@link
+ * #captured()} therefore polls every reporter still attached to a registry 
before answering.
+ */
+public class CapturingMetricsReporter extends CustomizableMetricsReporter {
+
+  private static final Map<String, Long> CAPTURED = new ConcurrentHashMap<>();
+  /** Reporters whose registry is still live, so a not-yet-reported gauge can 
still be read. */
+  private static final List<CapturingMetricsReporter> ATTACHED = new 
CopyOnWriteArrayList<>();
+
+  public CapturingMetricsReporter(Properties props, MetricRegistry registry) {
+    super(props, registry);
+    ATTACHED.add(this);
+  }
+
+  public static Map<String, Long> captured() {
+    ATTACHED.forEach(CapturingMetricsReporter::report);
+    return CAPTURED;
+  }
+
+  public static void reset() {
+    CAPTURED.clear();
+  }
+
+  @Override
+  public void start() {
+  }
+
+  @Override
+  public void report() {
+    getRegistry().getGauges().forEach((name, gauge) -> {
+      Object value = gauge.getValue();
+      if (value instanceof Number) {
+        CAPTURED.put(name, ((Number) value).longValue());
+      }
+    });
+  }
+
+  @Override
+  public void stop() {
+    // Detach before the registry goes stale, so a later reset() is not undone 
by re-reading dead gauges.
+    ATTACHED.remove(this);
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/RliLookupMetricsTestBase.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/RliLookupMetricsTestBase.scala
new file mode 100644
index 000000000000..addaae8254eb
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/RliLookupMetricsTestBase.scala
@@ -0,0 +1,136 @@
+/*
+ * 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.hudi.functional
+
+import org.apache.hudi.DataSourceWriteOptions
+import org.apache.hudi.common.config.HoodieMetadataConfig
+import org.apache.hudi.common.config.metrics.HoodieMetricsConfig
+import org.apache.hudi.config.HoodieIndexConfig
+import org.apache.hudi.metrics.{MetricsReporterType, RecordIndexLookupMetrics}
+import org.apache.hudi.testutils.CapturingMetricsReporter
+
+import scala.collection.JavaConverters._
+
+/**
+ * Shared plumbing for the record level index lookup metric tests: index 
selection, and reading the
+ * counters back the way an operator would -- off the configured metrics 
reporter.
+ */
+abstract class RliLookupMetricsTestBase extends RecordLevelIndexTestBase {
+
+  /** Overridden by the partitioned subclasses; both variants are separate 
closures on separate paths. */
+  protected def isPartitionedRli: Boolean = false
+
+  /**
+   * Copy-on-write throughout. Tagging is an index-level concern and nothing 
in the lookup closures or the
+   * drain branches on table type, so merge-on-read exercises identical code 
and is not covered separately.
+   */
+  protected def indexLabel: String = if (isPartitionedRli) "partitioned RLI" 
else "global RLI"
+
+  /**
+   * The drain reports to the configured reporter, so these tests need one. 
`commonOpts` turns the global
+   * record index on, so the metadata-partition flags and the index type are 
flipped together below to
+   * select the partitioned variant.
+   */
+  protected def metricsOpts: Map[String, String] = Map(
+    HoodieMetricsConfig.TURN_METRICS_ON.key -> "true",
+    // Explicit: collection is opted into by name, not inherited from metrics 
being on.
+    HoodieMetricsConfig.RLI_LOOKUP_METRICS_ENABLE.key -> "true",
+    // The type still has to be set: it defaults to GRAPHITE, whose config 
builder NPEs without a prefix.
+    // The factory prefers the class when one is given, so this only keeps the 
builder happy.
+    HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key -> 
MetricsReporterType.INMEMORY.name(),
+    HoodieMetricsConfig.METRICS_REPORTER_CLASS_NAME.key -> 
classOf[CapturingMetricsReporter].getName)
+
+  protected def rliOpts: Map[String, String] = {
+    val withTableType = Map(DataSourceWriteOptions.TABLE_TYPE.key ->
+      DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL) ++ metricsOpts
+    if (isPartitionedRli) {
+      commonOpts ++ withTableType ++ Map(
+        HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key -> 
"false",
+        HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key -> "true",
+        HoodieIndexConfig.INDEX_TYPE.key -> "RECORD_LEVEL_INDEX")
+    } else {
+      commonOpts ++ withTableType ++ Map(
+        HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key -> 
"true",
+        HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key -> "false",
+        HoodieIndexConfig.INDEX_TYPE.key -> "GLOBAL_RECORD_LEVEL_INDEX")
+    }
+  }
+
+  /**
+   * A lookup that happened stamps the full counter set, zeros included, so an 
absent key means nothing was
+   * looked up at all. The default is defensive against exactly that case.
+   */
+  protected def counterOrZero(counters: Map[String, String], metric: String): 
Long =
+    counters.getOrElse(metric, "0").toLong
+
+  /**
+   * The counters as an operator would read them: off the metrics reporter. 
{@code Metrics} is keyed by
+   * base path, so building a handle here returns the same instance the write 
published into.
+   */
+  protected def rliCountersFromLatestCommit(): Map[String, String] = {
+    val marker = "." + RecordIndexLookupMetrics.METRIC_ACTION +
+      "." + RecordIndexLookupMetrics.METRIC_QUALIFIER + "."
+    CapturingMetricsReporter.captured().asScala.toMap
+      .collect { case (name, value) if name.contains(marker) =>
+        name.substring(name.indexOf(marker) + marker.length) -> value.toString 
}
+  }
+
+  /** The reporter is process-wide, so each test starts from a clean slate. */
+  @org.junit.jupiter.api.BeforeEach
+  def resetCapturedMetrics(): Unit = CapturingMetricsReporter.reset()
+
+
+  /** Asserts the core invariant and returns the looked-up count. */
+  protected def assertSumInvariant(counters: Map[String, String]): Long = {
+    val lookedUp = counterOrZero(counters, RecordIndexLookupMetrics.KEY_COUNT)
+    val hits = counterOrZero(counters, RecordIndexLookupMetrics.KEY_HIT_COUNT)
+    val misses = counterOrZero(counters, 
RecordIndexLookupMetrics.KEY_MISS_COUNT)
+    org.junit.jupiter.api.Assertions.assertEquals(lookedUp, hits + misses,
+      "hits + misses must account for every key looked up")
+    // A lookup that happened must also report the time it took, or the timing 
metric is silently absent on
+    // paths nobody checked. Zero is allowed: a shard read can round below a 
millisecond.
+    if (lookedUp > 0) {
+      org.junit.jupiter.api.Assertions.assertTrue(
+        counters.contains(RecordIndexLookupMetrics.LOOKUP_TIME),
+        s"looked up $lookedUp keys but reported no 
${RecordIndexLookupMetrics.LOOKUP_TIME}; " +
+          s"counters were ${counters.keys.toSeq.sorted.mkString(", ")}")
+    }
+    lookedUp
+  }
+
+  /** Everything on the latest commit, for diagnosing an unexpectedly empty 
counter set. */
+  protected def allExtraMetadataFromLatestCommit(): Map[String, String] = {
+    metaClient.reloadActiveTimeline()
+    val lastInstant = 
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants().lastInstant().get()
+    
metaClient.getActiveTimeline.readCommitMetadata(lastInstant).getExtraMetadata.asScala.toMap
 +
+      ("__instant" -> lastInstant.toString)
+  }
+
+  protected def report(label: String, counters: Map[String, String]): Unit = {
+    println(s"\n===== $label =====")
+    if (counters.isEmpty) {
+      println("  (no RLI counters published) -- every gauge the reporter holds 
follows:")
+      CapturingMetricsReporter.captured().asScala.toSeq.sortBy(_._1).foreach { 
case (k, v) =>
+        println(f"    $k%-70s $v")
+      }
+    } else {
+      counters.toSeq.sorted.foreach { case (k, v) => println(f"  $k%-52s $v") }
+    }
+    println("=" * (12 + label.length) + "\n")
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsAcrossFailedCommit.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsAcrossFailedCommit.scala
new file mode 100644
index 000000000000..c5ae79386f3a
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsAcrossFailedCommit.scala
@@ -0,0 +1,125 @@
+/*
+ * 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.hudi.functional
+
+import org.apache.hudi.DataSourceWriteOptions._
+import 
org.apache.hudi.client.transaction.SimpleConcurrentFileWritesConflictResolutionStrategy
+import org.apache.hudi.common.model.WriteConcurrencyMode
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.common.table.timeline.HoodieInstant
+import org.apache.hudi.common.util.{Option => HoodieOption}
+import org.apache.hudi.config.{HoodieLockConfig, HoodieWriteConfig}
+import org.apache.hudi.exception.HoodieWriteConflictException
+import org.apache.hudi.metrics.RecordIndexLookupMetrics
+import org.apache.hudi.testutils.CapturingMetricsReporter
+
+import org.apache.spark.sql.SaveMode
+import org.junit.jupiter.api.{Tag, Test}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
+
+/** A commit that never lands must not take its lookup counters with it. */
+@Tag("functional")
+class TestRliLookupMetricsAcrossFailedCommit extends RliLookupMetricsTestBase {
+
+  /** Options that make `preCommit` throw, i.e. after the snapshot and before 
the commit completes. */
+  private def conflictingOpts: Map[String, String] = rliOpts ++ Map(
+    HoodieWriteConfig.WRITE_CONCURRENCY_MODE.key -> 
WriteConcurrencyMode.OPTIMISTIC_CONCURRENCY_CONTROL.name,
+    HoodieLockConfig.LOCK_PROVIDER_CLASS_NAME.key ->
+      "org.apache.hudi.client.transaction.lock.InProcessLockProvider",
+    HoodieLockConfig.WRITE_CONFLICT_RESOLUTION_STRATEGY_CLASS_NAME.key ->
+      classOf[AlwaysConflictingResolutionStrategy].getName)
+
+  private def causeChain(t: Throwable): String = {
+    var current = t
+    val sb = new StringBuilder
+    while (current != null) {
+      sb.append(current.toString).append(" | ")
+      current = current.getCause
+    }
+    sb.toString
+  }
+
+  @Test
+  def testCountersSurviveACommitThatNeverLands(): Unit = {
+    val failedUpdates = 10
+    val retriedUpdates = 4
+    // Each upsert batch carries one fresh insert alongside its updates, so it 
looks up N + 1 keys.
+    val retriedLookups = retriedUpdates + 1
+
+    doWriteAndValidateDataAndRecordIndex(rliOpts, INSERT_OPERATION_OPT_VAL, 
SaveMode.Overwrite,
+      validate = false, numInserts = 80)
+    assertTrue(rliCountersFromLatestCommit().isEmpty, "the seeding insert 
performs no lookup")
+
+    // An upsert whose commit is rejected in preCommit. The lookups happened; 
the commit did not.
+    val failure = try {
+      doWriteAndValidateDataAndRecordIndex(conflictingOpts, 
UPSERT_OPERATION_OPT_VAL, SaveMode.Append,
+        validate = false, numUpdates = failedUpdates)
+      None
+    } catch {
+      case t: Throwable => Some(t)
+    }
+    assertTrue(failure.isDefined, "the injected conflict must fail the write")
+    
assertTrue(causeChain(failure.get).contains(AlwaysConflictingResolutionStrategy.MESSAGE),
+      s"the write must have failed on the injected conflict, not on something 
else: ${causeChain(failure.get)}")
+
+    // Nothing was published for it: the newest completed commit is still the 
seeding insert.
+    val afterFailure = rliCountersFromLatestCommit()
+    report(s"Failed commit ($indexLabel) -- latest completed commit, expected 
empty", afterFailure)
+    assertTrue(afterFailure.isEmpty,
+      s"a commit that never landed must not leave counters on the timeline; 
got $afterFailure")
+
+    // The next commit to succeed reports normally, and the failed attempt's 
counters do not corrupt it.
+    CapturingMetricsReporter.reset()
+    doWriteAndValidateDataAndRecordIndex(rliOpts, UPSERT_OPERATION_OPT_VAL, 
SaveMode.Append,
+      validate = false, numUpdates = retriedUpdates)
+
+    val counters = rliCountersFromLatestCommit()
+    report(s"Retry after failed commit ($indexLabel) -- expected 
$retriedLookups", counters)
+
+    assertTrue(counters.nonEmpty, "the retry must carry counters")
+    // The failed attempt's lookups are not carried into this commit. 
Publishing is skipped when the
+    // commit does not land, but the registry does not survive to the retry 
either: the DataSource path
+    // tears metrics down after every write, and Metrics.shutdown() 
flush-and-clears every Registry. So on
+    // this path an abandoned attempt's counters are dropped rather than 
double-counted, which is the safer
+    // of the two failure modes but is not a carry-forward guarantee.
+    assertEquals(retriedLookups.toLong,
+      assertSumInvariant(counters),
+      "the retry must report exactly its own lookups, uncontaminated by the 
attempt that never landed")
+  }
+}
+
+object AlwaysConflictingResolutionStrategy {
+  val MESSAGE = "injected conflict: this commit must not land"
+}
+
+/**
+ * Fails conflict resolution unconditionally, which is the first thing 
`preCommit` does. Loaded reflectively
+ * from `hoodie.write.lock.conflict.resolution.strategy`, so it needs a 
no-argument constructor.
+ */
+class AlwaysConflictingResolutionStrategy extends 
SimpleConcurrentFileWritesConflictResolutionStrategy {
+  override def getCandidateInstants(metaClient: HoodieTableMetaClient,
+                                    currentInstant: HoodieInstant,
+                                    lastSuccessfulInstant: 
HoodieOption[HoodieInstant]): java.util.stream.Stream[HoodieInstant] =
+    throw new 
HoodieWriteConflictException(AlwaysConflictingResolutionStrategy.MESSAGE)
+}
+
+/** The same coverage against the partitioned record level index. */
+@Tag("functional")
+class TestRliLookupMetricsAcrossFailedCommitPartitioned extends 
TestRliLookupMetricsAcrossFailedCommit {
+  override protected def isPartitionedRli: Boolean = true
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsOnDataSource.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsOnDataSource.scala
new file mode 100644
index 000000000000..f4bf0b4b0301
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsOnDataSource.scala
@@ -0,0 +1,154 @@
+/*
+ * 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.hudi.functional
+
+import org.apache.hudi.DataSourceWriteOptions._
+import org.apache.hudi.common.testutils.HoodieTestDataGenerator
+import 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.recordsToStrings
+import org.apache.hudi.metrics.RecordIndexLookupMetrics
+import org.apache.hudi.testutils.CapturingMetricsReporter
+
+import org.apache.spark.sql.SaveMode
+import org.junit.jupiter.api.{Tag, Test}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
+
+import scala.collection.JavaConverters._
+
+/** Record level index lookup counters on the Spark DataSource write path. */
+@Tag("functional")
+class TestRliLookupMetricsOnDataSource extends RliLookupMetricsTestBase {
+
+  /**
+   * An upsert of N updates also carries exactly one fresh insert (see
+   * `RecordLevelIndexTestBase.doWriteAndValidateDataAndRecordIndex`), so the 
miss count is 1.
+   */
+  @Test
+  def testCountersReachTheReporter(): Unit = {
+    val numUpdates = 20
+
+    doWriteAndValidateDataAndRecordIndex(rliOpts, INSERT_OPERATION_OPT_VAL, 
SaveMode.Overwrite, validate = false, numInserts = 100)
+
+    doWriteAndValidateDataAndRecordIndex(rliOpts, UPSERT_OPERATION_OPT_VAL, 
SaveMode.Append, validate = false, numUpdates = numUpdates)
+
+    val counters = rliCountersFromLatestCommit()
+    report(s"DataSource ($indexLabel) -- RLI counters on the commit", counters)
+
+    assertTrue(counters.nonEmpty, "the reporter must carry the RLI counters")
+    assertEquals(numUpdates.toString, 
counters(RecordIndexLookupMetrics.KEY_HIT_COUNT), "every updated key is a hit")
+    assertEquals("1", counters(RecordIndexLookupMetrics.KEY_MISS_COUNT), "the 
fresh insert is a miss")
+    assertEquals(numUpdates + 1L, assertSumInvariant(counters))
+    assertTrue(counters(RecordIndexLookupMetrics.SHARDS_READ).toInt > 0, "at 
least one shard was read")
+  }
+
+  /** Each commit must report only its own work: the drain clears the registry 
as it publishes. */
+  @Test
+  def testCountersArePerCommitNotCumulative(): Unit = {
+    val numUpdates = 10
+    doWriteAndValidateDataAndRecordIndex(rliOpts, INSERT_OPERATION_OPT_VAL, 
SaveMode.Overwrite, validate = false, numInserts = 100)
+
+    val perCommit = (1 to 3).map { commit =>
+      doWriteAndValidateDataAndRecordIndex(rliOpts, UPSERT_OPERATION_OPT_VAL, 
SaveMode.Append, validate = false, numUpdates = numUpdates)
+      val lookedUp = 
rliCountersFromLatestCommit().getOrElse(RecordIndexLookupMetrics.KEY_COUNT, "0")
+      println(s"[per-commit] commit $commit ($indexLabel): 
records_looked_up=$lookedUp")
+      lookedUp
+    }
+
+    perCommit.zipWithIndex.foreach { case (v, i) =>
+      assertEquals((numUpdates + 1).toString, v,
+        s"commit ${i + 1} must report only its own ${numUpdates + 1} lookups, 
not a running total")
+    }
+  }
+
+  /** A commit that performed no lookup must publish nothing of its own. */
+  @Test
+  def testACommitWithNoLookupCarriesNoCounters(): Unit = {
+    doWriteAndValidateDataAndRecordIndex(rliOpts, INSERT_OPERATION_OPT_VAL, 
SaveMode.Overwrite,
+      validate = false, numInserts = 60)
+
+    // A commit that does tag, so the registry is drained for the first time.
+    doWriteAndValidateDataAndRecordIndex(rliOpts, UPSERT_OPERATION_OPT_VAL, 
SaveMode.Append,
+      validate = false, numUpdates = 10)
+    assertTrue(rliCountersFromLatestCommit().nonEmpty, "the upsert must 
publish counters to drain")
+
+    // The reporter is a stream of emissions, not a running record, so clear 
what the upsert emitted
+    // before the write under test. What remains afterwards is what this 
commit alone published.
+    CapturingMetricsReporter.reset()
+
+    // A plain insert performs no index lookup, so its commit has nothing to 
report.
+    doWriteAndValidateDataAndRecordIndex(rliOpts, INSERT_OPERATION_OPT_VAL, 
SaveMode.Append,
+      validate = false, numInserts = 5)
+
+    val counters = rliCountersFromLatestCommit()
+    report(s"DataSource ($indexLabel) -- insert after a drained upsert, 
expecting nothing", counters)
+    assertTrue(counters.isEmpty,
+      s"a commit that looked nothing up must publish no counters of its own; 
got $counters")
+  }
+
+  /**
+   * INSERT with drop-dups resolves duplicates through a record index lookup 
before the write, and that
+   * lookup runs on the committing client's engine context. Its counters must 
therefore reach the reporter
+   * at the INSERT's commit. Regression guard: dedup used to run on a 
throwaway context (built inside
+   * `DataSourceUtils`) whose registry `postCommit` never drained, so the 
INSERT published nothing.
+   */
+  @Test
+  def testInsertDropDupsPublishesDedupLookupCounters(): Unit = {
+    val numSeed = 100
+    val numReoffered = 40 // existing keys offered again -> dropped as 
duplicates -> hits
+    val numFresh = 15 // brand-new keys -> kept -> misses
+
+    // Seed the table so the record index has keys to hit.
+    val seedBatch = doWriteAndValidateDataAndRecordIndex(rliOpts, 
INSERT_OPERATION_OPT_VAL, SaveMode.Overwrite,
+      validate = false, numInserts = numSeed)
+
+    // A batch mixing already-present keys with new ones, so both hits and 
misses are exercised.
+    val freshBatch = recordsToStrings(dataGen.generateInsertsAsPerSchema(
+      getInstantTime(), numFresh, 
HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA)).asScala
+    val freshDf = 
spark.read.json(spark.sparkContext.parallelize(freshBatch.toSeq, 2))
+    val insertBatch = seedBatch.limit(numReoffered).unionByName(freshDf)
+
+    // Isolate the INSERT under test from the seed's emissions.
+    CapturingMetricsReporter.reset()
+
+    insertBatch.write.format("hudi")
+      .options(rliOpts)
+      .option(OPERATION.key, INSERT_OPERATION_OPT_VAL)
+      .option(INSERT_DROP_DUPS.key, "true")
+      .mode(SaveMode.Append)
+      .save(basePath)
+
+    val counters = rliCountersFromLatestCommit()
+    report(s"DataSource INSERT drop-dups ($indexLabel)", counters)
+
+    assertTrue(counters.nonEmpty,
+      "INSERT with drop-dups resolves duplicates via an RLI lookup; its 
counters must reach the reporter")
+    assertEquals(numReoffered.toString, 
counters(RecordIndexLookupMetrics.KEY_HIT_COUNT),
+      "the re-offered keys already exist in the index")
+    assertEquals(numFresh.toString, 
counters(RecordIndexLookupMetrics.KEY_MISS_COUNT),
+      "the brand-new keys are misses")
+    assertEquals((numReoffered + numFresh).toLong, 
assertSumInvariant(counters),
+      "the dedup lookup examines every incoming record")
+    assertTrue(counters(RecordIndexLookupMetrics.SHARDS_READ).toInt > 0, "at 
least one shard was read")
+  }
+
+}
+
+/** The same coverage against the partitioned record level index. */
+@Tag("functional")
+class TestRliLookupMetricsOnDataSourcePartitioned extends 
TestRliLookupMetricsOnDataSource {
+  override protected def isPartitionedRli: Boolean = true
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsOnSparkSql.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsOnSparkSql.scala
new file mode 100644
index 000000000000..b4cd09e8eac9
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsOnSparkSql.scala
@@ -0,0 +1,138 @@
+/*
+ * 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.hudi.functional
+
+import org.apache.hudi.DataSourceWriteOptions._
+import org.apache.hudi.common.config.HoodieMetadataConfig
+import org.apache.hudi.config.HoodieIndexConfig
+import org.apache.hudi.metrics.RecordIndexLookupMetrics
+import org.apache.hudi.testutils.CapturingMetricsReporter
+
+import org.apache.spark.sql.SaveMode
+import org.junit.jupiter.api.{Tag, Test}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
+
+/** Record level index lookup counters on the Spark SQL write path. */
+@Tag("functional")
+class TestRliLookupMetricsOnSparkSql extends RliLookupMetricsTestBase {
+
+  private val sqlTable = "rli_lookup_metrics_tbl"
+  private val numSeedRecords = 60
+
+  /**
+   * Seeds a table through the DataSource so the record index exists, then 
exposes it to SQL and applies
+   * the index settings as session configs -- index type is a write config, 
not a table property.
+   */
+  private def seedTableAndRegisterForSql(): Unit = {
+    doWriteAndValidateDataAndRecordIndex(rliOpts, INSERT_OPERATION_OPT_VAL, 
SaveMode.Overwrite,
+      validate = false, numInserts = numSeedRecords)
+
+    spark.sql(s"drop table if exists $sqlTable")
+    spark.sql(s"create table $sqlTable using hudi location '$basePath'")
+
+    spark.sql("set hoodie.write.lock.provider = 
org.apache.hudi.client.transaction.lock.InProcessLockProvider")
+    spark.sql(s"set ${HoodieMetadataConfig.ENABLE.key} = true")
+    spark.sql(s"set 
${HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key} = 
${!isPartitionedRli}")
+    spark.sql(s"set ${HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key} 
= $isPartitionedRli")
+    spark.sql(s"set ${HoodieIndexConfig.INDEX_TYPE.key} = " +
+      (if (isPartitionedRli) "RECORD_LEVEL_INDEX" else 
"GLOBAL_RECORD_LEVEL_INDEX"))
+
+    // SQL DML does not inherit the DataSource options the seed write used, so 
the reporter has to be
+    // selected as a session config too or the write publishes nowhere the 
test can read.
+    metricsOpts.foreach { case (key, value) => spark.sql(s"set $key = $value") 
}
+    CapturingMetricsReporter.reset()
+  }
+
+  /**
+   * The default path. Optimized writes make UPDATE a prepped write, so no 
index lookup happens and no
+   * counters are produced. Documented behaviour, asserted so it cannot change 
unnoticed.
+   */
+  @Test
+  def testUpdateWithOptimizedWritesPerformsNoLookup(): Unit = {
+    seedTableAndRegisterForSql()
+    spark.sql(s"set ${SPARK_SQL_OPTIMIZED_WRITES.key} = true")
+
+    spark.sql(s"update $sqlTable set rider = 'rider-optimized'")
+
+    val counters = rliCountersFromLatestCommit()
+    report(s"Spark SQL UPDATE, optimized writes ON ($indexLabel) -- expected 
empty", counters)
+    assertTrue(counters.isEmpty,
+      "a prepped UPDATE already knows each record's location, so it performs 
no RLI lookup to report")
+  }
+
+  /**
+   * With optimized writes disabled the UPDATE goes through normal tagging, 
and every touched key is
+   * looked up in the index. No predicate means every row, so the counts are 
exact.
+   */
+  @Test
+  def testUpdateWithoutOptimizedWritesPublishesCounters(): Unit = {
+    seedTableAndRegisterForSql()
+    spark.sql(s"set ${SPARK_SQL_OPTIMIZED_WRITES.key} = false")
+
+    spark.sql(s"update $sqlTable set rider = 'rider-updated'")
+
+    val counters = rliCountersFromLatestCommit()
+    report(s"Spark SQL UPDATE, optimized writes OFF ($indexLabel)", counters)
+
+    assertTrue(counters.nonEmpty, "a non-prepped UPDATE must publish RLI 
counters at its commit")
+    val lookedUp = assertSumInvariant(counters)
+    assertEquals(numSeedRecords.toLong, lookedUp, "UPDATE with no predicate 
tags every row")
+    assertEquals(numSeedRecords.toString, 
counters(RecordIndexLookupMetrics.KEY_HIT_COUNT),
+      "every row being updated already exists in the index")
+    // A caller that looked something up stamps its whole counter set, zeros 
included, so the record on the
+    // timeline is internally consistent. Dropping just the zero components 
would leave a commit reporting
+    // hits and records_looked_up but no misses, forcing every consumer to 
treat absent as zero.
+    assertTrue(counters.contains(RecordIndexLookupMetrics.KEY_MISS_COUNT),
+      "a caller that looked keys up reports its full counter set, including 
the zeros")
+    assertEquals("0", counters(RecordIndexLookupMetrics.KEY_MISS_COUNT), "no 
new keys are introduced")
+    assertTrue(counters(RecordIndexLookupMetrics.SHARDS_READ).toInt > 0, "at 
least one shard was read")
+  }
+
+  /** MERGE INTO is not a prepped write, so its keys are tagged and must be 
counted. */
+  @Test
+  def testMergeIntoPublishesCounters(): Unit = {
+    seedTableAndRegisterForSql()
+
+    val numMerged = 25
+    spark.sql(
+      s"""create or replace temporary view rli_merge_src as
+         |select _row_key, partition, timestamp, 'rider-merged' as rider
+         |from $sqlTable limit $numMerged""".stripMargin)
+
+    spark.sql(
+      s"""merge into $sqlTable t
+         |using rli_merge_src s
+         |on t._row_key = s._row_key
+         |when matched then update set t.rider = s.rider, t.timestamp = 
s.timestamp""".stripMargin)
+
+    val counters = rliCountersFromLatestCommit()
+    report(s"Spark SQL MERGE INTO ($indexLabel)", counters)
+
+    assertTrue(counters.nonEmpty, "a MERGE INTO must publish RLI counters at 
its commit")
+    val lookedUp = assertSumInvariant(counters)
+    assertEquals(numMerged.toLong, lookedUp, s"the merge tags its $numMerged 
matched keys")
+    assertEquals(numMerged.toString, 
counters(RecordIndexLookupMetrics.KEY_HIT_COUNT),
+      "every merged key already exists in the index")
+  }
+}
+
+/** The same coverage against the partitioned record level index. */
+@Tag("functional")
+class TestRliLookupMetricsOnSparkSqlPartitioned extends 
TestRliLookupMetricsOnSparkSql {
+  override protected def isPartitionedRli: Boolean = true
+}
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestRliMetricsOnStreamerPath.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestRliMetricsOnStreamerPath.java
new file mode 100644
index 000000000000..1d83653f01c4
--- /dev/null
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestRliMetricsOnStreamerPath.java
@@ -0,0 +1,128 @@
+/*
+ * 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.hudi.utilities.deltastreamer;
+
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.config.metrics.HoodieMetricsConfig;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.config.HoodieIndexConfig;
+import org.apache.hudi.metrics.RecordIndexLookupMetrics;
+import org.apache.hudi.utilities.testutils.CapturingMetricsReporter;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The RLI lookup counters must reach commit metadata on the DeltaStreamer 
path, not only on the Spark DataSource path.
+ */
+@Tag("functional")
+public class TestRliMetricsOnStreamerPath extends HoodieDeltaStreamerTestBase {
+
+  /** Selects the global or partitioned record index. */
+  private static void enableRecordIndex(HoodieDeltaStreamer.Config cfg, 
boolean partitioned) {
+    cfg.configs.add(HoodieMetadataConfig.ENABLE.key() + "=true");
+    cfg.configs.add(HoodieMetricsConfig.TURN_METRICS_ON.key() + "=true");
+    cfg.configs.add(HoodieMetricsConfig.RLI_LOOKUP_METRICS_ENABLE.key() + 
"=true");
+    cfg.configs.add(HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key() + 
"=INMEMORY");
+    cfg.configs.add(HoodieMetricsConfig.METRICS_REPORTER_CLASS_NAME.key() + "="
+        + CapturingMetricsReporter.class.getName());
+    
cfg.configs.add(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key()
 + "=" + !partitioned);
+    cfg.configs.add(HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key() 
+ "=" + partitioned);
+    cfg.configs.add(HoodieIndexConfig.INDEX_TYPE.key() + "="
+        + (partitioned ? "RECORD_LEVEL_INDEX" : "GLOBAL_RECORD_LEVEL_INDEX"));
+  }
+
+  /**
+   * Reads the counters the way an operator would, off the configured 
reporter. Goes through the reporter
+   * rather than {@code Metrics}, which is keyed by base path and cannot be 
addressed reliably from here.
+   */
+  private static Map<String, String> rliCountersOnLatestCommit(String 
tableBasePath) {
+    String marker = RecordIndexLookupMetrics.METRIC_ACTION
+        + "." + RecordIndexLookupMetrics.METRIC_QUALIFIER + ".";
+    Map<String, String> rli = new HashMap<>();
+    CapturingMetricsReporter.captured().forEach((name, value) -> {
+      int at = name.indexOf(marker);
+      if (at == 0 || (at > 0 && name.charAt(at - 1) == '.')) {
+        rli.put(name.substring(at + marker.length()), String.valueOf(value));
+      }
+    });
+    return rli;
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  public void testRliCountersReachTheReporterOnStreamerPath(boolean 
partitioned) throws Exception {
+    String label = partitioned ? "partitioned" : "global";
+    String tableBasePath = basePath + "/test_rli_metrics_streamer_" + label;
+
+    // Sync 1 -- build the table and the record index.
+    HoodieDeltaStreamer.Config insertCfg =
+        TestHoodieDeltaStreamer.TestHelpers.makeConfig(tableBasePath, 
WriteOperationType.INSERT);
+    enableRecordIndex(insertCfg, partitioned);
+    new HoodieDeltaStreamer(insertCfg, jsc).sync();
+
+    // Sync 2 -- upsert, which tags incoming keys against the record index.
+    HoodieDeltaStreamer.Config upsertCfg =
+        TestHoodieDeltaStreamer.TestHelpers.makeConfig(tableBasePath, 
WriteOperationType.UPSERT);
+    enableRecordIndex(upsertCfg, partitioned);
+    new HoodieDeltaStreamer(upsertCfg, jsc).sync();
+
+    Map<String, String> counters = rliCountersOnLatestCommit(tableBasePath);
+
+    System.out.println("\n===== DeltaStreamer (" + label + " RLI) -- RLI 
counters on the commit =====");
+    if (counters.isEmpty()) {
+      System.out.println("  (none found)");
+    } else {
+      counters.entrySet().stream()
+          .sorted(Map.Entry.comparingByKey())
+          .forEach(e -> System.out.println(String.format("  %-52s %s", 
e.getKey(), e.getValue())));
+    }
+    
System.out.println("==========================================================\n");
+
+    assertFalse(counters.isEmpty(),
+        "the commit-boundary drain must fire on the DeltaStreamer path; 
hudi-utilities never calls "
+            + "Metrics.shutdownAllMetrics, so nothing else would publish 
these");
+
+    String lookedUp = RecordIndexLookupMetrics.KEY_COUNT;
+    assertTrue(counters.containsKey(lookedUp),
+        "tag-location traffic must be attributed on the streamer path too; got 
" + counters.keySet());
+
+    long records = Long.parseLong(counters.get(lookedUp));
+    long hits = 
Long.parseLong(counters.get(RecordIndexLookupMetrics.KEY_HIT_COUNT));
+    long misses = 
Long.parseLong(counters.get(RecordIndexLookupMetrics.KEY_MISS_COUNT));
+    // Exact, not an invariant: misses is derived as records - hits at the 
emission site, so
+    // records == hits + misses holds by construction and would survive a 
doubled count. The workload is
+    // deterministic -- the first sync writes 1000 records, the second updates 
500 and inserts 500.
+    assertEquals(1000L, records, "the upsert sync looked up every key from the 
first sync");
+    assertEquals(500L, hits, "the 500 updates hit the index");
+    assertEquals(500L, misses, "the 500 fresh inserts missed");
+    // Shard count is not pinned: it follows the index file-group layout, 
which differs between the
+    // global and partitioned variants (10 and 3 on this workload).
+    
assertTrue(Long.parseLong(counters.get(RecordIndexLookupMetrics.SHARDS_READ)) > 
0,
+        "at least one shard was read");
+  }
+}
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/CapturingMetricsReporter.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/CapturingMetricsReporter.java
new file mode 100644
index 000000000000..fede84cab130
--- /dev/null
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/CapturingMetricsReporter.java
@@ -0,0 +1,73 @@
+/*
+ * 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.hudi.utilities.testutils;
+
+import org.apache.hudi.metrics.custom.CustomizableMetricsReporter;
+
+import com.codahale.metrics.MetricRegistry;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * Records gauge values so a test can read them without having to guess which 
{@code Metrics} instance the
+ * write published into -- that is keyed by base path, and reconstructing the 
key from outside is fragile.
+ */
+public class CapturingMetricsReporter extends CustomizableMetricsReporter {
+
+  private static final Map<String, Long> CAPTURED = new ConcurrentHashMap<>();
+  private static final List<CapturingMetricsReporter> ATTACHED = new 
CopyOnWriteArrayList<>();
+
+  public CapturingMetricsReporter(Properties props, MetricRegistry registry) {
+    super(props, registry);
+    ATTACHED.add(this);
+  }
+
+  /** Polls every still-attached registry first: nothing forces a report on 
the DeltaStreamer path. */
+  public static Map<String, Long> captured() {
+    ATTACHED.forEach(CapturingMetricsReporter::report);
+    return CAPTURED;
+  }
+
+  public static void reset() {
+    CAPTURED.clear();
+  }
+
+  @Override
+  public void start() {
+  }
+
+  @Override
+  public void report() {
+    getRegistry().getGauges().forEach((name, gauge) -> {
+      Object value = gauge.getValue();
+      if (value instanceof Number) {
+        CAPTURED.put(name, ((Number) value).longValue());
+      }
+    });
+  }
+
+  @Override
+  public void stop() {
+    ATTACHED.remove(this);
+  }
+}

Reply via email to