apps/api/app/services/call_concurrency/ and is backed by Redis.
Concurrency slots are separate from the per-second rate limit. The rate limit governs how quickly calls start; the concurrency cap governs how many are active.
Why concurrency is capped
An outbound campaign will happily dial as fast as it is allowed to. Three things break if nothing stops it:- Telephony provider limits. Providers cap concurrent channels per account and reject or throttle beyond that.
- Runtime capacity. Each live call holds one WebSocket and one full voice pipeline in the runtime.
- Provider spend. Every concurrent call is concurrent STT, LLM, and TTS usage against your own keys.
Organisation slots
Every acquisition first checks the organisation-wide cap. The limit comes fromget_org_concurrent_limit in campaign_repository.py: the concurrent_call_limit field on the Organizations document if set and at least 1, otherwise DEFAULT_ORG_CONCURRENCY_LIMIT.
That default is 10, defined in apps/api/app/constants/campaign.py and overridable with the DEFAULT_ORG_CONCURRENCY_LIMIT environment variable:
concurrent_calls:{org_id}, one member per slot, scored by acquisition time. The set expires after 3600 seconds of inactivity.
Campaign-scoped slots
A campaign can hold a tighter cap than its organisation.CreateCampaignRequest.max_concurrency defaults to 5 and is bounded 1–20 by the schema; it is stored in orchestrator_metadata.max_concurrency.
_validate_max_concurrency in apps/api/app/routers/campaign.py rejects a value above the organisation limit at both create and update time:
CampaignCallDispatcher.acquire_concurrent_slot passes scope_key = f"campaign:{campaign_id}" and scope_max_concurrent = max_concurrency. That gives a second sorted set, concurrent_calls:campaign:{campaign_id}, checked in the same operation as the organisation set. A slot is only granted when both caps have room.
Nothing outside campaigns passes a scope_key today, so scoped slots are a campaign feature in practice even though the service is generic.
The sliding-window rate limiter
Separately from slots,rate_limiter.acquire_token implements a one-second sliding window against rate_limit:{org_id}:
- Entries older than one second are trimmed.
- If fewer than
rate_limitentries remain, one is added and the token is granted. - The key expires after 2 seconds, so an idle organisation leaves nothing behind.
campaign.rate_limit_per_second (1–20, default 1) and spins at 50 ms intervals until a token is granted. Unlike slot acquisition, this loop has no timeout.
Acquiring and releasing a slot
CallConcurrencyService.acquire_org_slot takes timeout: float = 0 and retry_interval: float = 1. With the default timeout of 0, the first failed attempt raises CallConcurrencyLimitError immediately. The campaign dispatcher overrides it with CONCURRENT_SLOT_TIMEOUT = 120.0, so a campaign call retries roughly once a second for two minutes before giving up.
Acquisition and binding are two steps for a reason: the slot is taken before the outbound call is placed, and bound to the resulting call_id afterwards.
bind_call_slot writes the hash call_slot_mapping:{call_id} holding org_id, slot_id, and scope_key, with a TTL of stale_call_timeout. It uses a Lua script that refuses to overwrite an existing key: if the mapping is already there, the new slot is released and CallSlotAlreadyBoundError is raised, so one call can never hold two slots.
Release happens through release_call_slot(call_id), driven by POST /campaign/internal/call-status. It looks up the mapping, removes the slot member from the organisation set, the scope set, and the fleet set, then deletes the mapping. If the outbound call fails before binding, dispatch_call releases the unbound slot directly.
Stale slot reclamation
A crashed worker or a call that never reports a terminal status would otherwise leak a slot forever.RateLimiter.stale_call_timeout is 1200 seconds (20 minutes), and every acquisition begins by trimming entries scored older than now - 1200 from the organisation set, the scope set, and the fleet set. Reclamation is lazy — it happens when someone next tries to acquire, not on a timer.
The call_slot_mapping:{call_id} hash carries the same 1200-second TTL, so the mapping expires on the same schedule as the slot it describes.
The from-number pool uses the same timeout: its Lua script resets any number whose busy timestamp is older than the cutoff back to score 0 (free).
Fleet-wide limits
Every acquisition also adds a member to one global sorted set,FLEET_CONCURRENT_KEY = "concurrent_calls_fleet", with the member string {org_id}:{slot_id}. It is trimmed for staleness and cleaned up on release exactly like the per-organisation set.
The fleet set is written and cleaned but never checked against a maximum. It is observability —
ZCARD concurrent_calls_fleet gives you live calls across every organisation — not an enforced cap. There is no fleet-wide limit in the code today.Why Lua
Every check-and-acquire is a singleEVAL. The naive alternative — read the count, compare it, then add a member — is a race: two workers can both read 9 of 10 in use and both add a member, leaving 11 active calls against a limit of 10.
Redis executes a Lua script atomically, so trim, count, compare, and add happen as one indivisible operation. Three scripts matter:
The from-number pool adds two more, one to reclaim-and-pick a free caller ID and one to return it to the pool.
Only acquisition is atomic.
release_concurrent_slot issues three separate ZREM calls — releasing is idempotent, so it does not need a script.
Tuning
Set the organisation limit to what your telephony account actually allows, then set each campaign’s
max_concurrency at or below it. There is no reason to raise max_concurrency past the organisation limit — the API rejects it.
Failure modes
Because slot state lives only in Redis, wiping Redis loses the record of every in-flight call. Active calls continue, but their slots are gone and their eventual release is a no-op.
Related
- Campaigns — the only caller that uses scoped slots today
- Calls and call artifacts — the call lifecycle a slot is bound to
- Workers and orchestrator
- Environment variables
- Campaign troubleshooting