Skip to content

Fail fast in MirrorMaker 2 when source offsets become invalid - #23091

Open
onkar2405 wants to merge 1 commit into
apache:trunkfrom
onkar2405:mm2-offset-validation
Open

Fail fast in MirrorMaker 2 when source offsets become invalid#23091
onkar2405 wants to merge 1 commit into
apache:trunkfrom
onkar2405:mm2-offset-validation

Conversation

@onkar2405

Copy link
Copy Markdown

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=earliest to the replication
consumer (MirrorConnectorConfig#sourceConsumerConfig), so the broker's OFFSET_OUT_OF_RANGE error
is 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) in MirrorSourceTask#poll — logs a warning and
returns 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

File Purpose
connect/mirror/.../mirror/DataLossException.java Unreplicated records removed by retention
connect/mirror/.../mirror/TopicResetException.java Source topic deleted and recreated
connect/mirror/.../mirror/MirrorSourceTaskOffsetValidationTest.java Unit tests for detection and classification
connect/mirror/.../mirror/integration/MirrorConnectorsIntegrationOffsetValidationTest.java End-to-end tests on embedded clusters

Modified

File Change
MirrorSourceConfig.java New offset.validation.enabled config; sourceConsumerConfig override that sets auto.offset.reset=none
MirrorSourceTask.java Flag plumbing; seekToBeginning for uncommitted partitions; OffsetOutOfRangeException handling and classification
MirrorSourceConfigTest.java Four tests covering the new config and its effect on the consumer config

Both exceptions extend ConnectException. Roughly 120 lines of production code and 330 lines of
tests.

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 the
consumer 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=none makes the broker's own out-of-range signal the trigger. It costs
nothing 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 partition
separates them cleanly:

Log start offset Meaning Exception
> 0 Records ahead of our position were deleted by retention DataLossException
== 0 The log begins at zero again — the topic was recreated TopicResetException

MirrorSourceTask#classifyOffsetOutOfRange queries Consumer#beginningOffsets for the affected
partitions 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 beginningOffsets
lookup itself fails, the task still fails — as TopicResetException — rather than falling back to the
silent 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 earliest after a topic recreation duplicates or interleaves data on the DR
cluster. 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.

Note: the task description asks for a TopicResetException and fail-fast behaviour (Task 2), while
the evaluation criteria mention "auto-recovery for topic resets". This PR implements fail-fast per
the explicit task requirement. Auto-recovery would be a small follow-up given the detection logic is
already in place.

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.enabled defaults to false, so upgrading is a no-op and operators of WAL-style
topics opt in deliberately.

Let validation override an explicit reset policy.
If a user sets both offset.validation.enabled=true and consumer.auto.offset.reset=latest, the
override wins and none is applied, because any other policy silently defeats the feature. Rejecting
the combination with a ConfigException would also be defensible; this is asserted in
testOffsetValidationTakesPrecedenceOverExplicitAutoOffsetReset so the choice is explicit rather
than incidental.

How it integrates with the MirrorMaker 2 lifecycle

Hook Change
MirrorSourceConfig#sourceConsumerConfig (override) Sets auto.offset.reset=none when enabled. The shared helper in MirrorConnectorConfig is untouched, so MirrorCheckpointConnector and MirrorHeartbeatConnector keep their existing consumer settings.
MirrorSourceTask#start Reads the flag from the task config.
MirrorSourceTask#initializeConsumer Collects partitions with no committed offset and calls seekToBeginning on them.
MirrorSourceTask#poll New catch (OffsetOutOfRangeException) placed ahead of the existing catch (KafkaException), so the specific case is handled before the generic warn-and-continue path.

Preserving first-start behaviour. With auto.offset.reset=none the consumer has no fallback
position, so a partition MM2 has never replicated would fail on its first poll. initializeConsumer
therefore seeks those partitions to the beginning explicitly. This reproduces exactly what
auto.offset.reset=earliest did, but only for genuinely new partitions — an offset that exists and
is 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 handling
applies unchanged; no new lifecycle hooks were required.


Configuration

Name Type Default Importance Description
offset.validation.enabled boolean false medium Fail the task with DataLossException / TopicResetException when the offset to replicate from is no longer available on the source cluster. Sets consumer.auto.offset.reset=none on 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 beginningOffsets lookup still fails the task

  • with the flag off, poll returns null and never queries beginningOffsets — default behaviour preserved

  • seekToBeginning is called for uncommitted partitions only when the flag is on

  • deterministic partition ordering in the error message
    Unit — MirrorSourceConfigTest (4 added)

  • validation off by default, consumer still gets auto.offset.reset=earliest

  • validation on yields auto.offset.reset=none

  • validation overrides an explicitly configured reset policy

  • other consumer configs (max.poll.records, enable.auto.commit) are unaffected
    Integration — MirrorConnectorsIntegrationOffsetValidationTest (embedded Connect clusters)

  • Data loss: replicate a topic, stop the source connector, rewind its offsets to the start of each
    partition, then deleteRecords past that point on the source cluster. Restarting the connector
    fails 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 deleteRecords and topic recreation rather than waiting on retention, so
    they 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, MirrorSourceTaskTest and MirrorConnectorsIntegrationBaseTest on
    trunk, and identified the two places responsible for the masking behaviour — the
    putIfAbsent(AUTO_OFFSET_RESET_CONFIG, "earliest") in sourceConsumerConfig and the
    catch (KafkaException) in poll().

  • Drafted all of the production code: the two exception classes, classifyOffsetOutOfRange, the
    seekToBeginning handling in initializeConsumer, and the config plumbing.

  • Drafted all of the tests, including the deleteRecords / topic-recreation approach used to make
    the 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 the TestUtils#waitForCondition overloads.

  • Checked whether the new config key would break existing assertions in MirrorSourceConfigTest,
    MirrorMakerConfigTest and MirrorConnectorConfigTest, and confirmed it would not — which also
    surfaced that MirrorSourceConfig#sourceConsumerConfig had no test coverage, prompting the four
    added 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
    MirrorConnectorConfig helper on the subclass instead of editing it, and validation taking
    precedence over an explicit auto.offset.reset. The AI flagged the last one as a genuine
    either-way choice rather than deciding it silently.

  • The full diff was read and reviewed by me before commit.

… 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
@github-actions github-actions Bot added triage PRs from the community connect mirror-maker-2 labels Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant