Skip to content

feat(metrics): report record index lookup counters at the commit boundary - #19575

Open
rahil-c wants to merge 7 commits into
apache:masterfrom
rahil-c:rli-lookup-metrics
Open

feat(metrics): report record index lookup counters at the commit boundary#19575
rahil-c wants to merge 7 commits into
apache:masterfrom
rahil-c:rli-lookup-metrics

Conversation

@rahil-c

@rahil-c rahil-c commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Describe the issue this Pull Request addresses

HoodieMetadataMetrics has declared three record-index lookup metrics for some time:

public static final String LOOKUP_RECORD_INDEX_TIME_STR = "lookup_record_index_time";
public static final String LOOKUP_RECORD_INDEX_KEYS_COUNT_STR = "lookup_record_index_key_count";
public static final String LOOKUP_RECORD_INDEX_KEYS_HITS_COUNT_STR = "lookup_record_index_key_hit_count";

Nothing in the repository references any of them, and HoodieBackedTableMetadata#readRecordIndexLocationsWithKeys records why:

// TODO [HUDI-9544]: Metric does not work for rdd based API due to lazy evaluation.

So with RLI enabled there is currently no way to tell how many keys were looked up, how many hit the index, or how many shards were read — which is often the most expensive phase of an upsert.

The numbers already exist. keysToLookup.size() and the size of the returned map are local variables inside RecordIndexFileGroupLookupFunction.call(). But the function returns only the hits — a miss produces no output row — so the driver cannot recover the denominator from the resulting RDD at any price. Recovering records_looked_up or shards_read would each cost an extra Spark job over data already computed, and caller attribution is not reconstructible after the fact at all.

Executors could always count; they had no way to report.

Relates to HUDI-9544 and #19063.

Summary and Changelog

Four counters are emitted per RLI shard and published at each commit, so an operator can see the index hit rate, how much of the index was read, and whether the index is earning its keep.

fix(metrics): harden DistributedRegistry — these registries live in process-wide static maps that outlive the SparkContext, the write client and the table.

  • set() is last-writer-wins, which is neither commutative nor associative; inside an AccumulatorV2 the driver merges executor copies in an unspecified order, so an executor-side set() is non-deterministic. Now rejected from inside a task. No production caller exists today.
  • After a SparkContext restart in the same JVM (shells, notebooks, Spark Connect) the cached registry stayed bound to the dead context and executor updates silently stopped arriving. isRegistered() cannot detect this — it consults a Spark-global weak-reference table that SparkContext.stop() never clears, and the static map pins the registry so it is never collected. The application id is now stamped at registration and compared on lookup; on mismatch the entry is evicted and a fresh instance built, since AccumulatorV2.register() throws if the accumulator already carries registration metadata. Evict-and-recreate is atomic, or racing callers leave two live accumulators for one metric name with only one reachable.

feat(metrics): record and publish the countersrecords_looked_up, hits, misses, shards_read, each tagged by caller so tag-location traffic is distinguishable from the read client's dedupe traffic. The registry is resolved on the driver and captured by the lookup closure, so it rides inside the closure and nothing is resolved by name on an executor. Only increment/add are used.

They are drained once, at the commit boundary, in BaseHoodieWriteClient#commitStats. The single drain is load-bearing: both sinks consume the registry destructively — and Registry.getAllMetrics(flush=true, …) clears it when a reporter scrapes — so draining twice would give commit metadata or the reporter, never both. One read fans out to the commit's extra metadata as hoodie.rli.lookup.<caller>.<metric> and to the metrics reporter as gauges.

Two details matter for correctness. The snapshot is taken before the commit is written but the counters are only released after it lands, so a conflict in preCommit or a failure in commit does not destroy them or publish gauges for rolled-back work. And releasing subtracts exactly what was published rather than clearing, so a straggler task's accumulator update arriving mid-drain is carried into the next commit instead of being silently dropped.

Since every engine write path reaches commitStats, this covers Spark DataSource, all four Spark SQL DML commands and StreamSync by construction. It also gives the DeltaStreamer path a reporting cadence it did not have: registerHoodieCommonMetrics is otherwise reachable only from Metrics.flush() (no production callers) and Metrics.shutdownAllMetrics() (called only by HoodieSparkSqlWriter), so a long-running streaming job previously published these only from the JVM shutdown hook.

