A per-service concurrency limit — the bulkhead pattern.
The circuit breaker bounds how many failures you tolerate and the rate limiter bounds how often calls start. Neither bounds how many are in flight at once, and that is the gap that opens when a service degrades rather than fails. Calls still succeed, just slowly, so the breaker sees nothing; and a limit of 100 calls per second against a service that slows to 10 seconds per call leaves roughly a thousand processes parked in the same call, each holding a connection out of the pool.
A concurrency limit caps that.
use ExternalService,
concurrency: [limit: 25, reclaim_after: :timer.seconds(30)]Over the limit a call is not dropped — it returns
ExternalService.ServiceSaturated to its caller, which is then free to enqueue
the work, serve something stale, or answer 503. There is no cooldown: unlike the
circuit breaker, a slot is available again the instant the call holding it
finishes, so recovery is continuous rather than something you arrange.
An optional :wait budget absorbs short bursts by parking a caller for a
bounded time before shedding. Waiting callers hold no slot, so the number parked
is bounded by arrival rate times the budget. :infinity is deliberately not
accepted — an unbounded wait is the pile-up a concurrency limit exists to
prevent.
Saturation is your backpressure rather than the service's failure, so it
does not melt the circuit breaker and is not retried, exactly like
ExternalService.RateLimited.
How slots are tracked
State is an :atomics array with one slot per permit, so this needs no
process, supervisor, or registry — the same shape as
ExternalService.RateLimiter.Local.
Each slot holds the monotonic millisecond deadline until which it counts as
occupied. There is deliberately no "free" sentinel: System.monotonic_time/1
may be negative, so a slot is free precisely when its deadline has passed.
Why slots expire
A slot is released in an after block, which covers everything that happens
inside the calling process — returning, raising, throwing, exiting. It does
not cover the process being terminated from outside: an exit signal, even
the ordinary :shutdown a supervisor sends, kills the process without running
after. That is not exotic — it is what happens to in-flight callers on every
deploy that drains requests.
A plain counter would leak on each of those and ratchet toward permanently
wedged. Instead :reclaim_after bounds how long a slot may be held before it
is considered abandoned and reused. The trade is that reclaiming a slot from a
call that was merely slow briefly admits more than :limit. Over-admitting for
one window is a far better failure than wedging forever, which is why
:reclaim_after is required rather than defaulted: it must be longer than any
legitimate call, and only you know your client's timeout.
Releasing uses a compare-and-exchange rather than a write, so a slow caller whose slot was already reclaimed cannot evict whoever holds it now.
Summary
Types
Returned by call/2 when no slot was available.
A configured concurrency limit.
How long a call may wait for a slot before being shed.
Functions
Reports how many of service's slots are currently held.
Reports the configured limit for service, or nil if it has none.
Discards service's in-flight accounting, freeing every slot.
Types
@type saturated() :: {ExternalService.Concurrency, :saturated}
Returned by call/2 when no slot was available.
@type service() :: ExternalService.service()
@type t() :: %ExternalService.Concurrency{ limit: term(), reclaim_after: term(), service: term(), sleep: term(), slots: term(), wait: term() } | nil
A configured concurrency limit.
nil means the service has none, in which case calls pass straight through.
@type wait() :: false | non_neg_integer()
How long a call may wait for a slot before being shed.
false (the default) sheds immediately. An integer is a millisecond budget.
:infinity is deliberately not accepted: an unbounded wait is the pile-up a
concurrency limit exists to prevent.
Functions
@spec in_flight(service()) :: non_neg_integer()
Reports how many of service's slots are currently held.
A service with no concurrency limit — including one that was never started —
answers 0.
@spec limit(service()) :: pos_integer() | nil
Reports the configured limit for service, or nil if it has none.
@spec reset(service()) :: :ok | {:error, :not_found}
Discards service's in-flight accounting, freeing every slot.
Symmetric with ExternalService.CircuitBreaker.reset/1 and
ExternalService.RateLimiter.reset/1, and included in
ExternalService.reset_all/1.
This does not stop any call that is actually running — it only forgets that the slots were taken — so outside of tests it is a way to clear slots leaked by callers that were killed, rather than something to reach for routinely.