Move realtime stream offset fetch out of the ideal-state update lock - #19170
Move realtime stream offset fetch out of the ideal-state update lock#19170shounakmk219 wants to merge 8 commits into
Conversation
RealtimeSegmentValidationManager's ensureAllPartitionsConsuming fetched the stream offsets inside the Helix ideal-state update lambda, so on a table with many partitions the offset I/O (which can take minutes) was held under the per-table ideal-state lock and re-run on every ZK CAS retry, stalling concurrent segment commits. Pre-fetch the offsets from a read-only snapshot of the ideal state, outside the lock (preFetchOffsets), and pass them into the package-private ensureAllPartitionsConsuming, whose updater lambda now performs only in-memory ideal-state mutation. Lock hold-time is proportional to the mutation, and the offset fetch runs once regardless of CAS retries. The smallest-offset stream fetch is gated (anyPartitionNeedsSmallestOffset) so it runs only on a reset or when a partition actually needs a new CONSUMING segment. A partition that starts needing repair after the lock-free snapshot is deferred to the next validation run rather than being repaired with substituted start offsets.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #19170 +/- ##
============================================
- Coverage 67.10% 67.09% -0.02%
Complexity 1424 1424
============================================
Files 3468 3468
Lines 222353 222377 +24
Branches 34999 35002 +3
============================================
- Hits 149212 149205 -7
- Misses 61224 61266 +42
+ Partials 11917 11906 -11
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:
|
| // stream round-trips can take minutes; doing them here (against a snapshot of the ideal state) keeps the | ||
| // per-table ideal-state lock hold-time proportional to the in-memory ideal-state mutation, not to the offset | ||
| // I/O. The updater lambda below performs no stream I/O, so it is also cheap to re-run on ZK CAS retries. | ||
| IdealState snapshotIdealState = HelixHelper.getTableIdealState(_helixManager, realtimeTableName); |
There was a problem hiding this comment.
Are we fetching the latest consuming segment offset here? If so, we will lose recent segment commit when using IS snapshot right?
There was a problem hiding this comment.
only the offset pre-fetch inputs come from the snapshot, every read that drives a repair decision or a written offset is from the fresh IS under the lock. We fetch the latestSegmentZKMetadataMap again as part of ensureAllPartitionsConsuming.
There was a problem hiding this comment.
(MAJOR) I'm still confused. _streamMetadataList is provided by the PreFetchedOffsets, and I don't see it being updated during the retry. How does it handle new segment being added?
There was a problem hiding this comment.
the _streamMetadataList answers only "which stream partitions exist and what's their start offset," and a segment commit doesn't change that, for retries we anyways get fresh IS and fresh ZK read (latestSegmentZKMetadataMap) which are used for any mutation decisions.
There was a problem hiding this comment.
Please double check what is stored within the StreamMetadata. Based on my code reading, the PartitionGroupMetadata stores the start offset of the next consuming segment (Pinot knowledge), instead of the oldest offset available from upstream.
There was a problem hiding this comment.
Pull request overview
Moves realtime stream offset fetching outside the IdealState update lock to reduce lock contention and avoid repeated fetches on CAS retries.
Changes:
- Prefetches stream metadata and smallest offsets before IdealState updates.
- Defers repairs when required offsets were not prefetched.
- Adds regression tests for offset handling and restoration.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
PinotLLCRealtimeSegmentManager.java |
Implements off-lock offset prefetching and repair gating. |
PinotLLCRealtimeSegmentManagerTest.java |
Adds tests for prefetched offsets and deferred repairs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| boolean isTableEnabled = idealState.isEnabled(); | ||
| boolean isTablePaused = isTablePaused(idealState); | ||
| boolean offsetsHaveToChange = offsetCriteria != null; | ||
| if (isTableEnabled && !isTablePaused) { | ||
| List<PartitionGroupConsumptionStatus> currentPartitionGroupConsumptionStatusList = | ||
| offsetsHaveToChange ? List.of() | ||
| // offsets from metadata are not valid anymore; fetch for all partitions | ||
| : getPartitionGroupConsumptionStatusList(idealState, streamConfigs); | ||
| // FIXME: Right now, we assume topics are sharing same offset criteria | ||
| OffsetCriteria originalOffsetCriteria = streamConfigs.get(0).getOffsetCriteria(); | ||
| // Read the smallest offset when a new partition is detected | ||
| streamConfigs.stream() | ||
| .forEach(streamConfig -> streamConfig.setOffsetCriteria( | ||
| offsetsHaveToChange ? offsetCriteria : OffsetCriteria.SMALLEST_OFFSET_CRITERIA)); | ||
| List<StreamMetadata> streamMetadataList = | ||
| getNewStreamMetadataList(streamConfigs, currentPartitionGroupConsumptionStatusList, idealState); | ||
| streamConfigs.stream().forEach(streamConfig -> streamConfig.setOffsetCriteria(originalOffsetCriteria)); | ||
| return ensureAllPartitionsConsuming(tableConfig, streamConfigs, idealState, streamMetadataList, | ||
| offsetCriteria); | ||
| return ensureAllPartitionsConsuming(tableConfig, streamConfigs, idealState, | ||
| preFetchedOffsets._streamMetadataList, offsetCriteria, |
There was a problem hiding this comment.
topic level pause was not handled before so skipping it in this refactor as well
…nt lookup Addresses review feedback on the offset pre-fetch: anyPartitionNeedsSmallestOffset previously built a full latest-segment ZK metadata map just to decide whether the smallest-offset fetch was needed, adding a third O(partitions) PropertyStore scan on every healthy validation cycle (on top of getPartitionGroupConsumptionStatusList and the updater's own read). The gate now derives the latest LLC segment per partition from the snapshot ideal state's segment names alone (no ZK reads) and only builds the latest-segment ZK metadata map when the smallest-offset fetch is actually required. Extract the repeated "latest LLC segment per partition" logic into getLatestLLCSegmentPerPartition(Collection<String>) and reuse it in getLatestSegmentZKMetadataMap, getPartitionGroupConsumptionStatusList, and the gate.
…e update The existing test helper called preFetchOffsets and the package-private repair sequentially, so no test covered the public ensureAllPartitionsConsuming path's core guarantee. Add a test that drives the public method with a controlled static HelixHelper: it supplies the snapshot ideal state and applies the updater twice to simulate a ZK CAS conflict, then asserts the stream offset fetch happened before HelixHelper.updateIdealState (outside the updater) and was not repeated on retry.
| // stream round-trips can take minutes; doing them here (against a snapshot of the ideal state) keeps the | ||
| // per-table ideal-state lock hold-time proportional to the in-memory ideal-state mutation, not to the offset | ||
| // I/O. The updater lambda below performs no stream I/O, so it is also cheap to re-run on ZK CAS retries. | ||
| IdealState snapshotIdealState = HelixHelper.getTableIdealState(_helixManager, realtimeTableName); |
There was a problem hiding this comment.
(MAJOR) I'm still confused. _streamMetadataList is provided by the PreFetchedOffsets, and I don't see it being updated during the retry. How does it handle new segment being added?
Minor cleanup in the latest-LLC-segment helpers: rename the local map in getLatestLLCSegmentPerPartition, and pre-size the result map in getLatestSegmentZKMetadataMap with Maps.newHashMapWithExpectedSize since its final size equals the number of latest segments.
| if (partitionIdToSmallestOffset == null) { | ||
| partitionIdToSmallestOffset = | ||
| fetchPartitionGroupIdToSmallestOffset(streamConfigs, idealState, latestSegmentZKMetadataMap); | ||
| LOGGER.info("Smallest stream offsets not fetched this cycle; deferring repair of partition: {} of table: " |
There was a problem hiding this comment.
can this happen? If yes, then why not fetchPartitionGroupIdToSmallestOffset here as fallback?
There was a problem hiding this comment.
this can happen if a partition looses its consuming segment between the snapshot and the lock or retries on updater. Not falling back to fetchPartitionGroupIdToSmallestOffset as this PR is trying to pull out all the IO ops out of the IS lock, it should be picked in the next RVM run anyways.
| latestSegmentName); | ||
| // Do not create a new CONSUMING segment when the partition has no smallest stream offset (it has reached | ||
| // end of life). | ||
| StreamPartitionMsgOffset smallestStreamOffset = partitionIdToSmallestOffset.get(partitionId); |
There was a problem hiding this comment.
Is it guaranteed partitionIdToSmallestOffset will have partitionId?
There was a problem hiding this comment.
yes it should have all the partitionId entries fetched from upstream
| private boolean anyPartitionNeedsSmallestOffset(IdealState idealState) { | ||
| Map<String, Map<String, String>> instanceStatesMap = idealState.getRecord().getMapFields(); | ||
| Map<Integer, LLCSegmentName> partitionGroupIdToLatestSegment = | ||
| getLatestLLCSegmentPerPartition(instanceStatesMap.keySet()); |
There was a problem hiding this comment.
this derives the latest segment from IS names but the repair loop derives it from getLLCSegments (property store), so a CONSUMING segment in IS with no ZK metadata (the orphan IdealStateGroupCommit's cancellation comment describes) keeps the gate false on every run while the loop keeps deferring that partition, and unlike pre-PR nothing heals it (repairSegmentsInErrorState skips metadata-less segments). gating on getLatestLLCSegmentPerPartition(getLLCSegments(realtimeTableName)) against the snapshot IS is one getChildNames call and matches the loop exactly.
| // - null otherwise: the lock-free snapshot gate saw no partition needing a new CONSUMING segment, so the | ||
| // smallest offsets were not fetched. Start offsets are NOT the stream-smallest in this case, so they must | ||
| // not be substituted; a partition that turns out to need a new segment now (it started needing repair after | ||
| // the snapshot) is deferred to the next validation run below. |
There was a problem hiding this comment.
"next validation run" is one RVM period, 1h by default, not the 15 min in the description, and the window that lands a partition here is the whole pre-fetch. pre-PR a last replica going OFFLINE mid-fetch was still repaired in the same run via the CAS retry; worth a forced re-run when the lambda defers, or at least the 1h in the description.
Problem
RealtimeSegmentValidationManager(RVM) callsPinotLLCRealtimeSegmentManager.ensureAllPartitionsConsuming, which fetched the stream offsets inside the Helix ideal-state update lambda (HelixHelper.updateIdealState→IdealStateGroupCommit). On a table with many partitions the offset I/O can take minutes, and because it ran inside the updater it (a) held the per-table ideal-state lock for that whole time — stalling concurrent segment commits — and (b) was re-executed on every ZK version-checked CAS retry.This is the follow-up to #19116 (which batched the Kafka fetch): batching shrinks the fetch, this change removes it from the lock entirely.
Fix
ensureAllPartitionsConsumingnow reads a read-only snapshot of the ideal state (HelixHelper.getTableIdealState), does the early enabled/paused check, and calls a new@VisibleForTesting preFetchOffsets(...)to computestreamMetadataListand the per-partition smallest offsets before enteringHelixHelper.updateIdealState.ensureAllPartitionsConsuming(..., preFetchedPartitionIdToSmallestOffset), which mutates the fresh IS from the pre-fetched offsets. No stream I/O runs under the lock, so lock hold-time is proportional to the mutation and the fetch is not repeated on CAS retries.offsetCriteria != null) or whenanyPartitionNeedsSmallestOffset(...)finds a partition whose latest segment has no CONSUMING replica (the only repair path that consults it). Healthy tables fetch nothing.Testing
PinotLLCRealtimeSegmentManagerTest+RealtimeSegmentValidationManagerTest: 57 tests pass, including new cases —testEnsureAllPartitionsConsumingHonorsPreFetchedSmallestOffset,testEnsureAllPartitionsConsumingDefersRepairWhenSmallestOffsetsNotPreFetched,testPreFetchOffsetsSkipsSmallestOffsetFetchForHealthyTable, andtestPreFetchOffsetsRestoresOffsetCriteriaOnFailure.Concurrency notes (IS updated by another process during an RVM run)
The mutation path is unchanged (serialized per table via
IdealStateGroupCommit, version-checked CAS), so concurrent commits are not lost — a commit landing mid-run causes a CAS retry and RVM re-runs on the newer IS. Only the offset inputs are a snapshot. Residual, all self-healing within one 15-min cycle:Depends on
Complementary to #19116 (batching). Independent at the code level — this PR only touches
pinot-controllerand calls pre-existing stream methods — but both together give the full win (fast fetch + off-lock).