Fail fast in MirrorMaker 2 when source offsets become invalid - #23091
Open
onkar2405 wants to merge 1 commit into
Open
Fail fast in MirrorMaker 2 when source offsets become invalid#23091onkar2405 wants to merge 1 commit into
onkar2405 wants to merge 1 commit into
Conversation
… on, the replication consumer runs with auto.offset.reset=none - Catch OffsetOutOfRangeException in MirrorSourceTask.poll and classify it by the partition's log start offset: >0 means retention purged unreplicated records (DataLossException), ==0 means the topic was recreated (TopicResetException) - Log topic, partition, requested offset and log start offset before failing - Seek uncommitted partitions to the beginning in initializeConsumer, so first starts behave as they do today under auto.offset.reset=earliest - Add unit tests for both detection paths, the disabled path and the config wiring, plus integration tests using deleteRecords and topic recreation
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
MirrorMaker 2 masks two failure modes that matter when a Kafka topic is used as a write-ahead log
and replicated to a DR cluster.
1. Silent data loss. If the source topic's retention policy deletes records before MM2 replicates
them, the consumer's next fetch resumes at a valid-but-later offset. The gap in the replicated stream
is never reported.
2. Source topic reset. If a source topic is deleted and recreated, the tracked offset becomes
invalid. MM2 rewinds to the earliest offset and re-replicates the new topic on top of the previously
mirrored data.
Both share a root cause. MM2 applies
consumer.auto.offset.reset=earliestto the replicationconsumer (
MirrorConnectorConfig#sourceConsumerConfig), so the broker'sOFFSET_OUT_OF_RANGEerroris resolved inside the consumer and never reaches the connector. Even if it did, the only place that
could see it — the broad
catch (KafkaException e)inMirrorSourceTask#poll— logs a warning andreturns
null.This PR adds opt-in offset validation: MM2 detects the invalid offset, logs the topic, partition and
problematic offset, and fails the task with a purpose-built exception.
Design Rationale
Files and classes modified
New
connect/mirror/.../mirror/DataLossException.javaconnect/mirror/.../mirror/TopicResetException.javaconnect/mirror/.../mirror/MirrorSourceTaskOffsetValidationTest.javaconnect/mirror/.../mirror/integration/MirrorConnectorsIntegrationOffsetValidationTest.javaModified
MirrorSourceConfig.javaoffset.validation.enabledconfig;sourceConsumerConfigoverride that setsauto.offset.reset=noneMirrorSourceTask.javaseekToBeginningfor uncommitted partitions;OffsetOutOfRangeExceptionhandling and classificationMirrorSourceConfigTest.javaBoth exceptions extend
ConnectException. Roughly 120 lines of production code and 330 lines oftests.
Why this implementation strategy
Let the consumer detect the problem, rather than checking offsets ourselves.
The alternative is to track the last replicated offset per partition and compare it against the
offset of the next record returned by
poll(), flagging a gap. That duplicates bookkeeping theconsumer already does, only notices a problem once records start flowing again — a purged idle
partition would never be flagged — and adds per-record cost on the hot path.
Setting
auto.offset.reset=nonemakes the broker's own out-of-range signal the trigger. It costsnothing when replication is healthy and it fires on the first fetch after the offset goes bad.
Distinguish the two conditions by log start offset.
Both surface as the same
OffsetOutOfRangeException. The log start offset of the affected partitionseparates them cleanly:
> 0DataLossException== 0TopicResetExceptionMirrorSourceTask#classifyOffsetOutOfRangequeriesConsumer#beginningOffsetsfor the affectedpartitions and applies that rule. When a single batch contains both conditions, data loss wins,
because it is the one with unrecoverable consequences on the target cluster. If the
beginningOffsetslookup itself fails, the task still fails — as
TopicResetException— rather than falling back to thesilent path.
Fail fast rather than auto-recover.
Neither situation has a safe automatic remedy. Skipping the gap loses records with no record of what
was lost; rewinding to
earliestafter a topic recreation duplicates or interleaves data on the DRcluster. Both are operator decisions. Failing the task surfaces the problem through Connect's status
API with a message naming the topic, partition, requested offset and log start offset; the operator
resumes by resetting the connector offsets via the Connect offsets API.
Ship it disabled by default.
Enabling this unconditionally would change the failure semantics of every existing MM2 deployment: a
cluster quietly tolerating retention-driven gaps would start failing tasks after an upgrade.
offset.validation.enableddefaults tofalse, so upgrading is a no-op and operators of WAL-styletopics opt in deliberately.
Let validation override an explicit reset policy.
If a user sets both
offset.validation.enabled=trueandconsumer.auto.offset.reset=latest, theoverride wins and
noneis applied, because any other policy silently defeats the feature. Rejectingthe combination with a
ConfigExceptionwould also be defensible; this is asserted intestOffsetValidationTakesPrecedenceOverExplicitAutoOffsetResetso the choice is explicit ratherthan incidental.
How it integrates with the MirrorMaker 2 lifecycle
MirrorSourceConfig#sourceConsumerConfig(override)auto.offset.reset=nonewhen enabled. The shared helper inMirrorConnectorConfigis untouched, soMirrorCheckpointConnectorandMirrorHeartbeatConnectorkeep their existing consumer settings.MirrorSourceTask#startMirrorSourceTask#initializeConsumerseekToBeginningon them.MirrorSourceTask#pollcatch (OffsetOutOfRangeException)placed ahead of the existingcatch (KafkaException), so the specific case is handled before the generic warn-and-continue path.Preserving first-start behaviour. With
auto.offset.reset=nonethe consumer has no fallbackposition, so a partition MM2 has never replicated would fail on its first poll.
initializeConsumertherefore seeks those partitions to the beginning explicitly. This reproduces exactly what
auto.offset.reset=earliestdid, but only for genuinely new partitions — an offset that exists andis invalid is no longer silently repaired.
No changes to connector startup, task assignment, offset syncs, checkpoints, or heartbeats. Because
both exceptions extend
ConnectException, the Connect runtime's existing task-failure handlingapplies unchanged; no new lifecycle hooks were required.
Configuration
offset.validation.enabledfalseDataLossException/TopicResetExceptionwhen the offset to replicate from is no longer available on the source cluster. Setsconsumer.auto.offset.reset=noneon the replication consumer.Testing
Unit —
MirrorSourceTaskOffsetValidationTest(mocked consumer)data loss when the log start offset is ahead of the requested offset
topic reset when the log start offset is zero
data loss takes precedence in a mixed batch
a failed
beginningOffsetslookup still fails the taskwith the flag off,
pollreturnsnulland never queriesbeginningOffsets— default behaviour preservedseekToBeginningis called for uncommitted partitions only when the flag is ondeterministic partition ordering in the error message
Unit —
MirrorSourceConfigTest(4 added)validation off by default, consumer still gets
auto.offset.reset=earliestvalidation on yields
auto.offset.reset=nonevalidation overrides an explicitly configured reset policy
other consumer configs (
max.poll.records,enable.auto.commit) are unaffectedIntegration —
MirrorConnectorsIntegrationOffsetValidationTest(embedded Connect clusters)Data loss: replicate a topic, stop the source connector, rewind its offsets to the start of each
partition, then
deleteRecordspast that point on the source cluster. Restarting the connectorfails the task with
DataLossException.Topic reset: replicate a topic, stop the source connector, delete and recreate the source topic
with the same partition count, produce a short batch. Restarting the connector fails the task with
TopicResetException.Both drive the failure with
deleteRecordsand topic recreation rather than waiting on retention, sothey are deterministic and not timing-dependent.
AI Usage Documentation
AI was used substantially on this change, in an agentic setup (Claude, via Anthropic's Cowork
desktop tool). Being specific about the split:
What the AI did
Read the current
MirrorSourceTask,MirrorSourceConfig,MirrorConnectorConfig,MirrorSourceTaskConfig,MirrorSourceTaskTestandMirrorConnectorsIntegrationBaseTestontrunk, and identified the two places responsible for the masking behaviour — theputIfAbsent(AUTO_OFFSET_RESET_CONFIG, "earliest")insourceConsumerConfigand thecatch (KafkaException)inpoll().Drafted all of the production code: the two exception classes,
classifyOffsetOutOfRange, theseekToBeginninghandling ininitializeConsumer, and the config plumbing.Drafted all of the tests, including the
deleteRecords/ topic-recreation approach used to makethe integration scenarios deterministic.
Verified every external API it relied on against this checkout rather than from memory —
OffsetOutOfRangeException#offsetOutOfRangePartitions,RecordsToDelete#beforeOffset,EmbeddedKafkaCluster#createTopic/createAdminClient/consume,EmbeddedConnect#connectorStatus/stopConnector/resumeConnector,ConnectorStateInfo.TaskState#trace, and theTestUtils#waitForConditionoverloads.Checked whether the new config key would break existing assertions in
MirrorSourceConfigTest,MirrorMakerConfigTestandMirrorConnectorConfigTest, and confirmed it would not — which alsosurfaced that
MirrorSourceConfig#sourceConsumerConfighad no test coverage, prompting the fouradded config tests.
Wrote this PR description.
What the AI did not do
It never compiled or ran anything. Its sandbox had only JDK 11 and no usable access to the repo
working tree, so every Gradle run — unit tests, integration tests and checkstyle — was executed
locally by me.
The design decisions were reviewed and accepted by me rather than taken on trust: opt-in default,
fail-fast over auto-recovery, log start offset as the classification signal, overriding the shared
MirrorConnectorConfighelper on the subclass instead of editing it, and validation takingprecedence over an explicit
auto.offset.reset. The AI flagged the last one as a genuineeither-way choice rather than deciding it silently.
The full diff was read and reviewed by me before commit.