Thanks — this is a great, specific question. I’ll address exactly the part you asked about: “How do I enforce a global, race-free concurrency limit for SIP participants across all rooms (multiple service instances), without affecting WebRTC calls?”
Short answer
• There is no built‑in LiveKit feature that enforces a global SIP concurrency limit at the trunk level or as a global semaphore.
• The recommended, production‑grade LiveKit-native pattern is to use LiveKit webhooks (participant lifecycle events) as the authoritative source of truth and maintain an atomic counter in a shared persistent store (e.g., PostgreSQL). Use webhook-driven, transactional increments/decrements plus idempotent handling and a final agent-side guard for UX. This avoids race conditions across multiple instances and does not require Redis.
Why your current list_rooms() approach is unsafe
• list_rooms() is a read-only, eventually consistent view and cannot provide an atomic reservation. Two instances can both read the same count concurrently and both allow a call → race condition.
• There’s no locking or pre-creation hook in LiveKit that atomically blocks room creation based on a global SIP counter.
Recommended architecture (production‑grade)
1. Inbound SIP call arrives at your SIP trunk → LiveKit creates/dispatches room according to your dispatch rules.
2. LiveKit sends webhook events for participant lifecycle (participant_joined, participant_left, room_finished).
3. Your webhook endpoint:
◦ Verifies signature / authenticity.
◦ Processes events transactionally against a shared persistent store (Postgres or similar).
◦ On participant_joined: if participant.kind == "SIP", attempt an atomic increment only if current_count < MAX. If increment fails (limit reached), mark the call as “reject/wait” (see UX options below).
◦ On participant_left / room_finished: decrement (guard against underflow) in a transaction.
◦ Ensure idempotency and ordered processing for the same resource (webhook retries / duplicates).
1. Agent-side check: when an agent joins a dispatched SIP room, have the agent confirm the call was accepted by the counter, and if not, play busy/waiting tone and disconnect. This is a secondary guard (not a substitute for the webhook counter).
2. Optional: scope the counter per SIP trunk or globally, depending on your available DIDs and business rules.
Why this works
• Webhooks represent lifecycle changes and are retried/delivered in order for a resource. Processing them into an ACID store with atomic updates gives a single source of truth and prevents double-counting.
• Using a DB transaction like:
UPDATE sip_counter SET value = value + 1 WHERE value < MAX RETURNING value; lets you atomically reserve a slot or fail.
• This is globally consistent across instances because all instances read/write the same DB.
Implementation notes and best practices
• Use an ACID DB (Postgres) to avoid needing Redis. Postgres supports atomic UPDATE ... RETURNING and row-level locking for the counter.
• Make webhook processing idempotent:
◦ Store webhook event IDs or use event timestamps + resource IDs to reject duplicates.
◦ Ensure decrement operations are guarded so you don’t underflow the counter.
• Scope counters if helpful:
◦ Global counter for all SIP calls, or
◦ Per-trunk counters if you have multiple SIP trunks/DIDs.
• Use metadata on participants or rooms to tie the webhook event to the SIP trunk or original call id (helps sharding).
• Use the agent join as a last UX/defensive check: the agent can play a hold/busy prompt if your DB check shows no slot was available (this avoids a poor caller experience if the webhook and room creation race somehow).
• Keep room creation & dispatch as-is; do not rely on list_rooms() for quota enforcement.
Alternatives (and why they’re less desirable)
• Single-instance in-memory counter: only safe if you guarantee a single process handles all webhooks (not scalable).
• Redis semaphore: works well technically, but you said you want to avoid Redis. If you did use Redis, a Lua-based INCR/DECR or Redlock could work — just ensure correct expire/cleanup on crashes.
• Trying to enforce at SIP trunk provider level: LiveKit does not provide a trunk-level concurrency enforcement primitive you can configure to block calls for you, so you’d still need an external control path.
Concrete webhook flow (high level)
1. participant_joined webhook (SIP):
◦ Begin DB transaction
◦ SELECT current FROM sip_counter FOR UPDATE
◦ If current < MAX:
▪︎ UPDATE sip_counter SET current = current + 1
▪︎ Commit; mark call accepted
◦ Else:
▪︎ Commit; mark call rejected/waiting
▪︎ Trigger operator to play waiting tone (via agent control or via programmatic SIP response if you can)
1. participant_left / room_finished webhook:
◦ Begin transaction
◦ Decrement counter (guard to >= 0)
◦ Commit
UX options when limit reached
• Immediate SIP rejection (busy tone) — best for short queues.
• Play waiting/hold tone in an intermediate room while caller waits for slot; when webhook frees a slot, agent moves the caller into a live agent room.
• Place caller in a queue service that polls the counter or listens for events to promote callers into an available slot.
Edge cases & robustness
• Crashed or failed webhook processing: LiveKit retries webhooks; ensure your handler is idempotent and can resume.
• Late participant_left events: safe decrements and reconciliation job that periodically reconciles active SIP participants vs counter using list_rooms() can correct drift.
• Reconciliation job: periodic scan + reconcile (compares DB counter to actual active SIP participants) to catch mismatches; run as background job.
Answering your specific checklist
• Is there a way to enforce concurrent SIP call limits at LiveKit SIP trunk level?
◦ No — LiveKit does not provide a trunk-level concurrency limit feature.
• Recommended pattern using webhooks?
◦ Yes — webhook-driven, transactional counter in a shared DB is recommended.
• Built-in concurrency control mechanism?
◦ No built-in global semaphore; use webhooks + external atomic counter.
• Should this always be handled externally (DB/Redis)?
◦ Yes — handle concurrency with an external authoritative store. You can use Postgres (preferred here) instead of Redis to avoid adding new infra.
If you’d like, I can:
• Sketch specific SQL statements and example webhook handler code (Node/Python) for the atomic increment/decrement pattern.
• Suggest a reconciliation query that compares DB counter vs actual SIP participants via the LiveKit API.
• Outline how to implement a waiting-room flow if you prefer callers to queue rather than immediate rejection.
Which of the above would be most helpful next: example code for the webhook handler (Node or Python), or the reconciliation SQL/flow?
Sources:
How to support rooms over 1000 users | LiveKit |
server-sdk-ruby | livekit/livekit_composite | DeepWiki |
server-sdk-js | livekit/livekit_composite | DeepWiki |
Server-side | livekit/livekit_composite | DeepWiki |
SIP Lifecycle Management Agent | LiveKit Documentation