Antonio Gagliardi created KAFKA-21435:
-----------------------------------------

             Summary: MirrorCheckpointTask.stop() closes its stores while the 
scheduler is still starting them
                 Key: KAFKA-21435
                 URL: https://issues.apache.org/jira/browse/KAFKA-21435
             Project: Kafka
          Issue Type: Bug
          Components: mirrormaker
    Affects Versions: 4.4.0, 4.3.0, 4.2.0, 4.1.0, 4.0.0, 3.9.0
            Reporter: Antonio Gagliardi
         Attachments: kafka-mm2-checkpoint-race-reproducer-2026-11-06.tar.gz

{{MirrorCheckpointTask.stop()}} closes {{CheckpointStore}} and 
{{OffsetSyncStore}} before shutting down the {{Scheduler}} that may still be 
asynchronously starting them, so two threads use one {{KafkaConsumer}} 
concurrently.
h2. What happens

(Line numbers in this section are from {{trunk}} / 4.4.0; see the per-branch 
table under "Affected versions".)

{{MirrorCheckpointTask.start()}} hands the long-running store initialisation to 
a {{Scheduler}} thread so that {{start()}} does not block 
({{{}MirrorCheckpointTask.java:116{}}}):
{code:java}
scheduler.executeAsync(() -> {
    // loading the stores are potentially long running operations, so they run 
asynchronously
    // to avoid blocking task::start (until a task has completed starting it 
cannot be stopped)
    boolean checkpointsReadOk = checkpointStore.start();
    offsetSyncStore.start(!checkpointsReadOk);
    ...
}, "starting checkpoint and offset sync stores");
{code}
{{stop()}} then tears the task down in this order 
({{{}MirrorCheckpointTask.java:141-145{}}}):
{code:java}
public void stop() {
    stopping = true;
    Utils.closeQuietly(topicFilter, "topic filter");
    Utils.closeQuietly(checkpointStore, "checkpoints store");
    Utils.closeQuietly(offsetSyncStore, "offset sync store");   // line 141
    ...
    Utils.closeQuietly(scheduler, "scheduler");                 // line 145
}
{code}
The {{offsetSyncStore}} is closed at line 141, but the only thing that waits 
for the async initialiser — {{{}Scheduler.close(){}}}, which calls 
{{executor.shutdown()}} and {{awaitTermination(...)}} — does not run until line 
145. If a rebalance stops the task while the async init is still in flight, the 
*task thread* closes the {{KafkaConsumer}} that the *Scheduler thread* is 
actively using. {{KafkaConsumer}} is explicitly not safe for multi-threaded 
access, so whichever thread calls {{acquire()}} second throws 
{{{}ConcurrentModificationException{}}}.
h2. CheckpointStore has the same race

The observed stack traces are all from {{{}OffsetSyncStore{}}}, but 
{{checkpointStore.start()}} runs on the same scheduler thread, and 
{{closeQuietly(checkpointStore, ...)}} runs on the task thread one line earlier 
than the offset sync store. {{CheckpointStore}} owns a second, independent 
{{{}KafkaConsumer{}}}, and closes it by the same route ({{{}close(){}}} → 
{{releaseResources()}} → {{backingStore::stop}} → {{KafkaBasedLog.stop()}} → 
{{{}consumer.close(){}}}). A task stopped while {{readCheckpointsImpl()}} is 
inside {{backingStore.start(true)}} races on the checkpoints-topic consumer 
exactly as it does on the offset-syncs one.

Two aggravating details specific to this store:
 * {{releaseResources()}} is reachable concurrently from both threads — the 
scheduler thread runs it in a {{finally}} block, and the task thread runs it 
via {{close()}} — so {{backingStore}} and {{cpAdmin}} can be closed twice, from 
two threads.
 * {{backingStore}} and {{cpAdmin}} are plain (non-{{{}volatile{}}}) fields 
written on the scheduler thread and read on the task thread, so there is no 
guaranteed visibility of the consumer the task thread is trying to close.

h2. Why KafkaBasedLog.stop() does not protect against this

