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 4e022b88e4c8 CAMEL-25525: camel-keycloak - Do not route the latest 
events of a poll a second time (#27676)
4e022b88e4c8 is described below

commit 4e022b88e4c80b8c11274cc982959800634e58c5
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Oct 11 12:31:47 2026 +0530

    CAMEL-25525: camel-keycloak - Do not route the latest events of a poll a 
second time (#27676)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/catalog/docs/keycloak-consumer.adoc      |   3 +-
 .../src/main/docs/keycloak-consumer.adoc           |   3 +-
 .../camel/component/keycloak/KeycloakConsumer.java |  16 ++-
 .../keycloak/KeycloakConsumerLatestEventsTest.java | 159 +++++++++++++++++++++
 4 files changed, 175 insertions(+), 6 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-consumer.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-consumer.adoc
index 87dca08674b0..9c0484ac670c 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-consumer.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-consumer.adoc
@@ -908,6 +908,7 @@ Before consuming events, you must enable event logging in 
Keycloak:
 The consumer uses fingerprint-based deduplication to prevent processing the 
same event multiple times:
 
 * Events are uniquely identified by combining timestamp and event-specific 
properties
-* Fingerprints are cached per timestamp and cleared when moving to newer 
timestamps
+* Fingerprints of the events with the latest timestamp are kept between polls, 
because Keycloak returns those events
+again on the next poll; fingerprints of older events are dropped when a newer 
timestamp is seen
 * Maximum cache size is 1000 fingerprints to prevent memory issues
 * This ensures reliable event processing even with high event volumes
diff --git a/components/camel-keycloak/src/main/docs/keycloak-consumer.adoc 
b/components/camel-keycloak/src/main/docs/keycloak-consumer.adoc
index 87dca08674b0..9c0484ac670c 100644
--- a/components/camel-keycloak/src/main/docs/keycloak-consumer.adoc
+++ b/components/camel-keycloak/src/main/docs/keycloak-consumer.adoc
@@ -908,6 +908,7 @@ Before consuming events, you must enable event logging in 
Keycloak:
 The consumer uses fingerprint-based deduplication to prevent processing the 
same event multiple times:
 
 * Events are uniquely identified by combining timestamp and event-specific 
properties
-* Fingerprints are cached per timestamp and cleared when moving to newer 
timestamps
+* Fingerprints of the events with the latest timestamp are kept between polls, 
because Keycloak returns those events
+again on the next poll; fingerprints of older events are dropped when a newer 
timestamp is seen
 * Maximum cache size is 1000 fingerprints to prevent memory issues
 * This ensures reliable event processing even with high event volumes
diff --git 
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakConsumer.java
 
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakConsumer.java
index f380ae01b936..bcda15834994 100644
--- 
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakConsumer.java
+++ 
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakConsumer.java
@@ -174,8 +174,7 @@ public class KeycloakConsumer extends 
ScheduledBatchPollingConsumer {
 
         // Update lastEventTime if we processed events with newer timestamps
         if (highestEventTime > (lastEventTime != null ? lastEventTime : 0)) {
-            // Clear fingerprints when moving to a new timestamp to free memory
-            processedEventFingerprints.clear();
+            forgetOlderFingerprints(highestEventTime);
             lastEventTime = highestEventTime;
             LOG.debug("Updated last event time to: {}", lastEventTime);
         }
@@ -183,6 +182,16 @@ public class KeycloakConsumer extends 
ScheduledBatchPollingConsumer {
         return queue;
     }
 
+    /**
+     * Forgets the fingerprints of the events older than the given time, to 
free memory. The fingerprints of the events
+     * at that time are kept: the next poll returns those events again (the 
query has no lower time bound, dateFrom is a
+     * date), and they must not be routed a second time.
+     */
+    private void forgetOlderFingerprints(long eventTime) {
+        String prefix = eventTime + "|";
+        processedEventFingerprints.removeIf(fingerprint -> 
!fingerprint.startsWith(prefix));
+    }
+
     private String getEventFingerprint(EventRepresentation event) {
         // Create a composite key from event properties to uniquely identify it
         return event.getTime() + "|"
@@ -264,8 +273,7 @@ public class KeycloakConsumer extends 
ScheduledBatchPollingConsumer {
 
         // Update lastEventTime if we processed events with newer timestamps
         if (highestEventTime > (lastEventTime != null ? lastEventTime : 0)) {
-            // Clear fingerprints when moving to a new timestamp to free memory
-            processedEventFingerprints.clear();
+            forgetOlderFingerprints(highestEventTime);
             lastEventTime = highestEventTime;
             LOG.debug("Updated last admin event time to: {}", lastEventTime);
         }
diff --git 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/KeycloakConsumerLatestEventsTest.java
 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/KeycloakConsumerLatestEventsTest.java
new file mode 100644
index 000000000000..26cfe33f1c31
--- /dev/null
+++ 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/KeycloakConsumerLatestEventsTest.java
@@ -0,0 +1,159 @@
+/*
+ * 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.keycloak;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.keycloak.admin.client.Keycloak;
+import org.keycloak.admin.client.resource.RealmResource;
+import org.keycloak.representations.idm.AdminEventRepresentation;
+import org.keycloak.representations.idm.EventRepresentation;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Keycloak returns the events of the latest timestamp again on the next poll, 
so the consumer must remember that it
+ * routed them when it moves its checkpoint to that timestamp.
+ */
+class KeycloakConsumerLatestEventsTest extends CamelTestSupport {
+
+    @BindToRegistry("keycloakClient")
+    private final Keycloak keycloakClient = Mockito.mock(Keycloak.class);
+
+    private final RealmResource realmResource = 
Mockito.mock(RealmResource.class);
+
+    // the events in the realm, the most recent first as Keycloak returns them
+    private final List<EventRepresentation> events = new ArrayList<>();
+    private final List<AdminEventRepresentation> adminEvents = new 
ArrayList<>();
+
+    @Override
+    protected void doPreSetup() {
+        when(keycloakClient.realm(anyString())).thenReturn(realmResource);
+        when(realmResource.getEvents(any(), any(), any(), any(), any(), any(), 
anyInt(), anyInt()))
+                .thenAnswer(invocation -> new ArrayList<>(events));
+        when(realmResource.getAdminEvents(any(), any(), any(), any(), any(), 
any(), any(), any(), anyInt(), anyInt()))
+                .thenAnswer(invocation -> new ArrayList<>(adminEvents));
+    }
+
+    @Override
+    protected RoutesBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("keycloak:events?keycloakClient=#keycloakClient&realm=test-realm&eventType=events"
+                     + "&startScheduler=false")
+                        .routeId("events")
+                        .to("mock:events");
+
+                
from("keycloak:admin-events?keycloakClient=#keycloakClient&realm=test-realm&eventType=admin-events"
+                     + "&startScheduler=false")
+                        .routeId("admin-events")
+                        .to("mock:admin-events");
+            }
+        };
+    }
+
+    @Test
+    void routesEachEventOnce() throws Exception {
+        events.add(event(2000, "LOGOUT", "user-456"));
+        events.add(event(1000, "LOGIN", "user-123"));
+
+        assertEquals(2, poll("events"));
+        assertEquals(0, poll("events"), "the events of the first poll must not 
be routed again");
+
+        // a new event, and a second one at the same time as the previous 
latest event
+        events.add(0, event(3000, "LOGIN", "user-789"));
+        events.add(1, event(2000, "LOGIN", "user-000"));
+        assertEquals(2, poll("events"));
+        assertEquals(0, poll("events"), "the events of the second poll must 
not be routed again");
+
+        MockEndpoint mock = getMockEndpoint("mock:events");
+        mock.expectedMessageCount(4);
+        mock.assertIsSatisfied();
+        assertEquals(List.of("user-456", "user-123", "user-789", "user-000"), 
userIds(mock));
+    }
+
+    @Test
+    void routesEachAdminEventOnce() throws Exception {
+        adminEvents.add(adminEvent(2000, "UPDATE", "users/2"));
+        adminEvents.add(adminEvent(1000, "CREATE", "users/1"));
+
+        assertEquals(2, poll("admin-events"));
+        assertEquals(0, poll("admin-events"), "the admin events of the first 
poll must not be routed again");
+
+        // a new admin event, and a second one at the same time as the 
previous latest admin event
+        adminEvents.add(0, adminEvent(3000, "DELETE", "users/1"));
+        adminEvents.add(1, adminEvent(2000, "CREATE", "users/3"));
+        assertEquals(2, poll("admin-events"));
+        assertEquals(0, poll("admin-events"), "the admin events of the second 
poll must not be routed again");
+
+        MockEndpoint mock = getMockEndpoint("mock:admin-events");
+        mock.expectedMessageCount(4);
+        mock.assertIsSatisfied();
+        assertEquals(List.of("users/2", "users/1", "users/1", "users/3"), 
resourcePaths(mock));
+    }
+
+    private int poll(String routeId) throws Exception {
+        return ((KeycloakConsumer) 
context.getRoute(routeId).getConsumer()).poll();
+    }
+
+    private static List<String> userIds(MockEndpoint mock) {
+        List<String> answer = new ArrayList<>();
+        for (Exchange exchange : mock.getReceivedExchanges()) {
+            
answer.add(exchange.getIn().getBody(EventRepresentation.class).getUserId());
+        }
+        return answer;
+    }
+
+    private static List<String> resourcePaths(MockEndpoint mock) {
+        List<String> answer = new ArrayList<>();
+        for (Exchange exchange : mock.getReceivedExchanges()) {
+            
answer.add(exchange.getIn().getBody(AdminEventRepresentation.class).getResourcePath());
+        }
+        return answer;
+    }
+
+    private static EventRepresentation event(long time, String type, String 
userId) {
+        EventRepresentation event = new EventRepresentation();
+        event.setTime(time);
+        event.setType(type);
+        event.setUserId(userId);
+        return event;
+    }
+
+    private static AdminEventRepresentation adminEvent(long time, String 
operationType, String resourcePath) {
+        AdminEventRepresentation event = new AdminEventRepresentation();
+        event.setTime(time);
+        event.setOperationType(operationType);
+        event.setResourceType("USER");
+        event.setResourcePath(resourcePath);
+        return event;
+    }
+}

Reply via email to