test(metrics): functional coverage on all three write paths, against both the global and the partitioned record index — separate closures on separate code paths. Assertions read counters off the latest commit on the timeline. Invariant throughout: hits + misses == records_looked_up == incoming keys.

Measured, all passing:

Path Global RLI Partitioned RLI
DataSource upsert (20 updates + 1 insert) 20 / 1 / 21, 8 shards 20 / 1 / 21, 3 shards
SQL UPDATE (optimized writes off) 60 / 0 / 60, 10 shards 60 / 0 / 60, 3 shards
SQL MERGE INTO 25 / 0 / 25 25 / 0 / 25
DeltaStreamer sync() ×2 500 / 500 / 1000, 10 shards 500 / 500 / 1000, 3 shards

Merge On Read reports identically to Copy On Write (20 / 1 / 21), which is expected — tagging happens in the index, above the layer that decides base-file-rewrite versus log-append — but is asserted rather than assumed.

test(metrics): task retry and stage recompute. Previously documented as at-least-once without being measured; now both halves are pinned, using a dedicated SparkContext because the shared harness runs local[*], which never retries. A task that fails and is re-attempted contributes nothing from the failed attempt — 100 rows processed, 100 counted. A second evaluation of the same uncached RDD counts twice — 200 for a single pass of 100, which is the shape of the real exposure under speculation or a recomputed stage.

test(metrics): multiple tables in one JVM. The registry lives in Registry.REGISTRY_MAP, a process-wide static keyed by table name, and that same map is the origin of several bugs in #19063 including counters leaking between write clients. Two tables are written with interleaved upserts of different sizes; each commit must carry only its own counts, so a leak surfaces as a wrong number rather than only as a missing reset. Measured: A reports 12, B reports 7, despite A's 30 landing between them.

perf(metrics): bounded allocation. An earlier revision built a HashSet over every key routed to a shard in order to count distinct keys — an allocation that grows with shard size, which on a large table means a second copy of millions of keys per task. The membership set is now built from the keys that were found instead, bounding it by hit count; the partitioned path gets it free, since its result is already a map keyed by record key. Counting also switched from distinct keys to records, which is the better answer to "how many records were read" and keeps hits + misses == records_looked_up exact regardless of duplicates.

Scale check

Run outside CI on a 7.5M row table where one upsert tags 1.49M keys across 10 shards:

hoodie.rli.lookup.tag.hits                745,654
hoodie.rli.lookup.tag.misses              745,654
hoodie.rli.lookup.tag.records_looked_up 1,491,308
hoodie.rli.lookup.tag.shards_read              10

Exact. On overhead I can only report that it was not separable from noise on this harness: the bulk-insert phase, which performs no tagging and therefore cannot be affected by this change, varied more between runs than the instrumented upsert did. I would not claim a percentage from it. The harness is not included in this PR — happy to share it if a reviewer wants to reproduce.

Impact

New config hoodie.metrics.rli.lookup.enable, default true. When off, the registry is never resolved and executors do no extra work.

Behaviour worth flagging for reviewers. Spark SQL UPDATE and DELETE perform no record-index lookup at all — they set _hoodie.spark.sql.writes.prepped=true whenever hoodie.spark.sql.optimized.writes.enable is on (it defaults to true), and a prepped write already knows each record's location from the rows it just read. Reporting nothing there is correct rather than a gap, and there are tests pinning both sides so a future change to the prepped path cannot silently alter what operators see. MERGE INTO is not a prepped write and does report.

Timeline footprint. The commit-metadata sink adds roughly eight short entries per commit on an RLI table, and commit metadata is retained permanently including archives. That is the main reason for the config gate, and a fair thing to argue about — see the open question below.

Gauge semantics. The reporter sink publishes gauges, so each commit overwrites the previous value: the reported number is the last commit's, not a running total. rate() will not behave as one might expect on these.

No format change, no public API break.

Risk Level

low

Additive and gated. The registry changes are the only ones touching existing behaviour: the set() rejection has no production callers today, and the staleness eviction only fires where updates were already being silently dropped.

