Skip to content

fix(plc4j/spi): RequestThrottle silently drops a limit reduction while requests are in flight - #2734

Merged
sruehl merged 10 commits into
apache:developfrom
alxkm:fix/throttle-lost-limit-reduction
Oct 12, 2026
Merged

sruehl merged 10 commits into
apache:developfrom
alxkm:fix/throttle-lost-limit-reduction

Conversation

@alxkm

@alxkm alxkm commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

RequestThrottle.adjustMaxConcurrentRequests() loses the part of a reduction it cannot apply while requests are in flight, leaving the throttle permanently above its configured limit. This change defers that part as a permit debt that is settled as the running requests complete.

Problem

adjustMaxConcurrentRequests() took permits away with

int toDrain = Math.min(-difference, semaphore.availablePermits());

and then updated maxConcurrentRequests unconditionally. A permit that is in flight cannot be taken back, so whenever more requests were running than the new limit allows, the clamped-away part of the reduction was silently discarded while the field claimed it had been applied. As the running requests returned their permits, the semaphore climbed back to the old count and stayed there: the throttle permanently allowed more concurrent requests than it was configured for, which is the one thing the class exists to prevent. Repeated adjustments compound it - shrinking under load and raising the limit again leaves the pool over budget for good.

This is not limited to the case where no permit is free. With 3 permits and 2 in flight, reducing to 1 takes the single free permit and drops the second; getInFlightRequests() then reports 1 while 2 requests are running, and -1 once they finish.

Fix

The part of a reduction that cannot be applied immediately is remembered as a permit debt and settled out of the permits the running requests return, so the reduction takes effect as soon as it can instead of being lost. Raising the limit cancels outstanding debt before handing out new permits. The adjustment log line reports how much of a reduction is still outstanding.

Reducing no longer blocks. The old acquireUninterruptibly() could wait, under the instance monitor, for permits that only the in-flight requests could hand back, because availablePermits() may drop between the check and the call. Free permits are now taken with non-blocking tryAcquire(), and whatever is left becomes debt.

getInFlightRequests() now counts the permits handed out directly instead of deriving the number from the ceiling and the free permits. The derived value could read negative not only after a lost reduction but also while the limit was being raised, because the permits were released before the ceiling was updated. While a debt is outstanding the counter may temporarily exceed getMaxConcurrentRequests(); that is the number of requests actually running, and it settles as they complete.

Tests

The 15 existing tests are unchanged and keep passing. The 8 added tests all fail without this change. A package-private getPermitDebt() exposes the outstanding debt to the tests.

Reachability

RequestThrottle is a final field on ConnectionBase, so it outlives any number of connect() calls, and connect() has no re-entry guard: an application-driven reconnect re-runs S7's SetupCommunication and adjusts a throttle that is already serving traffic. No driver triggers this on its own today, so this is a latent defect rather than an observed production failure.

Alternative considered

A larger change would drop the semaphore from the execute() path in favour of an AtomicInteger in-flight counter checked against a volatile maximum, which would make a reduction a plain assignment. That touches the public acquire()/release() pair - unused elsewhere in the tree, but public API - so it is left out of this change.

@ottlukas

ottlukas commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Hi @alxkm ,

Thanks for opening this Pull Request.

Per our updated contribution guidelines, we ask that contributors who plan to introduce substantial changes or new capabilities—such as work touching our Serial Peripheral Interface (SPI)—first introduce themselves on the dev mailing list (dev@plc4x.apache.org).

Could you please post a brief introduction on the mailing list covering:

A quick introduction of yourself and your context.

Your goals and intent for this PR, specifically what you are looking to achieve with these changes to the SPI. (Your use case and machines etc.)

Open-source maintainability relies heavily on context and architectural alignment before reviewing code diffs. Introducing yourself on the list gives the broader community a chance to understand the use case, offer early architectural feedback, and coordinate efforts effectively.

Once you've posted to the list, please link the thread or drop a note here so we can align on the proposed changes before proceeding with the code review.

Thanks!

This helps us to assure maintainability and is a way against AI Slop protection with Pull requests just opening but no intent on further contributions.

@alxkm

alxkm commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Hi @ottlukas

Thanks - makes sense. Posted an introduction and the context behind this PR
to the dev list: https://lists.apache.org/thread/4y0c7omcydg1b7szcnw5wm3tswq0v3tl

Short version: no hardware and no production use case behind it, I came to
this from the JVM side. Happy to align on the approach there before the code
review, and to rework or split the change if that fits better.

…be applied at once