{{KafkaBasedLog.stop()}} looks like it waits for its reader before closing the 
consumer, but the guard does not apply during startup:
{code:java}
public void stop() {
    synchronized (this) { stopRequested = true; }
    if (consumer != null) consumer.wakeup();
    if (thread != null) { thread.join(); }          // thread is still null here
    ...
    Utils.closeQuietly(consumer, "KafkaBasedLog consumer for topic " + topic);
}
{code}
{{thread}} (the {{{}WorkThread{}}}) is only assigned *after* the blocking part 
of {{start()}} has completed. While {{start()}} is still running, {{{}thread == 
null{}}}, the {{join()}} is skipped, and {{stop()}} proceeds directly to 
{{consumer.close()}} — concurrently with the Scheduler thread's use of that 
same consumer inside {{{}start(){}}}. Note also that {{start()}} never checks 
the {{stopRequested}} flag that {{stop()}} sets.
h2. Observed manifestations

Both are the same race, caught at different instants.

*1. {{wakeup()}} lands while the init thread is blocked in {{poll()}}* — the 
reported production symptom, a pair of exceptions:
{code:java}
ERROR [Scheduler for MirrorCheckpointTask: 
A->B|A->B.MirrorCheckpointConnector-3-starting checkpoint and offset sync 
stores]
      Scheduler - ... caught exception in scheduled task: starting checkpoint 
and offset sync stores
org.apache.kafka.common.errors.WakeupException: null
        at 
org.apache.kafka.clients.consumer.internals.ConsumerNetworkClient.maybeTriggerWakeup(ConsumerNetworkClient.java:531)
        at 
org.apache.kafka.clients.consumer.internals.ClassicKafkaConsumer.poll(ClassicKafkaConsumer.java:641)
        at 
org.apache.kafka.clients.consumer.KafkaConsumer.poll(KafkaConsumer.java:895)
        at 
org.apache.kafka.connect.util.KafkaBasedLog.poll(KafkaBasedLog.java:443)
        at 
org.apache.kafka.connect.util.KafkaBasedLog.readToLogEnd(KafkaBasedLog.java:482)
        at 
org.apache.kafka.connect.util.KafkaBasedLog.start(KafkaBasedLog.java:278)
        at 
org.apache.kafka.connect.mirror.OffsetSyncStore.backingStoreStart(OffsetSyncStore.java:121)
        at 
org.apache.kafka.connect.mirror.OffsetSyncStore.start(OffsetSyncStore.java:115)
        at 
org.apache.kafka.connect.mirror.MirrorCheckpointTask.lambda$start$1(MirrorCheckpointTask.java:116)
        at org.apache.kafka.connect.mirror.Scheduler.run(Scheduler.java:97)

WARN  [task-thread-A->B.MirrorCheckpointConnector-3]
      Utils - Failed to close KafkaBasedLog consumer for topic 
mm2-offset-syncs.A.internal
java.util.ConcurrentModificationException: KafkaConsumer is not safe for 
multi-threaded access.
      currentThread(name: task-thread-A->B.MirrorCheckpointConnector-3, id: 72) 
otherThread(id: 143)
        at 
org.apache.kafka.clients.consumer.internals.ClassicKafkaConsumer.acquire(ClassicKafkaConsumer.java:1256)
        at 
org.apache.kafka.clients.consumer.internals.ClassicKafkaConsumer.close(ClassicKafkaConsumer.java:1127)
        at 
org.apache.kafka.clients.consumer.KafkaConsumer.close(KafkaConsumer.java:1782)
{code}
Here {{WakeupException}} is the documented, expected effect of 
{{{}wakeup(){}}}; the {{ConcurrentModificationException}} on {{close()}} is the 
defect.

*2. The close lands while the init thread is in {{partitionsFor()}}* — the 
reproducer hits this variant most often. The 
{{ConcurrentModificationException}} surfaces on the Scheduler thread instead, 
with no {{WakeupException}} at all:
{code:java}
ERROR Scheduler for MirrorCheckpointTask: dr->local|MirrorCheckpointConnector-5
      caught exception in scheduled task: starting checkpoint and offset sync 
stores
java.util.ConcurrentModificationException: KafkaConsumer is not safe for 
multi-threaded access.
      currentThread(name: Scheduler for MirrorCheckpointTask: 
dr->local|MirrorCheckpointConnector-5-starting
      checkpoint and offset sync stores, id: 1640) otherThread(id: 295)
        at 
org.apache.kafka.clients.consumer.internals.ClassicKafkaConsumer.acquire(ClassicKafkaConsumer.java:1256)
        at 
org.apache.kafka.clients.consumer.internals.ClassicKafkaConsumer.partitionsFor(ClassicKafkaConsumer.java:931)
        at 
org.apache.kafka.clients.consumer.KafkaConsumer.partitionsFor(KafkaConsumer.java:1418)
        at 