Verified by 39 tests: 13 in hudi-spark-client covering registry lifecycle, SparkContext restart and task-retry semantics; 24 functional tests spanning Spark DataSource, Spark SQL and both table types against both index variants, plus multi-table isolation; and 2 real DeltaStreamer syncs. Plus the out-of-CI scale check above.

Documentation Update

The new config hoodie.metrics.rli.lookup.enable carries a full description, including the timeline-retention caveat. Happy to add a website page describing the counters if reviewers think this warrants one.

Open question for reviewers

Is true the right default? Observability out of the box is the argument for; permanent per-commit timeline growth is the argument against. I lean toward defaulting to false for a release or two and flipping once it has baked, and would rather take that steer now than after it ships.

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

🤖 Generated with Claude Code

https://claude.ai/code/session_01VZsqnoc1EKhEa7459mh6sK

…ross-client leaks

DistributedRegistry instances are cached in process-wide static maps that outlive the
SparkContext, the write client and the table. Three consequences, all on the path any
executor-side metric has to travel:

- set() is last-writer-wins, which is neither commutative nor associative. Inside an
  AccumulatorV2 the driver merges executor copies in an unspecified order, so an
  executor-side set() yields a non-deterministic value. It is now rejected from inside a
  task; increment()/add() remain available. No production caller exists today.

- After a SparkContext restart in the same JVM (shells, notebooks, Spark Connect), the
  cached registry stays bound to the dead context and executor updates silently stop
  arriving. isRegistered() cannot detect this: it consults a Spark-global weak-reference
  table that SparkContext.stop() never clears, and the static map pins the registry so it
  is never collected. The application id is now stamped at registration and compared on
  lookup; on mismatch the entry is evicted and a fresh instance built, because
  AccumulatorV2.register() throws if the accumulator already carries registration
  metadata. Evict-and-recreate is done atomically, or racing callers leave two live
  accumulators for one metric name with only one of them reachable.

- HoodieWrapperFileSystem counters leaked into the next write client for the same table.
  They are now cleared both when the registries are created and when the client closes.

Relates to apache#19063.
…dary

HoodieMetadataMetrics has declared lookup_record_index_time,
lookup_record_index_key_count and lookup_record_index_key_hit_count for some time, but
nothing references them, and readRecordIndexLocationsWithKeys carries
"TODO [HUDI-9544]: Metric does not work for rdd based API due to lazy evaluation."

The numbers exist -- keysToLookup.size() and the size of the returned map are local
variables inside RecordIndexFileGroupLookupFunction.call() -- but the function returns
only the hits, so a miss produces no output row and the driver cannot recover the
denominator from the resulting RDD at any price. Executors could always count; they had
no way to report.

This adds four counters per RLI shard (records_looked_up, hits, misses, shards_read),
tagged by caller so tag-location traffic is distinguishable from the read client's
dedupe traffic. The registry is resolved on the driver and captured by the lookup
closure, so it travels with the closure and nothing is looked up by name on the
executor. Only increment/add are used.

They are drained once, at the commit boundary, in BaseHoodieClient#updateExtraMetadata.
A single drain matters: both sinks consume the registry destructively -- the drain
clears it, and Registry.getAllMetrics(flush=true, ...) clears it when a reporter scrapes
-- so draining twice would yield commit metadata or the reporter, never both. One read
fans out to the commit's extra metadata as hoodie.rli.lookup.<caller>.<metric> and to
the metrics reporter as gauges, then clears.

Because every engine write path reaches commitStats -- Spark DataSource, all four Spark
SQL DML commands and StreamSync -- this covers them all by construction. It also gives
the DeltaStreamer path a reporting cadence it did not have: registerHoodieCommonMetrics
is otherwise reachable only from Metrics.flush() (no production callers) and
Metrics.shutdownAllMetrics() (called only by HoodieSparkSqlWriter), so a streaming job
previously published metrics only from the JVM shutdown hook.

Gated by hoodie.metrics.rli.lookup.enable, default true. The gate matters because commit
metadata is retained in the timeline permanently including archives; when it is off the
registry is never resolved and executors do no extra work.

