Federico Mariani created CAMEL-23996:
----------------------------------------

             Summary: camel-kafka: KafkaIdempotentRepository and 
SingleNodeKafkaResumeStrategy correctness issues
                 Key: CAMEL-23996
                 URL: https://issues.apache.org/jira/browse/CAMEL-23996
             Project: Camel
          Issue Type: Bug
          Components: camel-kafka
            Reporter: Federico Mariani


Code review of the Kafka idempotent repository and the single-node resume 
strategy found several correctness bugs. Filing together as one cleanup of the 
two classes. All line numbers refer to current {{main}} (4.22.0-SNAPSHOT).

h3. KafkaIdempotentRepository

*1. add() commits to the local cache before the Kafka broadcast; a broadcast 
failure is not rolled back → message wrongly filtered as duplicate* (lines 
473-498)

{{add(key)}} does {{cache.put(key, key)}} and then {{broadcastAction(key, 
add)}}, which throws {{RuntimeCamelException}} if the synchronous 
{{producer.send(...).get()}} fails. The key stays in the local cache. With an 
eager {{IdempotentConsumer}} (the default), the exchange fails, and on 
redelivery {{add()}} hits {{cache.containsKey(key) == true}} → returns false → 
the message is filtered as a "duplicate" and never processed. Peers also never 
received the add, so the cluster is inconsistent with this node. Pattern 
unchanged since CAMEL-10927.

Fix: broadcast first and only cache on successful send, or 
{{cache.remove(key)}} in the failure path before rethrowing.

*2. add() check-then-act is not atomic* (lines 473-484)

{{if (cache.containsKey(key)) ... else cache.put(key, key)}} — two concurrent 
exchanges with the same key (multi-partition consumer, parallel routes) both 
pass. Other repositories synchronize {{add}} because {{IdempotentConsumer}} 
relies on atomicity. The cache is a thread-safe {{SimpleLRUCache}}: use 
{{cache.putIfAbsent(key, key) == null}} and only broadcast when insertion won.

*3. doStop() closes the KafkaConsumer while the poller thread may still be 
inside poll() → ConcurrentModificationException, leaked consumer* (lines 
383-393)

{{doStop}} calls {{ExecutorServiceManager.shutdown(executorService)}} 
(non-blocking), then {{stopService(poller)}}, then immediately 
{{IOHelper.close(consumer)}}. The poller thread is typically blocked inside 
{{consumer.poll(100ms)}}; {{KafkaConsumer}} is not thread-safe and {{close()}} 
from a second thread throws {{ConcurrentModificationException}}, which 
{{IOHelper.close}} does not swallow — it propagates out of {{doStop()}} and the 
consumer is left unclosed (leaked network threads). The window is nearly the 
whole poll interval, so this fires with high probability on every context stop 
(logged as WARN, hence unnoticed). Structure from CAMEL-20682.

Fix: await poller termination before closing the consumer, or close the 
consumer from the poller thread itself.

*4. clear() never clears the local cache; with startupOnly=true it never takes 
effect at all* (lines 522-525)

{{clear()}} only broadcasts and relies on the poller consuming the record back; 
with {{startupOnly=true}} there is no running poller, so a JMX/managed 
{{clear()}} silently does nothing until restart. Fix: also {{cache.clear()}} 
locally, mirroring {{add}}/{{remove}}.

*Minor:* a null-valued record on the topic throws NPE from 
{{CacheAction.valueOf(null)}} — during {{populateCache()}} this aborts 
CamelContext startup (line 451); the "unexpected action" warning logs the key 
instead of the value (lines 465-468); {{doStart}} mutates caller-supplied 
{{consumerConfig}}/{{producerConfig}} Properties in place.

h3. SingleNodeKafkaResumeStrategy

*5. Duplicate subscribe discards the rebalance listener that fills the cache* 
(lines 209-212)

{code:java}
subscribe(consumer);   // subscribes with a listener that seeks back 
cache.capacity() records
LOG.debug("Loading records from topic {}", 
resumeStrategyConfiguration.getTopic());
consumer.subscribe(Collections.singletonList(resumeStrategyConfiguration.getTopic()));
  // replaces subscription + listener!
{code}

A second {{subscribe}} replaces the previous subscription *and* its 
{{ConsumerRebalanceListener}} with a no-op. With {{FillPolicy.MINIMIZING}} 
({{auto.offset.reset=latest}} per the builder, which always generates a fresh 
random group id) the consumer starts at log end, the rewind never runs, the 
resume cache stays empty → the route silently reprocesses everything. In 
CAMEL-18356 there were two consumers (one subscribe each); a later 
consolidation merged them and both subscribe calls survived. Fix: delete the 
plain {{consumer.subscribe(...)}} line.

*6. Initialization wait is inverted/dead, so resume() can run before offsets 
are loaded* (lines 359-366)

{{getAdapter()}} calls {{waitForInitialization()}} only when {{adapter == 
null}}. In the normal flow {{DefaultRoute}} sets the adapter *before* 
{{ResumeStrategyHelper.resume()}} runs, so {{getAdapter()}} returns immediately 
and {{resume()}} races the async topic load ({{initLatch}} is never awaited). 
Conversely, waiting when the adapter is null is pointless — the refresh thread 
never sets it. Guard added in CAMEL-18688. Fix: wait on {{initLatch}} (when 
non-null, i.e. after {{loadCache()}}) regardless of adapter nullity.

*7. stop() unlocks a lock it may not hold → IllegalMonitorStateException, 
skipped consumer shutdown* (lines 394-409)

If {{writeLock.tryLock(1, SECONDS)}} returns false (e.g. another thread is 
inside {{updateLastOffset}} blocked in {{producer.send}} on metadata for up to 
{{max.block.ms}}) or throws {{InterruptedException}}, execution still reaches 
{{finally \{ writeLock.unlock(); \}}} → {{IllegalMonitorStateException}} out of 
{{stop()}}, and the consumer-shutdown block below (wakeup + executor shutdown) 
is skipped → refresh thread and consumer leak. From CAMEL-18362. Fix: only 
unlock when acquired.

*Minor:* {{produce()}} send-failures are only logged with the default null 
callback (resume state silently lost); javadoc still references the 
{{producerErrors}} counter removed in CAMEL-18148, leaving {{RecordError}} an 
unused public class; {{consume(int, Consumer)}} has no callers; 
{{consumer}}/{{initLatch}} fields are written by the executor thread and read 
by {{stop()}} without volatile.
----
_This issue was researched and written by Claude Code on behalf of Federico 
Mariani (GitHub: Croway). Findings were verified against the source and git 
history before filing._



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to