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 f86ebea1ad33 CAMEL-24187: camel-core - CamelContext.getEndpoint() 
never caches a uri containing a % character
f86ebea1ad33 is described below

commit f86ebea1ad331d046ad9fe91a08cbdf8c495e21a
Author: croway <[email protected]>
AuthorDate: Thu Jul 16 16:39:56 2026 +0200

    CAMEL-24187: camel-core - CamelContext.getEndpoint() never caches a uri 
containing a % character
    
    AbstractCamelContext.doGetEndpoint() normalizes the uri once for its cache
    lookup, but on a cache miss handed that already-normalized uri to
    addEndpointToRegistry(), which normalized it a second time before storing
    the registry key. EndpointHelper.normalizeEndpointUri() is not idempotent
    for uris containing a literal '%' (it re-encodes it to '%25' on every
    pass), so the stored key never matched the lookup key computed on a later
    call, and a brand-new Endpoint was created and started on every single
    getEndpoint() call for such a uri instead of the cached singleton being
    reused.
    
    Add a normalized-aware overload of getEndpointKey()/addEndpointToRegistry()
    so only the doGetEndpoint() call site (whose uri is provably already
    normalized) skips the redundant second normalization pass. The public
    hasEndpoint(String), addEndpoint(String, Endpoint) and
    removeEndpoints(String) APIs still receive raw, caller-supplied uris and
    keep normalizing exactly as before.
    
    Discovered while investigating CAMEL-24171 (camel-plc4x S7 tag addresses
    like RAW(%DB1.DBX0.0:BOOL) contain a '%', unlike Modbus' RAW(coil:1),
    which is why only the S7 route reloaded its DefaultPlcDriverManager on
    every poll).
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 .../Plc4XPollEnrichDoStartReproducerTest.java      | 161 +++++++++++++++++++++
 .../camel/impl/engine/AbstractCamelContext.java    |  38 ++++-
 .../camel/impl/DefaultEndpointRegistryTest.java    |  26 ++++
 3 files changed, 220 insertions(+), 5 deletions(-)

diff --git 
a/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/Plc4XPollEnrichDoStartReproducerTest.java
 
