[server] Fix latestProcessedVtPosition corruption during EOP leader-to-local switch#2809
Open
sushantmane wants to merge 1 commit into
Open
[server] Fix latestProcessedVtPosition corruption during EOP leader-to-local switch#2809sushantmane wants to merge 1 commit into
sushantmane wants to merge 1 commit into
Conversation
consumeRemotely flag updateOffsetsFromConsumerRecord branches on `shouldProduceToVersionTopic`, which reads `pcs.consumeRemotely()` at call time. The flag can flip false (during the EOP leader→local switch in checkLongRunningTaskState) between the leader's produce callback and the drainer's processing of the same record. When that happens, in-flight leader-produced records that were consumed from an upstream topic fall through to the local branch and write `consumerRecord.getPosition()` (the upstream-broker offset, with a different topicId) into `latestProcessedVtPosition`. The local-VT slot ends up holding a position that belongs to a different physical shard than the local VT, breaking later position-diff operations (e.g., getIngestionProgressPercentage) with PositionCompareOrDiffException: "cannot compare position of different shards". Switch the dispatch to use the record's `leaderProducedRecordContext`. A non-null context means the record was leader-produced from an upstream consume; its local-VT position is `producedPosition` (the local-broker offset), regardless of the current flag state. updateOffsetsAsRemoteConsumeLeader already handles the chunk sub-case (consumedPosition=EARLIEST) as a no-op. This makes the offset-update decision per-record and race-free across the EOP-to-local transition. ## Testing Done - Compiled with `./gradlew :clients:da-vinci-client:compileJava`. - Unit test to follow in a separate commit (capturing dispatch with both null-context follower path and non-null-context leader-produce path while shouldProduceToVersionTopic returns false).
3 tasks
There was a problem hiding this comment.
Pull request overview
This PR fixes a race in LeaderFollowerStoreIngestionTask.updateOffsetsFromConsumerRecord where an EOP leader-to-local switch can cause the task to write an upstream-broker PubSubPosition into the local version-topic (latestProcessedVtPosition), corrupting ingestion progress tracking and triggering shard/topicId mismatch errors downstream.
Changes:
- Switch offset-update dispatch from the live
consumeRemotely/shouldProduceToVersionTopicflag to the per-recordleaderProducedRecordContextprovenance. - Ensure leader-produced records update local VT progress using
producedPosition(local broker) rather thanconsumerRecord.getPosition()(upstream/source broker).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * updateOffsetsAsRemoteConsumeLeader already handles the chunk-record sub-case | ||
| * (consumedPosition=EARLIEST) as a no-op via its hasCorrespondingUpstreamMessage check. | ||
| */ | ||
| if (leaderProducedRecordContext == null) { |
KaiSernLim
approved these changes
May 20, 2026
Comment on lines
+1886
to
+1902
| /* | ||
| * Dispatch on the record's own provenance, not on the live consumeRemotely flag. | ||
| * | ||
| * A non-null leaderProducedRecordContext means this record was produced to local VT by the | ||
| * leader after consuming it from upstream. Its local-VT position is producedPosition (the | ||
| * local-broker offset returned by the producer callback), not consumerRecord.getPosition() | ||
| * (the offset on the upstream/source topic). | ||
| * | ||
| * Branching on shouldProduceToVersionTopic (which reads pcs.consumeRemotely() live) is racy: | ||
| * if the flag flips false between the leader's produce and the drainer's processing of the | ||
| * same record, the local branch below would capture consumerRecord.getPosition() (the | ||
| * upstream offset on a different broker, with a different topicId) into | ||
| * latestProcessedVtPosition. | ||
| * | ||
| * updateOffsetsAsRemoteConsumeLeader already handles the chunk-record sub-case | ||
| * (consumedPosition=EARLIEST) as a no-op via its hasCorrespondingUpstreamMessage check. | ||
| */ |
Contributor
There was a problem hiding this comment.
Suggested change
| /* | |
| * Dispatch on the record's own provenance, not on the live consumeRemotely flag. | |
| * | |
| * A non-null leaderProducedRecordContext means this record was produced to local VT by the | |
| * leader after consuming it from upstream. Its local-VT position is producedPosition (the | |
| * local-broker offset returned by the producer callback), not consumerRecord.getPosition() | |
| * (the offset on the upstream/source topic). | |
| * | |
| * Branching on shouldProduceToVersionTopic (which reads pcs.consumeRemotely() live) is racy: | |
| * if the flag flips false between the leader's produce and the drainer's processing of the | |
| * same record, the local branch below would capture consumerRecord.getPosition() (the | |
| * upstream offset on a different broker, with a different topicId) into | |
| * latestProcessedVtPosition. | |
| * | |
| * updateOffsetsAsRemoteConsumeLeader already handles the chunk-record sub-case | |
| * (consumedPosition=EARLIEST) as a no-op via its hasCorrespondingUpstreamMessage check. | |
| */ | |
| /* | |
| * leaderProducedRecordContext != null means this record was produced to local VT by the leader after consuming it | |
| * from upstream. Its local-VT position is producedPosition (returned by the producer callback), not | |
| * consumerRecord.getPosition() (the offset on the upstream/source topic). | |
| * | |
| * Branching on shouldProduceToVersionTopic (which reads pcs.consumeRemotely() live) is racy: if the flag flips | |
| * false between the leader's produce and the drainer's processing of the same record, the local branch below would | |
| * capture consumerRecord.getPosition() (the upstream offset on a different broker, with a different topicId) into | |
| * latestProcessedVtPosition. | |
| */ |
super nit: I feel like this comment can be shortened
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.
Problem Statement
LeaderFollowerStoreIngestionTask.updateOffsetsFromConsumerRecordbranches onshouldProduceToVersionTopic(pcs), which readspcs.consumeRemotely()live. The flag can flip false (viasetConsumeRemotely(false)in the EOP leader-to-local switch atcheckLongRunningTaskState) between the leader's produce callback and the drainer's processing of the same record. When that happens, in-flight leader-produced records that were consumed from an upstream topic fall through to the local branch and writeconsumerRecord.getPosition()(the upstream-broker offset, with a different topicId) intolatestProcessedVtPosition. The local-VT slot ends up holding a position that belongs to a different physical shard than the local VT.Downstream effects:
TopicManager.getIngestionProgressPercentage->TopicMetadataFetcher.diffPosition->XcConsumerAdapter.positionDifferencefails withPositionCompareOrDiffException: cannot compare position of different shards(PositionUtils.validateSameShard).topicId, triggering topic-id-mismatch fallbacks in downstream adapters.Solution
Dispatch on the record's own
leaderProducedRecordContextinstead of on the liveconsumeRemotelyflag:context != null-> record was produced to local VT by the leader after consuming from upstream. Its local-VT position isproducedPosition(returned by the local producer callback). Goes throughupdateOffsetsAsRemoteConsumeLeader, which already handles the chunk sub-case (consumedPosition=EARLIEST) as a no-op viahasCorrespondingUpstreamMessage.context == null-> record was consumed by a follower or by a leader consuming local VT directly. Its local-VT position isconsumerRecord.getPosition(). Same body as before.The offset-update decision is now per-record, eliminating the race across the leader-to-local transition (and any other path where the flag and the in-flight record diverge).
Why this is safe across store types
leaderProducedRecordContextis always null (drainer-queue insert atStoreIngestionTask.java:1473-1479passesnull). Falls through to the local branch. No change.LeaderProducerCallbackqueues with a populated context. Goes throughupdateOffsetsAsRemoteConsumeLeader. No change in steady state; the race-prone window during the EOP transition now correctly usesproducedPosition.updateOffsetsFromConsumerRecordvia inheritance. Same dispatch.hasCorrespondingUpstreamMessage=false.updateOffsetsAsRemoteConsumeLeaderskips them today via the internal check, unchanged.Testing Done
./gradlew :clients:da-vinci-client:compileJava.LeaderFollowerStoreIngestionTaskTestcontinues to pass.shouldProduceToVersionTopicreturns false) will follow in a separate commit.