Concurrent-Call Cap Architecture¶
Two layered caps gate outbound calls in this PBX. Knowing which one fires for a given call is a frequent operator question — this doc captures the layout and how it's surfaced in the UI + CDR.
Status: shipped 2026-05-20 via PR #260 (dialplan enforcement) + PR #262 (effective-cap UI) + PR #263 (CDR
cap_rejecteduserfield + call-logs badge).If you're debugging why a call shows "all circuits busy" or "Org cap" / "Trunk cap" in call-logs, also read Error 61.
The two caps¶
| Cap | Where it lives | Scope | Editor surface |
|---|---|---|---|
| Org cap | organizations.limits.concurrent_calls (JSON column) | Across all trunks for that org — total simultaneous outbound calls | Admin → Organisations → <org> → "Concurrent Calls" |
| Trunk cap | sip_trunks.max_channels (column) | One specific trunk — simultaneous calls on that trunk only | Editor → Trunks → <trunk> → "Channels" |
The cap a caller actually experiences is min(org_cap, trunk_cap). A trunk with max_channels = 50 inside an org with limits.concurrent_calls = 10 will be capped at 10. The trunk knob is upper-bounded by the org knob; never the other way around.
Where it's enforced¶
api/src/services/asterisk/dialplanGenerator.js → generateOutboundContext().
For each outbound route, the generated ext_<org>.conf outbound context emits:
; Org cap (one counter per org)
exten => _X.,n,Set(GROUP(orgCap)=orgCap)
exten => _X.,n,GotoIf($[${GROUP_COUNT(orgCap@orgCap)} > ${ORG_MAX}]?org_limit_reached,1)
; Trunk cap (one counter per trunk)
exten => _X.,n,Set(GROUP(trunkCap_<trunkId>)=trunkCap_<trunkId>)
exten => _X.,n,GotoIf($[${GROUP_COUNT(trunkCap_<trunkId>@trunkCap)} > ${TRUNK_MAX}]?trunk_limit_reached,1)
; Then the actual Dial against the trunk
exten => _X.,n,Dial(PJSIP/${EXTEN}@<trunk_endpoint>,...)
[…]
exten => org_limit_reached,1,Set(CDR(userfield)=org_cap_rejected)
exten => org_limit_reached,n,Playback(all-circuits-busy)
exten => org_limit_reached,n,Hangup()
exten => trunk_limit_reached,1,Set(CDR(userfield)=trunk_cap_rejected)
exten => trunk_limit_reached,n,Playback(all-circuits-busy)
exten => trunk_limit_reached,n,Hangup()
Both caps fire before the Dial. Order matters — if org cap fires first, the call never tries any trunk and the trunk counter isn't incremented. So the labels accurately reflect which cap was the binding constraint, not "both caught it."
The trunkCap_<trunkId> category (not just trunkCap) gives each trunk its own independent counter. Without per-trunk categorization, GROUP_COUNT would mix all trunks together and the cap would behave as a system-wide knob instead of per-trunk.
CDR userfield ↔ UI badge¶
asterisk_cdr.userfield is stamped by the org_limit_reached / trunk_limit_reached branches above. The /api/v1/calls SQL pulls it into cap_rejected as a derived field:
CASE
WHEN t.userfield = 'org_cap_rejected' THEN 'org'
WHEN t.userfield = 'trunk_cap_rejected' THEN 'trunk'
ELSE NULL
END as cap_rejected,
In editor/app/dashboard/[orgId]/calls/page.tsx, the Status column short-circuits to a destructive-variant badge when cap_rejected is set:
cap_rejected value | Badge label | Variant |
|---|---|---|
'org' | Org cap | destructive |
'trunk' | Trunk cap | destructive |
null | (normal Completed / Missed / etc. via effectiveCallStatus) | — |
Operators can also filter call-logs by direction + status to find these rejections. Hover shows "Rejected: org concurrent-call cap reached. Caller heard 'all circuits busy'." or the trunk variant.
Effective-cap UI¶
Trunks page (PR #262):
- Channels column: shows
<trunk_cap> / <org_cap>when they differ (e.g.10 / 50), or just<n>when they match. A tooltip explains the effective cap ismin(trunk, org). - Edit / Create dialog: helper text under the "Channels" input reads "Effective cap: min(trunk, org) =
". Updates live as the operator types.
Admin → Organisations → <org>: shows org Concurrent Calls alongside the highest-cap trunk's effective cap, so operators see what each trunk will actually accept.
Common semantic gotcha: what counts as a "channel"¶
Operators often ask: "If channels = 5 and there are 7 phones ringing, is that 5 channels in use or 7?"
A channel is occupied from Dial-attempt through Hangup, not just during talk time:
- Ringing the callee phone → uses a channel
- Talking to the callee → uses the same channel
- On hold → still uses the channel
- Bridged with another caller → uses 2 channels (yours + theirs)
For a queue ring-all strategy with 10 members and 1 inbound caller, the inbound caller uses 1 inbound channel. The queue Dial of those 10 agents creates ~10 outbound Local→PJSIP channels. So the org's total channel count (across inbound + outbound) jumps to ~11 momentarily. But this cap applies to outbound through the trunk specifically — internal ring-outs to extensions don't touch the trunk and don't count.
Tested empirically on staging 2026-05-20: trunk max_channels=1, placed 2 outbound calls. Second call sat in the queue waiting; the queue did not hang it up. (See Error 61 for the bug history.)
Gaps / known limitations¶
| # | Item | State |
|---|---|---|
| 1 | qm-helper.js paths (queue members of type=phone that ring via a trunk) bypass the per-trunk cap — they ring directly through pjsip without hitting the outbound context's GROUP() block | Tracked in PR #260 body; not blocking common org configs but should be closed before any org uses lots of phone-target queue members |
| 2 | Trunk tooltip text "Limits simultaneous calls on this trunk" doesn't clarify outbound-only — could mislead an operator into thinking it caps inbound too | Deferred polish |
| 3 | No alerting when an org regularly hits its cap. Today operators learn from call-logs badges only. A dashboard counter (e.g. "12 cap rejections this week") would help right-size the cap | Future work |
Physical capacity ceiling (the host limit beneath the policy caps)¶
The org/trunk caps above are policy knobs. Underneath them sits a physical ceiling set by CPU, file descriptors, and the WSS listener. Read-only audit on prod (147.93.168.216), 2026-07-09:
The softphone media path — AstraCall registers over wss://devsip:8089/ws to a transport-wss, webrtc=yes, allow=opus,alaw, direct_media=no endpoint (DTLS-SRTP + ICE, RTP 10000–20000, MixMonitor-recorded). The Tata trunk is allow=alaw only, so every opus softphone call transcodes opus↔alaw at the bridge — measured ~2.3% of a core per direction (asterisk -rx 'core show translation') → ~6–8% of a core per opus call with DTLS + recording. That transcode is the real limiter.
| Host | opus↔alaw (transcoded) | alaw↔alaw (passthrough) |
|---|---|---|
| PROD (6 vCPU EPYC 9224, 23 GiB) | ~40–70 calls; plan ~50 (CPU-bound) | 600+ by CPU, but FD limit 1024 caps ~120–150 |
| STAGING (6 vCPU shared, 11 GiB + swap) | ~20–35 | same 1024-FD wall |
Tuning levers + current state:
| Lever | File | Now | Recommended |
|---|---|---|---|
| Global concurrent cap | asterisk.conf maxcalls/maxload | commented — unbounded | maxcalls=100, maxload=4.5 (crash guard) |
| File descriptors | asterisk.conf maxfiles | 1024 — sleeper bottleneck | maxfiles=8192 (binds before CPU on alaw-heavy load) |
| WSS registered sessions | http.conf sessionlimit | default 100 | set explicitly if the softphone fleet nears 100 |
| RTP ports | rtp.conf 10000–20000 | ~2500-call ceiling | not a constraint at this scale |
Bottleneck order (realistic opus traffic): per-org policy cap (10) → Tata contracted channels (verify the number) → CPU/opus transcode (~40–70) → FDs (1024, ~120–150) → WSS sessionlimit (100). Biggest cheap win: force alaw on softphone endpoints — deletes the transcode, roughly doubles capacity (this is already the direction per the codec-constraints decision). The maxfiles/maxcalls edits need an Asterisk restart → maintenance window, not live. (Host-type note: prod's CPU model is passed through like a dedicated-vCPU VDS; staging's is a masked/shared qemu model — inference from CPU fingerprint, not a read SKU; the Contabo panel is authoritative.)
Where to see if an extension is
transport=ws+webrtc=yes:ssh root@<vps>thenasterisk -rx 'pjsip show endpoint <ext>'(look fortransport-wss+webrtc: true), or check the sourceusers.sip_port='wss'column that the generator keys off (see prod WSS enablement).
Related¶
- Dashboard — Call Pickup Time — sister dashboard work shipped same week. Different metric (agent ring-to-pickup, not cap rejections), but lives in the same overview page and uses the same time-window selector.
- Error 61: Trunk max_channels stored but not enforced — the bug history
- Error 62: Call-logs softphone→PSTN misclassification — adjacent CDR-direction work shipped same week
dialplanGenerator.generateOutboundContext— code that emits the dialplan blocks aboveapi/tests/trunk-max-channels.test.js— 13 unit tests covering trunk-cap emission (including regression-trap:trunk_cap=0emits NOTHING) + CDR userfield assertions