voonhous commented on code in PR #19575:
URL: https://github.com/apache/hudi/pull/19575#discussion_r3853268963


##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java:
##########
@@ -255,6 +256,12 @@ public boolean commitStats(String instantTime, 
TableWriteStats tableWriteStats,
       return true;
     }
     extraMetadata = updateExtraMetadata(extraMetadata);
+    // The commit is the boundary for the record index lookup counters. 
Snapshot them into this commit's
+    // metadata now, but only release them from the registry once the commit 
has actually landed --
+    // hooked here rather than in updateExtraMetadata because that is shared 
with table-service
+    // scheduling, which would otherwise consume the counters into a 
compaction or clustering plan.
+    ExecutorMetrics.DrainedCounters executorCounters =
+        ExecutorMetrics.snapshotIntoCommitMetadata(extraMetadata.get(), 
config);

Review Comment:
   `snapshotIntoCommitMetadata` mutates the enriched `extraMetadata` map, and 
the same `Option` goes to `runTableServicesInline` at L302. From there 
`scheduleTableServiceInternal` re-enriches with `putAll`, 
`ScheduleCompactionActionExecutor:109` puts it on the plan, and 
`CompactHelpers:85` copies the plan's extra metadata into the completed 
compaction commit, so an inline compaction carries the triggering commit's 
`hoodie.rli.lookup.*` values. That is the misattribution the comment above says 
this placement avoids, and no new test enables inline services. Could we stamp 
into a copy used only for `CommitUtils.buildMetadata`, and add one MOR 
inline-compaction case asserting the compaction commit has no 
`hoodie.rli.lookup.*` key?



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/ExecutorMetrics.java:
##########
@@ -0,0 +1,135 @@
+/*
+ * 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.common.metrics.Registry;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Commit-boundary drain for executor-collected metrics, generic over {@link 
ExecutorMetricRegistry}. On
+ * the shared commit path, so it covers Spark DataSource, Spark SQL and 
DeltaStreamer alike.
+ */
+public class ExecutorMetrics {
+
+  private ExecutorMetrics() {
+  }
+
+  /**
+   * Snapshots into commit metadata without consuming. Split from {@link 
#publishAndRelease} so a commit
+   * that never lands neither loses its counters nor publishes gauges for 
rolled-back work. An all-zero
+   * registry is skipped to keep residue off the timeline; zeros are otherwise 
kept, since an explicit
+   * {@code misses=0} is meaningful.
+   */
+  public static DrainedCounters snapshotIntoCommitMetadata(Map<String, String> 
commitMetadata,
+                                                           HoodieWriteConfig 
config) {
+    return snapshotIntoCommitMetadata(commitMetadata, config, 
Arrays.asList(ExecutorMetricRegistry.values()));
+  }
+
+  /** Visible for testing the collection machinery against a group it does not 
ship with. */
+  static DrainedCounters snapshotIntoCommitMetadata(Map<String, String> 
commitMetadata,
+                                                    HoodieWriteConfig config,
+                                                    Collection<? extends 
ExecutorMetricGroup> groups) {
+    List<Drained> drained = new ArrayList<>();
+    for (ExecutorMetricGroup metricRegistry : groups) {
+      if (!metricRegistry.isEnabled(config)) {
+        continue;
+      }
+      Registry registry = Registry.REGISTRY_MAP.get(
+          Registry.makeKey(config.getTableName(), 
metricRegistry.scopedName(config.getBasePath())));
+      if (registry == null) {
+        continue;
+      }
+      Map<String, Long> counts = new HashMap<>();
+      boolean recordedSomething = false;
+      for (Map.Entry<String, Long> counter : 
registry.getAllCounts(false).entrySet()) {
+        if (counter.getValue() == null) {
+          continue;
+        }
+        counts.put(counter.getKey(), counter.getValue());
+        recordedSomething |= counter.getValue() != 0L;
+      }
+      if (!recordedSomething) {

Review Comment:
   A commit that performs no lookup (insert-only, bulk_insert, an empty 
micro-batch) takes this `continue`, so the `rli.lookup.*` gauges registered by 
an earlier commit keep that commit's values and a scheduled reporter 
(pushgateway) re-emits them indefinitely; per key, `dedupe.*` goes stale while 
`tag.*` updates, so the two sinks disagree. HUDI-3373 (#4760) added 
`HoodieMetrics.updateMetricsForEmptyData` for exactly this on the `commit.*` 
gauges. Could we publish explicit zeros for the group's counter names when the 
registry is missing or all-zero (keeping the timeline skip), or add these names 
to `updateMetricsForEmptyData`?



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/ExecutorMetrics.java:
##########
@@ -0,0 +1,135 @@
+/*
+ * 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.common.metrics.Registry;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Commit-boundary drain for executor-collected metrics, generic over {@link 
ExecutorMetricRegistry}. On
+ * the shared commit path, so it covers Spark DataSource, Spark SQL and 
DeltaStreamer alike.
+ */
+public class ExecutorMetrics {
+
+  private ExecutorMetrics() {
+  }
+
+  /**
+   * Snapshots into commit metadata without consuming. Split from {@link 
#publishAndRelease} so a commit
+   * that never lands neither loses its counters nor publishes gauges for 
rolled-back work. An all-zero
+   * registry is skipped to keep residue off the timeline; zeros are otherwise 
kept, since an explicit
+   * {@code misses=0} is meaningful.
+   */
+  public static DrainedCounters snapshotIntoCommitMetadata(Map<String, String> 
commitMetadata,
+                                                           HoodieWriteConfig 
config) {
+    return snapshotIntoCommitMetadata(commitMetadata, config, 
Arrays.asList(ExecutorMetricRegistry.values()));
+  }
+
+  /** Visible for testing the collection machinery against a group it does not 
ship with. */
+  static DrainedCounters snapshotIntoCommitMetadata(Map<String, String> 
commitMetadata,
+                                                    HoodieWriteConfig config,
+                                                    Collection<? extends 
ExecutorMetricGroup> groups) {
+    List<Drained> drained = new ArrayList<>();
+    for (ExecutorMetricGroup metricRegistry : groups) {
+      if (!metricRegistry.isEnabled(config)) {
+        continue;
+      }
+      Registry registry = Registry.REGISTRY_MAP.get(
+          Registry.makeKey(config.getTableName(), 
metricRegistry.scopedName(config.getBasePath())));
+      if (registry == null) {
+        continue;
+      }
+      Map<String, Long> counts = new HashMap<>();
+      boolean recordedSomething = false;
+      for (Map.Entry<String, Long> counter : 
registry.getAllCounts(false).entrySet()) {
+        if (counter.getValue() == null) {
+          continue;
+        }
+        counts.put(counter.getKey(), counter.getValue());
+        recordedSomething |= counter.getValue() != 0L;
+      }
+      if (!recordedSomething) {
+        continue;
+      }
+      counts.forEach((name, value) ->
+          commitMetadata.put(metricRegistry.commitMetadataPrefix() + name, 
String.valueOf(value)));
+      drained.add(new Drained(metricRegistry, registry, counts));
+    }
+    return drained.isEmpty() ? DrainedCounters.EMPTY : new 
DrainedCounters(drained);
+  }
+
+  /**
+   * Release subtracts what was published rather than clearing, so a straggler 
task's update arriving after
+   * the snapshot survives. Publishing here rather than letting the reporter 
scrape is what lets both sinks
+   * work at once: {@link Registry#getAllMetrics} consumes the registry when 
it scrapes.
+   */
+  public static void publishAndRelease(DrainedCounters counters, HoodieMetrics 
hoodieMetrics) {

Review Comment:
   A failed or abandoned attempt's counters are stamped on whatever commits 
next, so that commit's `key_count` no longer describes its own records. Your 
earlier branch fixed exactly this at `dd4a48c` (clear at `startCommit`: "an 
abandoned 80-key attempt followed by a 10-key commit reported 80"); this PR 
reverses it and asserts the opposite. Could we restore clear-at-`startCommit`, 
flip the `AcrossFailedCommit` assertion to "the retry reports only its own 
lookups", and add an insert-after-failed-upsert case?
   
   <details><summary>Why clear-at-startCommit is safe, and what else picks up 
leftovers</summary>
   
   - `startCommit` precedes every in-tree lookup: `HoodieSparkSqlWriter:533` 
before `:553 handleInsertDuplicates`; `StreamSync:877` before `:1104`; 
write-path `tagLocation` runs inside `upsert/delete`.
   - Leftovers also land on `commitTableChange` (ALTER SCHEMA, 
`BaseHoodieWriteClient:1872`) and on a successful empty commit 
(`hoodie.allow.empty.commit=false` returns at L255, above the snapshot).
   - With `hoodie.metrics.on=true` the carry-over does not hold anyway: 
`DefaultSource:194 cleanup()` -> `Metrics.shutdownAllMetrics()` -> 
`Registry.getAllMetrics(true, true)` clears every registry after each 
DataSource write.
   </details>



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDReadClient.java:
##########
@@ -210,8 +212,16 @@ public JavaRDD<HoodieRecord<T>> 
filterExists(JavaRDD<HoodieRecord<T>> hoodieReco
    * @return Tagged RDD of Hoodie records
    */
   public JavaRDD<HoodieRecord<T>> tagLocation(JavaRDD<HoodieRecord<T>> 
hoodieRecords) throws HoodieIndexException {
-    return HoodieJavaRDD.getJavaRDD(
-        index.tagLocation(HoodieJavaRDD.of(hoodieRecords), context, 
hoodieTable));
+    // Lookups driven from the read client are dedupe traffic, not 
tag-location traffic. Label them so
+    // the two are distinguishable in the reported counters. Driver-side only: 
the label is
+    // captured when the lookup closure is built.
+    String previousCaller = 
RecordIndexLookupMetrics.setCaller(RecordIndexMetricNames.CALLER_DEDUPE);

Review Comment:
   `CALLER_DEDUPE` has no test coverage: `grep -rn CALLER_DEDUPE` under 
`src/test` returns nothing, and `TestRliMetricsOnStreamerPath` asserts only 
`tag.*`. It is reachable on the DataSource path too (`insert.drop.duplicates` 
-> `HoodieSparkSqlWriter:553` -> `resolveDuplicates`), which is cheaper to test 
than a streamer arm. Also `checkExists` (L188) calls `index.tagLocation` 
directly, so it is labelled `tag` while `filterExists` is `dedupe`, contrary to 
the comment above. Could we add one DataSource case with 
`insert.drop.duplicates=true` asserting both `dedupe.*` and `tag.*` on the same 
commit, and set the label once for the whole read client?



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/RecordIndexLookupMetrics.java:
##########
@@ -0,0 +1,117 @@
+/*
+ * 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.index;
+
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.metrics.Registry;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.metrics.DistributedRegistry;
+import org.apache.hudi.metrics.ExecutorMetricGroup;
+import org.apache.hudi.metrics.ExecutorMetricRegistry;
+import org.apache.hudi.metrics.RecordIndexMetricNames;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/** Executor-side emission for the record index lookup counters. */
+public class RecordIndexLookupMetrics {
+
+  /** Set by the read client around its own tagging call, so dedupe traffic is 
attributable separately. */
+  private static final ThreadLocal<String> CALLER =
+      ThreadLocal.withInitial(() -> 
RecordIndexMetricNames.CALLER_TAG_LOCATION);
+
+  private RecordIndexLookupMetrics() {
+  }
+
+  public static String currentCaller() {
+    return CALLER.get();
+  }
+
+  /** Restore rather than clear, so a nested tagging call does not reset the 
label. */
+  public static String setCaller(String caller) {
+    String previous = CALLER.get();
+    CALLER.set(caller);
+    return previous;
+  }
+
+  public static void restoreCaller(String previous) {
+    CALLER.set(previous);
+  }
+
+  /**
+   * The registries a lookup task collects into, keyed by bare name. Includes 
every entry on
+   * {@link ExecutorMetricRegistry}. Delivery is by closure capture, which is 
deterministic; resolution is
+   * by name, which lets code below the write API take part without a 
signature change.
+   */
+  public static Map<String, Registry> resolveBundle(HoodieEngineContext 
context, HoodieWriteConfig config) {
+    return resolveBundle(context, config, 
Arrays.asList(ExecutorMetricRegistry.values()));
+  }
+
+  /** Visible for testing the bundle against a group the enum does not ship 
with. */
+  public static Map<String, Registry> resolveBundle(HoodieEngineContext 
context, HoodieWriteConfig config,
+                                                    Collection<? extends 
ExecutorMetricGroup> groups) {
+    Map<String, Registry> bundle = new HashMap<>();
+    for (ExecutorMetricGroup metricRegistry : groups) {
+      if (!metricRegistry.isEnabled(config)) {
+        continue;
+      }
+      Registry registry = context.getMetricRegistry(config.getTableName(),

Review Comment:
   `getMetricRegistry` starts with `tableName.isEmpty()`, and 
`config.getTableName()` can be null: `TBL_NAME` has no default and 
`Builder.validate()` only requires `BASE_PATH`. Pre-PR that line sat behind 
`hoodie.metrics.on`; now every RLI `tagLocation` reaches it, so the public 
`SparkRDDReadClient(ctx, basePath, sqlCtx, RECORD_INDEX)` ctor (L104) or any 
write config built without `forTable()` NPEs on an RLI table. Could we skip 
collection when the table name is null or empty?
   ```suggestion
         String tableName = config.getTableName();
         if (tableName == null || tableName.isEmpty()) {
           continue;
         }
         Registry registry = context.getMetricRegistry(tableName,
   ```



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/ExecutorMetricRegistry.java:
##########
@@ -0,0 +1,107 @@
+/*
+ * 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.common.metrics.Registry;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.function.Predicate;
+
+/**
+ * Every class of executor-collected metric, and the only thing a new one is 
added to. The driver must
+ * declare it up front because an {@code AccumulatorV2} must be registered 
with the {@code SparkContext}
+ * before a task can contribute; the bundle sent to executors and the commit 
drain both iterate this.
+ */
+public enum ExecutorMetricRegistry implements ExecutorMetricGroup {
+
+  RECORD_INDEX_LOOKUP(
+      "HoodieRecordIndexLookup",
+      "hoodie.rli.lookup.",

Review Comment:
   Once written these keys are permanent timeline content, so the naming is 
worth settling now. No existing extra-metadata key starts with `hoodie.` 
(`schema`, `deltastreamer.checkpoint.key`, `latest_schema`, and #18183's 
`hudi.version` / `engine` / `config.<key>`, which deliberately prefixes real 
config keys with `config.`), so `hoodie.` reads as a config key that does not 
exist. The full key also repeats itself: 
`hoodie.rli.lookup.tag.lookup_record_index_key_count`. Could the prefix be 
`rli.lookup.` (or `metrics.record.index.lookup.`) and the counters drop the 
`lookup_record_index_` segment, e.g. `rli.lookup.tag.key_count`?



##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestRliMetricsOnStreamerPath.java:
##########
@@ -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.utilities.deltastreamer;
+
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.testutils.HoodieTestUtils;
+import org.apache.hudi.config.HoodieIndexConfig;
+import org.apache.hudi.metrics.RecordIndexMetricNames;
+
+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(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"));
+  }
+
+  private static Map<String, String> rliCountersOnLatestCommit(String 
tableBasePath) throws Exception {
+    HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder()
+        .setConf(HoodieTestUtils.getDefaultStorageConf())
+        .setBasePath(tableBasePath)
+        .build();
+    metaClient.reloadActiveTimeline();
+    HoodieInstant lastInstant = metaClient.getActiveTimeline()
+        .getCommitsTimeline().filterCompletedInstants().lastInstant().get();
+    HoodieCommitMetadata commitMetadata = 
metaClient.getActiveTimeline().readCommitMetadata(lastInstant);
+    Map<String, String> rli = new HashMap<>();
+    commitMetadata.getExtraMetadata().forEach((k, v) -> {
+      if (k.startsWith(RecordIndexMetricNames.COMMIT_METADATA_PREFIX)) {
+        rli.put(k, v);
+      }
+    });
+    return rli;
+  }
+
+  private static String tagKey(String metric) {
+    return RecordIndexMetricNames.COMMIT_METADATA_PREFIX
+        + 
RecordIndexMetricNames.key(RecordIndexMetricNames.CALLER_TAG_LOCATION, metric);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  public void testRliCountersReachCommitMetadataOnStreamerPath(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 = tagKey(RecordIndexMetricNames.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(tagKey(RecordIndexMetricNames.KEY_HIT_COUNT)));
+    long misses = 
Long.parseLong(counters.get(tagKey(RecordIndexMetricNames.KEY_MISS_COUNT)));
+    assertTrue(records > 0, "the upsert sync looked up at least one key");
+    assertEquals(records, hits + misses, "hits + misses must account for every 
key looked up");

Review Comment:
   `records > 0` plus `records == hits + misses` cannot fail on a doubled 
count: `misses` is computed as `records - hits` at the emission site 
(`RecordIndexLookupMetrics:111`), so the identity holds by construction. This 
is the only DeltaStreamer-path test and the values are deterministic (CI logged 
`1000 / 500 / 500` on both arms: `sourceLimit=1000`, then 500 updates + 500 
inserts). Could we assert the exact counts instead?
   ```suggestion
       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");
   ```



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/RecordIndexLookupMetrics.java:
##########
@@ -0,0 +1,117 @@
+/*
+ * 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.index;
+
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.metrics.Registry;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.metrics.DistributedRegistry;
+import org.apache.hudi.metrics.ExecutorMetricGroup;
+import org.apache.hudi.metrics.ExecutorMetricRegistry;
+import org.apache.hudi.metrics.RecordIndexMetricNames;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/** Executor-side emission for the record index lookup counters. */
+public class RecordIndexLookupMetrics {
+
+  /** Set by the read client around its own tagging call, so dedupe traffic is 
attributable separately. */
+  private static final ThreadLocal<String> CALLER =
+      ThreadLocal.withInitial(() -> 
RecordIndexMetricNames.CALLER_TAG_LOCATION);
+
+  private RecordIndexLookupMetrics() {
+  }
+
+  public static String currentCaller() {
+    return CALLER.get();
+  }
+
+  /** Restore rather than clear, so a nested tagging call does not reset the 
label. */
+  public static String setCaller(String caller) {
+    String previous = CALLER.get();
+    CALLER.set(caller);
+    return previous;
+  }
+
+  public static void restoreCaller(String previous) {
+    CALLER.set(previous);
+  }
+
+  /**
+   * The registries a lookup task collects into, keyed by bare name. Includes 
every entry on
+   * {@link ExecutorMetricRegistry}. Delivery is by closure capture, which is 
deterministic; resolution is
+   * by name, which lets code below the write API take part without a 
signature change.
+   */
+  public static Map<String, Registry> resolveBundle(HoodieEngineContext 
context, HoodieWriteConfig config) {
+    return resolveBundle(context, config, 
Arrays.asList(ExecutorMetricRegistry.values()));
+  }
+
+  /** Visible for testing the bundle against a group the enum does not ship 
with. */
+  public static Map<String, Registry> resolveBundle(HoodieEngineContext 
context, HoodieWriteConfig config,
+                                                    Collection<? extends 
ExecutorMetricGroup> groups) {
+    Map<String, Registry> bundle = new HashMap<>();
+    for (ExecutorMetricGroup metricRegistry : groups) {
+      if (!metricRegistry.isEnabled(config)) {

Review Comment:
   This gate makes the RLI registry the first thing to enter 
`DISTRIBUTED_REGISTRY_MAP` without opt-in (pre-PR: only 
`DistributedRegistryUtil`, behind `hoodie.metrics.on` AND 
`hoodie.metrics.executor.enable`). That static map is captured into every 
`HoodieSparkEngineContext.map/mapToPair/flatMap` closure (L141-206), so every 
such job for every table in the JVM now carries one accumulator per RLI table 
per task, and nothing evicts. Could the gate follow `LOCK_METRICS_ENABLE` 
(`.withInferFunction(cfg -> cfg.getBoolean(TURN_METRICS_ON))`), and could this 
registry stay out of the engine-context captures, which only need the FS 
registries?
   
   <details><summary>Per-task cost</summary>
   
   The closure carries an empty `copyAndReset` copy (cheap), but per task Spark 
registers it with the `TaskContext`, ships an update back even when zero, and 
retains one unnamed `AccumulableInfo` per task per accumulator in the driver 
AppStatusStore/UI and the event log. The single-table overhead run in the 
description cannot show this.
   </details>



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/DistributedRegistry.java:
##########
@@ -68,9 +79,36 @@ public void add(String name, long value) {
 
   @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) {

Review Comment:
   These guards throw from inside a task, and `release()`'s copy runs at 
`BaseHoodieWriteClient:287` after the commit has landed, inside a `try` that 
catches only `IOException`, so a metrics problem would surface as a failed 
write on a completed instant. That contradicts the policy stated in 
`NoOpRegistry` ("a metrics gap must not fail a write"), and 
`Metrics.registerGauge` already swallows for the same reason. Could both guards 
`LOG.warn` and return instead, and the drain at L264/L287 be wrapped in a 
catch-all?



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/RecordIndexMetricNames.java:
##########
@@ -0,0 +1,56 @@
+/*
+ * 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;
+
+/**
+ * Counter names for the record index lookup phase. Collection itself is 
generic -- see
+ * {@link ExecutorMetricRegistry} and {@link ExecutorMetrics}.
+ */
+public class RecordIndexMetricNames {
+
+  /** Scoping, prefix and reporter naming all live on the enum entry. */
+  public static final String REGISTRY_NAME = 
ExecutorMetricRegistry.RECORD_INDEX_LOOKUP.registryName();
+
+  public static final String COMMIT_METADATA_PREFIX =
+      ExecutorMetricRegistry.RECORD_INDEX_LOOKUP.commitMetadataPrefix();
+
+  // Counters are tagged by caller so dedupe traffic is distinguishable from 
tag-location traffic.
+  public static final String CALLER_TAG_LOCATION = "tag";
+  public static final String CALLER_DEDUPE = "dedupe";
+
+  public static final String KEY_COUNT = "lookup_record_index_key_count";

Review Comment:
   Two small things on the names. (1) The value counts records, not distinct 
keys (`keysToLookup` has one entry per `HoodieRecord`), while the name says 
`key_count` and the config doc says "records looked up". (2) L37-43 re-declare 
the literal strings of `HoodieMetadataMetrics.LOOKUP_RECORD_INDEX_*`, dead 
since `2a0d2aeef6a0` (HUDI-7391), and `HoodieBackedTableMetadata:356` still 
carries the `TODO [HUDI-9544]` the description says this closes. Could the 
counter be `record_count` (or the doc say keys), and could the dead constants 
be referenced or deleted and the TODO updated?



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/RecordIndexMetricNames.java:
##########
@@ -0,0 +1,56 @@
+/*
+ * 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;
+
+/**
+ * Counter names for the record index lookup phase. Collection itself is 
generic -- see
+ * {@link ExecutorMetricRegistry} and {@link ExecutorMetrics}.
+ */
+public class RecordIndexMetricNames {
+
+  /** Scoping, prefix and reporter naming all live on the enum entry. */
+  public static final String REGISTRY_NAME = 
ExecutorMetricRegistry.RECORD_INDEX_LOOKUP.registryName();
+
+  public static final String COMMIT_METADATA_PREFIX =
+      ExecutorMetricRegistry.RECORD_INDEX_LOOKUP.commitMetadataPrefix();
+
+  // Counters are tagged by caller so dedupe traffic is distinguishable from 
tag-location traffic.
+  public static final String CALLER_TAG_LOCATION = "tag";
+  public static final String CALLER_DEDUPE = "dedupe";
+
+  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. Revives the 
third dead upstream constant,
+   * {@code HoodieMetadataMetrics.LOOKUP_RECORD_INDEX_TIME_STR}. */
+  public static final String LOOKUP_TIME = "lookup_record_index_time";

Review Comment:
   `SparkRDDWriteClient:189` already publishes `index.lookup.duration` via 
`HoodieMetrics.updateIndexMetrics` (driver wall-clock for the whole 
`tagLocation`), so this adds a second lookup-time metric with a different 
meaning (sum of per-shard executor time). Could we drop `LOOKUP_TIME`, or state 
in the javadoc and the config doc how the two differ?



##########
hudi-common/src/main/java/org/apache/hudi/common/config/metrics/HoodieMetricsConfig.java:
##########
@@ -109,6 +109,17 @@ 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

Review Comment:
   nit, feel free to ignore: every sibling boolean here has a `Builder` setter 
(`withExecutorMetrics`, `withLockingMetrics`, `withCompactionLogBlockMetrics`). 
Could this one get `withRecordIndexLookupMetrics(boolean)` for parity?



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java:
##########
@@ -278,11 +279,43 @@ public String getApplicationId() {
     return javaSparkContext.sc().applicationId();
   }
 
+  /**
+   * Drops a registry from both process-wide maps. Only for tests that create 
their own SparkContexts:
+   * without it they leave accumulators bound to stopped contexts behind for 
whatever runs next in the
+   * same JVM.
+   */
+  @VisibleForTesting
+  public static void removeMetricRegistryForTesting(String tableName, String 
registryName) {
+    DISTRIBUTED_REGISTRY_MAP.remove(tableName.isEmpty() ? registryName : 
tableName + "." + registryName);
+    Registry.REGISTRY_MAP.remove(Registry.makeKey(tableName, registryName));
+  }
+
   @Override
   public Registry getMetricRegistry(String tableName, String registryName) {

Review Comment:
   The process-wide map is what forces the SHA-256 digest, the stale-context 
branch, the replacement branch below and `removeMetricRegistryForTesting`, and 
it is the source of the null-`tableName` NPE and the unbounded growth raised in 
`RecordIndexLookupMetrics`. #19063 lists "tying registry lifetime to 
`HoodieEngineContext` / write client rather than the JVM process" as the 
intended follow-up, and it looks reachable: `StreamSync:1104` reuses the write 
client's context for dedupe, and `HoodieSparkSqlWriter:553` has 
`client.getEngineContext` in scope (`DataSourceUtils:298` currently builds a 
throwaway one). Could the registry be an instance field on 
`HoodieSparkEngineContext` keyed by base path, with `commitStats` reading it 
off `context`?



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metrics/DistributedRegistry.java:
##########
@@ -48,9 +51,17 @@ public String getName() {
   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) {

Review Comment:
   `isRegisteredWith` + the `compute` rewrite, the `set()` executor guard and 
release-at-commit are gaps 2, 7 and 8 of #19063 (the guard is proposed there 
verbatim), and they fix `hoodie.metrics.executor.enable` on master today for 
the existing `HoodieWrapperFileSystem` user. That issue's own breakdown puts 
them in PR 1 / PR 2, and the description here does not reference it. Could the 
`DistributedRegistry` / `getMetricRegistry` hardening go out as its own PR 
under #19063, leaving this one to add the counters on top?



##########
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.metrics.Registry
+import org.apache.hudi.config.HoodieIndexConfig
+import org.apache.hudi.metrics.RecordIndexMetricNames
+
+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 la
+ */
+abstract class RliLookupMetricsTestBase extends RecordLevelIndexTestBase {

Review Comment:
   The drain has one engine-agnostic hook (`commitStats:264/287`), but the 
functional suite re-covers it 30 times: 79 full MDT+RLI table writes and 30 
SparkContext restarts on the single UT_FT_2 job. Could we consolidate to 
roughly 4 classes / 28 writes?
   
   - drop `TestRliLookupMetricsOnDataSourceMor*`: no instrumented code branches 
on table type (22 writes)
   - fold the one distinct SQL case (non-prepped UPDATE exact counts) into the 
existing `TestGlobalRecordLevelIndexWithSQL`
   - replace `TestRliLookupMetricsReporting` (8 writes, 4 SparkContexts) with 
one `TestHoodieMetrics` unit test calling `publishAndRelease`
   - drop `TestRliLookupMetricsMultiTable`: its tables have different 
`TBL_NAME`s, which separate the keys on their own, so it cannot exercise the 
digest 
(`TestRecordIndexMetricNames.countersAreScopedByBasePathNotOnlyByTableName` 
does)
   - drop the AcrossFailedCommit Partitioned twin and the streamer partitioned 
arm (covered by `TestRliLookupMetricsOnDataSourcePartitioned`)
   - unit side: merge `TestExecutorMetricsGenericity` into 
`TestRecordIndexMetricNames`; fold `TestRegistryExecutorLookup` into the 
existing `TestRegistry` (3 of 7 pass on master unchanged); merge the two 
self-managed-SparkContext `DistributedRegistry` files, dropping the 16-thread 
test (`ConcurrentHashMap.compute` serialises per key, so it cannot fail) and 
`testRepeatedEvaluationDoubleCounts` (pins Spark, not Hudi)



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsOnSparkSql.scala:
##########
@@ -0,0 +1,134 @@
+/*
+ * 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.RecordIndexMetricNames
+
+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"))
+
+    clearRliRegistry()
+  }
+
+  /**
+   * 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,

Review Comment:
   This assertion has no positive control in the method: the seed is an INSERT 
(no lookup), so `counters.isEmpty` also holds if the feature is broken or 
mis-gated. Could the test first assert that a preceding upsert did publish (as 
`TestRliLookupMetricsOnDataSource:85` does) before asserting the prepped UPDATE 
is silent?



##########
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.metrics.Registry
+import org.apache.hudi.config.HoodieIndexConfig
+import org.apache.hudi.metrics.RecordIndexMetricNames
+
+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 la

Review Comment:
   nit: the sentence is cut off.
   ```suggestion
    * Shared plumbing for the record level index lookup metric tests: index 
selection, and reading the counters back the way an operator would -- off the 
latest completed commit.
   ```



##########
hudi-common/src/test/java/org/apache/hudi/common/TestRegistryExecutorLookup.java:
##########
@@ -0,0 +1,167 @@
+/*
+ * 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.common;
+
+import org.apache.hudi.common.metrics.ExecutorMetricsContext;
+import org.apache.hudi.common.metrics.LocalRegistry;
+import org.apache.hudi.common.metrics.Registry;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Characterizes how an executor reaches a driver-published registry by name. 
*/
+public class TestRegistryExecutorLookup {

Review Comment:
   Same as the genericity test: `publishAsDriverDoes` and the bare-name lookups 
leave non-zero entries in the process-wide `Registry.REGISTRY_MAP` with no 
`@AfterEach`; `TestRegistry.testGetAllMetrics:64` in this package only survives 
because its own `flush=true` scrape wipes them. Could this file get the same 
cleanup (or be folded into `TestRegistry`)?



##########
hudi-common/src/main/java/org/apache/hudi/common/config/metrics/HoodieMetricsConfig.java:
##########
@@ -109,6 +109,17 @@ 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(true)
+      .markAdvanced()
+      .sinceVersion("1.3.0")
+      .withDocumentation("Collect counters for the record level index lookup 
phase (records looked up, "

Review Comment:
   The doc reads as engine-neutral, but only the Spark RLI lookup emits: Flink 
maps `RECORD_LEVEL_INDEX` to `FlinkInMemoryStateIndex` and the Java client has 
no RLI case, so on those engines the drain finds no registry. Two write clients 
on the same table in one JVM (OCC, in-process async services) also share one 
registry and both snapshot before either releases. Could the doc say "Spark 
only" and note that counters are per table per JVM rather than per writer?



##########
hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistry.java:
##########
@@ -184,6 +187,131 @@ public void testAddMetricsParallel() {
     Assertions.assertEquals(finalExpectedSum, metricCounts.get(METRIC_1));
   }
 
+  @Test
+  public void testSetThrowsOnExecutor() {
+    // 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/Then: set() invoked on an executor must fail - it is 
non-commutative under accumulator merges.
+    // The UnsupportedOperationException thrown on the executor surfaces 
wrapped in a SparkException.
+    assertFailsOnExecutorWith("DistributedRegistry.set() must not be called 
from a Spark executor", () ->
+        engineContext.map(data, value -> {
+          registry.set(METRIC_1, value);
+          return null;
+        }, 1));
+  }
+
+  @Test
+  public void testReleaseThrowsOnExecutor() {
+    // Given: a registry registered to the spark context
+    String registryName = REGISTRY_NAME + "_testReleaseOnExecutor";
+    Registry registry = engineContext.getMetricRegistry("", registryName);
+
+    List<Integer> data = new ArrayList<>();
+    data.add(1);
+
+    // When/Then: release() invoked on an executor must fail - clamping and 
eviction are order-dependent
+    // under accumulator merges. The UnsupportedOperationException surfaces 
wrapped in a SparkException.
+    assertFailsOnExecutorWith("DistributedRegistry.release() must not be 
called from a Spark executor", () ->
+        engineContext.map(data, value -> {
+          registry.release(Collections.singletonMap(METRIC_1, (long) value));
+          return null;
+        }, 1));
+  }
+
+  /**
+   * Asserts the job failed because the executor-side guard fired, not for 
some unrelated reason such as a serialization error.
+   */
+  private static void assertFailsOnExecutorWith(String expectedMessage, 
Executable executable) {

Review Comment:
   nit, feel free to ignore: matching the exception message substring means a 
reword of the guard text breaks these two tests without any behaviour change. 
Could `assertFailsOnExecutorWith` match `UnsupportedOperationException` on the 
cause chain instead?



##########
hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metrics/TestExecutorMetricsGenericity.java:
##########
@@ -0,0 +1,170 @@
+/*
+ * 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.common.metrics.ExecutorMetricsContext;
+import org.apache.hudi.common.metrics.LocalRegistry;
+import org.apache.hudi.common.metrics.Registry;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The claim this feature exists to support is that adding a class of executor 
metric costs a declaration
+ * plus two lines at the emission site. These tests hold that claim to a 
measurement rather than an
+ * argument, by collecting a group the shipping code has never heard of.
+ */
+public class TestExecutorMetricsGenericity {
+
+  private static final String BASE_PATH = "file:///tmp/test_generic_metrics";
+
+  /** A class of metric added by a hypothetical future contributor: a name, a 
prefix, and no config. */
+  private static final ExecutorMetricGroup STORAGE_CALLS = new 
ExecutorMetricGroup() {
+    @Override
+    public String registryName() {
+      return "HoodieStorageCalls";
+    }
+
+    @Override
+    public String commitMetadataPrefix() {
+      return "hoodie.storage.calls.";
+    }
+
+    @Override
+    public String metricAction() {
+      return "storage";
+    }
+
+    @Override
+    public String metricQualifier() {
+      return "calls";
+    }
+
+    @Override
+    public boolean isEnabled(HoodieWriteConfig config) {
+      return true;
+    }
+
+    @Override
+    public String scopedName(String basePath) {
+      return registryName() + ".test";
+    }
+  };
+
+  private static HoodieWriteConfig config() {
+    return 
HoodieWriteConfig.newBuilder().withPath(BASE_PATH).forTable("generic_metrics_table").build();
+  }
+
+  private static Registry seed(HoodieWriteConfig cfg) {
+    Registry registry = new 
LocalRegistry(STORAGE_CALLS.scopedName(cfg.getBasePath()));
+    Registry.REGISTRY_MAP.put(

Review Comment:
   These `put`s go into the process-wide `Registry.REGISTRY_MAP` with no 
`@AfterEach`, and `twoGroupsInOneCommitDoNotMix` never releases, so `open=3` / 
`tag.lookup_record_index_key_count=11` outlive the class under 
`reuseForks=true`; the sibling `TestRecordIndexMetricNames:52` cleans up. Also 
the `allMatch` at L117 cannot fail (the map was just filled from a single-group 
list). Could this get the same `@AfterEach removeIf`, and the `allMatch` be 
dropped?



##########
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.metrics.Registry
+import org.apache.hudi.config.HoodieIndexConfig
+import org.apache.hudi.metrics.RecordIndexMetricNames
+
+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 la
+ */
+abstract class RliLookupMetricsTestBase extends RecordLevelIndexTestBase {
+
+  /** Overridden by the partitioned subclasses; both variants are separate 
closures on separate paths. */
+  protected def isPartitionedRli: Boolean = false
+
+  /**
+   * Table type under test. Tagging is an index-level concern and does not 
branch on table type, so MOR
+   * is expected to behave identically -- the MOR subclasses exist to prove 
that rather than assume it.
+   */
+  protected def tableTypeOpt: String = 
DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL
+
+  protected def indexLabel: String = {
+    val idx = if (isPartitionedRli) "partitioned RLI" else "global RLI"
+    val tt = if (tableTypeOpt == 
DataSourceWriteOptions.MOR_TABLE_TYPE_OPT_VAL) "MOR" else "COW"
+    s"$idx, $tt"
+  }
+
+  /**
+   * `commonOpts` turns the global record index on, so the metadata-partition 
flags and the index type
+   * have to be flipped together to select the partitioned variant.
+   */
+  protected def rliOpts: Map[String, String] = {
+    val withTableType = Map(DataSourceWriteOptions.TABLE_TYPE.key -> 
tableTypeOpt)
+    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")
+    }
+  }
+
+  protected def counterKey(caller: String, metric: String): String =
+    RecordIndexMetricNames.COMMIT_METADATA_PREFIX + 
RecordIndexMetricNames.key(caller, metric)
+
+  protected def tagKey(metric: String): String =
+    counterKey(RecordIndexMetricNames.CALLER_TAG_LOCATION, metric)
+
+  /**
+   * A caller that looked something up stamps its full counter set, zeros 
included, so an absent key means
+   * that caller contributed nothing at all. The default is defensive against 
exactly that case.
+   */
+  protected def counterOrZero(counters: Map[String, String], caller: String, 
metric: String): Long =
+    counters.getOrElse(counterKey(caller, metric), "0").toLong
+
+  /** The counters as an operator would read them: off the latest completed 
commit. */
+  protected def rliCountersFromLatestCommit(): Map[String, String] = {
+    metaClient.reloadActiveTimeline()
+    val lastInstant = 
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants().lastInstant().get()
+    
metaClient.getActiveTimeline.readCommitMetadata(lastInstant).getExtraMetadata.asScala.toMap
+      .filter { case (k, _) => 
k.startsWith(RecordIndexMetricNames.COMMIT_METADATA_PREFIX) }
+  }
+
+  /** Leftover counters from a previous write would otherwise be folded into 
the next commit. */
+  protected def clearRliRegistry(): Unit = {

Review Comment:
   `clearRliRegistry()` is a no-op at all five call sites: `@TempDir` is per 
method, so the base-path digest (and the registry key) is unique per test, and 
the seeding write is an INSERT that this suite itself asserts produces no 
counters. Could the helper and its calls be deleted, so the tests do not imply 
a cross-write leak that the per-method temp dir already rules out?



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/RecordIndexLookupMetrics.java:
##########
@@ -0,0 +1,117 @@
+/*
+ * 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.index;
+
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.metrics.Registry;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.metrics.DistributedRegistry;
+import org.apache.hudi.metrics.ExecutorMetricGroup;
+import org.apache.hudi.metrics.ExecutorMetricRegistry;
+import org.apache.hudi.metrics.RecordIndexMetricNames;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/** Executor-side emission for the record index lookup counters. */
+public class RecordIndexLookupMetrics {
+
+  /** Set by the read client around its own tagging call, so dedupe traffic is 
attributable separately. */
+  private static final ThreadLocal<String> CALLER =
+      ThreadLocal.withInitial(() -> 
RecordIndexMetricNames.CALLER_TAG_LOCATION);
+
+  private RecordIndexLookupMetrics() {
+  }
+
+  public static String currentCaller() {
+    return CALLER.get();
+  }
+
+  /** Restore rather than clear, so a nested tagging call does not reset the 
label. */
+  public static String setCaller(String caller) {
+    String previous = CALLER.get();
+    CALLER.set(caller);
+    return previous;
+  }
+
+  public static void restoreCaller(String previous) {
+    CALLER.set(previous);
+  }
+
+  /**
+   * The registries a lookup task collects into, keyed by bare name. Includes 
every entry on
+   * {@link ExecutorMetricRegistry}. Delivery is by closure capture, which is 
deterministic; resolution is
+   * by name, which lets code below the write API take part without a 
signature change.
+   */
+  public static Map<String, Registry> resolveBundle(HoodieEngineContext 
context, HoodieWriteConfig config) {
+    return resolveBundle(context, config, 
Arrays.asList(ExecutorMetricRegistry.values()));
+  }
+
+  /** Visible for testing the bundle against a group the enum does not ship 
with. */
+  public static Map<String, Registry> resolveBundle(HoodieEngineContext 
context, HoodieWriteConfig config,
+                                                    Collection<? extends 
ExecutorMetricGroup> groups) {
+    Map<String, Registry> bundle = new HashMap<>();
+    for (ExecutorMetricGroup metricRegistry : groups) {
+      if (!metricRegistry.isEnabled(config)) {
+        continue;
+      }
+      Registry registry = context.getMetricRegistry(config.getTableName(),
+          metricRegistry.scopedName(config.getBasePath()));
+      // Only the accumulator-backed registry aggregates back to the driver, 
so anything else is left out
+      // rather than bound: a bound LocalRegistry would collect on the 
executor and be dropped on the floor,
+      // whereas leaving it out makes the lookup resolve to a no-op that 
reports nothing.
+      if (registry instanceof DistributedRegistry) {
+        bundle.put(metricRegistry.registryName(), registry);
+      }
+    }
+    return bundle.isEmpty() ? Collections.emptyMap() : bundle;
+  }
+
+  /**
+   * 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.
+   *
+   * @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(String caller, Collection<String> 
keysLookedUp,

Review Comment:
   The query read path (`PartitionedRecordLevelIndexSupport.scala:107`, 1-arg 
ctor, `caller = null`) and the disabled path both still start the timer, run 
the O(n) `found::contains` scan and build `"null.<metric>"` keys, all into 
`NoOpRegistry`. Could this return before the scan when the registry is a no-op 
(or, if the bundle is passed in directly, when it is null)?



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/ExecutorMetricRegistry.java:
##########
@@ -0,0 +1,107 @@
+/*
+ * 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.common.metrics.Registry;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.function.Predicate;
+
+/**
+ * Every class of executor-collected metric, and the only thing a new one is 
added to. The driver must
+ * declare it up front because an {@code AccumulatorV2} must be registered 
with the {@code SparkContext}
+ * before a task can contribute; the bundle sent to executors and the commit 
drain both iterate this.
+ */
+public enum ExecutorMetricRegistry implements ExecutorMetricGroup {
+
+  RECORD_INDEX_LOOKUP(
+      "HoodieRecordIndexLookup",
+      "hoodie.rli.lookup.",
+      "rli",
+      "lookup",
+      HoodieWriteConfig::isRecordIndexLookupMetricsEnabled);
+
+  private final String registryName;
+  private final String commitMetadataPrefix;
+  private final String metricAction;
+  private final String metricQualifier;
+  private final Predicate<HoodieWriteConfig> enabled;
+
+  ExecutorMetricRegistry(String registryName, String commitMetadataPrefix, 
String metricAction,
+                         String metricQualifier, Predicate<HoodieWriteConfig> 
enabled) {
+    this.registryName = registryName;
+    this.commitMetadataPrefix = commitMetadataPrefix;
+    this.metricAction = metricAction;
+    this.metricQualifier = metricQualifier;
+    this.enabled = enabled;
+  }
+
+  /** The bare name emitting code passes to {@link 
Registry#getRegistry(String)}. */
+  @Override
+  public String registryName() {
+    return registryName;
+  }
+
+  @Override
+  public String commitMetadataPrefix() {
+    return commitMetadataPrefix;
+  }
+
+  @Override
+  public String metricAction() {
+    return metricAction;
+  }
+
+  @Override
+  public String metricQualifier() {
+    return metricQualifier;
+  }
+
+  /** Gating here, rather than in the drain, is what lets a new class of 
metric need no new config. */
+  @Override
+  public boolean isEnabled(HoodieWriteConfig config) {
+    return enabled.test(config);
+  }
+
+  /**
+   * Driver-side {@code REGISTRY_MAP} key. Table name alone is not an 
identity: two tables can share one
+   * and would then share a registry. Executors use the bare {@link 
#registryName()}.
+   */
+  @Override
+  public String scopedName(String basePath) {

Review Comment:
   Because this name contains a `.`, `Registry.getAllMetrics(true, true)` (run 
by `Metrics.shutdown`, i.e. after every DataSource write with metrics on) skips 
the common prefix and republishes any leftover counters as 
`<table>.HoodieRecordIndexLookup.<12-hex>.tag.*`, a per-base-path metric name, 
while also clearing the registry. Could this registry be excluded from the 
common scrape, or given a digest-free name for it?



##########
hudi-io/src/main/java/org/apache/hudi/common/metrics/Registry.java:
##########
@@ -190,6 +199,28 @@ static void setRegistries(Collection<Registry> registries) 
{
    */
   void set(String name, long value);
 
+  /**
+   * Subtract a set of counts previously read out of this registry, clamping 
every counter at zero.
+   *
+   * Used to hand a batch of counters over to a consumer that owns them from 
then on -- the commit-boundary
+   * drain for the record index lookup counters -- without discarding whatever 
arrived after they were read.
+   *
+   * Clamping is what distinguishes this from {@code add(name, -value)}. The 
registry can be emptied
+   * underneath a caller by an unrelated destructive scrape ({@link 
#getAllMetrics(boolean, boolean)} with
+   * {@code flush=true} clears every registry in the process), and an 
unbounded subtraction would then leave
+   * negative counters behind for good.
+   *
+   * The default is a best-effort read-modify-write. Implementations able to 
do this atomically should
+   * override it, and should drop counters that reach zero rather than leaving 
them at zero, so a registry
+   * nobody is writing to reads as empty.
+   *
+   * @param counts the counts to release, as returned by {@link 
#getAllCounts(boolean)}.
+   */
+  default void release(Map<String, Long> counts) {

Review Comment:
   This default and the `LocalRegistry` override are unreachable in production: 
the only `release` caller is `ExecutorMetrics:94`, whose registry is always a 
`DistributedRegistry` (Flink/Java never create the drain key), and only 
`TestDistributedRegistry` exercises `release`. Could the default be dropped 
(leaving `release` on `DistributedRegistry`, or abstract), or a `LocalRegistry` 
test added if it is meant to be supported?



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/common/HoodieSparkEngineContext.java:
##########
@@ -278,11 +279,43 @@ public String getApplicationId() {
     return javaSparkContext.sc().applicationId();
   }
 
+  /**
+   * Drops a registry from both process-wide maps. Only for tests that create 
their own SparkContexts:
+   * without it they leave accumulators bound to stopped contexts behind for 
whatever runs next in the
+   * same JVM.
+   */
+  @VisibleForTesting
+  public static void removeMetricRegistryForTesting(String tableName, String 
registryName) {

Review Comment:
   Two things here. (1) In local mode `setRegistries` also indexes the same 
registry under `Registry.makeKey("", prefixedName)`, so this remover and the 
staleness branch below evict only the `table::registry` key and the accumulator 
bound to the stopped context survives under `::table.registry`. (2) This is the 
only `*ForTesting` public static in `src/main`; the convention is 
`@VisibleForTesting` on a normally named method. Could it also drop the 
`""`-keyed entry and be renamed `removeMetricRegistry`?



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/ExecutorMetricGroup.java:
##########
@@ -0,0 +1,45 @@
+/*
+ * 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.config.HoodieWriteConfig;
+
+/**
+ * One class of executor-collected metric. Implemented by {@link 
ExecutorMetricRegistry}, which enumerates
+ * the ones that ship; kept an interface so the collection machinery can be 
exercised against a group it
+ * does not know about.
+ */
+public interface ExecutorMetricGroup {

Review Comment:
   This interface has one production implementer (the enum, one constant), both 
`groups` overloads are only reachable from tests, and the 3-arg 
`RecordIndexLookupMetrics.resolveBundle` (L72) has no caller at all. #19063 
notes the next executor-metric users (index and MDT read paths) need histogram 
primitives first, so the second group is likely a different shape. Could we 
inline this to one concrete class now (also dropping 
`TestExecutorMetricsGenericity` and the test-only public helpers 
`RecordIndexMetricNames.registryName` / `DrainedCounters.isEmpty`), and extract 
the abstraction when a second group lands?



##########
hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metrics/TestDistributedRegistry.java:
##########
@@ -184,6 +187,131 @@ public void testAddMetricsParallel() {
     Assertions.assertEquals(finalExpectedSum, metricCounts.get(METRIC_1));
   }
 
+  @Test
+  public void testSetThrowsOnExecutor() {
+    // 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/Then: set() invoked on an executor must fail - it is 
non-commutative under accumulator merges.
+    // The UnsupportedOperationException thrown on the executor surfaces 
wrapped in a SparkException.
+    assertFailsOnExecutorWith("DistributedRegistry.set() must not be called 
from a Spark executor", () ->
+        engineContext.map(data, value -> {
+          registry.set(METRIC_1, value);
+          return null;
+        }, 1));
+  }
+
+  @Test
+  public void testReleaseThrowsOnExecutor() {
+    // Given: a registry registered to the spark context
+    String registryName = REGISTRY_NAME + "_testReleaseOnExecutor";
+    Registry registry = engineContext.getMetricRegistry("", registryName);
+
+    List<Integer> data = new ArrayList<>();
+    data.add(1);
+
+    // When/Then: release() invoked on an executor must fail - clamping and 
eviction are order-dependent
+    // under accumulator merges. The UnsupportedOperationException surfaces 
wrapped in a SparkException.
+    assertFailsOnExecutorWith("DistributedRegistry.release() must not be 
called from a Spark executor", () ->
+        engineContext.map(data, value -> {
+          registry.release(Collections.singletonMap(METRIC_1, (long) value));
+          return null;
+        }, 1));
+  }
+
+  /**
+   * Asserts the job failed because the executor-side guard fired, not for 
some unrelated reason such as a serialization error.
+   */
+  private static void assertFailsOnExecutorWith(String expectedMessage, 
Executable executable) {
+    SparkException thrown = Assertions.assertThrows(SparkException.class, 
executable);
+    StringBuilder chain = new StringBuilder();
+    for (Throwable t = thrown; t != null; t = t.getCause()) {
+      chain.append(t).append('\n');
+      if (t.getCause() == t) {
+        break;
+      }
+    }
+    Assertions.assertTrue(chain.toString().contains(expectedMessage),
+        "expected the executor-side guard to fail the job, got: " + chain);
+  }
+
+  @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 testGetMetricRegistryReplacesNonDistributedRegistry() {

Review Comment:
   This test never enters the branch it names: `getMetricRegistry` removes the 
seeded `LocalRegistry` at `HoodieSparkEngineContext:308` before 
`getRegistryOfClass` runs, so `computeIfAbsent` creates a fresh 
`DistributedRegistry` and the `!(instanceof)` fallback at L310 stays uncovered. 
Could the test seed via `Registry.getRegistryOfClass` from a second thread 
released after the remove (or the branch be dropped if the instance-scoped 
registry lands)?



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/RecordIndexLookupMetrics.java:
##########
@@ -0,0 +1,117 @@
+/*
+ * 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.index;
+
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.metrics.Registry;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.metrics.DistributedRegistry;
+import org.apache.hudi.metrics.ExecutorMetricGroup;
+import org.apache.hudi.metrics.ExecutorMetricRegistry;
+import org.apache.hudi.metrics.RecordIndexMetricNames;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/** Executor-side emission for the record index lookup counters. */
+public class RecordIndexLookupMetrics {
+
+  /** Set by the read client around its own tagging call, so dedupe traffic is 
attributable separately. */
+  private static final ThreadLocal<String> CALLER =
+      ThreadLocal.withInitial(() -> 
RecordIndexMetricNames.CALLER_TAG_LOCATION);
+
+  private RecordIndexLookupMetrics() {
+  }
+
+  public static String currentCaller() {
+    return CALLER.get();
+  }
+
+  /** Restore rather than clear, so a nested tagging call does not reset the 
label. */
+  public static String setCaller(String caller) {
+    String previous = CALLER.get();
+    CALLER.set(caller);
+    return previous;
+  }
+
+  public static void restoreCaller(String previous) {
+    CALLER.set(previous);
+  }
+
+  /**
+   * The registries a lookup task collects into, keyed by bare name. Includes 
every entry on
+   * {@link ExecutorMetricRegistry}. Delivery is by closure capture, which is 
deterministic; resolution is
+   * by name, which lets code below the write API take part without a 
signature change.
+   */
+  public static Map<String, Registry> resolveBundle(HoodieEngineContext 
context, HoodieWriteConfig config) {
+    return resolveBundle(context, config, 
Arrays.asList(ExecutorMetricRegistry.values()));
+  }
+
+  /** Visible for testing the bundle against a group the enum does not ship 
with. */
+  public static Map<String, Registry> resolveBundle(HoodieEngineContext 
context, HoodieWriteConfig config,
+                                                    Collection<? extends 
ExecutorMetricGroup> groups) {
+    Map<String, Registry> bundle = new HashMap<>();
+    for (ExecutorMetricGroup metricRegistry : groups) {
+      if (!metricRegistry.isEnabled(config)) {
+        continue;
+      }
+      Registry registry = context.getMetricRegistry(config.getTableName(),
+          metricRegistry.scopedName(config.getBasePath()));
+      // Only the accumulator-backed registry aggregates back to the driver, 
so anything else is left out
+      // rather than bound: a bound LocalRegistry would collect on the 
executor and be dropped on the floor,
+      // whereas leaving it out makes the lookup resolve to a no-op that 
reports nothing.
+      if (registry instanceof DistributedRegistry) {
+        bundle.put(metricRegistry.registryName(), registry);
+      }
+    }
+    return bundle.isEmpty() ? Collections.emptyMap() : bundle;
+  }
+
+  /**
+   * 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.
+   *
+   * @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(String caller, Collection<String> 
keysLookedUp,
+                                       Collection<String> foundKeys, long 
elapsedMs) {
+    if (keysLookedUp.isEmpty()) {
+      return;
+    }
+    Registry registry = 
Registry.getRegistry(RecordIndexMetricNames.REGISTRY_NAME);

Review Comment:
   Both callers of `recordShardLookup` already hold `metricsBundle` as a field 
(`SparkMetadataTableGlobalRecordLevelIndex:214`, 
`PartitionedRecordIndexFileGroupLookupFunction:92`), so resolving by name here 
can only return `metricsBundle.get(REGISTRY_NAME)`; nothing shipped emits 
without the bundle in hand. Passing that `Registry` in directly (null -> 
return) would let `ExecutorMetricsContext`, `NoOpRegistry`, 
`TestRegistryExecutorLookup`, the `HoodieSparkTable` binding and the 
`Registry.getRegistry` contract change go (about 330 lines, both hudi-io 
additions) with identical behaviour. Could we take that shape and defer name 
resolution until an emitter that lacks the bundle exists?



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/PartitionedRecordIndexFileGroupLookupFunction.java:
##########
@@ -44,36 +47,57 @@ public class PartitionedRecordIndexFileGroupLookupFunction
     implements PairFlatMapFunction<Iterator<Pair<String, String>>, String, 
HoodieRecordGlobalLocation> {
 
   private final HoodieTableMetadata metadataTable;
+  // Empty when no counters should be collected; see 
RecordIndexLookupMetrics#resolveBundle.
+  private final Map<String, Registry> metricsBundle;
+  private final String caller;
 
+  /** Uninstrumented, for the query-side read path. */
   public PartitionedRecordIndexFileGroupLookupFunction(HoodieTableMetadata 
metadataTable) {
+    this(metadataTable, Collections.emptyMap(), null);
+  }
+
+  public PartitionedRecordIndexFileGroupLookupFunction(HoodieTableMetadata 
metadataTable,
+                                                       Map<String, Registry> 
metricsBundle, String caller) {
     this.metadataTable = metadataTable;
+    this.metricsBundle = metricsBundle;
+    this.caller = caller;
   }
 
   @Override
   public Iterator<Tuple2<String, HoodieRecordGlobalLocation>> 
call(Iterator<Pair<String, String>> partitionPathRecordKeyIterator) {
-    String partitionName = null;
-    List<String> keysToLookup = new ArrayList<>();
-    while (partitionPathRecordKeyIterator.hasNext()) {
-      Pair<String, String> partitionPathRecordKey = 
partitionPathRecordKeyIterator.next();
-      keysToLookup.add(partitionPathRecordKey.getRight());
-      if (partitionName == null) {
-        partitionName = partitionPathRecordKey.getLeft();
+    // Bound for the whole task so a metric raised deeper in the lookup 
resolves here too.

Review Comment:
   nit, feel free to ignore: the binding is released in the `finally` before 
Spark consumes the returned iterator, so it covers `call()` (the shard read), 
not the whole task.
   ```suggestion
       // Bound for the duration of the shard read so a metric raised deeper in 
the lookup resolves here too.
   ```



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestRliLookupMetricsReporting.scala:
##########
@@ -0,0 +1,114 @@
+/*
+ * 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.metrics.HoodieMetricsConfig
+import org.apache.hudi.metrics.{ExecutorMetricRegistry, RecordIndexMetricNames}
+
+import org.apache.spark.sql.SaveMode
+import org.junit.jupiter.api.{Tag, Test}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
+
+import java.io.{ByteArrayOutputStream, PrintStream}
+
+/** The counters must reach a live metrics reporter, not only commit metadata. 
*/
+@Tag("functional")
+class TestRliLookupMetricsReporting extends RliLookupMetricsTestBase {
+
+  private def metricsOpts: Map[String, String] = rliOpts ++ Map(
+    HoodieMetricsConfig.TURN_METRICS_ON.key -> "true",
+    HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key -> "CONSOLE")
+
+  /** Captures stdout for the duration of the write. */
+  private def captureStdout(body: => Unit): String = {
+    val buffer = new ByteArrayOutputStream()
+    val original = System.out
+    try {
+      System.setOut(new PrintStream(buffer, true, "UTF-8"))
+      body
+    } finally {
+      System.setOut(original)
+    }
+    buffer.toString("UTF-8")
+  }
+
+  @Test
+  def testCountersReachBothCommitMetadataAndTheReporter(): Unit = {
+    val numUpdates = 12
+
+    doWriteAndValidateDataAndRecordIndex(metricsOpts, 
INSERT_OPERATION_OPT_VAL, SaveMode.Overwrite,
+      validate = false, numInserts = 50)
+    clearRliRegistry()
+
+    val stdout = captureStdout {
+      doWriteAndValidateDataAndRecordIndex(metricsOpts, 
UPSERT_OPERATION_OPT_VAL, SaveMode.Append,
+        validate = false, numUpdates = numUpdates)
+    }
+
+    // Sink 1 -- the timeline.
+    val counters = rliCountersFromLatestCommit()
+    report(s"Reporter test ($indexLabel) -- commit metadata", counters)
+    assertTrue(counters.nonEmpty, "the commit must still carry the counters 
when a reporter is configured")
+    assertEquals(numUpdates.toString, 
counters(tagKey(RecordIndexMetricNames.KEY_HIT_COUNT)))
+    assertEquals((numUpdates + 1).toLong, assertSumInvariant(counters, 
RecordIndexMetricNames.CALLER_TAG_LOCATION))
+
+    // Sink 2 -- the reporter. Gauge names are 
<prefix>.rli.lookup.<caller>.<metric>.
+    val gaugePrefix = 
s"${ExecutorMetricRegistry.RECORD_INDEX_LOOKUP.metricAction}.${ExecutorMetricRegistry.RECORD_INDEX_LOOKUP.metricQualifier}"
+    val reported = stdout.linesIterator.filter(_.contains(gaugePrefix)).toSeq
+
+    println(s"\n===== Reporter test ($indexLabel) -- ConsoleMetricsReporter 
output =====")
+    if (reported.isEmpty) println("  (no rli.lookup gauges printed)") else 
reported.foreach(l => println(s"  ${l.trim}"))
+    
println("=======================================================================\n")
+
+    assertTrue(reported.nonEmpty,
+      s"ConsoleMetricsReporter must publish the '$gaugePrefix' gauges; the 
drain feeds the reporter and " +
+        "commit metadata from a single read, so finding them in the commit but 
not here means the " +
+        "reporter sink regressed")
+    Seq(RecordIndexMetricNames.KEY_HIT_COUNT, 
RecordIndexMetricNames.KEY_MISS_COUNT,
+      RecordIndexMetricNames.KEY_COUNT, 
RecordIndexMetricNames.SHARDS_READ).foreach { metric =>
+      val name = 
s"$gaugePrefix.${RecordIndexMetricNames.key(RecordIndexMetricNames.CALLER_TAG_LOCATION,
 metric)}"
+      assertTrue(stdout.contains(name), s"reporter output must contain the 
gauge '$name'")

Review Comment:
   This checks that the gauge name was printed, never a value, so a regression 
that publishes zeros or a stale map to `Metrics.registerGauges` passes. The 
console output has `value = N` on the next line and the expected value is 
derivable (`numUpdates + 1`). Could the test assert the value too (or, per the 
consolidation note, move to a `TestHoodieMetrics` unit test that reads the 
gauge directly)?



##########
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.metrics.Registry
+import org.apache.hudi.config.HoodieIndexConfig
+import org.apache.hudi.metrics.RecordIndexMetricNames
+
+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 la
+ */
+abstract class RliLookupMetricsTestBase extends RecordLevelIndexTestBase {
+
+  /** Overridden by the partitioned subclasses; both variants are separate 
closures on separate paths. */
+  protected def isPartitionedRli: Boolean = false
+
+  /**
+   * Table type under test. Tagging is an index-level concern and does not 
branch on table type, so MOR
+   * is expected to behave identically -- the MOR subclasses exist to prove 
that rather than assume it.
+   */
+  protected def tableTypeOpt: String = 
DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL
+
+  protected def indexLabel: String = {
+    val idx = if (isPartitionedRli) "partitioned RLI" else "global RLI"
+    val tt = if (tableTypeOpt == 
DataSourceWriteOptions.MOR_TABLE_TYPE_OPT_VAL) "MOR" else "COW"
+    s"$idx, $tt"
+  }
+
+  /**
+   * `commonOpts` turns the global record index on, so the metadata-partition 
flags and the index type
+   * have to be flipped together to select the partitioned variant.
+   */
+  protected def rliOpts: Map[String, String] = {
+    val withTableType = Map(DataSourceWriteOptions.TABLE_TYPE.key -> 
tableTypeOpt)
+    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")
+    }
+  }
+
+  protected def counterKey(caller: String, metric: String): String =
+    RecordIndexMetricNames.COMMIT_METADATA_PREFIX + 
RecordIndexMetricNames.key(caller, metric)
+
+  protected def tagKey(metric: String): String =
+    counterKey(RecordIndexMetricNames.CALLER_TAG_LOCATION, metric)
+
+  /**
+   * A caller that looked something up stamps its full counter set, zeros 
included, so an absent key means
+   * that caller contributed nothing at all. The default is defensive against 
exactly that case.
+   */
+  protected def counterOrZero(counters: Map[String, String], caller: String, 
metric: String): Long =
+    counters.getOrElse(counterKey(caller, metric), "0").toLong
+
+  /** The counters as an operator would read them: off the latest completed 
commit. */
+  protected def rliCountersFromLatestCommit(): Map[String, String] = {
+    metaClient.reloadActiveTimeline()
+    val lastInstant = 
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants().lastInstant().get()
+    
metaClient.getActiveTimeline.readCommitMetadata(lastInstant).getExtraMetadata.asScala.toMap
+      .filter { case (k, _) => 
k.startsWith(RecordIndexMetricNames.COMMIT_METADATA_PREFIX) }
+  }
+
+  /** Leftover counters from a previous write would otherwise be folded into 
the next commit. */
+  protected def clearRliRegistry(): Unit = {
+    Registry.REGISTRY_MAP.asScala.foreach {
+      case (key, registry) => if 
(key.contains(RecordIndexMetricNames.REGISTRY_NAME)) registry.clear()
+    }
+  }
+
+  /** Asserts the core invariant and returns the looked-up count. */
+  protected def assertSumInvariant(counters: Map[String, String], caller: 
String): Long = {
+    val lookedUp = counterOrZero(counters, caller, 
RecordIndexMetricNames.KEY_COUNT)
+    val hits = counterOrZero(counters, caller, 
RecordIndexMetricNames.KEY_HIT_COUNT)
+    val misses = counterOrZero(counters, caller, 
RecordIndexMetricNames.KEY_MISS_COUNT)
+    org.junit.jupiter.api.Assertions.assertEquals(lookedUp, hits + misses,
+      s"hits + misses must account for every key looked up by '$caller'")
+    // A caller that looked something up 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(counterKey(caller, 
RecordIndexMetricNames.LOOKUP_TIME)),
+        s"'$caller' looked up $lookedUp keys but reported no 
${RecordIndexMetricNames.LOOKUP_TIME}; " +
+          s"counters were ${counters.keys.toSeq.sorted.mkString(", ")}")
+      org.junit.jupiter.api.Assertions.assertTrue(
+        counterOrZero(counters, caller, RecordIndexMetricNames.LOOKUP_TIME) >= 
0L,

Review Comment:
   nit, feel free to ignore: `>= 0L` on an unsigned counter cannot fail (the 
`contains` above is the real check), and the `report(...)` helpers at L124-134 
are `println`s that assert nothing, run 30+ times into CI logs (13 `println`s 
exist across the other 72 functional Scala files combined). Could both go, 
relying on the assertion messages that already interpolate `got $counters`?



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metrics/ExecutorMetrics.java:
##########
@@ -0,0 +1,135 @@
+/*
+ * 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.common.metrics.Registry;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Commit-boundary drain for executor-collected metrics, generic over {@link 
ExecutorMetricRegistry}. On
+ * the shared commit path, so it covers Spark DataSource, Spark SQL and 
DeltaStreamer alike.
+ */
+public class ExecutorMetrics {
+
+  private ExecutorMetrics() {
+  }
+
+  /**
+   * Snapshots into commit metadata without consuming. Split from {@link 
#publishAndRelease} so a commit
+   * that never lands neither loses its counters nor publishes gauges for 
rolled-back work. An all-zero
+   * registry is skipped to keep residue off the timeline; zeros are otherwise 
kept, since an explicit
+   * {@code misses=0} is meaningful.
+   */
+  public static DrainedCounters snapshotIntoCommitMetadata(Map<String, String> 
commitMetadata,
+                                                           HoodieWriteConfig 
config) {
+    return snapshotIntoCommitMetadata(commitMetadata, config, 
Arrays.asList(ExecutorMetricRegistry.values()));
+  }
+
+  /** Visible for testing the collection machinery against a group it does not 
ship with. */
+  static DrainedCounters snapshotIntoCommitMetadata(Map<String, String> 
commitMetadata,
+                                                    HoodieWriteConfig config,
+                                                    Collection<? extends 
ExecutorMetricGroup> groups) {
+    List<Drained> drained = new ArrayList<>();
+    for (ExecutorMetricGroup metricRegistry : groups) {
+      if (!metricRegistry.isEnabled(config)) {
+        continue;
+      }
+      Registry registry = Registry.REGISTRY_MAP.get(
+          Registry.makeKey(config.getTableName(), 
metricRegistry.scopedName(config.getBasePath())));
+      if (registry == null) {
+        continue;
+      }
+      Map<String, Long> counts = new HashMap<>();
+      boolean recordedSomething = false;
+      for (Map.Entry<String, Long> counter : 
registry.getAllCounts(false).entrySet()) {
+        if (counter.getValue() == null) {

Review Comment:
   nit, feel free to ignore: this branch is unreachable. 
`LocalRegistry.getAllCounts` boxes a primitive `long`, and 
`DistributedRegistry`'s map is a `ConcurrentHashMap`, which forbids null 
values. Could it go?



##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/HoodieSparkTable.java:
##########
@@ -142,9 +146,25 @@ protected Option<HoodieTableMetadataWriter> 
getMetadataWriter(
     return Option.empty();
   }
 
+  /**
+   * Carries the task thread's state onto the pool threads Hudi hands record 
writing to.
+   *
+   * <p>The metrics binding travels alongside the {@link TaskContext} for the 
same reason the
+   * TaskContext does: both are thread-local, and {@code 
BoundedInMemoryExecutor} and
+   * {@code DisruptorExecutor} run on threads the task did not create. Without 
this, a metric raised
+   * while writing records resolves against an unbound thread and is discarded.
+   *
+   * <p>Nothing unbinds afterwards. These pools are created and shut down per 
operation, so the thread
+   * does not outlive the binding; a pool that were shared across tables would 
need an unbind hook on
+   * the other side, which no current executor provides.
+   */
   @Override
   public Runnable getPreExecuteRunnable() {
     final TaskContext taskContext = TaskContext.get();
-    return () -> TaskContext$.MODULE$.setTaskContext(taskContext);
+    final Map<String, Registry> metricsBinding = 
ExecutorMetricsContext.capture();
+    return () -> {
+      TaskContext$.MODULE$.setTaskContext(taskContext);
+      ExecutorMetricsContext.bind(metricsBinding);

Review Comment:
   Confirmed on both counts. Every `getPreExecuteRunnable` call site is 
write-stage (`HoodieMergeHelper:154`, `SparkLazyInsertIterable:77`, the 
bootstrap handlers, 
`SingleSparkJobConsistentHashingExecutionStrategy:132/217`), a different stage 
from the lookup, and both lookup functions `unbind` in a `finally`, so 
`capture()` returns `emptyMap` and the only effect of `bind(emptyMap)` is 
`isBound() == true`, which turns every `Registry.getRegistry` miss on those 
pool threads into `NoOpRegistry`. Inert today (no production `getRegistry` runs 
on executors) and untested. Could it be dropped until something actually emits 
from a writer-pool thread?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to