This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 3651230156ec CAMEL-26651, CAMEL-26652: camel-git - consumer honours
scheduler options and redelivers failed items (#29270)
3651230156ec is described below
commit 3651230156ecaa4659333ef3b857dc89762a74fe
Author: Federico Mariani <[email protected]>
AuthorDate: Fri Nov 6 08:21:28 2026 +0100
CAMEL-26651, CAMEL-26652: camel-git - consumer honours scheduler options
and redelivers failed items (#29270)
The git consumer honours the scheduler options (greedy, backoff,
sendEmptyMessageWhenIdle) and redelivers items whose processing failed, and
opens the repository before the first poll can run.
AI-generated with Claude Code on behalf of Federico Mariani.
Co-Authored-By: Claude <[email protected]>
---
.../apache/camel/catalog/docs/git-component.adoc | 15 ++
.../camel-git/src/main/docs/git-component.adoc | 15 ++
.../apache/camel/component/git/GitEndpoint.java | 4 +-
.../git/consumer/AbstractGitConsumer.java | 65 ++++++-
.../component/git/consumer/GitBranchConsumer.java | 3 +-
.../component/git/consumer/GitCommitConsumer.java | 3 +-
.../component/git/consumer/GitTagConsumer.java | 3 +-
.../GitConsumerFailedItemPooledExchangeTest.java | 34 ++++
.../git/consumer/GitConsumerFailedItemTest.java | 216 +++++++++++++++++++++
.../git/consumer/GitConsumerFirstPollTest.java | 129 ++++++++++++
.../git/consumer/GitConsumerOptionsTest.java | 92 +++++++++
.../git/consumer/RecordingExceptionHandler.java | 57 ++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 21 ++
13 files changed, 648 insertions(+), 9 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/git-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/git-component.adoc
index 578506e8aab0..64710f52b5e2 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/git-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/git-component.adoc
@@ -103,6 +103,21 @@ YAML::
----
====
+The consumer polls the repository with the scheduler options of the endpoint,
such as `delay` (500 milliseconds by
+default) and `initialDelay`. Each poll walks the history of the branch, or
lists the tags or the branches, to find the
+items that it has not delivered yet, so raise `delay` for a large repository
that changes seldom.
+
+A commit, tag or branch is marked consumed only when the route processed it
without a failure. When the exchange
+fails, the failure is reported to the exception handler of the consumer, and
the item is delivered again on the next
+poll. It then comes after the items that followed it in the same poll, which
were already delivered: the commits come
+from the newest to the oldest, and the tags and the branches in the order of
their names.
+
+A poll counts only the items that it marked consumed. After a poll whose items
all failed, a `greedy` consumer does
+not poll again at once and `sendEmptyMessageWhenIdle` sends no empty message,
while `backoffIdleThreshold` and
+`backoffMultiplier` count it as an idle poll, so they can slow down the
retries of an item that keeps failing. When an
+item keeps failing, handle the failure in the route, for example with a dead
letter channel or a handled
+`onException`, so that the item is marked consumed.
+
=== Shallow Clone
Use the `depth` option to perform a shallow clone, fetching only a limited
number of commits. This reduces clone time and disk usage for large
repositories when full history is not needed.
diff --git a/components/camel-git/src/main/docs/git-component.adoc
b/components/camel-git/src/main/docs/git-component.adoc
index 578506e8aab0..64710f52b5e2 100644
--- a/components/camel-git/src/main/docs/git-component.adoc
+++ b/components/camel-git/src/main/docs/git-component.adoc
@@ -103,6 +103,21 @@ YAML::
----
====
+The consumer polls the repository with the scheduler options of the endpoint,
such as `delay` (500 milliseconds by
+default) and `initialDelay`. Each poll walks the history of the branch, or
lists the tags or the branches, to find the
+items that it has not delivered yet, so raise `delay` for a large repository
that changes seldom.
+
+A commit, tag or branch is marked consumed only when the route processed it
without a failure. When the exchange
+fails, the failure is reported to the exception handler of the consumer, and
the item is delivered again on the next
+poll. It then comes after the items that followed it in the same poll, which
were already delivered: the commits come
+from the newest to the oldest, and the tags and the branches in the order of
their names.
+
+A poll counts only the items that it marked consumed. After a poll whose items
all failed, a `greedy` consumer does
+not poll again at once and `sendEmptyMessageWhenIdle` sends no empty message,
while `backoffIdleThreshold` and
+`backoffMultiplier` count it as an idle poll, so they can slow down the
retries of an item that keeps failing. When an
+item keeps failing, handle the failure in the route, for example with a dead
letter channel or a handled
+`onException`, so that the item is marked consumed.
+
=== Shallow Clone
Use the `depth` option to perform a shallow clone, fetching only a limited
number of commits. This reduces clone time and disk usage for large
repositories when full history is not needed.
diff --git
a/components/camel-git/src/main/java/org/apache/camel/component/git/GitEndpoint.java
b/components/camel-git/src/main/java/org/apache/camel/component/git/GitEndpoint.java
index 6f2e98bfffb9..48cb56c1df83 100644
---
a/components/camel-git/src/main/java/org/apache/camel/component/git/GitEndpoint.java
+++
b/components/camel-git/src/main/java/org/apache/camel/component/git/GitEndpoint.java
@@ -160,11 +160,13 @@ public class GitEndpoint extends ScheduledPollEndpoint {
throw new IllegalArgumentException("type must be specified for git
consumer");
}
- return switch (type) {
+ Consumer consumer = switch (type) {
case COMMIT -> new GitCommitConsumer(this, processor);
case TAG -> new GitTagConsumer(this, processor);
case BRANCH -> new GitBranchConsumer(this, processor);
};
+ configureConsumer(consumer);
+ return consumer;
}
/**
diff --git
a/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/AbstractGitConsumer.java
b/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/AbstractGitConsumer.java
index abd08078cc78..82bb4d2df603 100644
---
a/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/AbstractGitConsumer.java
+++
b/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/AbstractGitConsumer.java
@@ -36,6 +36,8 @@ public abstract class AbstractGitConsumer extends
ScheduledBatchPollingConsumer
private final GitEndpoint endpoint;
private Repository repo;
private Git git;
+ // whether the last poll found items to deliver, consumed or not
+ private boolean lastPollFoundItems;
protected AbstractGitConsumer(GitEndpoint endpoint, Processor processor) {
super(endpoint, processor);
@@ -44,9 +46,10 @@ public abstract class AbstractGitConsumer extends
ScheduledBatchPollingConsumer
@Override
protected void doStart() throws Exception {
- super.doStart();
+ // open the repository before super.doStart() starts the scheduler,
which may poll at once (initialDelay=0)
this.repo = getLocalRepository();
this.git = new Git(repo);
+ super.doStart();
}
@Override
@@ -68,10 +71,18 @@ public abstract class AbstractGitConsumer extends
ScheduledBatchPollingConsumer
return git;
}
+ /**
+ * Hands the exchanges of a poll to the route.
+ *
+ * @return the number of items marked consumed. A failed item, or one that
was not handed to the route, is not
+ * counted: a poll whose items all failed does not make a greedy
consumer poll again at once, and the
+ * backoff options count it as an idle poll.
+ */
@Override
public int processBatch(Queue<Object> exchanges) throws Exception {
int total = exchanges.size();
- int answer = total;
+ int consumed = 0;
+ lastPollFoundItems = total > 0;
if (this.maxMessagesPerPoll > 0 && total > this.maxMessagesPerPoll) {
LOG.debug("Limiting to maximum messages to poll {} as there were
{} messages in this poll.",
this.maxMessagesPerPoll, total);
@@ -85,11 +96,55 @@ public abstract class AbstractGitConsumer extends
ScheduledBatchPollingConsumer
exchange.setProperty(ExchangePropertyKey.BATCH_COMPLETE, index ==
total - 1);
this.pendingExchanges = total - index - 1;
Object value = onPreProcessed(exchange);
- getProcessor().process(exchange);
- onProcessed(exchange, value);
+ try {
+ // only an item the route accepted is marked consumed, so a
failed one is delivered again on the
+ // next poll
+ if (processExchange(exchange)) {
+ onProcessed(exchange, value);
+ consumed++;
+ }
+ } finally {
+ releaseExchange(exchange, false);
+ }
+ }
+
+ // release the exchanges that were not handed to the route, when the
consumer stops in the middle of a batch
+ Exchange remaining;
+ while ((remaining = (Exchange) exchanges.poll()) != null) {
+ releaseExchange(remaining, false);
+ }
+
+ return consumed;
+ }
+
+ @Override
+ protected void processEmptyMessage() throws Exception {
+ // a poll whose items all failed counts no item, but it found and
delivered them, so it is not idle
+ if (!lastPollFoundItems) {
+ super.processEmptyMessage();
}
+ }
- return answer;
+ /**
+ * Hands the exchange to the route and reports a failure through the
exception handler of the consumer.
+ * <p/>
+ * A route that fails does not throw from {@code process()}: the failure
is left on the exchange, so it is read
+ * there. The exchange is created with {@code createExchange(false)}, so
it is not reset before its outcome is read.
+ *
+ * @return {@code true} if the route processed the exchange without a
failure
+ */
+ private boolean processExchange(Exchange exchange) {
+ try {
+ getProcessor().process(exchange);
+ } catch (Exception e) {
+ exchange.setException(e);
+ }
+ Exception cause = exchange.getException();
+ if (cause != null) {
+ getExceptionHandler().handleException("Error processing exchange",
exchange, cause);
+ return false;
+ }
+ return true;
}
public abstract Object onPreProcessed(Exchange exchange);
diff --git
a/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitBranchConsumer.java
b/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitBranchConsumer.java
index 6fb10ce7cedc..25366ddd9092 100644
---
a/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitBranchConsumer.java
+++
b/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitBranchConsumer.java
@@ -42,7 +42,8 @@ public class GitBranchConsumer extends AbstractGitConsumer {
List<Ref> call =
getGit().branchList().setListMode(ListMode.ALL).call();
for (Ref ref : call) {
if (!branchesConsumed.contains(ref.getName())) {
- Exchange e = createExchange(true);
+ // released by processBatch once the outcome of the route has
been read
+ Exchange e = createExchange(false);
e.getMessage().setBody(ref.getName());
e.getMessage().setHeader(GitConstants.GIT_BRANCH_NAME,
ref.getName());
e.getMessage().setHeader(GitConstants.GIT_BRANCH_LEAF,
ref.getLeaf().getName());
diff --git
a/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitCommitConsumer.java
b/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitCommitConsumer.java
index dd40c7cf10b9..35a7a14995df 100644
---
a/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitCommitConsumer.java
+++
b/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitCommitConsumer.java
@@ -59,7 +59,8 @@ public class GitCommitConsumer extends AbstractGitConsumer {
}
for (RevCommit commit : commits) {
if (!commitsConsumed.contains(commit.getId())) {
- Exchange e = createExchange(true);
+ // released by processBatch once the outcome of the route has
been read
+ Exchange e = createExchange(false);
e.getMessage().setBody(commit.getFullMessage());
e.getMessage().setHeader(GitConstants.GIT_COMMIT_ID,
commit.getName());
e.getMessage().setHeader(GitConstants.GIT_COMMIT_AUTHOR_NAME,
commit.getAuthorIdent().getName());
diff --git
a/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitTagConsumer.java
b/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitTagConsumer.java
index 6f9fe0121700..ba46c50cf657 100644
---
a/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitTagConsumer.java
+++
b/components/camel-git/src/main/java/org/apache/camel/component/git/consumer/GitTagConsumer.java
@@ -41,7 +41,8 @@ public class GitTagConsumer extends AbstractGitConsumer {
List<Ref> call = getGit().tagList().call();
for (Ref ref : call) {
if (!tagsConsumed.contains(ref.getName())) {
- Exchange e = createExchange(true);
+ // released by processBatch once the outcome of the route has
been read
+ Exchange e = createExchange(false);
e.getMessage().setBody(ref.getName());
e.getMessage().setHeader(GitConstants.GIT_BRANCH_NAME,
ref.getName());
e.getMessage().setHeader(GitConstants.GIT_BRANCH_LEAF,
ref.getLeaf().getName());
diff --git
a/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerFailedItemPooledExchangeTest.java
b/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerFailedItemPooledExchangeTest.java
new file mode 100644
index 000000000000..3c4f926fcea3
--- /dev/null
+++
b/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerFailedItemPooledExchangeTest.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.git.consumer;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.impl.engine.PooledExchangeFactory;
+
+/**
+ * The same cases with the pooled exchange factory, which resets an exchange
when its unit of work is done: the consumer
+ * must read the outcome of the exchange before it is released.
+ */
+public class GitConsumerFailedItemPooledExchangeTest extends
GitConsumerFailedItemTest {
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext camelContext = super.createCamelContext();
+ camelContext.getCamelContextExtension().setExchangeFactory(new
PooledExchangeFactory());
+ return camelContext;
+ }
+}
diff --git
a/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerFailedItemTest.java
b/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerFailedItemTest.java
new file mode 100644
index 000000000000..bd918057fd0c
--- /dev/null
+++
b/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerFailedItemTest.java
@@ -0,0 +1,216 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.git.consumer;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.git.GitTestSupport;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.support.ScheduledBatchPollingConsumer;
+import org.apache.camel.support.ScheduledPollConsumer;
+import org.eclipse.jgit.api.Git;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+/**
+ * A commit, tag or branch whose exchange failed is reported to the exception
handler and delivered again on the next
+ * poll, and it is marked consumed once the route accepts it. A poll counts
only the items that it marked consumed, so a
+ * failed item does not make a greedy consumer poll again at once, and it
counts as an idle poll for the backoff. The
+ * scheduler is not started: the test polls itself, or runs the scheduled task
itself.
+ */
+public class GitConsumerFailedItemTest extends GitTestSupport {
+
+ private final RecordingExceptionHandler exceptionHandler = new
RecordingExceptionHandler();
+ private final List<String> attempts = new CopyOnWriteArrayList<>();
+ private final AtomicBoolean failing = new AtomicBoolean(true);
+ private volatile int recoverAtAttempt = Integer.MAX_VALUE;
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("recordingExceptionHandler", exceptionHandler);
+ }
+
+ @Test
+ public void failedCommitIsDeliveredAgain() throws Exception {
+ try (Git git = getGitTestRepository()) {
+ commitFile(git);
+ }
+ GitCommitConsumer consumer = (GitCommitConsumer)
context.getRoute("commits").getConsumer();
+
+ assertFailedItemIsDeliveredAgain(consumer::poll, commitMessage);
+ }
+
+ @Test
+ public void failedTagIsDeliveredAgain() throws Exception {
+ try (Git git = getGitTestRepository()) {
+ commitFile(git);
+ git.tag().setName(tagTest).call();
+ }
+ GitTagConsumer consumer = (GitTagConsumer)
context.getRoute("tags").getConsumer();
+
+ assertFailedItemIsDeliveredAgain(consumer::poll, "refs/tags/" +
tagTest);
+ }
+
+ @Test
+ public void failedBranchIsDeliveredAgain() throws Exception {
+ try (Git git = getGitTestRepository()) {
+ commitFile(git);
+ }
+ GitBranchConsumer consumer = (GitBranchConsumer)
context.getRoute("branches").getConsumer();
+
+ assertFailedItemIsDeliveredAgain(consumer::poll, "refs/heads/master");
+ }
+
+ @Test
+ public void greedyConsumerDoesNotPollAgainAfterFailedCommit() throws
Exception {
+ try (Git git = getGitTestRepository()) {
+ commitFile(git);
+ }
+ ScheduledPollConsumer consumer = (ScheduledPollConsumer)
context.getRoute("greedyCommits").getConsumer();
+ // the route recovers at the 10th attempt, so that a consumer that
polls again at once stops there
+ recoverAtAttempt = 10;
+
+ // one run of the scheduler: the commit fails and nothing is consumed,
so the consumer waits for the next run
+ consumer.run();
+ assertEquals(List.of(commitMessage), attempts);
+
+ consumer.run();
+ assertEquals(List.of(commitMessage, commitMessage), attempts);
+ }
+
+ @Test
+ public void greedyConsumerPollsAgainWhileCommitsAreConsumed() throws
Exception {
+ failing.set(false);
+ try (Git git = getGitTestRepository()) {
+ commit(git, "first");
+ commit(git, "second");
+ commit(git, "third");
+ }
+ ScheduledBatchPollingConsumer consumer
+ = (ScheduledBatchPollingConsumer)
context.getRoute("greedyCommits").getConsumer();
+ // not an option of the git endpoint: each poll then consumes one
commit and leaves the older ones
+ consumer.setMaxMessagesPerPoll(1);
+
+ // the commits come from the newest, and the consumer polls again at
once as long as a poll consumed one
+ consumer.run();
+ assertEquals(List.of("third", "second", "first"), attempts);
+ }
+
+ @Test
+ public void failedCommitCountsAsIdlePollForTheBackoff() throws Exception {
+ try (Git git = getGitTestRepository()) {
+ commitFile(git);
+ }
+ ScheduledPollConsumer consumer = (ScheduledPollConsumer)
context.getRoute("backoffCommits").getConsumer();
+
+ // backoffIdleThreshold=1 and backoffMultiplier=3: after a poll that
consumed nothing, 2 runs are skipped
+ for (int run = 0; run < 7; run++) {
+ consumer.run();
+ }
+ assertEquals(List.of(commitMessage, commitMessage, commitMessage),
attempts);
+ }
+
+ @Test
+ public void failedCommitIsNotFollowedByEmptyMessage() throws Exception {
+ try (Git git = getGitTestRepository()) {
+ commitFile(git);
+ }
+ ScheduledPollConsumer consumer = (ScheduledPollConsumer)
context.getRoute("idleCommits").getConsumer();
+
+ // the poll delivered the commit, even if the route failed: it is not
idle
+ consumer.run();
+ assertEquals(List.of(commitMessage), attempts);
+
+ // once the commit is consumed, the next poll finds nothing and sends
an empty message
+ failing.set(false);
+ consumer.run();
+ consumer.run();
+ assertEquals(Arrays.asList(commitMessage, commitMessage, null),
attempts);
+ }
+
+ private void assertFailedItemIsDeliveredAgain(Callable<Integer> poll,
String item) throws Exception {
+ MockEndpoint done = getMockEndpoint("mock:done");
+
+ // the route fails: the failure is reported, and the item is offered
again on the next poll. A poll counts
+ // only the items that it marked consumed.
+ assertEquals(0, poll.call());
+ assertEquals(0, poll.call());
+ assertEquals(List.of(item, item), attempts);
+ assertEquals(List.of(item, item), exceptionHandler.getBodies());
+ assertInstanceOf(IllegalStateException.class,
exceptionHandler.getExceptions().get(0));
+ assertInstanceOf(IllegalStateException.class,
exceptionHandler.getExceptions().get(1));
+ assertEquals(0, done.getReceivedCounter());
+
+ // the route accepts it: the item is marked consumed and not offered
again
+ failing.set(false);
+ assertEquals(1, poll.call());
+ assertEquals(0, poll.call());
+ assertEquals(List.of(item, item, item), attempts);
+ assertEquals(2, exceptionHandler.getBodies().size());
+ assertEquals(1, done.getReceivedCounter());
+ }
+
+ private void commitFile(Git git) throws Exception {
+ new File(getGitDir(), filenameToAdd).createNewFile();
+ git.add().addFilepattern(filenameToAdd).call();
+ git.commit().setMessage(commitMessage).call();
+ }
+
+ private void commit(Git git, String message) throws Exception {
+ git.commit().setMessage(message).setAllowEmpty(true).call();
+ }
+
+ private void attempt(Exchange exchange) {
+ attempts.add(exchange.getMessage().getBody(String.class));
+ if (failing.get() && attempts.size() < recoverAtAttempt) {
+ throw new IllegalStateException("Simulated failure");
+ }
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() throws Exception {
+ // force create git repo before routes
+ getTestRepository();
+ final String uri = "git://" + getGitDir().getPath()
+ +
"?startScheduler=false&exceptionHandler=#recordingExceptionHandler&type=";
+
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from(uri +
"commit&branchName=master").routeId("commits").process(e ->
attempt(e)).to("mock:done");
+ from(uri + "tag").routeId("tags").process(e ->
attempt(e)).to("mock:done");
+ from(uri + "branch").routeId("branches").process(e ->
attempt(e)).to("mock:done");
+ from(uri +
"commit&branchName=master&greedy=true").routeId("greedyCommits")
+ .process(e -> attempt(e)).to("mock:done");
+ from(uri +
"commit&branchName=master&backoffIdleThreshold=1&backoffMultiplier=3").routeId("backoffCommits")
+ .process(e -> attempt(e)).to("mock:done");
+ from(uri +
"commit&branchName=master&sendEmptyMessageWhenIdle=true").routeId("idleCommits")
+ .process(e -> attempt(e)).to("mock:done");
+ }
+ };
+ }
+}
diff --git
a/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerFirstPollTest.java
b/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerFirstPollTest.java
new file mode 100644
index 000000000000..8818b788ae4b
--- /dev/null
+++
b/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerFirstPollTest.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.git.consumer;
+
+import java.io.File;
+import java.util.List;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.git.GitTestSupport;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.spi.ScheduledPollConsumerScheduler;
+import org.apache.camel.support.service.ServiceSupport;
+import org.eclipse.jgit.api.Git;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The scheduler may run the first poll as soon as the consumer starts it
(initialDelay=0), so the consumer must have
+ * opened the repository before it starts the scheduler. The scheduler of this
test runs the first poll right away, in
+ * the thread that starts the consumer.
+ */
+public class GitConsumerFirstPollTest extends GitTestSupport {
+
+ private final RecordingExceptionHandler exceptionHandler = new
RecordingExceptionHandler();
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("recordingExceptionHandler", exceptionHandler);
+ registry.bind("commitScheduler", new PollOnStartScheduler());
+ registry.bind("tagScheduler", new PollOnStartScheduler());
+ registry.bind("branchScheduler", new PollOnStartScheduler());
+ }
+
+ @Test
+ public void firstPollFindsTheRepository() throws Exception {
+ // the first polls ran while the consumers were started, one route
after the other
+ assertEquals(List.of(), exceptionHandler.getExceptions(), "the first
polls do not fail");
+ List<Object> bodies =
getMockEndpoint("mock:done").getReceivedExchanges().stream()
+ .map(exchange -> exchange.getMessage().getBody())
+ .toList();
+ assertEquals(List.of(commitMessage, "refs/tags/" + tagTest,
"refs/heads/master"), bodies);
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() throws Exception {
+ // the repository has a commit, a tag and a branch before the
consumers start
+ try (Git git = getGitTestRepository()) {
+ new File(getGitDir(), filenameToAdd).createNewFile();
+ git.add().addFilepattern(filenameToAdd).call();
+ git.commit().setMessage(commitMessage).call();
+ git.tag().setName(tagTest).call();
+ }
+ final String uri = "git://" + getGitDir().getPath() +
"?exceptionHandler=#recordingExceptionHandler&type=";
+
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from(uri +
"commit&branchName=master&scheduler=#commitScheduler").to("mock:done");
+ from(uri + "tag&scheduler=#tagScheduler").to("mock:done");
+ from(uri +
"branch&scheduler=#branchScheduler").to("mock:done");
+ }
+ };
+ }
+
+ /**
+ * Runs the task once when the scheduler is started.
+ */
+ private static final class PollOnStartScheduler extends ServiceSupport
implements ScheduledPollConsumerScheduler {
+
+ private CamelContext camelContext;
+ private Runnable task;
+ private boolean started;
+
+ @Override
+ public void onInit(Consumer consumer) {
+ // noop
+ }
+
+ @Override
+ public void scheduleTask(Runnable task) {
+ this.task = task;
+ }
+
+ @Override
+ public void unscheduleTask() {
+ task = null;
+ }
+
+ @Override
+ public void startScheduler() {
+ if (!started && task != null) {
+ started = true;
+ task.run();
+ }
+ }
+
+ @Override
+ public boolean isSchedulerStarted() {
+ return started;
+ }
+
+ @Override
+ public void setCamelContext(CamelContext camelContext) {
+ this.camelContext = camelContext;
+ }
+
+ @Override
+ public CamelContext getCamelContext() {
+ return camelContext;
+ }
+ }
+}
diff --git
a/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerOptionsTest.java
b/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerOptionsTest.java
new file mode 100644
index 000000000000..dac3a10c3733
--- /dev/null
+++
b/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/GitConsumerOptionsTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.git.consumer;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.LoggingLevel;
+import org.apache.camel.component.git.GitEndpoint;
+import org.apache.camel.component.git.GitTestSupport;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.support.BridgeExceptionHandlerToErrorHandler;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The scheduler and error handler options of the endpoint are applied to the
consumer of every type.
+ */
+public class GitConsumerOptionsTest extends GitTestSupport {
+
+ private final RecordingExceptionHandler exceptionHandler = new
RecordingExceptionHandler();
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("recordingExceptionHandler", exceptionHandler);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "commit", "tag", "branch" })
+ public void schedulerOptionsReachTheConsumer(String type) throws Exception
{
+ AbstractGitConsumer consumer = createConsumer(type,
"delay=60000&initialDelay=5000&timeUnit=SECONDS"
+ +
"&startScheduler=false&useFixedDelay=false&greedy=true"
+ +
"&backoffMultiplier=4&backoffIdleThreshold=2"
+ +
"&backoffErrorThreshold=3&repeatCount=7"
+ +
"&runLoggingLevel=DEBUG&sendEmptyMessageWhenIdle=true");
+
+ assertEquals(60000, consumer.getDelay());
+ assertEquals(5000, consumer.getInitialDelay());
+ assertEquals(TimeUnit.SECONDS, consumer.getTimeUnit());
+ assertFalse(consumer.isStartScheduler());
+ assertFalse(consumer.isUseFixedDelay());
+ assertTrue(consumer.isGreedy());
+ assertEquals(4, consumer.getBackoffMultiplier());
+ assertEquals(2, consumer.getBackoffIdleThreshold());
+ assertEquals(3, consumer.getBackoffErrorThreshold());
+ assertEquals(7, consumer.getRepeatCount());
+ assertEquals(LoggingLevel.DEBUG, consumer.getRunLoggingLevel());
+ assertTrue(consumer.isSendEmptyMessageWhenIdle());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "commit", "tag", "branch" })
+ public void exceptionHandlerReachesTheConsumer(String type) throws
Exception {
+ AbstractGitConsumer consumer = createConsumer(type,
"exceptionHandler=#recordingExceptionHandler");
+
+ assertSame(exceptionHandler, consumer.getExceptionHandler());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "commit", "tag", "branch" })
+ public void bridgeErrorHandlerReachesTheConsumer(String type) throws
Exception {
+ AbstractGitConsumer consumer = createConsumer(type,
"bridgeErrorHandler=true");
+
+ assertInstanceOf(BridgeExceptionHandlerToErrorHandler.class,
consumer.getExceptionHandler());
+ }
+
+ private AbstractGitConsumer createConsumer(String type, String options)
throws Exception {
+ GitEndpoint endpoint = context.getEndpoint("git://" +
getGitDir().getPath() + "?type=" + type + "&" + options,
+ GitEndpoint.class);
+ return (AbstractGitConsumer) endpoint.createConsumer(exchange -> {
+ });
+ }
+}
diff --git
a/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/RecordingExceptionHandler.java
b/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/RecordingExceptionHandler.java
new file mode 100644
index 000000000000..132ef420473a
--- /dev/null
+++
b/components/camel-git/src/test/java/org/apache/camel/component/git/consumer/RecordingExceptionHandler.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.git.consumer;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.spi.ExceptionHandler;
+
+/**
+ * Records what a consumer reports to its exception handler. The body of the
exchange is read when the failure is
+ * reported, as the consumer may release the exchange afterwards.
+ */
+class RecordingExceptionHandler implements ExceptionHandler {
+
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private final List<Throwable> exceptions = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void handleException(Throwable exception) {
+ handleException(null, null, exception);
+ }
+
+ @Override
+ public void handleException(String message, Throwable exception) {
+ handleException(message, null, exception);
+ }
+
+ @Override
+ public void handleException(String message, Exchange exchange, Throwable
exception) {
+ bodies.add(exchange != null ?
exchange.getMessage().getBody(String.class) : null);
+ exceptions.add(exception);
+ }
+
+ List<String> getBodies() {
+ return bodies;
+ }
+
+ List<Throwable> getExceptions() {
+ return exceptions;
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 41c97b62f1f8..4de48ed4ae14 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -7049,3 +7049,24 @@ contains neither the extension nor
`camel-quarkus-observability-services`, which
A project that lists the dependency but does not want the health extension in
the Quarkus export must remove it.
Without the dependency nothing changes: the health extension then only comes
with `--observe` (which
`camel kubernetes export` always sets), as part of
`camel-quarkus-observability-services`.
+
+=== camel-git - the consumer applies the scheduler and error handler options
+
+The `git` consumer (`type=commit`, `tag` and `branch`) now applies the
scheduler options of the endpoint, such as
+`delay`, `initialDelay`, `startScheduler`, `greedy` and the `backoff` options,
and the `bridgeErrorHandler` and
+`exceptionHandler` options. Before, it ignored them and always polled with an
initial delay of 1 second and a delay of
+500 milliseconds. A route that sets one of these options now gets what it
configured: for example, `delay=60000` now
+polls once a minute.
+
+=== camel-git - a failed commit, tag or branch is delivered again on the next
poll
+
+The `git` consumer now marks a commit, tag or branch consumed only when its
exchange completes without a failure. A
+failed exchange (an exception that the route did not handle) is reported to
the exception handler of the consumer,
+which logs it at WARN by default, and the item is delivered again on the next
poll. Before, the item was marked
+consumed whether the route succeeded or not, so a failed item was never
delivered again. The item then comes after
+the items that followed it in the same poll, which were already delivered,
such as the older commits.
+
+An item that fails every time is now routed again at every poll. Handle the
failure in the route, for example with a
+dead letter channel or a handled `onException`, so that the item is marked
consumed. To retry it less often, raise
+`delay`, or set `backoffIdleThreshold` and `backoffMultiplier`: a poll counts
only the items that it marked consumed,
+so a poll whose items all failed is an idle poll, after which a `greedy`
consumer does not poll again at once.