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 a9ca7702ba76 CAMEL-26472: camel-core - Throttle EIP: do not reschedule 
the clean of the throttling state on every exchange (#28816)
a9ca7702ba76 is described below

commit a9ca7702ba7693548bace7ebad3ed2e9800939d7
Author: Adriano Machado <[email protected]>
AuthorDate: Mon Nov 2 01:30:33 2026 -0500

    CAMEL-26472: camel-core - Throttle EIP: do not reschedule the clean of the 
throttling state on every exchange (#28816)
    
    …f the throttling state on every exchange
    
    Both throttler modes scheduled a new clean and cancelled the previous one
    for every exchange. The pending clean now stays while at least a fifth of
    the clean period is left. In total requests mode that is 2 periods, so it
    still runs after the delay of the permit returned, which matters as a clean
    removes a state whose permits are all returned even if they are delayed
    (CAMEL-25300). A clean now runs 8 to 10 periods after the last permit was
    returned, instead of exactly 10.
    
    ThrottlePermit.compareTo compares the scheduled times, without reading the
    clock twice.
    
    _Claude Code on behalf of Adriano Machado (@ammachado)_
    
    _This was generated by an AI agent and may contain inaccuracies.
    Please verify before relying on it._
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../apache/camel/processor/AbstractThrottler.java  | 20 ++++++++++++
 .../processor/ConcurrentRequestsThrottler.java     |  7 ++--
 .../camel/processor/TotalRequestsThrottler.java    | 12 +++----
 .../ConcurrentRequestsThrottlerCleanTest.java      | 23 ++++++++++++-
 .../requests/TotalRequestsThrottlerCleanTest.java  | 38 ++++++++++++++++++++--
 5 files changed, 85 insertions(+), 15 deletions(-)

diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/AbstractThrottler.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/AbstractThrottler.java
index 3a7bb0b973cd..892278ca723f 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/AbstractThrottler.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/AbstractThrottler.java
@@ -17,6 +17,9 @@
 package org.apache.camel.processor;
 
 import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 
 import org.apache.camel.AsyncCallback;
 import org.apache.camel.CamelContext;
@@ -62,6 +65,23 @@ public abstract class AbstractThrottler extends 
BaseProcessorSupport
         this.maxRequestsExpression = 
ObjectHelper.notNull(maxRequestsExpression, "maxConcurrentRequestsExpression");
     }
 
+    /**
+     * Schedules a clean of a throttling state, unless the pending one can 
stay. This is called for every exchange, and
+     * scheduling and cancelling takes the executor queue lock about three 
times. The pending clean stays while at least
+     * a fifth of the clean period is left, so it may run up to a fifth 
earlier than the clean period after this call.
+     */
+    void scheduleClean(AtomicReference<ScheduledFuture<?>> cleanFuture, 
Runnable clean, long cleanPeriodMillis) {
+        ScheduledFuture<?> pending = cleanFuture.get();
+        if (pending != null && pending.getDelay(TimeUnit.MILLISECONDS) >= 
cleanPeriodMillis / 5) {
+            return;
+        }
+        ScheduledFuture<?> next = asyncExecutor.schedule(clean, 
cleanPeriodMillis, TimeUnit.MILLISECONDS);
+        ScheduledFuture<?> prev = cleanFuture.getAndSet(next);
+        if (prev != null) {
+            prev.cancel(false);
+        }
+    }
+
     protected static boolean handleInterrupt(
             Exchange exchange, AsyncCallback callback, InterruptedException e, 
boolean doneSync) {
         // determine if we can still run, or the camel context is forcing a 
shutdown
diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/ConcurrentRequestsThrottler.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/ConcurrentRequestsThrottler.java
index 82e7033650ba..ba5e9247b542 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/ConcurrentRequestsThrottler.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/ConcurrentRequestsThrottler.java
@@ -223,6 +223,7 @@ public class ConcurrentRequestsThrottler extends 
AbstractThrottler {
         private final String key;
         private final Lock lock = new ReentrantLock();
         private final AtomicReference<ScheduledFuture<?>> cleanFuture = new 
AtomicReference<>();
+        private final Runnable cleanTask = this::clean;
         private volatile int throttleRate;
         private final WrappedSemaphore semaphore;
         // guarded by lock
@@ -319,11 +320,7 @@ public class ConcurrentRequestsThrottler extends 
AbstractThrottler {
         public void release(final Exchange exchange) {
             semaphore.release();
             try {
-                ScheduledFuture<?> next = asyncExecutor.schedule(this::clean, 
CLEAN_PERIOD, TimeUnit.MILLISECONDS);
-                ScheduledFuture<?> prev = cleanFuture.getAndSet(next);
-                if (prev != null) {
-                    prev.cancel(false);
-                }
+                scheduleClean(cleanFuture, cleanTask, CLEAN_PERIOD);
                 if (LOG.isTraceEnabled()) {
                     LOG.trace("Permit released, for exchangeId: {}", 
exchange.getExchangeId());
                 }
diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/TotalRequestsThrottler.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/TotalRequestsThrottler.java
index f949915439ad..a7181559a44a 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/TotalRequestsThrottler.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/TotalRequestsThrottler.java
@@ -237,6 +237,7 @@ public class TotalRequestsThrottler extends 
AbstractThrottler {
         private final Lock lock = new ReentrantLock();
         private final DelayQueue<ThrottlePermit> delayQueue = new 
DelayQueue<>();
         private final AtomicReference<ScheduledFuture<?>> cleanFuture = new 
AtomicReference<>();
+        private final Runnable cleanTask = this::clean;
         private volatile int throttleRate;
         // not the lock above: a decrease of the throttle rate holds that lock 
while it waits for permits, which an
         // exchange that took a permit returns only after it checked whether 
the state was removed
@@ -317,15 +318,12 @@ public class TotalRequestsThrottler extends 
AbstractThrottler {
             permit.setDelayMs(getTimePeriodMillis());
             delayQueue.put(permit);
             try {
-                // clean up 10 periods after the last permit was returned 
(with the current period, which can be
+                // clean up 8 to 10 periods after the last permit was returned 
(with the current period, which can be
                 // changed at runtime), when no permit is delayed any longer
                 long period = getTimePeriodMillis();
                 long cleanPeriodMillis = period > Long.MAX_VALUE / 10 ? 
Long.MAX_VALUE : period * 10;
-                ScheduledFuture<?> next = asyncExecutor.schedule(this::clean, 
cleanPeriodMillis, TimeUnit.MILLISECONDS);
-                ScheduledFuture<?> prev = cleanFuture.getAndSet(next);
-                if (prev != null) {
-                    prev.cancel(false);
-                }
+                // a clean that stays pending runs at least 2 periods from 
now, so after the delay of this permit
+                scheduleClean(cleanFuture, cleanTask, cleanPeriodMillis);
                 // try and incur the least amount of overhead while releasing 
permits back to the queue
                 if (LOG.isTraceEnabled()) {
                     LOG.trace("Permit released, for exchangeId: {}", 
exchange.getExchangeId());
@@ -424,7 +422,7 @@ public class TotalRequestsThrottler extends 
AbstractThrottler {
 
         @Override
         public int compareTo(final Delayed o) {
-            return Long.compare(getDelay(TimeUnit.MILLISECONDS), 
o.getDelay(TimeUnit.MILLISECONDS));
+            return Long.compare(scheduledTime, ((ThrottlePermit) 
o).scheduledTime);
         }
     }
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/throttle/concurrent/ConcurrentRequestsThrottlerCleanTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/throttle/concurrent/ConcurrentRequestsThrottlerCleanTest.java
index cddaed78ad0a..60f36f91d469 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/processor/throttle/concurrent/ConcurrentRequestsThrottlerCleanTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/throttle/concurrent/ConcurrentRequestsThrottlerCleanTest.java
@@ -118,6 +118,26 @@ public class ConcurrentRequestsThrottlerCleanTest extends 
ContextTestSupport {
         assertEquals(0, throttler.getCurrentMaximumRequests());
     }
 
+    @Test
+    public void testCleanStaysScheduledWhileFarFromFiring() throws Exception {
+        template.sendBody("direct:start", "first");
+        template.sendBody("direct:start", "second");
+
+        // scheduling and cancelling per exchange would take the executor 
queue lock about three times
+        assertEquals(1, executor.scheduled.size());
+    }
+
+    @Test
+    public void testCleanIsRescheduledWhenCloseToFiring() throws Exception {
+        // otherwise a state still in use when the pending clean runs would 
never be cleaned
+        executor.pendingDelayMillis = TimeUnit.SECONDS.toMillis(1);
+
+        template.sendBody("direct:start", "first");
+        template.sendBody("direct:start", "second");
+
+        assertEquals(2, executor.scheduled.size());
+    }
+
     @Override
     protected RouteBuilder createRouteBuilder() {
         return new RouteBuilder() {
@@ -151,6 +171,7 @@ public class ConcurrentRequestsThrottlerCleanTest extends 
ContextTestSupport {
      */
     private static final class CapturingExecutor extends 
ScheduledThreadPoolExecutor {
         private final List<Runnable> scheduled = new CopyOnWriteArrayList<>();
+        private volatile long pendingDelayMillis = TimeUnit.DAYS.toMillis(1);
 
         CapturingExecutor() {
             super(1);
@@ -160,7 +181,7 @@ public class ConcurrentRequestsThrottlerCleanTest extends 
ContextTestSupport {
         @Override
         public ScheduledFuture<?> schedule(Runnable command, long delay, 
TimeUnit unit) {
             scheduled.add(command);
-            return super.schedule(command, 1, TimeUnit.DAYS);
+            return super.schedule(command, pendingDelayMillis, 
TimeUnit.MILLISECONDS);
         }
 
         void runScheduled() {
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/throttle/requests/TotalRequestsThrottlerCleanTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/throttle/requests/TotalRequestsThrottlerCleanTest.java
index ebd7c98755a5..90561c11e7e5 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/processor/throttle/requests/TotalRequestsThrottlerCleanTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/throttle/requests/TotalRequestsThrottlerCleanTest.java
@@ -61,7 +61,7 @@ public class TotalRequestsThrottlerCleanTest extends 
ContextTestSupport {
 
         // race took its permit from the state that replaced the cleaned one, 
so that state hands out only one more
         // permit, not a second full set next to the permit race took. The 
clean runs here while the permit of first
-        // is still delayed (in practice it runs 10 periods later), so first, 
race and second all pass: this test is
+        // is still delayed (in practice it runs 8 to 10 periods later), so 
first, race and second all pass: this test is
         // about the permits of the replacement state, not about the 
per-period limit
         template.sendBody("direct:start", "second");
         Exception e = assertThrows(CamelExecutionException.class,
@@ -97,6 +97,39 @@ public class TotalRequestsThrottlerCleanTest extends 
ContextTestSupport {
         assertEquals(List.of(TimeUnit.HOURS.toMillis(20)), executor.delays);
     }
 
+    @Test
+    public void testCleanStaysScheduledWhileFarFromFiring() throws Exception {
+        template.sendBody("direct:start", "first");
+        template.sendBody("direct:start", "second");
+
+        // scheduling and cancelling per exchange would take the executor 
queue lock about three times
+        assertEquals(1, executor.delays.size());
+    }
+
+    @Test
+    public void testCleanIsRescheduledWhenCloseToFiring() throws Exception {
+        // the clean removes a state whose permits are all returned, even if 
they are still delayed, so it must not run
+        // within the period of the permit returned now, or the next exchanges 
would get a fresh set of permits
+        executor.pendingDelayMillis = TimeUnit.MINUTES.toMillis(90);
+
+        template.sendBody("direct:start", "first");
+        template.sendBody("direct:start", "second");
+
+        assertEquals(2, executor.delays.size());
+    }
+
+    @Test
+    public void testCleanIsRescheduledWhenTimePeriodIsRaised() throws 
Exception {
+        TotalRequestsThrottler throttler = context.getProcessor("throttler", 
TotalRequestsThrottler.class);
+
+        template.sendBody("direct:start", "first");
+        // such as from JMX: the pending clean is now within two periods
+        throttler.setTimePeriodMillis(TimeUnit.DAYS.toMillis(1));
+        template.sendBody("direct:start", "second");
+
+        assertEquals(List.of(TimeUnit.HOURS.toMillis(10), 
TimeUnit.DAYS.toMillis(10)), executor.delays);
+    }
+
     @Override
     protected RouteBuilder createRouteBuilder() {
         return new RouteBuilder() {
@@ -125,6 +158,7 @@ public class TotalRequestsThrottlerCleanTest extends 
ContextTestSupport {
     private static final class CapturingExecutor extends 
ScheduledThreadPoolExecutor {
         private final List<Runnable> scheduled = new CopyOnWriteArrayList<>();
         private final List<Long> delays = new CopyOnWriteArrayList<>();
+        private volatile long pendingDelayMillis = TimeUnit.DAYS.toMillis(1);
 
         CapturingExecutor() {
             super(1);
@@ -135,7 +169,7 @@ public class TotalRequestsThrottlerCleanTest extends 
ContextTestSupport {
         public ScheduledFuture<?> schedule(Runnable command, long delay, 
TimeUnit unit) {
             scheduled.add(command);
             delays.add(unit.toMillis(delay));
-            return super.schedule(command, 1, TimeUnit.DAYS);
+            return super.schedule(command, pendingDelayMillis, 
TimeUnit.MILLISECONDS);
         }
 
         void runScheduled() {

Reply via email to