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 50d17f9c2614 CAMEL-25838: camel-iggy - a consumer client created after 
a failed poll joins the consumer group (#28259)
50d17f9c2614 is described below

commit 50d17f9c26140cf55deae517310b0a310e3e6c5b
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Oct 22 18:46:47 2026 +0530

    CAMEL-25838: camel-iggy - a consumer client created after a failed poll 
joins the consumer group (#28259)
    
    * CAMEL-25838: camel-iggy - a consumer client created after a failed poll 
joins the consumer group
    
    The consumer joined its consumer group once, with the client it borrowed
    when it started. Since CAMEL-25289 the client of a failed poll is
    discarded and the pool creates a new client, which only logs in. The Iggy
    server keeps the group membership per connection and answers a poll of a
    client that is not a member with ConsumerGroupMemberNotFound, so after one
    failed poll (a dropped connection, a server restart) every poll failed and
    nothing was consumed until the route was restarted.
    
    The consumer now remembers which clients joined the group and makes a
    client join before its first poll.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25838: camel-iggy - use AssertJ in IggyConsumerRejoinGroupTest, as 
the other unit tests of the module
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../apache/camel/component/iggy/IggyConsumer.java  |  31 +++++
 .../apache/camel/component/iggy/IggyEndpoint.java  |   8 ++
 .../camel/component/iggy/IggyFetchRecords.java     |   3 +
 .../iggy/IggyConsumerRejoinGroupTest.java          | 150 +++++++++++++++++++++
 4 files changed, 192 insertions(+)

diff --git 
a/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyConsumer.java
 
b/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyConsumer.java
index e331f9625a84..d0326e90eb33 100644
--- 
a/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyConsumer.java
+++ 
b/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyConsumer.java
@@ -17,7 +17,10 @@
 package org.apache.camel.component.iggy;
 
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.IdentityHashMap;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.TimeUnit;
 
@@ -36,6 +39,9 @@ public class IggyConsumer extends DefaultConsumer {
     private IggyClientConnectionPool iggyClientConnectionPool;
     private ExecutorService executor;
     private final List<IggyFetchRecords> tasks = new ArrayList<>();
+    // the pooled clients that joined the consumer group (the membership 
belongs to the connection of a client)
+    private final Set<IggyBaseClient> groupMembers
+            = Collections.synchronizedSet(Collections.newSetFromMap(new 
IdentityHashMap<>()));
 
     public IggyConsumer(IggyEndpoint endpoint, Processor processor) {
         super(endpoint, processor);
@@ -62,6 +68,7 @@ public class IggyConsumer extends DefaultConsumer {
         try {
             endpoint.initializeTopic(client);
             endpoint.initializeConsumerGroup(client);
+            groupMembers.add(client);
         } finally {
             iggyClientConnectionPool.returnClient(client);
         }
@@ -112,6 +119,7 @@ public class IggyConsumer extends DefaultConsumer {
             }
         }
         tasks.clear();
+        groupMembers.clear();
         executor = null;
         if (iggyClientConnectionPool != null) {
             iggyClientConnectionPool.close();
@@ -120,4 +128,27 @@ public class IggyConsumer extends DefaultConsumer {
 
         super.doStop();
     }
+
+    /**
+     * Makes the client a member of the consumer group unless it joined 
already. A client the pool created after a
+     * failed poll is a new connection, which the server does not know as a 
member: its polls would fail with
+     * ConsumerGroupMemberNotFound.
+     */
+    void joinConsumerGroupIfNeeded(IggyBaseClient client) {
+        if (!groupMembers.contains(client)) {
+            endpoint.joinConsumerGroup(client);
+            groupMembers.add(client);
+            LOG.info("Joined the consumer group {} of stream {} and topic {} 
again with a new client"
+                     + " (the client of a failed poll was discarded)",
+                    endpoint.getConfiguration().getConsumerGroupName(), 
endpoint.getConfiguration().getStreamName(),
+                    endpoint.getTopicName());
+        }
+    }
+
+    /**
+     * Forgets a client that is discarded.
+     */
+    void clientDiscarded(IggyBaseClient client) {
+        groupMembers.remove(client);
+    }
 }
diff --git 
a/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyEndpoint.java
 
b/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyEndpoint.java
index a8a68f9b6b64..b9d340db7061 100644
--- 
a/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyEndpoint.java
+++ 
b/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyEndpoint.java
@@ -178,6 +178,14 @@ public class IggyEndpoint extends DefaultEndpoint 
implements HeaderFilterStrateg
                     return consumerGroupDetails;
                 });
 
+        joinConsumerGroup(client);
+    }
+
+    /**
+     * Makes the client a member of the consumer group. The membership belongs 
to the connection of the client, so each
+     * client the consumer polls with must join.
+     */
+    public void joinConsumerGroup(IggyBaseClient client) {
         client.consumerGroups().joinConsumerGroup(
                 StreamId.of(getConfiguration().getStreamName()),
                 TopicId.of(getTopicName()),
diff --git 
a/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyFetchRecords.java
 
b/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyFetchRecords.java
index 4192edcf2712..5dd67287e722 100644
--- 
a/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyFetchRecords.java
+++ 
b/components/camel-iggy/src/main/java/org/apache/camel/component/iggy/IggyFetchRecords.java
@@ -97,6 +97,8 @@ public class IggyFetchRecords implements Runnable {
             IggyBaseClient client = iggyClientConnectionPool.borrowObject();
             boolean polled = false;
             try {
+                // a client created after a failed poll is not a member of the 
group yet
+                iggyConsumer.joinConsumerGroupIfNeeded(client);
                 polledMessages = poll(client, streamId, topicId, consumerId);
                 polled = true;
             } finally {
@@ -104,6 +106,7 @@ public class IggyFetchRecords implements Runnable {
                     iggyClientConnectionPool.returnClient(client);
                 } else {
                     // the client of a failed poll may be broken (connection 
lost): do not hand it out again
+                    iggyConsumer.clientDiscarded(client);
                     iggyClientConnectionPool.invalidateClient(client);
                 }
             }
diff --git 
a/components/camel-iggy/src/test/java/org/apache/camel/component/iggy/IggyConsumerRejoinGroupTest.java
 
b/components/camel-iggy/src/test/java/org/apache/camel/component/iggy/IggyConsumerRejoinGroupTest.java
new file mode 100644
index 000000000000..c5c28f0d24fc
--- /dev/null
+++ 
b/components/camel-iggy/src/test/java/org/apache/camel/component/iggy/IggyConsumerRejoinGroupTest.java
@@ -0,0 +1,150 @@
+/*
+ * 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.iggy;
+
+import java.io.Closeable;
+import java.math.BigInteger;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.component.iggy.client.IggyClientFactory;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.iggy.client.blocking.ConsumerGroupsClient;
+import org.apache.iggy.client.blocking.IggyBaseClient;
+import org.apache.iggy.consumergroup.ConsumerGroupDetails;
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyResourceNotFoundException;
+import org.apache.iggy.identifier.ConsumerId;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.Message;
+import org.apache.iggy.message.PolledMessages;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+/**
+ * The consumer group membership belongs to the connection of a client: the 
Iggy server answers a poll of a client that
+ * did not join the group with ConsumerGroupMemberNotFound. After a failed 
poll the consumer discards its client and the
+ * pool creates a new one, which must join the group before it polls. The Iggy 
clients are mocks that behave like the
+ * server in this respect; no Iggy server is needed.
+ */
+class IggyConsumerRejoinGroupTest {
+
+    private static final String URI
+            = 
"iggy:topic?streamName=stream&autoCreateStream=false&autoCreateTopic=false&consumerGroupName=group";
+
+    private final List<String> received = new CopyOnWriteArrayList<>();
+    private final List<IggyBaseClient> clients = new CopyOnWriteArrayList<>();
+
+    // how the first client fails its first poll
+    private boolean firstClientReconnectedOnItsOwn;
+
+    @Test
+    void testNewClientJoinsTheGroupAfterAFailedPoll() throws Exception {
+        consumeAfterTheFirstClientFailed();
+    }
+
+    @Test
+    void testNewClientJoinsTheGroupAfterTheClientReconnectedOnItsOwn() throws 
Exception {
+        // the iggy 0.9.0 TCP client replaces a lost connection and logs in 
again by itself (a channel pool of one
+        // connection that replays the login), but does not join the consumer 
group again: the server answers its next
+        // poll with ConsumerGroupMemberNotFound (5006), although the consumer 
saw it join
+        firstClientReconnectedOnItsOwn = true;
+        consumeAfterTheFirstClientFailed();
+    }
+
+    private void consumeAfterTheFirstClientFailed() throws Exception {
+        try (MockedConstruction<IggyClientFactory> factories = 
mockConstruction(IggyClientFactory.class,
+                withSettings().defaultAnswer(CALLS_REAL_METHODS),
+                (factory, context) -> doAnswer(invocation -> 
newClient()).when(factory).create());
+             CamelContext context = new DefaultCamelContext()) {
+            context.start();
+            Consumer consumer = 
context.getEndpoint(URI).createConsumer(exchange -> {
+                received.add(exchange.getIn().getBody(String.class));
+            });
+            consumer.start();
+
+            // the first client loses its connection at its first poll; the 
next client must consume
+            await().atMost(20, TimeUnit.SECONDS)
+                    .untilAsserted(() -> 
assertThat(received).containsExactly("after the failed poll"));
+
+            consumer.stop();
+        }
+    }
+
+    private IggyBaseClient newClient() throws Exception {
+        boolean first = clients.isEmpty();
+        AtomicBoolean member = new AtomicBoolean();
+        AtomicBoolean delivered = new AtomicBoolean();
+        IggyBaseClient client = mock(IggyBaseClient.class,
+                
withSettings().extraInterfaces(Closeable.class).defaultAnswer(RETURNS_DEEP_STUBS));
+        clients.add(client);
+
+        // the group exists; joining makes this client (its connection) a 
member
+        ConsumerGroupDetails group = mock(ConsumerGroupDetails.class);
+        ConsumerGroupsClient groups = client.consumerGroups();
+        when(groups.getConsumerGroup(any(StreamId.class), any(TopicId.class), 
any(ConsumerId.class)))
+                .thenReturn(Optional.of(group));
+        doAnswer(invocation -> {
+            member.set(true);
+            return null;
+        }).when(groups).joinConsumerGroup(any(StreamId.class), 
any(TopicId.class), any(ConsumerId.class));
+
+        when(client.messages().pollMessages(any(StreamId.class), 
any(TopicId.class), any(), any(), any(), any(),
+                anyBoolean())).thenAnswer(invocation -> {
+                    if (first && firstClientReconnectedOnItsOwn) {
+                        throw memberNotFound();
+                    }
+                    if (first) {
+                        // any failure of the poll discards the client (iggy 
throws IggyConnectionException and others)
+                        throw new IllegalStateException("Connection reset");
+                    }
+                    if (!member.get()) {
+                        throw memberNotFound();
+                    }
+                    if (delivered.compareAndSet(false, true)) {
+                        return new PolledMessages(
+                                0L, BigInteger.ZERO, 1L, 
List.of(Message.of("after the failed poll")));
+                    }
+                    return new PolledMessages(0L, BigInteger.ZERO, 0L, 
List.of());
+                });
+        return client;
+    }
+
+    private static IggyResourceNotFoundException memberNotFound() {
+        return new IggyResourceNotFoundException(
+                IggyErrorCode.CONSUMER_GROUP_MEMBER_NOT_FOUND, 5006, 
"consumer_group_member_not_found",
+                Optional.empty(), Optional.empty());
+    }
+}

Reply via email to