Companion module for the rewritten post 'Resilience4j Circuit Breaker in Spring Boot 4.1: What It's Still For', reworked around Framework 7 now shipping @Retryable/@ConcurrencyLimit in core. Covers what's still Resilience4j's job (circuit breaker, rate limiter, bulkhead's bounded wait, fallback methods, Actuator/Micrometer metrics), the off-by-one between maxAttempts and maxRetries, and two Boot-4.1 build breaks: spring-boot-starter-aop no longer exists (renamed to spring-boot-starter-aspectj, proven with Maven Central metadata and the renamed starter's own POM -- see resilience/docs/08-starter-aop-renamed-to-starter-aspectj.md in this same repo), and the resulting fix uses that renamed starter directly rather than assembling spring-aop + aspectjweaver by hand. Kept as its own module rather than a new top-level repository, alongside the existing resilience/ module for the sibling Framework-7 post.
3.0 KiB
4. @ConcurrencyLimit vs Resilience4j's Bulkhead
Previous: 03-spring-retryable.md | README | Next: 05-production-checklist.md
Source: ConcurrencyLimitedService.java,
R4jBulkheadService.java.
Tests: ConcurrencyLimitTest.java.
Transcripts: 04a, 04b, 04c.
Both cap concurrent invocations of one method at 2. Both were hit with 4 concurrent callers, each holding its slot for 300ms, in the same test run.
BLOCK: queue, no timeout
04a-concurrencylimit-block.txt: all 4 callers succeed. Sorted completion times were
[300, 300, 597, 600] ms — two callers finish almost immediately, the other two only after a
slot frees, for a total wall time of ~601ms instead of the ~300ms it would take with no limit
at all. There is no attribute on @ConcurrencyLimit to bound how long a blocked caller waits;
it queues until a slot opens, however long that takes.
REJECT: fail fast, no queue
04b-concurrencylimit-reject.txt, policy = ConcurrencyLimit.ThrottlePolicy.REJECT: 2 callers
succeed in ~300ms, and the other 2 are rejected in ~0ms — not made to wait at all. The exception
is org.springframework.resilience.InvocationRejectedException, found with javap on
ConcurrencyLimitBeanPostProcessor$RejectingConcurrencyThrottleInterceptor.onAccessRejected —
it is not documented on the @ConcurrencyLimit annotation itself.
What Resilience4j's Bulkhead adds: a bounded wait
04c-r4j-bulkhead-comparison.txt runs the identical 4-caller/300ms scenario against a
Resilience4j @Bulkhead(maxConcurrentCalls=2, maxWaitDuration=100ms). Two callers finish
normally around 300ms; the other two wait up to maxWaitDuration (observed: 111ms and 117ms,
close to the configured 100ms) and are rejected via the fallback method — a third option that
sits between Spring's BLOCK (wait forever) and REJECT (never wait): wait, but only briefly.
Neither @ConcurrencyLimit policy offers that middle ground.
Picking between them. If you want callers to queue briefly rather than either wait forever or fail instantly, Resilience4j's Bulkhead with a shortmaxWaitDurationis still the only one of the three that does it. If unlimited blocking is actually fine — e.g. limiting concurrent writers to a single-threaded resource —@ConcurrencyLimit's BLOCK policy is one annotation and one less dependency.
Next: 05-production-checklist.md.