org.apache.kafka.connect.util.KafkaBasedLog.start(KafkaBasedLog.java:248)
        at 
org.apache.kafka.connect.mirror.OffsetSyncStore.backingStoreStart(OffsetSyncStore.java:121)
        at 
org.apache.kafka.connect.mirror.OffsetSyncStore.start(OffsetSyncStore.java:115)
        at 
org.apache.kafka.connect.mirror.MirrorCheckpointTask.lambda$start$1(MirrorCheckpointTask.java:116)
        at org.apache.kafka.connect.mirror.Scheduler.run(Scheduler.java:97)
{code}
h2. Trigger

Anything that makes {{MirrorCheckpointConnector}} call 
{{context.requestTaskReconfiguration()}} often enough to stop checkpoint tasks 
shortly after they start. In the field this was a short 
{{refresh.groups.interval.seconds}} combined with a broad {{groups}} pattern 
matching churny consumer-group IDs: each change to the matched group set emits 
{{Tasks [...] configs updated}} and rebalances the checkpoint tasks.
h2. Impact
 * Recurring {{{}ERROR{}}}/{{{}WARN{}}} with stack traces on every affected 
rebalance. The task itself is not killed and mirroring continues, so the 
primary cost is alert fatigue and the difficulty of distinguishing this from a 
real fault.
 * *Leaked consumer — confirmed.* In manifestation (1) the 
{{ConcurrentModificationException}} is thrown from inside 
{{{}KafkaConsumer.close(){}}}, called through {{{}Utils.closeQuietly{}}}, which 
swallows it. {{close()}} therefore never completes and the consumer is left 
open, leaking its sockets and metrics once per affected task stop.

h2. Reproduction

Reproduced on 4.1.0. A standalone container-based reproducer is attached — two 
single-node KRaft clusters plus an MM2 driver, all running the stock 
{{apache/kafka:4.1.0}} image, driven by a single {{{}run.sh{}}}; podman or 
docker is the only prerequisite. Steps:
 # Start two single-node KRaft clusters, {{dr}} (source) and {{local}} (target).
 # Create a few topics on {{dr}} matching the mirrored pattern and seed them 
with a few thousand records (widens the race window).
 # Run {{connect-mirror-maker.sh}} for {{dr->local}} with {{{}groups = 
^(.{*}prod.{*})${}}}, {{{}refresh.groups.interval.seconds = 5{}}}, 
{{{}emit.checkpoints.enabled = true{}}}, and {{{}tasks.max = 8{}}}.
 # Continuously create new consumer groups on {{dr}} matching that pattern (one 
every ~3s), each committing offsets.
 # Grep the MM2 driver log for {{ConcurrentModificationException}} / 
{{{}WakeupException{}}}.

{*}Observed{*}: on the attached reproducer, 5 hits over a ~5 minute run — four 
different checkpoint tasks, distinct thread IDs, all the manifestation (2) 
variant above. An earlier run on a different 4.1.0 build saw 1 hit over ~5 
minutes and 36 {{Tasks [...] configs updated}} events. The rate is 
timing-dependent and varies with host speed, so a clean run is not evidence of 
absence; re-run or raise the churn rate.
h2. Affected versions

*Reproduced* on 4.1.0. Still present on {{trunk}} (commit {{{}98e0bebf80{}}}) 
and on 4.4.0, the latest release. The racing code is substantively unchanged 
from 4.1 through {{{}trunk{}}}.
||Branch||{{executeAsync}}||{{closeQuietly(offsetSyncStore)}}||{{closeQuietly(scheduler)}}||
|3.8|— (blocking {{{}execute{}}})|137|141|
|3.9|112|137|141|
|4.0|113|138|142|
|4.1|112|137|141|
|4.2|111|136|140|
|4.3|116|141|145|
|4.4|116|141|145|
|trunk|116|141|145|

The race became reachable on the normal code path in 3.9.0, via KAFKA-17242 / 
commit {{{}4763d0fc30cd{}}}, which replaced the blocking 
{{scheduler.execute(...)}} with {{{}scheduler.executeAsync(...){}}}:
 * {*}3.7, 3.8 and earlier{*}: {{{}scheduler.execute(...){}}}, which blocks on 
{{{}Future.get(admin.timeout.ms){}}}. {{start()}} normally returns only after 
the stores are initialised, so the task thread normally cannot reach {{stop()}} 
mid-init. However, {{execute()}} only waits up to the timeout — on timeout it 
returns while the submitted task keeps running. So the same collision was 
possible on 3.8 and earlier, but only when store initialisation overran 
{{{}admin.timeout.ms{}}}.
 * {*}3.9.0 onward{*}: {{executeAsync(...)}} returns immediately by design, so 
