Skip to content

CAMEL-26978: camel-kafka - an async manual commit is done by the consumer thread - #30008

Open
Croway wants to merge 2 commits into
apache:mainfrom
Croway:CAMEL-26978-kafka-async-manual-commit-thread
Open

Croway wants to merge 2 commits into
apache:mainfrom
Croway:CAMEL-26978-kafka-async-manual-commit-thread

Conversation

@Croway

@Croway Croway commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor

JIRA: CAMEL-26978

  • Since CAMEL-23997 (4.22.0), KafkaManualCommit.commit() with DefaultKafkaManualAsyncCommitFactory called commitAsync on the calling thread. After an aggregator, every commit failed with ConcurrentModificationException, and nothing was committed while the route ran.
  • commit() now records the offset, and the consumer thread commits the requested offsets before its next poll, as in 4.21 and as the docs say. Commits are still made only when the route calls commit(), as CAMEL-23997 requires.
  • A revoked partition drops its pending requested offset, and a commit requested after the revoke (for example by an aggregator that completes later) is dropped too, so it cannot move back the offset committed by the new owner of the partition.
  • Docs: rewrote the async commit paragraph and the thread note, and removed the unfinished "Note 2".

Tests: DefaultKafkaManualAsyncCommitTest (a MockConsumer that only the polling thread may use): a commit after an aggregator fails on main and passes now; a commit in the Kafka route still works; a commit called after the partition is revoked is not made.
Upgrade guide: entry "camel-kafka - the asynchronous manual commit is done by the consumer thread" in the 4.24 guide and in the 4.22 guide (4.22.2 to 4.22.3).
Backport: camel-4.22.x only. On camel-4.18.x, DefaultKafkaManualAsyncCommit.commit() only records the offset, so it is not affected.
Follow-up: CAMEL-26993 (with manual commits, a rebalance or a stop commits the last processed record, not the last committed one).

Claude Code on behalf of Federico Mariani

…umer thread

KafkaManualCommit.commit() with DefaultKafkaManualAsyncCommitFactory records the offset again, and the consumer
thread commits it before its next poll, so a commit from another thread, such as after an aggregator, no longer fails
with ConcurrentModificationException. Regression from CAMEL-23997.
@Croway
Croway requested review from davsclaus and oscerd November 15, 2026 11:08
@github-actions

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ConcurrentHashMap-based deferred-commit design is correct and thread-safe. requestCommit() can be called from any thread (concurrent put() on ConcurrentHashMap is safe), and commitRequestedOffsets() runs exclusively on the consumer thread before each poll — no concurrent iteration/modification is possible.

One doc nit on the upgrade guide.

Backport labels missing. This fixes a regression introduced in 4.22.0 (CAMEL-23997) — users on 4.22.x and 4.23.x hit ConcurrentModificationException with async manual commits from a non-consumer thread (e.g. after an aggregator). Please add port/camel-4.22.x and port/camel-4.18.x labels so the fix reaches maintenance branches.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@github-actions

github-actions Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • catalog/camel-catalog
  • components/camel-kafka
  • docs

🔁 1 test passed only after a retry on JDK 21 (1 retried attempt)

Recovered flaky tests on JDK 21 (1)
Module Test Failed attempts First failure
dsl/camel-jbang/camel-jbang-plugin-tui ArchitectureViewTest.showsGroupsWithTheirFacts 1 the first group is selected ==> expected: <group:billing> but was: <null>

ℹ️ These tests did not fail the build. Surefire retried them and they passed.
Retries are enabled project-wide by surefire.rerunFailingTestsCount in parent/pom.xml.


🔬 Scalpel shadow comparison — Scalpel: 9 of 726 tested, 25 compile-only — current: 9 all tested

Maveniverse Scalpel detected 9 affected modules (current approach: 9).

Skip-tests mode would test 9 modules (3 direct + 8 downstream), skip tests for 25 (generated code, meta-modules)

