shashank created CAMEL-25300:
--------------------------------

             Summary: Throttle EIP - the clean of the total requests throttler 
can hand out a second set of permits, so more exchanges than allowed pass in a 
time period
                 Key: CAMEL-25300
                 URL: https://issues.apache.org/jira/browse/CAMEL-25300
             Project: Camel
          Issue Type: Bug
          Components: camel-core
            Reporter: shashank


{{TotalRequestsThrottler}} keeps a {{ThrottlingState}} (a {{DelayQueue}} of 
permits) per correlation key. Every returned permit schedules {{clean()}} 10 
periods later ({{cleanPeriodMillis = timePeriodMillis * 10}}, computed once in 
the constructor), which does {{states.remove(key)}}.

* An exchange that looked up the state just before the clean runs still polls a 
permit from the removed state, while the next exchanges create a new state with 
all {{maximumRequests}} permits available at once. So in that period more 
exchanges than allowed pass (with 2 per period: 3).
* The permit of that exchange is returned to the removed state, which schedules 
the removed state's clean again. 10 periods later that clean does 
{{states.remove(key)}}, which removes whatever state is mapped then, also the 
new one while it is in use: its delayed permits are dropped, the next exchange 
gets a fresh state with all permits, and the clean of the dropped state repeats 
this. Under continuous traffic with concurrent senders the throttler can keep 
resetting every 10 periods.
* {{setTimePeriodMillis}} (JMX) does not change the clean period. After 
increasing the period to more than 10 times its initial value, the clean 
removes states whose permits are still delayed, and the next exchanges get new 
permits at once.

CAMEL-24926 fixed the same pattern in {{ConcurrentRequestsThrottler}}: its 
clean only removes its own state when no permit is out, and an exchange that 
took a permit from a removed state takes it from the current state instead.

h3. Reproduction

Test {{TotalRequestsThrottlerCleanTest}} (camel-core, the same technique as 
{{ConcurrentRequestsThrottlerCleanTest}} from CAMEL-24926: an executor that 
keeps the scheduled clean tasks, and the {{maximumRequests}} expression, which 
the throttler evaluates after it looked up the state, runs them for a given 
exchange). Throttle 2 per hour, {{rejectExecution(true)}}:

* {{testCleanAfterExchangeLookedUpState}}: "first", then "race" (the clean runs 
after it looked up the state, as if 10 periods had passed), then "second" must 
pass and the next exchange must be rejected. On main it passes too (3 exchanges 
in the period after the clean):
{noformat}
TotalRequestsThrottlerCleanTest.testCleanAfterExchangeLookedUpState:64 Expected 
org.apache.camel.CamelExecutionException to be thrown, but nothing was thrown.
{noformat}
* {{testCleanFollowsTimePeriod}}: after {{setTimePeriodMillis(2 hours)}} the 
clean is scheduled in 20 hours. On main:
{noformat}
TotalRequestsThrottlerCleanTest.testCleanFollowsTimePeriod:94 expected: 
<[72000000]> but was: <[36000000]>
{noformat}
* {{testCleanRemovesUnusedState}} (control): the clean still removes an unused 
state (passes on main).

The defect was found with a TLA+ model of the throttler states, the clean 
tasks, exchanges (lookup, poll, enqueue as separate steps) and time periods: 
"at most maximumRequests permits are handed out per period" is violated in 12 
steps (an exchange looks up the state, the clean removes it, the exchange takes 
a permit, another exchange creates a new state and takes a permit), and "the 
clean of a replaced state never removes the current state" in 13 steps. With 
the fix below both hold (2-3 exchanges, 1-2 permits, 2-4 periods).

h3. Proposed fix

As in CAMEL-24926:
* {{clean()}}: {{states.computeIfPresent(key, (k, s) -> s == this && 
markRemovedIfUnused() ? null : s)}}; a state is unused when all its permits are 
back in the queue (checked and marked {{removed}} under a lock of its own).
* Unlike CAMEL-24926, that lock is not the state's lock: in this mode a 
decrease of the maximum requests (a dynamic {{maximumRequests}} expression, 
such as a header) takes permits with {{DelayQueue.take()}} while it holds the 
state's lock, and waits when none is available. An exchange that took a permit 
must be able to check the flag and return its permit meanwhile, otherwise the 
decrease and every exchange of the key wait forever. (The concurrent requests 
throttler decreases with {{Semaphore.reducePermits}}, which does not wait.)
* An exchange that polled (or took) a permit from a state that is marked 
removed puts the permit back and takes one from the current state.
* The clean is scheduled with the current time period ({{10 x 
getTimePeriodMillis()}}, saturated). This belongs to the same fix: "unused" 
counts the permits in the queue, delayed or not, so a clean must not run while 
a permit is still delayed; with the period fixed at the initial value it could, 
after the period was increased.

{{TotalRequestsThrottlerRateDecreaseTest}} (regression test for the lock 
above): {{throttle(header("max")).timePeriodMillis(2000)}}; two exchanges take 
both permits, two more wait in {{take()}} (the test waits until their threads 
are parked), then an exchange with {{max=1}} decreases the maximum and waits 
for a permit to discard. All five must get through. It passes on main; with the 
first version of the fix (the flag under the state's lock) it hung: the thread 
dump shows the two exchanges parked in {{isRemoved()}} on the state's lock and 
the decreasing one in {{DelayQueue.take()}} inside 
{{calculateAndSetMaxRequestsPerPeriod}}.

With the fix the new tests and the throttle tests 
({{org/apache/camel/processor/throttle/**}}, {{Throttl*Test}}: 60 tests) pass.

Severity: the lookup race needs an exchange at the instant the clean runs 
(after 10 idle periods); once it happened, the reset can repeat under 
continuous traffic; the JMX case is deterministic. Minor.

Affected: 4.14.x, 4.18.x and main (same code).

Duplicate check (2026-10-03): JIRA text "TotalRequestsThrottler" (CAMEL-24928 
asyncDelayed NPE, CAMEL-24227 volatile JMX fields), summary 
"throttle"/"throttler" since 2023 (CAMEL-24926, CAMEL-20357, CAMEL-20267, ...: 
none about the clean of the total requests mode). GitHub pull requests 
"TotalRequestsThrottler", "throttler clean": #26775 (CAMEL-24928), #24985, 
#24713; the open #26871 touches {{ConcurrentRequestsThrottler}} interrupt 
handling only.

_Filed with Claude Code on behalf of allthingssecurity._




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to