every task start leaves an initialisation running concurrently with a task that 
is now eligible to be stopped. What changed in 3.9.0 is that the overlap went 
from an edge case to the normal path.

h2. Relationship to existing issues
 * *KAFKA-13856* ("MirrorCheckpointTask meets ConcurrentModificationException", 
still Open, affects 3.0.1) — *not a duplicate.* That report is about the 
{{checkpointsPerConsumerGroup}} {{HashMap}} field being mutated and traversed 
by the scheduler and worker threads concurrently. Same exception type and same 
class, different object and different fix. This issue is about the 
{{KafkaConsumer}} inside {{{}OffsetSyncStore{}}}'s {{{}KafkaBasedLog{}}}, and 
its stack traces go through {{{}ClassicKafkaConsumer.acquire{}}}, not 
{{{}HashMap{}}}.
 * *KAFKA-17242* (Fixed, 3.9.0) — the change that introduced the async start. 
Not a regression to revert: reverting it would restore the spurious timeout it 
fixed. This issue should be fixed on its own terms.

h2. Proposed fix

A candidate patch against {{trunk}} is ready (6 files, +352/−8, confined to 
{{{}connect/mirror{}}}; no public API change, so no KIP). {{stop()}} closes the 
{{Scheduler}} before the stores, so {{Scheduler.close()}} returns only after 
{{awaitTermination()}} succeeds and the stores are closed strictly after the 
thread loading them has finished. To bound that wait, each store gains a 
{{stopInitialization()}} method, called first, which sets a flag and calls 
{{Consumer::wakeup}} — the one consumer method safe from another thread. The 
resulting {{WakeupException}} is logged at DEBUG as an orderly shutdown rather 
than ERROR. {{start()}} remains non-blocking, so KAFKA-17242 is not regressed.
h3. Known limitation

{{{}Scheduler.close(){}}}'s {{awaitTermination}} timeout remains; if it 
expires, {{close()}} logs and returns, {{stop()}} proceeds, and the race window 
reopens as before. This is pre-existing behaviour shared with the other MM2 
tasks. {*}Fixed except for a pre-existing escape hatch{*}, not {*}proven 
impossible{*}.
h3. Rejected alternatives
 * *Guard the stores with a shutdown flag / make {{close()}} safe* — a flag 
gives no happens-before edge; doing it properly means holding a lock across the 
whole of {{{}KafkaBasedLog.start(){}}}, which reinstates the unbounded wait.
 * *Fix {{KafkaBasedLog}} so {{stop()}} waits for an in-progress {{start()}}* — 
the most general fix, but {{KafkaBasedLog}} is shared with the Connect herder 
where {{{}start(){}}}/{{{}stop(){}}} are single-threaded, so this is risk with 
no local benefit. Worth a separate hardening JIRA.
 * *Revert to the blocking {{scheduler.execute(...)}}* — reintroduces 
KAFKA-17242's spurious timeout and makes {{start()}} block again.
 * *{{shutdownNow()}} / interrupting the scheduler thread* — turns the blocked 
{{poll()}} into an {{InterruptException}} and leaves the consumer in an 
undefined state; {{wakeup()}} is the supported mechanism and leaves it 
closeable.

h3. Tests

{{MirrorCheckpointTaskTest.testStopDoesNotCloseOffsetSyncStoreWhileItIsStarting}}
 and {{...testStopDoesNotCloseCheckpointStoreWhileItIsStarting}} block a store 
mid-startup on the scheduler thread, call {{stop()}} from the task thread, and 
assert the store was not closed while that startup was in progress. The shipped 
race tests assert the *ordering invariant* using fakes, which is what makes 
them deterministic; the link from "ordering violated" to 
{{ConcurrentModificationException}} is established separately by the production 
stack traces above and by a single-threaded-access-checking consumer driven 
through a real {{KafkaBasedLog}} against unmodified {{{}trunk{}}}.
h2. Workaround

None that removes the race. Increasing {{refresh.groups.interval.seconds}} and 
narrowing the {{groups}} pattern reduces checkpoint-task rebalances and 
therefore how often it fires. The exceptions are logged and swallowed, so 
mirroring is not interrupted.

 

Assisted by Claude Opus 5



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to