# `ExternalService.Concurrency`
[🔗](https://github.com/jvoegele/external_service/blob/main/lib/external_service/concurrency.ex#L1)

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.

# `saturated`

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

Returned by `call/2` when no slot was available.

# `service`

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

# `t`

```elixir
@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`

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

# `in_flight`

```elixir
@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`

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

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

# `reset`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
