This is an automated email from the ASF dual-hosted git repository.

Croway 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 65ca4a521f4f CAMEL-25910: camel-kafka-share - deterministic simulation 
test of the share consumer (#28434)
65ca4a521f4f is described below

commit 65ca4a521f4fcc701e220336018f1f155e729699
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 26 19:31:37 2026 +0530

    CAMEL-25910: camel-kafka-share - deterministic simulation test of the share 
consumer (#28434)
    
    * CAMEL-25910: camel-kafka-share - deterministic simulation test of the 
share consumer
    
    Adds a camel-test-dst world for the camel-kafka-share consumer, in the
    dst package of its tests: KafkaShareConsumerDstTest (1000 seeds),
    KafkaShareWorld and FakeShareGroup. The real consumer runs against a fake
    share group (KIP-932) behind the ShareConsumer interface, swapped in
    through KafkaShareComponent.setKafkaShareClientFactory: acquisition locks
    that expire, delivery counts and the delivery limit, explicit
    acknowledgement, acknowledgements sent with poll, commit and close, the
    acknowledgement commit callback called with the offsets at a later poll
    or commit (as kafka-clients 4.3.1 does), wakeup. The seed picks onFailure, 
commitMode, pollOnError, the poll and
    shutdown timeouts, the backoff, maxPollRecords, the lock duration and the
    delivery limit, and injects consumer creation, poll and commit failures,
    slow processing, route restarts and suspend/resume.
    
    Invariants: the share consumer is used as its Javadoc requires; a record
    is accepted only for a delivery the route completed; every published
    record completes at least once or is archived by the group; the group is
    drained and a started route holds one share consumer once the faults
    cleared; no share consumer is left open after the route stopped.
    
    It passes on main (5000 of 5000 seeds). There is no past fix of this
    consumer to revert, so it was checked with two planted faults: a failed
    exchange accepted instead of onFailure fails 1953 of 5000 seeds, and
    pollOnError=RECONNECT ending the consumer thread fails 385 of 5000.
    Limits (consumersCount=1, slow processing only after the operations) are
    in the world's Javadoc. The test log of camel-kafka-share is at WARN and
    capped in size. The world is added to the list in the camel-test-dst
    page (and its catalog copy), whose intro no longer counts the worlds.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25910: camel-kafka-share - the DST world checks the acknowledgement 
type and suspends the CamelContext
    
    Review of 2026-10-25:
    
    - The acknowledgement type is checked for every delivery the group takes:
      the world records the failed deliveries next to the completed ones, and
      FakeShareGroup flags a type other than ACCEPT for a completed delivery,
      a type other than onFailure for a failed one, and REJECT for one that
      did not fail. A record that never completed is excused only when the
      group archived it at the delivery limit, or rejected a delivery that
      failed with onFailure=REJECT (before, any archived record was excused).
    - A third operator kind suspends and resumes the CamelContext. With the
      main code of CAMEL-25872 (20c94435789e) reverted, 2305 of 5000 seeds
      fail, all of them with a CamelContext suspend: the resumed consumer
      waits for a CamelContext start that never comes, so the route has no
      share consumer, and a later start or resume of the route runs the stale
      task and fails (NullPointerException on the executor).
    - The fake follows kafka-clients 4.3.1 more closely: the acknowledgement
      commit callback is also called before poll returns, after commitSync
      has its own result, and at close; a record can be acknowledged again
      until its acknowledgement is sent, and the last type wins
      (ShareInFlightBatch.acknowledge); a wakeup during the fetch stays
      pending, and the seed chooses whether poll still returns the records,
      so the consumer releases the records it no longer routes and commitSync
      sends the acknowledgements, then throws WakeupException; commitSync
      with nothing to send returns an empty map.
    - createConsumerBackoffMaxAttempts is seeded (0 or 3). After that many
      creation failures in a row the consumer thread gives up, and once the
      faults cleared the operator restarts the route. A comment says why
      pollOnError=STOP is not in the default set.
    - The finisher stops the route only when no exchange blocks in the
      route: the locks of a slow exchange can expire and settle the group
      while it still blocks below on the one stack, and a stop above it
      waits for ever.
    
    On main: 20000 of 20000 seeds pass. Planted faults (not committed): a
    failed exchange acknowledged with ACCEPT fails 1922 of 5000 seeds, with
    REJECT while onFailure=RELEASE 966 of 5000, and pollOnError=RECONNECT
    ending the consumer thread 377 of 5000.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25910: camel-kafka-share - the DST world row in alphabetical order, 
as the regression test for CAMEL-25872
    
    The kafka-share row of the camel-test-dst page (and its catalog copy,
    byte-identical) comes after camel-azure-eventhubs, and names CAMEL-25872:
    the world fails on the code before that fix.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25910: camel-kafka-share - the DST fake flags a RELEASE of a 
delivery the consumer did not route while it was not stopping
    
    KafkaShareFetchRecords releases a record without routing it only when it
    stops, and stop() always wakes up the share consumer. The fake share
    consumer now remembers that it was woken up (never cleared), and a
    RELEASE of a delivery that the route neither completed nor failed before
    that is a violation. Without it, a consumer that never routes passed: it
    released every record until the group archived it at the delivery limit,
    which the world excuses.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25910: camel-kafka-share - the DST world notes that its RELEASE 
check relies on the one-stack lock limit
    
    Review follow-up, comments only: the fake flags a RELEASE of a delivery the 
route did not route before the first
    wakeup of the share consumer. That holds in the world because a stop never 
waits for the processing lock on the one
    stack, so the stop wakes up the share consumer before the consumer thread 
releases the rest of its batch. With real
    threads, KafkaShareFetchRecords.stop() waits for that lock, and the rest of 
the batch is released before the wakeup.
    The contract bullet of FakeShareGroup and the limits in the KafkaShareWorld 
Javadoc now say so, so that a later change
    that models the lock wait does not read this as a consumer bug.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25910: camel-kafka-share - say in the fake share group which 
kafka-clients version its rules follow
    
    Review follow-up: the class Javadoc of FakeShareGroup now says that the 
rules were written against
    kafka-clients 4.3.1 and should be checked again when kafka-clients is 
upgraded.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/docs/test-dst.adoc    |   2 +
 components/camel-kafka-share/pom.xml               |   5 +
 .../component/kafka/share/dst/FakeShareGroup.java  | 581 +++++++++++++++++++++
 .../kafka/share/dst/KafkaShareConsumerDstTest.java |  41 ++
 .../component/kafka/share/dst/KafkaShareWorld.java | 398 ++++++++++++++
 .../src/test/resources/log4j2.properties           |  10 +-
 .../camel-test-dst/src/main/docs/test-dst.adoc     |   2 +
 7 files changed, 1037 insertions(+), 2 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/test-dst.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/test-dst.adoc
index 16bf1c96d140..9c0d809a57ad 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/test-dst.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/test-dst.adoc
@@ -60,6 +60,8 @@ These worlds are in the Camel code base and are the examples 
to copy:
 |camel-aws2-sqs |`SqsRedeliveryDstTest` (the extender while Camel's error 
handler waits to redeliver, in virtual
 time) |-
 |camel-azure-eventhubs |`EventHubsCheckpointDstTest` (checkpointing by batch 
size and timeout) |CAMEL-25683
+|camel-kafka-share |`KafkaShareConsumerDstTest` (acknowledgements, acquisition 
locks, reconnects, and suspend and resume
+of the route and of the CamelContext) |CAMEL-25872
 |camel-pulsar |`PulsarConsumerDstTest` (subscribe failures, broker and route 
restarts while the route starts and
 consumes) |CAMEL-25869
 |camel-slack |`SlackSuspendDstTest` (route suspend in the middle of a poll) 
|CAMEL-25684
diff --git a/components/camel-kafka-share/pom.xml 
b/components/camel-kafka-share/pom.xml
index 43f005b567c9..4e854da76914 100644
--- a/components/camel-kafka-share/pom.xml
+++ b/components/camel-kafka-share/pom.xml
@@ -59,6 +59,11 @@
             <version>${project.version}</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-test-dst</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.assertj</groupId>
             <artifactId>assertj-core</artifactId>
diff --git 
a/components/camel-kafka-share/src/test/java/org/apache/camel/component/kafka/share/dst/FakeShareGroup.java
 
b/components/camel-kafka-share/src/test/java/org/apache/camel/component/kafka/share/dst/FakeShareGroup.java
new file mode 100644
index 000000000000..2d079d22856f
--- /dev/null
+++ 
b/components/camel-kafka-share/src/test/java/org/apache/camel/component/kafka/share/dst/FakeShareGroup.java
@@ -0,0 +1,581 @@
+/*
+ * 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.kafka.share.dst;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.camel.test.dst.Dst;
+import org.apache.camel.test.dst.DstLedger;
+import org.apache.kafka.clients.consumer.AcknowledgeType;
+import org.apache.kafka.clients.consumer.AcknowledgementCommitCallback;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.ConsumerRecords;
+import org.apache.kafka.clients.consumer.ShareConsumer;
+import org.apache.kafka.common.KafkaException;
+import org.apache.kafka.common.TopicIdPartition;
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.errors.InvalidRecordStateException;
+import org.apache.kafka.common.errors.WakeupException;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.record.TimestampType;
+
+/**
+ * A fake Kafka share group (KIP-932) for one topic with one partition, and 
fake share consumers behind the
+ * {@link ShareConsumer} interface (dynamic proxies).
+ * <p>
+ * Written against kafka-clients 4.3.1: when kafka-clients is upgraded, check 
the rules below ([A] to [X]) against the
+ * new ShareConsumerImpl and ShareInFlightBatch.
+ * <p>
+ * Rules (KIP-932 and the KafkaShareConsumer Javadoc, Kafka 4.3):
+ * <ul>
+ * <li>[A] "records are acquired for a consumer ... with a time-limited 
acquisition lock"
+ * (group.share.record.lock.duration.ms). "If the lock times out, the record 
becomes available again". Every acquisition
+ * increments the delivery count; "once the delivery count reaches 
group.share.delivery.count.limit the record is
+ * archived" instead of becoming available.</li>
+ * <li>[E] explicit acknowledgement: "each record ... must be acknowledged ... 
using acknowledge(ConsumerRecord,
+ * AcknowledgeType) before the next call to poll", otherwise poll throws 
IllegalStateException ("All records must be
+ * acknowledged in explicit acknowledgement mode."). A record that is not 
waiting for an acknowledgement throws
+ * IllegalStateException ("The record cannot be acknowledged."). As 
kafka-clients 4.3.1 ShareInFlightBatch.acknowledge
+ * does, a record of the last poll can be acknowledged again until its 
acknowledgement is sent, and the last type wins;
+ * once it was sent (by a poll, commit or close), it is no longer waiting and 
throws.</li>
+ * <li>[C] the acknowledgements are sent with the next poll, commitSync, 
commitAsync or close. commitSync returns the
+ * result per partition; an acknowledgement of a record whose lock expired 
fails (InvalidRecordStateException).</li>
+ * <li>[K] the acknowledgement commit callback gets the result of the 
acknowledgements that were sent, with their
+ * offsets, where kafka-clients 4.3.1 ShareConsumerImpl calls 
handleCompletedAcknowledgements: at the start of poll,
+ * commitSync and commitAsync, before poll returns (the acknowledgements sent 
with that poll have their result by then
+ * in the fake), after commitSync has its own result, and at close after the 
acknowledgements were sent. Not from within
+ * commitAsync.</li>
+ * <li>[W] wakeup: "the thread which is blocking in an operation will throw 
WakeupException. If no thread is blocking
+ * ... the next call will raise it". As in kafka-clients 4.3.1: poll checks 
for a wakeup before it fetches; a wakeup
+ * while poll waits for the fetch wakes the fetch buffer and stays pending, so 
poll throws WakeupException when no
+ * records were fetched, and otherwise the seed chooses whether the records 
arrived (poll returns them) or not (poll
+ * throws). commitSync with acknowledgements to send sends them, then throws a 
pending WakeupException; commitSync with
+ * nothing to send returns an empty map and leaves the wakeup pending. 
commitAsync does not check for a wakeup.</li>
+ * <li>[X] close: the pending acknowledgements are sent; acquired records that 
were not acknowledged are released (the
+ * delivery count is kept).</li>
+ * <li>ACCEPT: done. RELEASE: available again (or archived at the delivery 
limit). REJECT: archived.</li>
+ * <li>Camel's contract (KafkaShareFetchRecords, the route never sets the 
acknowledge header): RELEASE of a delivery
+ * that the route neither completed nor failed only once the consumer stops, 
and it always wakes up its share consumer
+ * when it stops. Before the first wakeup of a share consumer such a RELEASE 
is a violation. This check relies on the
+ * stop not waiting for the processing lock (see the limits in {@link 
KafkaShareWorld}): with real threads,
+ * KafkaShareFetchRecords.stop() waits for that lock, and the consumer thread 
releases the rest of the batch before the
+ * wakeup.</li>
+ * </ul>
+ * Not modelled: several partitions, the batch_optimized acquire mode (a poll 
returns at most max.poll.records), lock
+ * renewal (RENEW), the heartbeat and assignment of the group.
+ * <p>
+ * Timing (one-stack rule): poll waits first and acquires at the end of its 
wait; acknowledgements take effect at once;
+ * commit and close do not block (the consumer calls commit while it holds its 
lock).
+ */
+final class FakeShareGroup {
+
+    static final String TOPIC = "dst";
+    static final Uuid TOPIC_ID = new Uuid(4711L, 42L);
+    static final TopicIdPartition PARTITION = new TopicIdPartition(TOPIC_ID, 
0, TOPIC);
+
+    enum State {
+        AVAILABLE,
+        ACQUIRED,
+        ACKNOWLEDGED,
+        ARCHIVED
+    }
+
+    /** The result of acknowledgements that were sent, for the acknowledgement 
commit callback. */
+    record CompletedAcks(Map<TopicIdPartition, Set<Long>> offsets, Exception 
error) {
+    }
+
+    static final class Rec {
+        final long offset;
+        final String id;
+        State state = State.AVAILABLE;
+        int deliveries;
+        // the delivery that was rejected (archived by REJECT), 0 if none
+        int rejected;
+        long lockUntil;
+        Fake owner;
+
+        Rec(long offset, String id) {
+            this.offset = offset;
+            this.id = id;
+        }
+    }
+
+    static final class Delivery {
+        final Rec rec;
+        final int number;
+        final ConsumerRecord<Object, Object> record;
+        AcknowledgeType ack;
+        // the acknowledgement was sent (with a poll, commit or close)
+        boolean sent;
+
+        Delivery(Rec rec, int number, ConsumerRecord<Object, Object> record) {
+            this.rec = rec;
+            this.number = number;
+            this.record = record;
+        }
+    }
+
+    final class Fake implements InvocationHandler {
+        final String name;
+        final List<Delivery> lastBatch = new ArrayList<>();
+        final List<Delivery> pendingAcks = new ArrayList<>();
+        boolean closed;
+        // a wakeup that is pending (cleared when it is thrown)
+        boolean wakeup;
+        // woken up at least once (never cleared): the consumer stops it with 
a wakeup, so only from then on it may
+        // release a delivery that it did not route
+        boolean wokenUp;
+        AcknowledgementCommitCallback callback;
+        // [K] the results of the acknowledgements sent, for the callback at 
the next poll or commit
+        final List<CompletedAcks> completedAcks = new ArrayList<>();
+
+        Fake(String name) {
+            this.name = name;
+        }
+
+        @Override
+        public Object invoke(Object proxy, Method method, Object[] args) {
+            String m = method.getName();
+            switch (m) {
+                case "toString":
+                    return "FakeShareConsumer[" + name + "]";
+                case "hashCode":
+                    return System.identityHashCode(this);
+                case "equals":
+                    return proxy == args[0];
+                case "wakeup":
+                    dst.trace("fake: " + name + " wakeup");
+                    wakeup = true;
+                    wokenUp = true;
+                    return null;
+                case "close":
+                    close();
+                    return null;
+                default:
+                    break;
+            }
+            checkOpen(m);
+            switch (m) {
+                case "subscribe":
+                    @SuppressWarnings("unchecked")
+                    Collection<String> topics = (Collection<String>) args[0];
+                    if (!topics.contains(TOPIC)) {
+                        violations.add(name + " subscribed to " + topics);
+                    }
+                    return null;
+                case "unsubscribe":
+                    return null;
+                case "subscription":
+                    return Set.of(TOPIC);
+                case "setAcknowledgementCommitCallback":
+                    callback = (AcknowledgementCommitCallback) args[0];
+                    return null;
+                case "poll":
+                    return poll((Duration) args[0]);
+                case "acknowledge":
+                    if (args.length != 2) {
+                        violations.add(name + " acknowledge" + args.length);
+                        throw new IllegalStateException("not modelled");
+                    }
+                    @SuppressWarnings("unchecked")
+                    ConsumerRecord<Object, Object> record = 
(ConsumerRecord<Object, Object>) args[0];
+                    acknowledge(record, (AcknowledgeType) args[1]);
+                    return null;
+                case "commitSync":
+                    return commitSync();
+                case "commitAsync":
+                    commitAsync();
+                    return null;
+                case "acquisitionLockTimeoutMs":
+                    return Optional.of((int) lockDurationMillis);
+                case "metrics":
+                    return Map.of();
+                default:
+                    violations.add(name + " called " + m + " (not modelled)");
+                    throw new UnsupportedOperationException(m);
+            }
+        }
+
+        private void checkOpen(String m) {
+            if (closed) {
+                violations.add(name + " used after close: " + m);
+                throw new IllegalStateException("This consumer has already 
been closed.");
+            }
+        }
+
+        /** [K] the callback gets the results of the acknowledgements sent 
before this call. */
+        private void handleCompletedAcknowledgements() {
+            List<CompletedAcks> completed = new ArrayList<>(completedAcks);
+            completedAcks.clear();
+            if (callback != null) {
+                for (CompletedAcks c : completed) {
+                    callback.onComplete(c.offsets(), c.error());
+                }
+            }
+        }
+
+        private ConsumerRecords<Object, Object> poll(Duration timeout) {
+            handleCompletedAcknowledgements();
+            // [E]
+            for (Delivery d : lastBatch) {
+                if (d.ack == null) {
+                    violations.add(name + " polled before acknowledging " + 
d.rec.id + "#" + d.number);
+                    dst.block(10, "poll refused");
+                    throw new IllegalStateException("All records must be 
acknowledged in explicit acknowledgement mode.");
+                }
+            }
+            // [W] a pending wakeup, before the fetch
+            if (wakeup) {
+                wakeup = false;
+                throw new WakeupException();
+            }
+            lastBatch.clear();
+            // [C] the acknowledgements go with the poll
+            send(false);
+            if (faults && dst.chance(pollFailRate)) {
+                dst.block(dst.between(1, 50), "poll fails");
+                ledger.record("pollFault", "c", name);
+                throw Dst.injected(new KafkaException("dst: poll failed"));
+            }
+            long waitMillis = Math.max(1, timeout.toMillis());
+            boolean ready = dst.awaitUntil(() -> wakeup || closed || 
hasAvailable(), waitMillis, name + " poll");
+            if (ready && !wakeup) {
+                // reads take effect at the end of their latency
+                dst.block(dst.between(1, 20), name + " fetch");
+            }
+            if (closed) {
+                violations.add(name + " closed while polling");
+                throw new IllegalStateException("This consumer has already 
been closed.");
+            }
+            if (wakeup) {
+                // [W] the wakeup woke the fetch and stays pending: without 
records the next fetch throws it, and
+                // with records the seed chooses whether they were fetched 
before the wakeup
+                if (!hasAvailable() || dst.chance(0.5)) {
+                    wakeup = false;
+                    throw new WakeupException();
+                }
+                dst.trace("fake: " + name + " woken up during the fetch, the 
records arrived, the wakeup stays pending");
+            }
+            expire();
+            Map<TopicPartition, List<ConsumerRecord<Object, Object>>> map = 
new LinkedHashMap<>();
+            List<ConsumerRecord<Object, Object>> list = new ArrayList<>();
+            for (Rec r : log) {
+                if (list.size() >= maxPollRecords) {
+                    break;
+                }
+                if (r.state == State.AVAILABLE) {
+                    r.state = State.ACQUIRED;
+                    r.deliveries++;
+                    r.lockUntil = dst.now() + lockDurationMillis;
+                    r.owner = this;
+                    ConsumerRecord<Object, Object> cr = new ConsumerRecord<>(
+                            TOPIC, 0, r.offset, Dst.EPOCH_MILLIS + dst.now(), 
TimestampType.CREATE_TIME, -1, -1,
+                            null, r.id, new RecordHeaders(), Optional.empty(), 
Optional.of((short) r.deliveries));
+                    lastBatch.add(new Delivery(r, r.deliveries, cr));
+                    list.add(cr);
+                    ledger.record("delivered", "t", r.id);
+                }
+            }
+            if (!list.isEmpty()) {
+                map.put(new TopicPartition(TOPIC, 0), list);
+                dst.trace("fake: " + name + " poll returns " + list.size() + " 
records");
+            }
+            // [K] before poll returns
+            handleCompletedAcknowledgements();
+            return new ConsumerRecords<>(map, Map.of());
+        }
+
+        private void acknowledge(ConsumerRecord<Object, Object> record, 
AcknowledgeType type) {
+            for (Delivery d : lastBatch) {
+                if (d.rec.offset == record.offset()) {
+                    // [E] no longer waiting for an acknowledgement once it 
was sent
+                    if (d.sent) {
+                        violations.add(name + " acknowledged " + d.rec.id + 
"#" + d.number + " after its " + d.ack.name()
+                                       + " was sent");
+                        throw new IllegalStateException("The record cannot be 
acknowledged.");
+                    }
+                    // RELEASE is for a delivery that the stopping consumer 
did not route, and the consumer always
+                    // wakes up its share consumer when it stops
+                    String key = d.rec.id + "#" + d.number;
+                    if (type == AcknowledgeType.RELEASE && !wokenUp && 
!completedDeliveries.contains(key)
+                            && !failedDeliveries.contains(key)) {
+                        violations.add(name + " released " + key + " without 
routing it, and it was not stopping");
+                    }
+                    if (d.ack == null) {
+                        pendingAcks.add(d);
+                    } else {
+                        // [E] not sent yet: the last type wins
+                        dst.trace("fake: " + name + " " + d.rec.id + "#" + 
d.number + " acknowledged again, "
+                                  + d.ack.name() + " -> " + type.name());
+                    }
+                    d.ack = type;
+                    return;
+                }
+            }
+            violations.add(name + " acknowledged a record not in its last 
poll: offset " + record.offset());
+            throw new IllegalStateException("The record cannot be 
acknowledged.");
+        }
+
+        private Map<TopicIdPartition, Optional<KafkaException>> commitSync() {
+            handleCompletedAcknowledgements();
+            if (pendingAcks.isEmpty()) {
+                // [W] nothing to send: no wakeup check
+                return Map.of();
+            }
+            KafkaException error = send(true);
+            // [W] the acknowledgements were sent before the wait for their 
result is woken up
+            if (wakeup) {
+                wakeup = false;
+                throw new WakeupException();
+            }
+            Map<TopicIdPartition, Optional<KafkaException>> result = new 
LinkedHashMap<>();
+            result.put(PARTITION, Optional.ofNullable(error));
+            // [K] after its own result
+            handleCompletedAcknowledgements();
+            return result;
+        }
+
+        private void commitAsync() {
+            handleCompletedAcknowledgements();
+            // the result reaches the callback at a later poll or commit
+            send(true);
+        }
+
+        /** Sends the pending acknowledgements. */
+        private KafkaException send(boolean mayFail) {
+            if (pendingAcks.isEmpty()) {
+                return null;
+            }
+            List<Delivery> acks = new ArrayList<>(pendingAcks);
+            pendingAcks.clear();
+            Set<Long> offsets = new TreeSet<>();
+            for (Delivery d : acks) {
+                d.sent = true;
+                offsets.add(d.rec.offset);
+            }
+            if (mayFail && faults && dst.chance(commitFailRate)) {
+                for (Delivery d : acks) {
+                    ledger.record("ackLost", "t", d.rec.id);
+                }
+                dst.trace("fake: " + name + " commit fails, " + acks.size() + 
" acknowledgements lost");
+                KafkaException failure = Dst.injected(new KafkaException("dst: 
commit failed"));
+                completedAcks.add(new CompletedAcks(Map.of(PARTITION, 
offsets), failure));
+                return failure;
+            }
+            expire();
+            KafkaException error = null;
+            for (Delivery d : acks) {
+                Rec r = d.rec;
+                if (r.state != State.ACQUIRED || r.deliveries != d.number || 
r.owner != this) {
+                    ledger.record("ackStale", "t", r.id);
+                    error = new InvalidRecordStateException("dst: the lock of 
" + r.id + "#" + d.number + " expired");
+                    continue;
+                }
+                apply(r, d.number, d.ack);
+            }
+            completedAcks.add(new CompletedAcks(Map.of(PARTITION, offsets), 
error));
+            return error;
+        }
+
+        private void close() {
+            if (closed) {
+                return;
+            }
+            dst.trace("fake: " + name + " close");
+            send(false);
+            expire();
+            // [X]
+            for (Delivery d : lastBatch) {
+                Rec r = d.rec;
+                if (d.ack == null && r.state == State.ACQUIRED && r.deliveries 
== d.number && r.owner == this) {
+                    release(r, "released on close");
+                }
+            }
+            lastBatch.clear();
+            closed = true;
+            open.remove(this);
+            // [K] at close, after the acknowledgements were sent
+            handleCompletedAcknowledgements();
+        }
+    }
+
+    private final Dst dst;
+    private final DstLedger ledger;
+    final List<Rec> log = new ArrayList<>();
+    final List<String> violations = new ArrayList<>();
+    final Set<Fake> open = new HashSet<>();
+    // the deliveries (id#deliveryCount) whose exchange completed, and those 
whose exchange failed
+    final Set<String> completedDeliveries = new HashSet<>();
+    final Set<String> failedDeliveries = new HashSet<>();
+    // the acknowledgement type of a failed exchange (the onFailure option of 
the endpoint)
+    AcknowledgeType expectedOnFailure = AcknowledgeType.RELEASE;
+    long lockDurationMillis = 30000;
+    int deliveryLimit = 5;
+    int maxPollRecords = 500;
+    double createFailRate;
+    // the createConsumerBackoffMaxAttempts of the component (0: retry for 
ever), and how often the creation failed that
+    // many times in a row
+    int createConsumerBackoffMaxAttempts;
+    int createGiveUps;
+    private int createFaultsInARow;
+    double pollFailRate;
+    double commitFailRate;
+    boolean faults = true;
+    private int created;
+
+    FakeShareGroup(Dst dst, DstLedger ledger) {
+        this.dst = dst;
+        this.ledger = ledger;
+    }
+
+    /** KafkaShareClientFactory.getShareConsumer. */
+    @SuppressWarnings("unchecked")
+    ShareConsumer<Object, Object> newConsumer() {
+        if (faults && dst.chance(createFailRate)) {
+            ledger.record("createFault", "c", "c" + created);
+            createFaultsInARow++;
+            if (createConsumerBackoffMaxAttempts > 0 && createFaultsInARow == 
createConsumerBackoffMaxAttempts) {
+                // as many failed attempts in a row as the consumer makes 
before it gives up
+                createGiveUps++;
+                createFaultsInARow = 0;
+                dst.trace("fake: " + createConsumerBackoffMaxAttempts + " 
creation failures in a row");
+            }
+            throw Dst.injected(new KafkaException("dst: Failed to construct 
kafka share consumer"));
+        }
+        createFaultsInARow = 0;
+        Fake fake = new Fake("c" + created++);
+        open.add(fake);
+        dst.trace("fake: created " + fake.name);
+        return (ShareConsumer<Object, Object>) Proxy.newProxyInstance(
+                ShareConsumer.class.getClassLoader(), new Class<?>[] { 
ShareConsumer.class }, fake);
+    }
+
+    void publish(String id) {
+        log.add(new Rec(log.size(), id));
+        ledger.record("published", "t", id);
+    }
+
+    boolean hasAvailable() {
+        expire();
+        for (Rec r : log) {
+            if (r.state == State.AVAILABLE) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** [A] locks that timed out. */
+    void expire() {
+        long now = dst.now();
+        for (Rec r : log) {
+            if (r.state == State.ACQUIRED && now >= r.lockUntil) {
+                ledger.record("lockExpired", "t", r.id);
+                release(r, "lock expired");
+            }
+        }
+    }
+
+    /**
+     * Applies an acknowledgement that the group took. The route never sets 
the acknowledge header, so the type is
+     * exact: ACCEPT for a delivery the route completed, onFailure for one 
that failed, and RELEASE only for one the
+     * consumer did not route (it was stopping; checked in acknowledge, 
against the wakeup).
+     */
+    private void apply(Rec r, int number, AcknowledgeType type) {
+        String key = r.id + "#" + number;
+        if (type == AcknowledgeType.ACCEPT && 
!completedDeliveries.contains(key)) {
+            violations.add("accepted " + key + " but the route did not 
complete that delivery");
+        }
+        if (type != AcknowledgeType.ACCEPT && 
completedDeliveries.contains(key)) {
+            violations.add(type.name() + " for " + key + " which the route 
completed");
+        }
+        if (failedDeliveries.contains(key) && type != expectedOnFailure) {
+            violations.add(type.name() + " for failed " + key + ", onFailure 
is " + expectedOnFailure.name());
+        }
+        if (type == AcknowledgeType.REJECT && !failedDeliveries.contains(key)) 
{
+            violations.add("rejected " + key + " which did not fail");
+        }
+        if (type == AcknowledgeType.ACCEPT) {
+            r.state = State.ACKNOWLEDGED;
+            r.owner = null;
+            ledger.record("accepted", "t", r.id);
+        } else if (type == AcknowledgeType.REJECT) {
+            r.state = State.ARCHIVED;
+            r.rejected = number;
+            r.owner = null;
+            ledger.record("rejected", "t", r.id);
+        } else if (type == AcknowledgeType.RELEASE) {
+            release(r, "released");
+        } else {
+            violations.add("acknowledgement type " + type + " not modelled");
+        }
+    }
+
+    private void release(Rec r, String why) {
+        r.owner = null;
+        if (r.deliveries >= deliveryLimit) {
+            r.state = State.ARCHIVED;
+            ledger.record("archived", "t", r.id);
+        } else {
+            r.state = State.AVAILABLE;
+        }
+        dst.trace("fake: " + r.id + " " + why + " -> " + r.state);
+    }
+
+    boolean settled() {
+        expire();
+        for (Rec r : log) {
+            if (r.state != State.ACKNOWLEDGED && r.state != State.ARCHIVED) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    String unsettled() {
+        StringBuilder sb = new StringBuilder();
+        for (Rec r : log) {
+            if (r.state != State.ACKNOWLEDGED && r.state != State.ARCHIVED) {
+                
sb.append(r.id).append('=').append(r.state).append("/d").append(r.deliveries)
+                        .append(r.owner != null ? "@" + r.owner.name : 
"").append(' ');
+            }
+        }
+        return sb.toString().trim();
+    }
+
+    String openNames() {
+        List<String> names = new ArrayList<>();
+        for (Fake f : open) {
+            names.add(f.name);
+        }
+        names.sort(null);
+        return names.toString();
+    }
+}
diff --git 
a/components/camel-kafka-share/src/test/java/org/apache/camel/component/kafka/share/dst/KafkaShareConsumerDstTest.java
 
b/components/camel-kafka-share/src/test/java/org/apache/camel/component/kafka/share/dst/KafkaShareConsumerDstTest.java
new file mode 100644
index 000000000000..0985328de40f
--- /dev/null
+++ 
b/components/camel-kafka-share/src/test/java/org/apache/camel/component/kafka/share/dst/KafkaShareConsumerDstTest.java
@@ -0,0 +1,41 @@
+/*
+ * 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.kafka.share.dst;
+
+import org.apache.camel.test.dst.Dst;
+import org.apache.camel.test.dst.DstInstrument;
+import org.apache.camel.test.dst.DstTest;
+
+/**
+ * Deterministic simulation test of the camel-kafka-share consumer against a 
fake share group, with consumer creation
+ * failures, poll and commit failures, acquisition locks that expire under 
slow processing, route restarts, suspend and
+ * resume of the route and of the CamelContext: every published record 
completes in the route at least once or is
+ * archived by the group as it should be, and each delivery is acknowledged 
with the type its exchange calls for (ACCEPT
+ * when it completed, onFailure when it failed). A failing seed is replayed 
with {@code -Ddst.seed=<seed>};
+ * {@code -Ddst.seeds=5000} runs more seeds; {@code 
-Ddst.share.pollOnError=RECONNECT} runs one pollOnError strategy.
+ */
+@DstInstrument(value = "org.apache.camel.component.kafka.share",
+               alsoRewrite = {
+                       "org.apache.camel.util.StopWatch", 
"org.apache.camel.impl.engine.DefaultShutdownStrategy",
+                       "org.apache.camel.support.task" })
+class KafkaShareConsumerDstTest {
+
+    @DstTest(seeds = 1000)
+    void everyRecordCompletesOrIsArchivedAndOnlyCompletedOnesAreAccepted(Dst 
dst) throws Exception {
+        new KafkaShareWorld(dst).run();
+    }
+}
diff --git 
a/components/camel-kafka-share/src/test/java/org/apache/camel/component/kafka/share/dst/KafkaShareWorld.java
 
b/components/camel-kafka-share/src/test/java/org/apache/camel/component/kafka/share/dst/KafkaShareWorld.java
new file mode 100644
index 000000000000..5ec8f75b2665
--- /dev/null
+++ 
b/components/camel-kafka-share/src/test/java/org/apache/camel/component/kafka/share/dst/KafkaShareWorld.java
@@ -0,0 +1,398 @@
+/*
+ * 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.kafka.share.dst;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Properties;
+
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.kafka.KafkaConsumerFatalException;
+import org.apache.camel.component.kafka.TaskHealthState;
+import org.apache.camel.component.kafka.share.KafkaShareClientFactory;
+import org.apache.camel.component.kafka.share.KafkaShareComponent;
+import org.apache.camel.component.kafka.share.KafkaShareConfiguration;
+import org.apache.camel.component.kafka.share.KafkaShareConstants;
+import org.apache.camel.component.kafka.share.KafkaShareConsumer;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.test.dst.Dst;
+import org.apache.camel.test.dst.DstCamel;
+import org.apache.camel.test.dst.DstExecutor;
+import org.apache.camel.test.dst.DstLedger;
+import org.apache.camel.test.dst.Invariants;
+import org.apache.kafka.clients.consumer.AcknowledgeType;
+import org.apache.kafka.clients.consumer.ShareConsumer;
+
+/**
+ * The world around a camel-kafka-share consumer: a fake share group ({@link 
FakeShareGroup}), a producer, a route whose
+ * processing is sometimes slow (longer than the acquisition lock) and 
sometimes fails, and an operator that starts,
+ * restarts, suspends and resumes the route, and suspends and resumes the 
CamelContext. Faults: the share consumer
+ * cannot be created, polls fail, commits fail, locks expire, a wakeup arrives 
while a poll fetches.
+ * <p>
+ * consumersCount is 1: a consumer thread loops for ever, and with the 
one-stack model a second looping thread nested
+ * above the first would never let it resume. For the same reason a route stop 
that runs while the consumer thread waits
+ * (in poll or in the route) cannot see it finish: the stop waits for its 
timeouts and the thread ends later. A stop
+ * above an exchange that blocks in the route would wait for ever, so the 
processing takes time (and can outlast the
+ * acquisition lock) only once the operations are done. The locks of the 
consumer do not exclude on the one stack, so a
+ * stop never waits for the processing lock of the consumer thread. The 
RELEASE check of the fake relies on this: with
+ * real threads, the stop waits for that lock, and the consumer thread 
releases the rest of the batch before the wakeup.
+ * <p>
+ * Properties: every published record completes in the route at least once, 
unless the share group archived it at the
+ * delivery limit, or rejected a delivery that failed with onFailure=REJECT; 
the acknowledgement type is exact (ACCEPT
+ * for a delivery the route completed, onFailure for one that failed, REJECT 
for no other, and RELEASE of a delivery the
+ * route did not route only once the consumer woke up its share consumer to 
stop, so a consumer that does not route
+ * cannot pass by releasing every record until the group archives it); the 
group is drained once the faults cleared; a
+ * started route holds one open share consumer, a stopped one none; the 
consumer uses the client as the Javadoc
+ * requires.
+ * <p>
+ * When the seed sets createConsumerBackoffMaxAttempts and the creation failed 
that many times in a row, the consumer
+ * thread gives up and the route has no share consumer: once the faults 
cleared, the operator restarts the route, once
+ * for each such run of failures.
+ */
+final class KafkaShareWorld {
+
+    static final String ROUTE = "share";
+
+    private final Dst dst;
+    private final DstLedger ledger;
+    private final FakeShareGroup group;
+    private final String onFailure;
+    private final String commitMode;
+    private final String pollOnError;
+    private final long pollTimeoutMs;
+    private final long shutdownTimeout;
+    private final long backoffInterval;
+    private final int backoffMaxAttempts;
+    private int restartsAfterGiveUp;
+    private final List<String> violations = new ArrayList<>();
+    private boolean faults = true;
+    private int sendsLeft;
+    private int opsLeft;
+    // exchanges that block in the route
+    private int blocked;
+    private boolean finished;
+    private String finalStatus;
+    private String finalOpen;
+    private int finalOpenCount;
+    private boolean finalSettled;
+
+    KafkaShareWorld(Dst dst) {
+        this.dst = dst;
+        this.ledger = new DstLedger(dst);
+        this.group = new FakeShareGroup(dst, ledger);
+        this.onFailure = dst.pick(List.of("RELEASE", "REJECT"));
+        group.expectedOnFailure = AcknowledgeType.valueOf(onFailure);
+        this.commitMode = dst.pick(List.of("SYNC", "ASYNC"));
+        // not STOP: it ends the consumer thread by design, so "a started 
route holds one share consumer" cannot hold
+        this.pollOnError = dst.pick(Arrays.asList(
+                System.getProperty("dst.share.pollOnError", 
"ERROR_HANDLER,RECONNECT,RETRY,DISCARD").split(",")));
+        this.pollTimeoutMs = dst.pick(List.of(100L, 1000L, 5000L));
+        this.shutdownTimeout = dst.pick(List.of(1000L, 30000L));
+        this.backoffInterval = dst.pick(List.of(500L, 5000L));
+        this.backoffMaxAttempts = dst.pick(List.of(0, 3));
+        group.createConsumerBackoffMaxAttempts = backoffMaxAttempts;
+        group.maxPollRecords = dst.pick(List.of(1, 3, 10));
+        group.lockDurationMillis = dst.pick(List.of(5000L, 15000L, 30000L));
+        group.deliveryLimit = dst.pick(List.of(5, 10));
+        group.createFailRate = dst.pick(List.of(0.0, 0.2, 0.5));
+        group.pollFailRate = dst.pick(List.of(0.0, 0.02, 0.1));
+        group.commitFailRate = dst.pick(List.of(0.0, 0.05));
+        dst.trace("world: " + config());
+    }
+
+    private String config() {
+        return "onFailure=" + onFailure + " commitMode=" + commitMode + " 
pollOnError=" + pollOnError
+               + " pollTimeoutMs=" + pollTimeoutMs + " shutdownTimeout=" + 
shutdownTimeout + " backoff=" + backoffInterval
+               + " backoffMaxAttempts=" + backoffMaxAttempts
+               + " maxPollRecords=" + group.maxPollRecords + " lock=" + 
group.lockDurationMillis + " deliveryLimit="
+               + group.deliveryLimit + " createFail=" + group.createFailRate + 
" pollFail=" + group.pollFailRate
+               + " commitFail=" + group.commitFailRate;
+    }
+
+    void run() throws Exception {
+        DefaultCamelContext context = DstCamel.newContext(dst);
+        KafkaShareComponent component = new KafkaShareComponent();
+        component.setCreateConsumerBackoffInterval(backoffInterval);
+        component.setCreateConsumerBackoffMaxAttempts(backoffMaxAttempts);
+        component.setKafkaShareClientFactory(new KafkaShareClientFactory() {
+            @Override
+            public ShareConsumer<Object, Object> getShareConsumer(Properties 
kafkaProps) {
+                return group.newConsumer();
+            }
+
+            @Override
+            public String getBrokers(KafkaShareConfiguration configuration) {
+                return configuration.getBrokers();
+            }
+        });
+        context.addComponent("kafka-share", component);
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("kafka-share:" + FakeShareGroup.TOPIC + 
"?brokers=127.0.0.1:9092&groupId=g&consumersCount=1"
+                     + "&pollTimeoutMs=" + pollTimeoutMs + "&maxPollRecords=" 
+ group.maxPollRecords
+                     + "&acquireMode=record_limit&onFailure=" + onFailure + 
"&commitMode=" + commitMode
+                     + "&pollOnError=" + pollOnError + "&shutdownTimeout=" + 
shutdownTimeout)
+                        .routeId(ROUTE).autoStartup(false)
+                        .process(exchange -> {
+                            String id = 
exchange.getMessage().getBody(String.class);
+                            Object d = 
exchange.getMessage().getHeader(KafkaShareConstants.DELIVERY_COUNT);
+                            ledger.record("routed", "t", id);
+                            // a route stop cannot see an exchange that blocks 
below it on the one stack finish (the
+                            // shutdown strategy waits for it for ever), so 
the processing takes time only once the
+                            // operations are done, and the finisher stops the 
route only when no exchange blocks
+                            if (faults && opsLeft == 0) {
+                                long latency = dst.chance(0.1)
+                                        ? group.lockDurationMillis + 
dst.between(0, group.lockDurationMillis)
+                                        : dst.between(0, 300);
+                                blocked++;
+                                try {
+                                    dst.block(latency, "process " + id + "#" + 
d);
+                                } finally {
+                                    blocked--;
+                                }
+                            }
+                            if (faults && dst.chance(0.05)) {
+                                group.failedDeliveries.add(id + "#" + d);
+                                ledger.record("failed", "t", id);
+                                throw Dst.injected(new 
IllegalStateException("dst: processing failed for " + id));
+                            }
+                            group.completedDeliveries.add(id + "#" + d);
+                            ledger.record("completed", "t", id);
+                        });
+            }
+        });
+        context.start();
+        DstExecutor operator = dst.executor("operator", 1);
+        try {
+            operator.schedule("start route", 0, () -> startRoute(context, 
operator));
+            planSends();
+            planOperations(context, operator);
+            operator.schedule("finisher", 1000, () -> finisher(context, 
operator, -1));
+            dst.runUntil(() -> finished, "the finisher");
+            // the consumer thread ends after the stop; let it close its share 
consumer
+            dst.runFor(2 * shutdownTimeout + 10_000);
+            dst.check(
+                    Invariants.of("the consumer uses the share consumer as 
documented",
+                            () -> group.violations.isEmpty() && 
violations.isEmpty()
+                                    ? null : group.violations + " " + 
violations + " (" + config() + ")"),
+                    Invariants.of("the route is started once the faults 
cleared",
+                            () -> "Started".equals(finalStatus) ? null : 
"route is " + finalStatus + " (" + config() + ")"),
+                    Invariants.of("a started route holds one share consumer",
+                            () -> finalOpenCount == 1
+                                    ? null : "open share consumers " + 
finalOpen + " (" + config() + ")"),
+                    Invariants.of("every published record completes at least 
once, or the group archived it as it should",
+                            this::everyRecordCompletedOrArchived),
+                    Invariants.of("the share group is drained once the faults 
cleared",
+                            () -> finalSettled ? null : "not settled at the 
end (" + config() + ")"),
+                    Invariants.of("no share consumer is left open after the 
route stopped",
+                            () -> group.open.isEmpty()
+                                    ? null : "open after stop: " + 
group.openNames() + " (" + config() + ")"));
+        } finally {
+            dst.dropPending();
+            context.stop();
+        }
+    }
+
+    private String everyRecordCompletedOrArchived() {
+        List<String> missing = new ArrayList<>();
+        List<String> completed = 
ledger.of("completed").stream().map(DstLedger.Entry::key).toList();
+        for (FakeShareGroup.Rec r : group.log) {
+            if (!completed.contains(r.id) && !archivedAsItShould(r)) {
+                missing.add(r.id + "=" + r.state + "/d" + r.deliveries + 
(r.rejected > 0 ? "/rejected#" + r.rejected : ""));
+            }
+        }
+        return missing.isEmpty() ? null : "never completed: " + missing + " (" 
+ config() + ")";
+    }
+
+    /**
+     * The excuse for a record that never completed: the group archived it at 
the delivery limit (the deliveries failed
+     * with onFailure=RELEASE, or their locks expired, or they were released 
when the consumer stopped), or it rejected
+     * a delivery that failed, with onFailure=REJECT.
+     */
+    private boolean archivedAsItShould(FakeShareGroup.Rec r) {
+        if (r.state != FakeShareGroup.State.ARCHIVED) {
+            return false;
+        }
+        if (r.rejected == 0) {
+            return r.deliveries >= group.deliveryLimit;
+        }
+        return "REJECT".equals(onFailure) && 
group.failedDeliveries.contains(r.id + "#" + r.rejected);
+    }
+
+    /**
+     * Whether the share consumer thread of the route gave up creating its 
share consumer, after as many creation
+     * failures in a row as createConsumerBackoffMaxAttempts, and the operator 
has not restarted the route for that yet.
+     */
+    private boolean consumerGaveUp(DefaultCamelContext context) {
+        if (restartsAfterGiveUp >= group.createGiveUps || 
context.getRoute(ROUTE) == null
+                || !(context.getRoute(ROUTE).getConsumer() instanceof 
KafkaShareConsumer consumer)) {
+            return false;
+        }
+        for (TaskHealthState state : consumer.healthStates()) {
+            if (state.isTerminated() && state.getLastError() instanceof 
KafkaConsumerFatalException) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Waits for the sends and operations, switches the faults off, waits for 
the group to drain (or a deadline), takes
+     * the final state and stops the route.
+     */
+    private void finisher(DefaultCamelContext context, DstExecutor operator, 
long deadline) {
+        if (deadline == -1 && (sendsLeft > 0 || opsLeft > 0)) {
+            operator.schedule("finisher", 500, () -> finisher(context, 
operator, -1));
+            return;
+        }
+        if (deadline == -1) {
+            long slow = dst.between(0, 3 * group.lockDurationMillis);
+            dst.trace("world: operations done, slow processing for " + slow + 
" ms");
+            operator.schedule("finisher", slow, () -> finisher(context, 
operator, -2));
+            return;
+        }
+        if (deadline == -2) {
+            faults = false;
+            group.faults = false;
+            dst.trace("world: faults cleared");
+            long d = dst.now() + 5 * 60_000L + 3 * group.lockDurationMillis;
+            operator.schedule("finisher", 500, () -> finisher(context, 
operator, d));
+            return;
+        }
+        ServiceStatus status = 
context.getRouteController().getRouteStatus(ROUTE);
+        if (status == ServiceStatus.Started && group.open.isEmpty() && 
consumerGaveUp(context)) {
+            restartsAfterGiveUp++;
+            dst.trace("world: the consumer gave up creating its share 
consumer, restart the route");
+            try {
+                context.getRouteController().stopRoute(ROUTE);
+                ledger.record("routeStopped", "r", ROUTE);
+            } catch (Exception e) {
+                violations.add("restart after the give up failed: " + e);
+            }
+            startRoute(context, operator);
+            operator.schedule("finisher", 500, () -> finisher(context, 
operator, deadline));
+            return;
+        }
+        boolean settled = group.settled();
+        // the locks of a slow exchange can expire and settle the group 
(delivery limit) while it still blocks below
+        if ((!settled || status != ServiceStatus.Started || group.open.size() 
!= 1 || blocked > 0)
+                && dst.now() < deadline) {
+            operator.schedule("finisher", 500, () -> finisher(context, 
operator, deadline));
+            return;
+        }
+        finalStatus = String.valueOf(status);
+        finalSettled = settled;
+        finalOpenCount = group.open.size();
+        finalOpen = group.openNames();
+        if (!settled) {
+            dst.trace("world: unsettled " + group.unsettled());
+        }
+        try {
+            context.getRouteController().stopRoute(ROUTE);
+        } catch (Exception e) {
+            violations.add("final stop failed: " + e);
+        }
+        finished = true;
+    }
+
+    /** The operator starts the route; a failed start is retried, as the 
supervising route controller does. */
+    private void startRoute(DefaultCamelContext context, DstExecutor operator) 
{
+        try {
+            context.getRouteController().startRoute(ROUTE);
+            ledger.record("routeStarted", "r", ROUTE);
+        } catch (Exception e) {
+            ledger.record("routeStartFailed", "r", ROUTE);
+            dst.trace("world: start failed (" + e.getMessage() + "), retry in 
2 s");
+            operator.schedule("retry start route", 2000, () -> 
startRoute(context, operator));
+        }
+    }
+
+    private void planSends() {
+        DstExecutor producer = dst.executor("producer", 1);
+        long t = dst.between(0, 500);
+        int n = 0;
+        int bursts = (int) dst.between(1, 4);
+        for (int b = 0; b < bursts; b++) {
+            int size = (int) dst.between(1, 12);
+            for (int i = 0; i < size; i++) {
+                String id = "m" + n++;
+                sendsLeft++;
+                producer.schedule("publish " + id, t, () -> {
+                    group.publish(id);
+                    sendsLeft--;
+                });
+                t += dst.between(0, 100);
+            }
+            t += dst.between(0, 8000);
+        }
+    }
+
+    private void planOperations(DefaultCamelContext context, DstExecutor 
operator) {
+        int ops = (int) dst.between(0, 4);
+        long t = dst.between(500, 3000);
+        for (int i = 0; i < ops; i++) {
+            int kind = dst.nextInt(3);
+            opsLeft++;
+            if (kind == 0) {
+                operator.schedule("restart route", t, () -> {
+                    try {
+                        if (context.getRouteController().getRouteStatus(ROUTE) 
== ServiceStatus.Started) {
+                            context.getRouteController().stopRoute(ROUTE);
+                            ledger.record("routeStopped", "r", ROUTE);
+                            startRoute(context, operator);
+                        }
+                    } catch (Exception e) {
+                        violations.add("restart failed: " + e);
+                    }
+                    opsLeft--;
+                });
+            } else if (kind == 1) {
+                operator.schedule("suspend and resume route", t, () -> {
+                    try {
+                        if (context.getRouteController().getRouteStatus(ROUTE) 
== ServiceStatus.Started) {
+                            context.getRouteController().suspendRoute(ROUTE);
+                            dst.block(dst.between(100, 3000), "route 
suspended");
+                            context.getRouteController().resumeRoute(ROUTE);
+                        }
+                    } catch (Exception e) {
+                        violations.add("suspend/resume failed: " + e);
+                    }
+                    opsLeft--;
+                });
+            } else {
+                operator.schedule("suspend and resume the CamelContext", t, () 
-> {
+                    try {
+                        if (context.getRouteController().getRouteStatus(ROUTE) 
== ServiceStatus.Started) {
+                            context.suspend();
+                            ledger.record("contextSuspended", "r", ROUTE);
+                            dst.block(dst.between(100, 3000), "CamelContext 
suspended");
+                            context.resume();
+                            ledger.record("contextResumed", "r", ROUTE);
+                        }
+                    } catch (Exception e) {
+                        violations.add("CamelContext suspend/resume failed: " 
+ e);
+                    }
+                    opsLeft--;
+                });
+            }
+            t += dst.between(500, 8000);
+        }
+    }
+}
diff --git a/components/camel-kafka-share/src/test/resources/log4j2.properties 
b/components/camel-kafka-share/src/test/resources/log4j2.properties
index bf65f4a08864..d8615f48d12d 100644
--- a/components/camel-kafka-share/src/test/resources/log4j2.properties
+++ b/components/camel-kafka-share/src/test/resources/log4j2.properties
@@ -15,9 +15,15 @@
 ## limitations under the License.
 ## ---------------------------------------------------------------------------
 
-appender.out.type = File
+appender.out.type = RollingFile
 appender.out.name = out
 appender.out.fileName = target/camel-kafka-share-test.log
+appender.out.filePattern = target/camel-kafka-share-test.%i.log
+appender.out.policies.type = Policies
+appender.out.policies.size.type = SizeBasedTriggeringPolicy
+appender.out.policies.size.size = 50MB
+appender.out.strategy.type = DefaultRolloverStrategy
+appender.out.strategy.max = 1
 appender.out.layout.type = PatternLayout
 appender.out.layout.pattern = %d [%-15.15t] %-5p %-30.30c{1} - %m%n
 appender.stdout.type = Console
@@ -25,7 +31,7 @@ appender.stdout.name = stdout
 appender.stdout.layout.type = PatternLayout
 appender.stdout.layout.pattern = %d [%-15.15t] %-5p %-30.30c{1} - %m%n
 
-rootLogger.level = INFO
+rootLogger.level = WARN
 rootLogger.appenderRef.out.ref = out
 
 logger.kafka.name = org.apache.kafka
diff --git a/components/camel-test/camel-test-dst/src/main/docs/test-dst.adoc 
b/components/camel-test/camel-test-dst/src/main/docs/test-dst.adoc
index 16bf1c96d140..9c0d809a57ad 100644
--- a/components/camel-test/camel-test-dst/src/main/docs/test-dst.adoc
+++ b/components/camel-test/camel-test-dst/src/main/docs/test-dst.adoc
@@ -60,6 +60,8 @@ These worlds are in the Camel code base and are the examples 
to copy:
 |camel-aws2-sqs |`SqsRedeliveryDstTest` (the extender while Camel's error 
handler waits to redeliver, in virtual
 time) |-
 |camel-azure-eventhubs |`EventHubsCheckpointDstTest` (checkpointing by batch 
size and timeout) |CAMEL-25683
+|camel-kafka-share |`KafkaShareConsumerDstTest` (acknowledgements, acquisition 
locks, reconnects, and suspend and resume
+of the route and of the CamelContext) |CAMEL-25872
 |camel-pulsar |`PulsarConsumerDstTest` (subscribe failures, broker and route 
restarts while the route starts and
 consumes) |CAMEL-25869
 |camel-slack |`SlackSuspendDstTest` (route suspend in the middle of a poll) 
|CAMEL-25684

Reply via email to