Note the counters are published as gauges, so each commit overwrites the previous value:
the reported number is the last commit's, not a running total.

Relates to HUDI-9544 and apache#19063.
…e path

Functional coverage across Spark DataSource, Spark SQL and DeltaStreamer, each against
both the global and the partitioned record index -- they are separate closures on
separate code paths. Assertions read the counters off the latest commit on the timeline,
which is what an operator actually consumes.

The invariant throughout is hits + misses == records_looked_up == incoming keys.

Two behaviours worth calling out, both pinned by tests:

- Spark SQL UPDATE and DELETE perform no record index lookup at all. They set
  _hoodie.spark.sql.writes.prepped=true whenever hoodie.spark.sql.optimized.writes.enable
  is on (it defaults to true), and a prepped write already knows each record's location
  from the rows it just read. Reporting nothing there is correct rather than a gap, so
  there are tests for both sides: empty with optimized writes on, exact counts with it
  off. MERGE INTO is not a prepped write and does report.

- TestRliLookupMetricsReporting asserts commit metadata and a live ConsoleMetricsReporter
  are both populated from the same write. That is precisely what a double drain would
  break, since either sink clears the registry as it reads.

RliLookupMetricsTestBase is a class rather than a trait deliberately: the helpers touch
metaClient, a protected Java field on HoodieCommonTestHarness, and a Scala trait compiles
to a separate class that cannot legally reach it.
@github-actions github-actions Bot added the size:XL PR with lines of changes > 1000 label Aug 10, 2026
@codecov-commenter

codecov-commenter commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.47368% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.76%. Comparing base (4853b57) to head (4f2ace6).
⚠️ Report is 6 commits behind head on master.

Files with missing lines Patch % Lines
...e/hudi/client/common/HoodieSparkEngineContext.java 58.33% 3 Missing and 2 partials ⚠️
...rg/apache/hudi/metrics/RecordIndexMetricNames.java 91.30% 0 Missing and 2 partials ⚠️
...rg/apache/hudi/index/RecordIndexLookupMetrics.java 90.90% 0 Missing and 2 partials ⚠️
...a/org/apache/hudi/metrics/DistributedRegistry.java 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19575      +/-   ##
============================================
- Coverage     77.09%   75.76%   -1.33%     
+ Complexity    32490    32111     -379     
============================================
  Files          2522     2524       +2     
  Lines        139112   139267     +155     
  Branches      16714    16768      +54     