b/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/Plc4XPollEnrichDoStartReproducerTest.java
new file mode 100644
index 000000000000..af99dfdc6275
--- /dev/null
+++ 
b/components/camel-plc4x/src/test/java/org/apache/camel/component/plc4x/Plc4XPollEnrichDoStartReproducerTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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.plc4x;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Endpoint;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.camel.util.PropertiesHelper;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.sameInstance;
+
+/**
+ * Reproducer for CAMEL-24171: the reporter observed that, on a route shaped 
like
+ *
+ * <pre>
+ * from(timer("s7").period(5000))
+ *         
.pollEnrich(plc4x("s7://localhost:102").autoReconnect(true).tags(Map.of("var1", 
"RAW(%DB1.DBX0.0:BOOL)")), 5000)
+ * </pre>
+ *
+ * {@code Plc4XEndpoint#doStart()} runs again on every poll cycle (reloading 
{@code DefaultPlcDriverManager}), while a
+ * structurally identical Modbus route only runs it once.
+ * <p>
+ * {@link #directGetEndpointCachingProbe()} isolates the actual root cause: it 
is not specific to S7, pollEnrich, or
+ * camel-plc4x at all. Any endpoint URI whose query contains a {@code %} 
character (as S7 tag addresses like
+ * {@code RAW(%DB1.DBX0.0:BOOL)} do, unlike Modbus' {@code RAW(coil:1)}) makes 
{@code CamelContext#getEndpoint(String)}
+ * return a brand-new {@link Endpoint} instance on every call instead of the 
cached singleton, even though
+ * {@code getEndpointUri()} prints byte-for-byte identical text both times. 
The generic, camel-core-level root cause
+ * (double-normalization in {@code AbstractCamelContext#getEndpointKey}) is 
reproduced without any plc4x involvement in
+ * {@code 
org.apache.camel.impl.DefaultEndpointRegistryTest#testGetEndpointIsCachedForUriContainingPercentCharacter}
 in
+ * camel-core. {@link 
#doStartRunsOnlyOnceEvenThoughBothRoutesArePolledManyTimes()} then shows the 
real-world
+ * consequence through the exact reported route shape: with a S7-style {@code 
%}-containing tag,
+ * {@code Plc4XEndpoint#doStart()} (which recreates {@code 
DefaultPlcDriverManager}) fires again on every single poll,
+ * while the Modbus-style route only runs it once.
+ */
+public class Plc4XPollEnrichDoStartReproducerTest extends CamelTestSupport {
+
+    final AtomicInteger modbusStartCount = new AtomicInteger();
+    final AtomicInteger s7StartCount = new AtomicInteger();
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext ctx = super.createCamelContext();
+        ctx.addComponent("plc4x", new CountingPlc4XComponent());
+        return ctx;
+    }
+
+    @Test
+    public void directGetEndpointCachingProbe() throws Exception {
+        String uri = 
"plc4x:mock:s7-probe?autoReconnect=true&tag.var1=RAW(%DB1.DBX0.0:BOOL)";
+
+        Endpoint first = context.getEndpoint(uri);
+        Endpoint second = context.getEndpoint(uri);
+
+        assertThat("getEndpointUri() must be identical between the two lookups 
(it is: normalization is stable)",
+                second.getEndpointUri(), is(first.getEndpointUri()));
+        assertThat("calling getEndpoint(uri) twice with the identical uri 
string must return the same cached instance",
+                second, sameInstance(first));
+    }
+
+    @Test
+    public void doStartRunsOnlyOnceEvenThoughBothRoutesArePolledManyTimes() 
throws Exception {
+        MockEndpoint modbusResult = getMockEndpoint("mock:modbusResult");
+        MockEndpoint s7Result = getMockEndpoint("mock:s7Result");
+        modbusResult.expectedMinimumMessageCount(10);
+        s7Result.expectedMinimumMessageCount(10);
+
+        MockEndpoint.assertIsSatisfied(context, 5, TimeUnit.SECONDS);
+
+        assertThat("Plc4XEndpoint#doStart() must only run once per endpoint 
for the modbus-shaped route",
+                modbusStartCount.get(), is(1));
+        assertThat("Plc4XEndpoint#doStart() must only run once per endpoint 
for the s7-shaped route",
+                s7StartCount.get(), is(1));
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("timer:modbus?period=20")
+                        
.pollEnrich("plc4x:mock:modbus-like?autoReconnect=true&tag.coil-1=RAW(coil:1)", 
5000)
+                        .routeId("modbus")
+                        .to("mock:modbusResult");
+
+                from("timer:s7?period=20")
+                        
.pollEnrich("plc4x:mock:s7-like?autoReconnect=true&tag.var1=RAW(%DB1.DBX0.0:BOOL)",
 5000)
+                        .routeId("s7")
+                        .to("mock:s7Result");
+            }
+        };
+    }
+
+    /**
+     * Duplicates {@link Plc4XComponent#createEndpoint} but hands back an 
endpoint whose doStart() is countable, without
+     * touching production code.
+     */
+    private class CountingPlc4XComponent extends Plc4XComponent {
+
+        @Override
+        protected Endpoint createEndpoint(String uri, String remaining, 
Map<String, Object> parameters) throws Exception {
+            AtomicInteger counter = uri.contains("s7-like") ? s7StartCount : 
modbusStartCount;
+            Plc4XEndpoint endpoint = new Plc4XEndpoint(uri, this) {
+                @Override
+                protected void doStart() throws Exception {
+                    counter.incrementAndGet();
+                    super.doStart();
+                }
+            };
+
+            Map<String, String> tags = 
getAndRemoveOrResolveReferenceParameter(parameters, "tags", Map.class);
+            Map<String, Object> map = 
PropertiesHelper.extractProperties(parameters, "tag.");
+            if (map != null) {
+                if (tags == null) {
+                    tags = new LinkedHashMap<>();
+                }
+                for (Map.Entry<String, Object> me : map.entrySet()) {
+                    tags.put(me.getKey(), me.getValue().toString());
+                }
+            }
+            if (tags != null) {
+                endpoint.setTags(tags);
+            }
+
+            String trigger = 
getAndRemoveOrResolveReferenceParameter(parameters, "trigger", String.class);
+            if (trigger != null) {
+                endpoint.setTrigger(trigger);
+            }
+            Integer period = 
getAndRemoveOrResolveReferenceParameter(parameters, "period", Integer.class);
+            if (period != null) {
+                endpoint.setPeriod(period);
+            }
+            setProperties(endpoint, parameters);
+            return endpoint;
+        }
+    }
+
+}
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
index c07f7b7bafef..f9b61322b617 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java
@@ -873,8 +873,8 @@ public abstract class AbstractCamelContext extends 
BaseService
                 if (answer != null) {
                     if (!prototype) {
                         addService(answer);
-                        // register in registry
-                        answer = addEndpointToRegistry(uri, answer);
+                        // register in registry (uri is already normalized 
above, so avoid normalizing it again)
+                        answer = addEndpointToRegistry(uri, answer, true);
                     } else {
                         addPrototypeService(answer);
                         // if there is endpoint strategies, then use the 
endpoints they return
@@ -925,6 +925,21 @@ public abstract class AbstractCamelContext extends 
BaseService
      * @return          the added endpoint
      */
     protected Endpoint addEndpointToRegistry(String uri, Endpoint endpoint) {
+        return addEndpointToRegistry(uri, endpoint, false);
+    }
+
+    /**
+     * Strategy to add the given endpoint to the internal endpoint registry
+     *
+     * @param  uri        uri of the endpoint
+     * @param  endpoint   the endpoint to add
+     * @param  normalized whether the uri is already normalized (for example 
because it was normalized earlier to do the
+     *                    cache lookup that preceded this call). 
Re-normalizing an already normalized uri is not
+     *                    idempotent for uris containing a literal {@code %} 
character, so callers must not normalize
+     *                    twice or the stored key will never match a later 
lookup key (CAMEL-24171).
+     * @return            the added endpoint
+     */
+    protected Endpoint addEndpointToRegistry(String uri, Endpoint endpoint, 
boolean normalized) {
         StringHelper.notEmpty(uri, "uri");
         ObjectHelper.notNull(endpoint, "endpoint");
 
@@ -933,7 +948,7 @@ public abstract class AbstractCamelContext extends 
BaseService
         for (EndpointStrategy strategy : getEndpointStrategies()) {
             endpoint = strategy.registerEndpoint(uri, endpoint);
         }
-        endpoints.put(getEndpointKey(uri, endpoint), endpoint);
+        endpoints.put(getEndpointKey(uri, endpoint, normalized), endpoint);
         return endpoint;
     }
 
@@ -955,11 +970,24 @@ public abstract class AbstractCamelContext extends 
BaseService
      * @return          the key
      */
     protected NormalizedUri getEndpointKey(String uri, Endpoint endpoint) {
+        return getEndpointKey(uri, endpoint, false);
+    }
+
+    /**
+     * Gets the endpoint key to use for lookup or whe adding endpoints to the 
{@link DefaultEndpointRegistry}
+     *
+     * @param  uri        the endpoint uri
+     * @param  endpoint   the endpoint
+     * @param  normalized whether the uri is already normalized; see
+     *                    {@link #addEndpointToRegistry(String, Endpoint, 
boolean)}
+     * @return            the key
+     */
+    protected NormalizedUri getEndpointKey(String uri, Endpoint endpoint, 
boolean normalized) {
         if (endpoint != null && !endpoint.isSingleton()) {
             int counter = endpointKeyCounter.incrementAndGet();
-            return NormalizedUri.newNormalizedUri(uri + ":" + counter, false);
+            return NormalizedUri.newNormalizedUri(uri + ":" + counter, 
normalized);
         } else {
-            return NormalizedUri.newNormalizedUri(uri, false);
+            return NormalizedUri.newNormalizedUri(uri, normalized);
         }
     }
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/impl/DefaultEndpointRegistryTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/impl/DefaultEndpointRegistryTest.java
index ccbb1604e32f..7511dd42efa3 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/impl/DefaultEndpointRegistryTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/impl/DefaultEndpointRegistryTest.java
@@ -102,6 +102,32 @@ public class DefaultEndpointRegistryTest {
         ctx.stop();
     }
 
+    // Testing the issue https://issues.apache.org/jira/browse/CAMEL-24171
+    @Test
+    public void testGetEndpointIsCachedForUriContainingPercentCharacter() {
+        DefaultCamelContext ctx = new DefaultCamelContext();
+        ctx.start();
+
+        // A % anywhere in the query string makes 
EndpointHelper.normalizeEndpointUri() non-idempotent:
+        // running it twice turns %41 into %2541 instead of leaving it alone. 
doGetEndpoint() normalizes
+        // the uri once for the cache lookup 
(NormalizedUri.newNormalizedUri(uri, true) - "already normalized").
+        // But on a cache miss it hands that *already normalized* uri to 
addEndpointToRegistry(), which calls
+        // getEndpointKey(uri, endpoint) -> 
NormalizedUri.newNormalizedUri(uri, false) - "not normalized yet,
+        // please normalize" - normalizing it a second time before storing it. 
The stored key (double
+        // normalized) then never matches the lookup key computed on a later 
call (single normalized), so a
+        // brand-new Endpoint (with its own doStart()) gets created on every 
single call instead of the
+        // cached singleton being reused.
+        String uri = "controlbus:route?routeId=RAW(%41)&action=status";
+
+        Endpoint first = ctx.getEndpoint(uri);
+        Endpoint second = ctx.getEndpoint(uri);
+
+        Assertions.assertSame(first, second,
+                "getEndpoint(uri) must return the cached singleton instance on 
a second call with the identical uri");
+        Assertions.assertEquals(1, ctx.getEndpoints().size(),
+                "the endpoint registry must not accumulate a new entry on 
every call for the same uri");
+    }
+
     @Test
     public void testMigration() {
         DefaultCamelContext ctx = new DefaultCamelContext();

Reply via email to