ExternalService.Concurrency (ExternalService v2.6.0)

Copy Markdown View Source

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.

t()

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

saturated()

@type saturated() :: {ExternalService.Concurrency, :saturated}

Returned by call/2 when no slot was available.

service()

@type service() :: ExternalService.service()

t()

@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.

wait()

@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

in_flight(service)

@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.

limit(service)

@spec limit(service()) :: pos_integer() | nil

Reports the configured limit for service, or nil if it has none.

reset(service)

@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.