Modules Scalpel would test (9)
  • camel-jbang-mcp ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-mcp ← downstream of org.apache.camel:camel-jbang-core
  • camel-jbang-plugin-tui ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-validate ← downstream of org.apache.camel:camel-yaml-dsl-validator
  • camel-jbang-run ← downstream of org.apache.camel:camel-catalog
  • camel-kafka ← components/camel-kafka/src/main/docs/kafka-component.adoc, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaFetchRecords.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/AsyncCommitManager.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/CommitManager.java, components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/DefaultKafkaManualAsyncCommit.java, components/camel-kafka/src/test/java/org/apache/camel/component/kafka/consumer/DefaultKafkaManualAsyncCommitTest.java
  • camel-launcher-container ← downstream of org.apache.camel:camel-launcher
  • camel-yaml-dsl-validator ← downstream of org.apache.camel:camel-catalog
  • camel-yaml-dsl-validator-maven-plugin ← downstream of org.apache.camel:camel-yaml-dsl-validator
Modules with tests skipped (25)
  • apache-camel
  • camel-allcomponents
  • camel-catalog-console
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

⚠️ Some tests are disabled on GitHub Actions (@DisabledIfSystemProperty(named = "ci.env.name")) and require manual verification:

  • components/camel-kafka: 2 test(s) disabled on GitHub Actions
All tested modules (36 modules, 5m 20s total)

Total reactor time: 5m 20s

Module Duration Status
Camel :: YAML DSL :: Validator 31.2s SUCCESS
Camel :: JBang :: Run 29.6s SUCCESS
Camel :: Component DSL 26.5s SUCCESS
Camel :: Catalog :: Camel Catalog 26.2s SUCCESS
Camel :: YAML DSL 25.5s SUCCESS
Camel :: JBang :: Plugin :: Validate 25.3s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 23.8s SUCCESS
Camel :: Kamelet Main 20.0s SUCCESS
Camel :: Assembly 15.3s SUCCESS
Camel :: JBang :: Plugin :: Testing 13.8s SUCCESS
Camel :: Docs 13.7s SUCCESS
Camel :: Catalog :: Camel Route Parser 9.0s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 8.6s SUCCESS
Camel :: YAML DSL :: Deserializers 8.5s SUCCESS
Camel :: JBang :: Plugin :: Generate 8.4s SUCCESS
Camel :: All Components Sync point 6.2s SUCCESS
Camel :: YAML DSL :: Maven Plugins 4.1s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 3.9s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 3.3s SUCCESS
Camel :: Catalog :: Maven 3.1s SUCCESS
Camel :: Coverage 2.7s SUCCESS
Camel :: Catalog :: Dummy Component 2.0s SUCCESS
Camel :: JBang :: Plugin :: MCP 1.8s SUCCESS
Camel :: JBang :: Plugin :: Edit 1.6s SUCCESS
Camel :: JBang :: Main 1.4s SUCCESS
Camel :: JBang :: Integration tests 1.2s SUCCESS
Camel :: Endpoint DSL :: Support 1.1s SUCCESS
Camel :: Catalog :: Console 1.1s SUCCESS
Camel :: Launcher :: Container 0.8s SUCCESS
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a
Camel :: JBang :: MCP n/a
Camel :: JBang :: Plugin :: TUI n/a
Camel :: Kafka n/a
Camel :: Launcher n/a

Top 20 slowest modules:

  • Camel :: YAML DSL :: Validator (31.2s)
  • Camel :: JBang :: Run (29.6s)
  • Camel :: Component DSL (26.5s)
  • Camel :: Catalog :: Camel Catalog (26.2s)
  • Camel :: YAML DSL (25.5s)
  • Camel :: JBang :: Plugin :: Validate (25.3s)
  • Camel :: JBang :: Plugin :: Kubernetes (23.8s)
  • Camel :: Kamelet Main (20.0s)
  • Camel :: Assembly (15.3s)
  • Camel :: JBang :: Plugin :: Testing (13.8s)
  • Camel :: Docs (13.7s)
  • Camel :: Catalog :: Camel Route Parser (9.0s)
  • Camel :: Catalog :: Camel Report Maven Plugin (8.6s)
  • Camel :: YAML DSL :: Deserializers (8.5s)
  • Camel :: JBang :: Plugin :: Generate (8.4s)
  • Camel :: All Components Sync point (6.2s)
  • Camel :: YAML DSL :: Maven Plugins (4.1s)
  • Camel :: YAML DSL :: Validator Maven Plugin (3.9s)
  • Camel :: Catalog :: Suggest (deprecated) (3.3s)
  • Camel :: Catalog :: Maven (3.1s)