============================================
- Hits         107243   105511    -1732     
- Misses        24291    26102    +1811     
- Partials       7578     7654      +76     
Components Coverage Δ
hudi-common 82.26% <100.00%> (-1.00%) ⬇️
hudi-client 79.84% <88.63%> (-2.89%) ⬇️
hudi-flink 85.74% <ø> (+0.39%) ⬆️
hudi-spark-datasource 65.51% <ø> (-5.09%) ⬇️
hudi-utilities 73.69% <ø> (+0.06%) ⬆️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 67.80% <ø> (+4.30%) ⬆️
hudi-sync 75.11% <ø> (ø)
hudi-io 79.32% <ø> (-0.14%) ⬇️
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 64.06% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 50.82% <14.73%> (+0.08%) ⬆️
flink-integration-tests 49.26% <41.17%> (+0.17%) ⬆️
hadoop-mr-java-client 43.83% <41.17%> (+0.09%) ⬆️
integration-tests 13.62% <14.73%> (+0.05%) ⬆️
spark-client-hadoop-common 50.45% <36.84%> (+0.82%) ⬆️
spark-java-tests 41.09% <85.26%> (-10.52%) ⬇️
spark-scala-tests 46.05% <26.31%> (+0.07%) ⬆️
utilities 36.92% <68.42%> (+0.34%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
.../org/apache/hudi/client/BaseHoodieWriteClient.java 81.63% <100.00%> (-1.28%) ⬇️
...java/org/apache/hudi/config/HoodieWriteConfig.java 90.71% <100.00%> (-1.33%) ⬇️
...ava/org/apache/hudi/client/SparkRDDReadClient.java 87.30% <100.00%> (-1.23%) ⬇️
...PartitionedRecordIndexFileGroupLookupFunction.java 100.00% <100.00%> (ø)
...ndex/SparkMetadataTableGlobalRecordLevelIndex.java 92.18% <100.00%> (+0.95%) ⬆️
...hudi/index/SparkMetadataTableRecordLevelIndex.java 95.83% <100.00%> (+0.59%) ⬆️
...udi/common/config/metrics/HoodieMetricsConfig.java 88.75% <100.00%> (+0.51%) ⬆️
...ache/hudi/PartitionedRecordLevelIndexSupport.scala 72.09% <ø> (ø)
...a/org/apache/hudi/metrics/DistributedRegistry.java 91.89% <75.00%> (-2.05%) ⬇️
...rg/apache/hudi/metrics/RecordIndexMetricNames.java 91.30% <91.30%> (ø)
... and 2 more

... and 269 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…ters

Nine issues found reviewing the previous three commits.

Correctness:

- The drain was hooked into BaseHoodieClient#updateExtraMetadata, which
  BaseHoodieTableServiceClient also calls when scheduling table services. Scheduling a
  compaction or clustering therefore consumed the counters and stamped them into the
  *plan*, so the next real commit published nothing. Moved to commitStats, on the commit
  path only.

- The drain also ran before the commit was written, so a conflict in preCommit or a
  failure in commit destroyed the counters and published reporter gauges for work that
  was rolled back. Split into snapshotIntoCommitMetadata (before, non-consuming) and
  publishAndRelease (after the commit lands).

- Releasing now subtracts exactly what was published instead of clearing the registry. A
  clear also discarded anything that arrived between the snapshot and the release -- a
  straggler task's accumulator update, or a concurrent lookup stage.

- resolveRegistry tested `instanceof Serializable`, which is always true: Registry itself
  extends Serializable. The guard never fired, so with a non-Spark engine context a
  driver-only LocalRegistry would serialize into the closure and collect into per-executor
  copies nothing reads, reporting zero. Tests for DistributedRegistry instead, which is
  the only implementation that aggregates back to the driver.

- misses was keysLookedUp - hits, mixing a raw input count with a deduplicated hit count,
  so a batch containing the same key twice reported a phantom miss. Counts distinct keys
  now. Only differs when hoodie.combine.before.upsert is turned off.

- DistributedRegistry#register stamped registeredAppId even when it skipped registration,
  letting any caller re-brand an accumulator bound to a dead SparkContext with the current
  application id -- masking the staleness the field exists to detect.

- getMetricRegistry could hand back a non-distributed registry: another thread can insert
  one under the shared key between the remove and the create, and getRegistryOfClass only
  logs the mismatch. It now replaces the entry rather than returning one that silently
  collects nothing.

Scope and hygiene:

- Dropped the HoodieWrapperFileSystem registry clearing entirely. It is unrelated to the
  record index counters, and clearing on write-client close ran *before*
  Metrics.shutdownAllMetrics reported, so every existing <table>.HoodieWrapperFileSystem.*
  gauge would have silently disappeared for users on the DataSource path.

- Restored the one-argument PartitionedRecordIndexFileGroupLookupFunction constructor
  rather than breaking a public signature and passing two nulls at the read call site.

- setCaller returns the previous label and restoreCaller puts it back, so nested tagging
  cannot silently reset the label to tag-location.

- sinceVersion corrected to 1.3.0, matching the tree.

- TestDistributedRegistryLifecycle removes its entries from both process-wide registry
  maps, instead of leaving accumulators bound to stopped SparkContexts for whatever runs
  next in the JVM.
@rahil-c
rahil-c force-pushed the rli-lookup-metrics branch from c4fa5fa to 5d3fa9e Compare August 11, 2026 23:56
@rahil-c
rahil-c marked this pull request as ready for review August 12, 2026 00:30
…ecompute

The record index lookup counters are incremented inside a Spark transformation, which
carries no exactly-once guarantee. That was documented as at-least-once but never
measured, and a reviewer on apache#19063 asked specifically for it to be verified.

Both halves are now pinned, using their own SparkContext because the shared harness runs
local[*], which never retries -- local[n, maxFailures] is required for a failed task to be
re-attempted at all.

A failed attempt contributes nothing. Partition 0 throws on its first attempt and succeeds
on the retry; 100 rows are processed and 100 are counted, because Spark ships accumulator
updates home only from attempts that succeed. This is the guarantee that makes ordinary
task retries safe for these counters.

A repeated evaluation does double count. Two actions over the same uncached RDD yield 200
for a single pass of 100. This is the shape of the real exposure -- a duplicate successful
attempt under speculation, or a stage recomputed after a fetch failure, reaches the
accumulator the same way. Asserting it keeps the caveat grounded, and makes a change in
either direction visible rather than silent.

If exactness is ever required, per-shard counters merged with max would make the increment
idempotent.
Two coverage gaps that production users would hit immediately.

Merge On Read. Tagging happens in the index, above the layer that decides whether an
update rewrites a base file or appends to a log file, so MOR is expected to produce
identical counters. Expected-to-be-identical is exactly the kind of claim that quietly
stops being true, so the DataSource suite now runs against both table types across both
index variants. Measured identical to COW.

Multiple tables in one JVM. The registry lives in Registry.REGISTRY_MAP, a process-wide
static keyed by table name, and that same static map is the origin of several bugs
catalogued in apache#19063 including counters leaking between write clients. The keying is what
provides isolation, so it is asserted rather than trusted: two tables are written with
interleaved upserts of deliberately different sizes, and each commit must carry only its
own counts. A leak shows up as a wrong number rather than only as a missing reset. A
second test covers the sequential case, where a table written after another finished must
start from zero.

Adds a tableTypeOpt hook to the shared test base so the table type can be varied by
subclass, matching how the index variant is already selected.

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

test. A couple of naming and design nits worth considering.

*
* <p>Lives in hudi-client-common rather than hudi-spark-client so the shared commit path can reach it.
* Because the drain happens on that shared path, it applies to every engine write path -- Spark
* DataSource, Spark SQL and DeltaStreamer -- without any of them knowing about it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit: the name RecordIndexMetricNames signals a constants class, but snapshotIntoCommitMetadata and publishAndRelease are substantive operations. Could you consider something like RecordIndexLookupMetricsHelper or RecordIndexCommitDrain — or splitting the constants into a separate RecordIndexMetricNames interface and putting the drain logic in a class with a name that reflects its responsibility?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

* @param commitMetadata the extra-metadata map being assembled for this commit
* @param config the write config, for the table name and the feature gate
* @return the snapshotted counters, to be handed to {@link #publishAndRelease} on success; empty
* when the feature is off or nothing was recorded

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit: snapshotIntoCommitMetadata mutates commitMetadata in-place AND returns a separate Map<String, Long> — two output channels from one call. Have you considered returning a small result object (or a Pair) that holds both the snapshot and whatever the caller needs to pass to publishAndRelease, so the side-effectful mutation is not implicit in the signature?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

…shard size

recordShardLookup built a HashSet over every key routed to the shard, purely to count
distinct keys. That allocation grows with shard size: harmless on a small table, but on a
large one a shard can hold millions of keys and the set is a second copy of all of them,
per task. This is the only allocation the counters added that scaled with data volume.

The membership set is now built from the keys that were *found* instead. Both collections
are already materialised by the caller, but the found set is bounded by the hit count
rather than by shard size, and the partitioned path gets it for free -- its result is
already a map keyed by record key, so keySet() costs nothing.

Counting switches from distinct keys to records, which is also the better answer to the
question being asked. "How many records were read" and "what is the hit rate" are about
records, so records_looked_up is now how many incoming records the shard was asked about
and hits is how many of them matched. hits + misses == records_looked_up stays exact
whether or not the batch contains duplicate keys, and a duplicate no longer shows up as a
phantom miss. With hoodie.combine.before.upsert at its default the batch is already
deduplicated, so the reported numbers are unchanged for the common configuration.

Verified across the full suite: 13 client tests, 24 Spark functional tests spanning
DataSource, Spark SQL and both table types against both index variants, and 2 real
DeltaStreamer syncs. Also re-checked on a 7.5M row table where a single upsert tags 1.49M
keys, which reports exactly 745,654 hits and 745,654 misses.
@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL PR with lines of changes > 1000

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants