Count primary keys not replaced at removal, not from a stale bitmap clone - #19504
Open
KKcorps wants to merge 3 commits into
Open
Count primary keys not replaced at removal, not from a stale bitmap clone#19504KKcorps wants to merge 3 commits into
KKcorps wants to merge 3 commits into
Conversation
…lone The "Found N primary keys not replaced" warning and the upsertInconsistentRows/partialUpsertKeysNotReplaced metric are both computed from a validDocIds bitmap that was cloned before segment replacement started. Records for those keys can arrive while the replacement runs. Ingestion correctly moves them onto the newer consuming segment, but the replacement is still reading the pre-replacement clone, so keys that were replaced perfectly well are reported as not replaced. The metric is a false positive and the alert built on it is muted in production as a result. Count at the action boundary instead. removeSegment already decides, per key, whether the key still belongs to the segment being removed, inside computeIfPresent under a recordLocation.getSegment() == segment check. That is the authoritative answer. Return how many keys passed that check and report only those, so a key ingestion already moved is never counted. BasePartitionUpsertMetadataManager gains removeSegmentAndGetNumKeysRemoved(IndexSegment, MutableRoaringBitmap), which by default delegates to removeSegment and returns the bitmap cardinality, keeping existing metadata-manager implementations behaving exactly as before. Both ConcurrentMap managers override it and count real removals. The warning and the metric now only fire when the count is above zero. For the consistent-deletes manager the reporting also moves after doRemoveSegment, because that path walks every doc in the segment rather than the valid ones, so the removal itself is where the count becomes knowable. Postmortem: ZD#7481, RCA-299. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyuTYJvV4ARAXR9wSXdjBi
KKcorps
force-pushed
the
kk/data-3101-upsert-metric-action-boundary
branch
from
September 8, 2026 05:44
36637a5 to
444c9bd
Compare
The count alone tells you a table is inconsistent but not which rows, so the next step is always a hunt. Keep the first few primary keys that fail the ownership check and log them. Free when nobody is looking. The list is not allocated unless the manager's logger has DEBUG enabled, so the steady state is one null check per removed key. The keys themselves cost nothing to obtain: they are already in hand at the increment, and UpsertUtils.getPrimaryKeyIterator hands out a fresh PrimaryKey per doc (PrimaryKeyReader.getPrimaryKey(int) allocates), so holding a reference is safe and needs no copy. Bounded at 8 keys per removal. DEBUG rather than WARN on purpose. A primary key is customer data, and every other log line in this package reports key counts rather than key values. The count stays at WARN where it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyuTYJvV4ARAXR9wSXdjBi
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #19504 +/- ##
============================================
- Coverage 67.72% 67.71% -0.01%
Complexity 1430 1430
============================================
Files 3489 3489
Lines 224634 224666 +32
Branches 35468 35473 +5
============================================
+ Hits 152130 152136 +6
- Misses 60485 60499 +14
- Partials 12019 12031 +12
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…e DEBUG line Follow-up on the DEBUG sample: put the keys in the warning that already reports the count, so one line carries both. Found 2 primary keys not replaced for segment: X, first 2: [[200], [300]] Flipping the DEBUG line to WARN in place would have been wrong. removeSegmentAndGetNumKeysRemoved is also on the plain segment-removal path, where removing every key the segment owns is normal, so a warning there would fire on every retention deletion and name customer keys for a healthy operation. Instead the sample is collected into a caller-supplied list. The replacement path passes one and reports it; the plain removal path passes null and collects nothing, so it neither samples nor logs. The base default cannot tell which candidates were actually removed, so it names no keys and logKeysNotReplaced falls back to the count-only message. The isDebugEnabled gate is gone with the DEBUG line. The cost is now one null check per removed key on the plain path, and one bounded ArrayList per replacement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyuTYJvV4ARAXR9wSXdjBi
KKcorps
marked this pull request as ready for review
September 8, 2026 09:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
The "Found N primary keys not replaced" warning and the
upsertInconsistentRows/partialUpsertKeysNotReplacedmetrics are computed from avalidDocIdsbitmap cloned beforesegment replacement began, and never reconciled against the live state afterwards. Keys that
ingestion correctly moved onto a newer segment mid-replacement get reported as not replaced. This
counts at the point where the code already decides, per key, whether the key still belongs to the
segment being removed.
The problem
doAddOrReplaceSegmenttakesvalidDocIdsForOldSegmentas a snapshot of the old segment's validdocs. The replacement then runs, which takes time on a large segment. While it runs, ingestion keeps
going: a new record for one of those primary keys updates the live record-location map and points
the key at the newer consuming segment. That is correct upsert behaviour, and it is exactly what
should happen.
At the end, the code reports
validDocIdsForOldSegment.getCardinality()— the size of the staleclone. Every key that moved during the window is counted as "not replaced" even though it was
replaced properly.
sequenceDiagram participant I as Ingestion participant R as Replacement participant M as Record location map R->>M: clone validDocIds of old segment Note over R: clone says keys {A, B} still valid I->>M: new record for key A M-->>I: A now points at the consuming segment Note over R: replacement finishes R->>R: report clone cardinality = 2 Note over R: ❌ A is counted, but A was replaced correctlyThe consequence is not just a noisy log.
UpsertInconsistentReplicasis built on these metrics andis muted across a large number of production environments because of this false positive, so a real
divergence on those tables now goes unreported.
The approach
removeSegmentalready answers the question correctly. For each candidate key it does acomputeIfPresentand only acts whenrecordLocation.getSegment() == segment, so a key ingestionmoved away fails that check and is skipped. That per-key check is the authoritative answer to "was
this key actually still owned by the old segment".
BasePartitionUpsertMetadataManager.removeSegmentAndGetNumKeysRemoved(IndexSegment, MutableRoaringBitmap), returning how many keys passed that ownership check.removeSegment(...)followed by the bitmap cardinality, so any metadata-managerimplementation that does not override keeps its current behaviour exactly.
ConcurrentMapmanagers, counting inside thecomputeIfPresentwhere theownership check happens.
Key components
BasePartitionUpsertMetadataManagerremoveSegmentAndGetNumKeysRemovedwith a compatible default; reporting moved to use its return valueConcurrentMapPartitionUpsertMetadataManagerremoveSegment(segment, iterator)refactored to share one private body with an optional counterConcurrentMapPartitionUpsertMetadataManagerForConsistentDeletesdoRemoveSegmentAndGetNumKeysRemoved, since that path walks every doc rather than the valid onesFlow after the change
sequenceDiagram participant R as doAddOrReplaceSegment participant Rm as removeSegmentAndGetNumKeysRemoved participant M as Record location map R->>Rm: candidate keys from the (stale) clone loop per candidate key Rm->>M: computeIfPresent(key) alt still points at the old segment M-->>Rm: removed, count it else already moved by ingestion M-->>Rm: untouched, not counted end end Rm-->>R: numKeysStillNotReplaced alt count > 0 R->>R: warn + updateInconsistentRowsMetric else count == 0 R->>R: silent, the replacement was clean endWhy the reporting moved after removal in the consistent-deletes manager
ConcurrentMapPartitionUpsertMetadataManagerForConsistentDeletes.doRemoveSegmentdeliberately walksevery doc in the segment, not just the valid ones, so it can decrement
distinctSegmentCountforevery key that was ever there. That means the count only becomes knowable once the removal has run,
so the warning and metric move below it. The
shouldRevertMetadataOnInconsistencyearly return isunchanged, and the removal itself was already unconditional on that path.
Behaviour changes
Previously they fired with an inflated count. A table with a clean replacement goes from a
spurious warning to silence.
will generally be smaller than what was reported before.
removeSegmentkeeps its signature andbehaviour for every existing caller.
Naming the keys
The count tells you a table is inconsistent but not which rows, so the next step was always a hunt.
The warning now carries a bounded sample of the keys:
The sample is collected into a caller-supplied list rather than logged where it is gathered, and
that placement is the whole design.
removeSegmentAndGetNumKeysRemovedis also on the plainsegment-removal path, where removing every key the segment owns is entirely normal. Logging keys
there would fire a warning on every retention deletion and name customer data for a healthy
operation. So the replacement path passes a list and reports it, while the plain removal path passes
nulland neither samples nor logs.The base class default cannot tell which of its candidates were actually removed, so it names no
keys, and
logKeysNotReplacedfalls back to the count-only message for any implementation that doesnot override the hook.
Capped at 8 keys (
NUM_SAMPLED_KEYS_NOT_REPLACED), because the count itself can be large and aprimary key is customer data. Getting the keys is free: they are already in hand at the increment,
and
UpsertUtils.getPrimaryKeyIteratorhands out a freshPrimaryKeyper doc(
PrimaryKeyReader.getPrimaryKey(int)allocates a new one), so a retained reference is safe andneeds no defensive copy.
Performance considerations
computeIfPresentthe codealready performs per key.
removeSegment(segment, iterator, @Nullable int[] counter)body is shared by thecounting and non-counting paths, so the non-counting path is unchanged apart from a null check per
key.
ArrayListper replacement.The plain removal path allocates nothing.
Testing
BasePartitionUpsertMetadataManagerTest.testReplaceSegmentDoesNotReportKeyMovedBeforeRemovalBasePartitionUpsertMetadataManagerTest.testReplaceSegmentReportsOnlyKeysStillOwnedAtRemovalremoveSegmentAndGetNumKeysRemovedtestsConcurrentMapPartitionUpsertMetadataManagerTest.testRemoveSegmentCountsOnlyKeysStillOwnedBySegmentConcurrentMapPartitionUpsertMetadataManagerForConsistentDeletesTest.testRemoveSegmentCountsOnlyKeysStillOwnedBySegmentEvery test in
org.apache.pinot.segment.local.upsertpasses: 104 tests, 0 failures.spotless,checkstyle,license:formatandlicense:checkare clean onpinot-segment-local.References
where the count is only knowable during the background RocksDB scan. That is a separate change in
the StarTree fork and does not affect this PR.