⚙️ View full build and test results

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Croway. The diagnosis is right: #24696 (CAMEL-23997) made DefaultKafkaManualAsyncCommit.commit() call commitAsync on the calling thread. After an aggregator that is the Aggregator thread, even with completionSize, because the reifier always runs the aggregate output on its own single-thread executor. Moving the commit back to the consumer thread is the right fix:

  • requestCommit only writes to a ConcurrentHashMap.
  • commitRequestedOffsets() runs on the polling thread before poll(), outside the lock, so stop()/wakeup() is not affected.
  • commit(partition) takes the pending request out, so the revoke on unsubscribe/close still flushes it.
  • The default methods keep the sync, noop and to-offset managers as they were.

The SingleThreadMockConsumer test is a good reproducer, and the docs, the catalog copy and the upgrade guide are updated.

  1. A request that arrives after the partition is revoked is committed for a partition this consumer no longer owns. AsyncCommitManager.commit(TopicPartition) removes the pending request on revoke. But an aggregator that completes later calls requestCommit again, and the next commitRequestedOffsets() sends a commitAsync for that partition. At revoke time ClassicRebalanceListener.onPartitionsRevoked already committed the last processed offset (from the offset cache). So this late commit is lower, and it can move the new owner's committed offset back, which means duplicates on the new owner. The polling thread can check the assignment:
    Set<TopicPartition> assigned = consumer.assignment();
    for (TopicPartition partition : requestedOffsets.keySet()) {
        Long offset = requestedOffsets.remove(partition);
        if (offset == null) {
            continue;
        }
        if (assigned.contains(partition)) {
            offsets.put(partition, new OffsetAndMetadata(offset + 1));
        } else {
            LOG.debug("Dropping the requested commit of offset {} on {}: the partition is no longer assigned to {}",
                    offset, partition, threadId);
        }
    }
    A test would help: in DefaultKafkaManualAsyncCommitTest, mockConsumer.rebalance(List.of()) before the aggregator completes.
  2. Optional: requestedOffsets.put means the last writer wins. Two aggregation groups that complete out of order (or with parallelProcessing) can replace a higher requested offset with a lower one before the flush. 4.21 had the same semantics, so this is not a regression, but requestedOffsets.merge(partition, partitionLastOffset, Math::max) is a cheap guard.
  3. Question, not for this PR: on revoke and on stop, commit(partition) prefers offsetCache, which KafkaRecordStreamingProcessor fills for every processed record, also with allowManualCommit=true. So records still held by an aggregator are committed on a rebalance although the route never called commit() for them. breakOnFirstError depends on that cache, so changing it is a design decision. Could you open a follow-up JIRA so it is not lost?
  4. Backport: only camel-4.22.x needs this. On camel-4.18.x, DefaultKafkaManualAsyncCommit.commit() still only calls recordOffset, so it is not affected.

Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

@Croway Croway added the port/camel-4.22.x Bug needs porting to camel-4.22.x label Nov 15, 2026
… partition was revoked is not made

The consumer thread drops a requested offset whose partition is no longer assigned, so a late commit (for example
by an aggregator) does not move back the offset committed by the new owner. Upgrade guide entry for 4.22.3, and the
4.24 entry split into shorter sentences.
@Croway

Croway commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@gnodet-bot Backport labels: port/camel-4.22.x is added. port/camel-4.18.x is not needed: on camel-4.18.x, DefaultKafkaManualAsyncCommit.commit() only calls commitManager.recordOffset(...) and never commits on the calling thread, so the regression from CAMEL-23997 is not there. The doc nit is answered in its thread.

Claude Code on behalf of Federico Mariani

@Croway