adjustMaxConcurrentRequests() took permits away with

    int toDrain = Math.min(-difference, semaphore.availablePermits());

and then updated maxConcurrentRequests unconditionally. A permit that is in
flight cannot be taken back, so whenever more requests were running than the new
limit allows, the clamped-away part of the reduction was silently discarded
while the field claimed it had been applied. As the running requests returned
their permits the semaphore climbed back to the old count and stayed there, so
the throttle permanently allowed more concurrent requests than it was configured
for - the one thing the class exists to prevent. Repeated adjustments compound
it: shrinking under load and raising the limit again leaves the pool over budget.

This is not limited to the case where no permit is free. With 3 permits, 2 of
them in flight, reducing to 1 takes the single free permit and drops the second.

Remember the part that cannot be applied immediately as a permit debt and settle
it out of the permits the running requests return, so the reduction takes effect
as soon as it can rather than being lost. Reducing no longer blocks either: the
old acquireUninterruptibly() could wait, under the instance monitor, for permits
that only the in-flight requests could hand back, because availablePermits() may
drop between the check and the call.

getInFlightRequests() derived its result from the maximum and the free permits,
which could go negative - already before this change, on the growth path, where
release(difference) ran before the field was updated, so a request started by
the drain in between saw a negative count. Track the permits handed out in a
counter instead, which cannot go negative and does not depend on an adjustment
being atomic with the read.

An adjustment that could not be applied in full now says so in the log; without
that there is nothing to go on when concurrency does not drop as expected.

The 15 existing tests are unchanged and keep passing. 12 are added. Four of them
read the permit debt through a package-private accessor and therefore only
compile against this version; drop those four together with their helper and the
remaining 8 compile against develop, where all 8 fail.

Signed-off-by: alxkm <alexanderklmn@gmail.com>
@ottlukas
ottlukas requested a review from splatch September 9, 2026 08:49
@alxkm
alxkm force-pushed the fix/throttle-lost-limit-reduction branch from a4dbce1 to 6b1c361 Compare September 9, 2026 12:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Permit release can race with limit reduction, temporarily allowing concurrency above the configured maximum.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Fixes permit-limit reductions under load by tracking deferred permit debt and actual in-flight requests.

Changes:

  • Retires returned permits against outstanding reduction debt.
  • Cancels debt before increasing available permits.
  • Adds regression and permit-accounting tests.
File Description
RequestThrottle.java Implements permit debt and direct in-flight tracking.
RequestThrottleTest.java Tests reductions, increases, and accounting invariants.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

…one lock

A permit returned while adjustMaxConcurrentRequests() was applying a reduction
could be counted twice. The completing request read a debt of zero and released
its permit, while the reduction failed to acquire that same permit and recorded
it as debt instead, so the throttle offered free permits although the reduced
limit was already taken by the requests still running.

The debt check and the release now run under the same lock as the reduction's
try-acquire, so a returned permit either goes back to the pool or retires part
of the reduction, never both. drain() runs outside the lock, so no request is
started while it is held.
@alxkm

alxkm commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Hi @splatch, @sruehl - a quick update: the Copilot finding is addressed in eae3e43 (details in the review thread), and the branch is now up to date with develop. Ready for review whenever you have time. Thanks!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

5 open findings
1 resolved since last review

🧠 Review effort: Lite

alxkm added 2 commits October 12, 2026 10:48
- Word the unsettled-debt log message so it also fits a raise that only
  cancels part of an earlier reduction.
- Fail fast when the race test's threads do not all get ready, instead of
  running the attempt with the threads not aligned.
- Run the race test 500 times instead of 2000; on the unfixed code it fails
  within the first couple of hundred attempts.
@sruehl
sruehl requested a balanced review from Copilot October 12, 2026 09:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

… fails

If an attempt failed before every thread was let go, the threads still in the
spin loop ignored the interrupt from shutdownNow() and kept spinning for the
rest of the test JVM. They now give up when interrupted. The test also gets
an overall timeout, so a slow or contended machine cannot stretch it out
without bound.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alxkm added 2 commits October 12, 2026 12:22
… time

While waiting to be let go, the race test's threads now spin briefly and then
park between checks instead of spinning the whole time. Besides not holding on
to a core, this lets go of the threads closely enough to hit the race within
the first few attempts on the unfixed code, so 100 attempts are enough. The
attempts are also bounded by time, so a slow machine runs fewer of them
instead of running into the timeout.
@sruehl
sruehl merged commit a928243 into apache:develop Oct 12, 2026
@alxkm
alxkm deleted the fix/throttle-lost-limit-reduction branch October 14, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants