Skip to content

fix: Stop circuit breaker spawning a thread per call - #1

Open
Ashish-CodeJourney wants to merge 9 commits into
initialcapacity:mainfrom
Ashish-CodeJourney:fix/circuit-breaker-thread-per-call
Open

fix: Stop circuit breaker spawning a thread per call#1
Ashish-CodeJourney wants to merge 9 commits into
initialcapacity:mainfrom
Ashish-CodeJourney:fix/circuit-breaker-thread-per-call

Conversation

@Ashish-CodeJourney

Copy link
Copy Markdown

withCircuitBreaker called Executors.newSingleThreadExecutor() on every invocation, so
every request through ProjectClient (allocations, backlog, timesheets) spawned a thread.
Nothing shut the executor down. JDK 21+ reclaims it via a Cleaner once unreachable, but
only after a GC, so threads pile up under load.

Now one bounded pool per breaker. Workers are reused, idle out after 60s, and run as
daemon threads so a straggler cannot hold the JVM open. New maxConcurrentCalls param
(default 10, last position, so existing call sites are unaffected) caps the pool: when it
saturates, submit is rejected, the existing catch counts it as a failure and the
fallback runs.

Tests: 8 passing in components:circuit-breaker-support, ran 6 times since the two new
concurrency tests are timing sensitive. Other modules need local MySQL and Redis, so only
this one was run.

Mutation testing by hand (no pitest here) caught a false negative worth flagging: the
first test counted thread names, so a constant-name mutant hid a mutant restoring the
original bug. It counts Thread instances now.

Follow up, not in this PR: testMaxFailures cannot fail, because fail() runs on the
worker thread and its AssertionError gets swallowed by the breaker's catch (e: Exception). That leaves the failure counting boundaries effectively untested.

CircuitBreaker creates a new single-thread executor on every call, so
each invocation spawns a fresh thread. The test drives 50 sequential
calls through one breaker and records the worker thread used by each.

Currently fails: expected 1 thread, was 50.
The previous assertion (exactly one thread) over-specified: a pooled
executor hands work off through a SynchronousQueue, so a returning
worker may not be parked yet when the next call is submitted, and a
correct implementation can legitimately use a second thread.

Assert the behaviour that matters instead: thread use stays within a
configured concurrency limit no matter how many calls are made. Adds
the maxConcurrentCalls parameter the test needs; it is not yet wired
up, so the assertion rather than the compiler reports the defect.

Currently fails: used 50 threads for 50 calls.
withCircuitBreaker created a new single-thread executor on every
invocation, so every request through ProjectClient spawned a fresh
thread. The executor was never shut down; on JDK 21+ a Cleaner
reclaims it once unreachable, but only after a GC, so threads pile up
under load. On the timeout path the abandoned pool was left holding a
task that cancel(true) only interrupts.

Use one bounded pool per breaker instead. Idle workers are reused and
time out after 60s, and maxConcurrentCalls caps thread growth when a
downstream stalls: once the pool is saturated, submit is rejected, the
existing catch treats it as a failure and the fallback runs.

5 tests passing.
Pool threads inherit daemon status from whichever thread happens to
submit first, so a worker can outlive the work it was created for and
hold the JVM open. Each server also starts a discovery heartbeat
scheduler that is never shut down, so a clean exit already depends on
no other non-daemon threads lingering.

Currently fails: worker thread is not a daemon.
Pool workers no longer inherit daemon status from the submitting
thread, so a lingering worker cannot hold the JVM open. Naming them
circuit-breaker-N also makes them identifiable in a thread dump.

6 tests passing.
Mutation testing surfaced a false negative: the thread-bound test
counted distinct thread names, so a mutant that gave every worker a
constant name masked a mutant that restored the original
pool-per-call bug. Both applied together left the suite green.

Collect Thread instances instead. The pair is now caught.
Mutation testing showed the pool's maximum size was unverified:
widening it to Int.MAX_VALUE left the suite green, because the
existing test issues calls sequentially and never saturates the pool.

Drive eight overlapping calls through a breaker limited to two and
assert only two workers are used, so the bulkhead that protects a
server from a stalled downstream is actually covered.
Mutation testing left cancel(true) -> cancel(false) alive. That
matters more now the pool is shared and bounded: an uninterrupted task
keeps its worker busy after the caller has given up, so repeated
timeouts would consume the concurrency limit and reject healthy calls.

Assert the abandoned work is interrupted once the breaker falls back.
Split the constructor across lines, name the idle worker timeout, and
drop the property qualifier from maxConcurrentCalls now that only the
pool it configures reads it. Refactor only, no behaviour change.
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.

1 participant