Croway commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@davsclaus

  1. Done in ea23377: commitRequestedOffsets(), which runs on the polling thread before poll(), now drops a requested offset whose partition is not in consumer.assignment(), with a DEBUG log. New test DefaultKafkaManualAsyncCommitTest.testCommitAfterRevokeIsNotCommitted: the partition is revoked with rebalance(List.of()), then commit() is called from the test thread, and no commit is made for the partition (it fails without the check); the two existing tests stay as the positive cases. The docs and both upgrade guides mention it.
  2. Not changed: last-writer-wins is what 4.21 did, and the fix stays a minimal port for 4.22.x.
  3. Opened CAMEL-26993 (relates to CAMEL-26978): with manual commits, KafkaRecordStreamingProcessor records every processed offset in the OffsetCache, and commit(partition) on revoke and stop commits it, also for records the route did not commit; it notes that breakOnFirstError relies on the same cache.
  4. Agreed, checked on camel-4.18.x: commit() only calls recordOffset. Only port/camel-4.22.x is set, and the 4.22 upgrade guide has the entry under 4.22.2 to 4.22.3.

Claude Code on behalf of Federico Mariani

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my earlier review — both issues are addressed.

  • Backport labels: port/camel-4.22.x added ✓. The author explains port/camel-4.18.x is not needed (the 4.18.x commit() only calls recordOffset(), never commitAsync() on the calling thread — the regression is not present there). Agreed.
  • Doc nit (upgrade guide sentence): Previous 36-word sentence ✅ rewritten. The file moved; the prior review thread is now outdated.

One new doc nit in the upgrade guides.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

Comment on lines +776 to +777
that the new owner of the partition committed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Doc nit (low): This sentence runs 30 words — just over the 25-word guideline. Split suggestion:

Suggested change
that the new owner of the partition committed.
A commit called after the partition is revoked is not made.
This prevents it from moving back the offset that the new owner of the partition committed.

Comment on lines +360 to +361
that the new owner of the partition committed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Doc nit (low): Same sentence, same nit — 30 words. Split suggestion:

Suggested change
that the new owner of the partition committed.
A commit called after the partition is revoked is not made.
This prevents it from moving back the offset that the new owner of the partition committed.

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Croway, the new commit covers everything from my earlier review:

  1. Revoked partition: commitRequestedOffsets() now drops a request whose partition is not in consumer.assignment(), with a DEBUG log. testCommitAfterRevokeIsNotCommitted reproduces it (revoke with rebalance(List.of()), then a late commit() from the test thread), and waiting for polls + 2 is a clean way to be sure a flush ran, without a sleep.
  2. Keeping last-writer-wins as in 4.21: fine for a minimal 4.22.x port.
  3. Thanks for opening CAMEL-26993.
  4. Backport: only port/camel-4.22.x is set and the 4.22 entry is under "4.22.2 to 4.22.3". I checked the tags: 4.22.0 to 4.22.2 commit on the calling thread, 4.21.0 does not, so the version wording in both guides is right. KafkaFetchRecords differs between camel-4.22.x and main, so the commitRequestedOffsets() hook in the poll loop may need a manual port.

One small leftover on the new lines, a suggestion rather than a blocker:

  • AsyncCommitManager.requestCommit also writes the late offset into offsetCache, and when commitRequestedOffsets() drops the request that entry stays (only a successful commit callback removes it). If the partition later comes back to this consumer and is revoked again before any of its records is processed, commit(partition) prefers that stale offsetCache value and commits it, moving back the other owner's offset, which is the case the new check is there to prevent. The window is narrow, but clearing the entry in the drop branch (consumer thread) is cheap; it needs a small OffsetCache.remove(TopicPartition):
    } else {
        offsetCache.remove(partition);
        LOG.debug("Dropping the requested commit of offset {} on {} from {}: the partition is no longer assigned",
                offset, partition, threadId);
    }
    The new test could cover it with a second rebalance(List.of(PARTITION)) and rebalance(List.of()), checking the last committed offset stays 2.

Doc: gnodet-bot's two newer nits (4_22.adoc:775, 4_24.adoc:360) on the 30-word "A commit called after the partition was revoked…" sentence are still open; the suggested split looks good to me.

Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants