fix: never reset live concurrency counters (cap leaked on long streams) - #1066
lloydmak99 wants to merge 2 commits into
Conversation
The per-org, per-model concurrency counter lived in a moka cache with a 600 s time-to-live measured from insertion. When the entry expired while requests were still streaming, the next acquire created a fresh zero counter and admitted up to `limit` more requests on top of the live ones; the old requests later decremented the orphaned counter, so the new one undercounted for its whole life. Long OpenRouter streams (p90 ~274 s) hit this every 10 minutes and ran up to 92 in flight against a limit of 64 on a single instance. Replace the cache with a locked registry of live holders: an entry exists only while a request holds a slot, is removed when its last holder is released, and can never be reset or evicted under an in-flight request. Release stays RAII (`ConcurrencySlot` drop) on completion, error, client disconnect and panic. The silent TTL backstop is replaced by a 60 s sweep that records slots-in-use histograms (environment tag only) and warns once per slot held longer than 30 minutes. Org limit cache, limit semantics and the rejection log line are unchanged.
Review · Status🟩 CompletedIronLoop completed the review and posted it to GitHub. ResultRun detailsAutomatic trigger · attempt 1 of 3 · completed in 23m 43s |
Review:
|
PierreLeGuen
left a comment
There was a problem hiding this comment.
No confirmed issues found.
Checks: git diff --check 5c7cab7: passed.; cargo +1.92.0 fmt --all -- --check: passed.; cargo +1.92.0 test -p services --lib -- --test-threads=4: 669 passed, 0 failed, 1 ignored.
There was a problem hiding this comment.
Review · Summary
Found one medium-severity resource-retention issue.
Findings: 🟠 Medium 1
Code-specific findings are attached to the diff.
Validation
- ✅ Concurrency registry tests — Six focused registry tests passed, covering admission, release, contention, long-held slots, and monitoring.
- ✅ Long-stream service regression — The service-level long-lived streaming-cap regression test passed.
Review details
- Run:
ba241264-974f-484e-bc18-c6790508af3a - Attempts: 1
Review follow-ups on #1066: - The sweep task's JoinHandle was dropped, so a panic (for example a poisoned std Mutex inside a metrics backend) would silently end leak detection. Keep the handle, supervise it and log an error if it ends with a JoinError; wrap the histogram calls in catch_unwind so one panicking tick does not stop the long-held-slot warnings. - Removing keys never shrinks a HashMap, so a burst of distinct (organization, model) keys pinned the map's high-water allocation for the life of the process. Shrink under the lock once capacity exceeds four times occupancy (never below 256), which only reallocates buckets and leaves live keys untouched.
Problem
The per-organization, per-model concurrency cap (
DEFAULT_CONCURRENT_LIMIT = 64, overridable via/v1/admin/organizations/{org_id}/concurrent-limit) did not reliably cap concurrency. An organization could run well above its limit on a single cloud-api instance.Root cause.
CompletionServiceImpl.concurrent_countswas amoka::future::Cache<(org_id, model_id), Arc<AtomicU32>>built withtime_to_live(600s)andmax_capacity(100_000). moka's TTL is measured from insertion and ignores whether requests still hold the counter. Ten minutes after a key was first used, the entry expired; the nexttry_acquire_concurrent_slotranget_withand created a freshAtomicU32(0), admitting up tolimitmore requests while the older requests were still streaming. When those older requests finished they decremented the orphanedArc, so the new counter also undercounted for its whole life. Capacity eviction could do the same. The doc comment onCONCURRENT_COUNT_TTL_SECSalready admitted the limit "can be temporarily exceeded"; the TTL was added in #447 as a backstop against leaked slots, before the RAII guards in the same PR made a leak impossible on the guarded paths.Evidence (2026-09-14, OpenRouter org
0d16829b-…, modelz-ai/glm-5.3-flash).current_count=64 limit=64, onemodel_id.cloud-api.near.airesolved only to cpu01 (40.160.1.150, re-verified today), so one instance and one counter should have held this traffic to 64.limitadmissions on top of the live requests.Changes
Design: a locked registry of live holders (option a), no TTL, no capacity eviction.
New module
crates/services/src/completions/concurrency.rs:ConcurrencySlotsis astd::sync::Mutex<HashMap<(org_id, model_id), KeyState>>where eachKeyStateholds a map ofslot_id → Holder { acquired_at, reported }. The in-flight count for a key isholders.len(), read and updated under the one lock, so acquire, release and key removal are atomic with respect to each other: two counters for the same key can never coexist, and a key with a live holder can never be removed because it is non-empty. No.awaitever happens under the lock, and the lock is taken withunwrap_or_else(|e| e.into_inner())so a poisoned mutex can never panic insideDrop.try_acquire(key, limit) -> Result<ConcurrencySlot, current_count>admits only whenholders.len() < limit. A rejection inserts nothing, so probing idle keys does not grow the registry.ConcurrencySlotis a non-CloneRAII handle whoseDropremoves its holder and drops the key when it becomes empty. Idle keys are therefore reclaimed on release, keeping memory bounded (requirement 3) without any sweep or TTL.sweep(now, threshold) -> SweepReportsnapshots in-use total, tracked keys, the busiest key, and slots held longer thanthreshold, marking each long-held slot as reported so it is logged once.spawn_slot_monitorruns the sweep every 60 s on the Tokio runtime (skipped when there is none), holding only aWeakto the registry. Each tick records three histograms with the environment tag only (no per-org cardinality):cloud_api.concurrent_slots.in_use,cloud_api.concurrent_slots.max_per_key,cloud_api.concurrent_slots.over_threshold, and emitswarn!(IDs only) for each slot held longer than 30 minutes: "Concurrent request slot held longer than the leak threshold". Real leaks now surface instead of being papered over.Why not option b (keep moka with a custom
Expiry): moka decides expiry at create/read/update time and cannot exempt entries from capacity eviction per entry, so "never evict while count > 0" would need re-insert tricks on every acquire and still leave a window. The registry is ~150 lines of plain code with obviously atomic semantics and one lock op per acquire and per release, which is negligible against a network hop per request.crates/services/src/completions/mod.rs:CONCURRENT_COUNT_TTL_SECSand the mokaconcurrent_countscache; the service now holdsconcurrent_slots: Arc<ConcurrencySlots>and starts the monitor innew().try_acquire_concurrent_slotreturns aConcurrencySlot. The rejection warn log (message,current_count,limit, IDs), the 429 error text andrecord_errorcall are byte-identical to before, so existing alerts keep working.ConcurrentSlotGuardwrapsOption<ConcurrencySlot>and keepsdisarm(); its manualDropis gone because dropping the slot releases it.InterceptStream.concurrent_counterbecameconcurrent_slot: Option<ConcurrencySlot>, released first thing in itsDropat exactly the point the old decrement happened. All seven acquire sites keep their shape and early-return ordering, so release still happens exactly once on normal completion, on error, on client disconnect (stream dropped) and on panic, via RAII only. No manual decrements remain anywhere.crates/services/src/completions/ports.rs:ConcurrentRequestGuard(used by the Anthropic/v1/messagesroute) now owns aConcurrencySlot; its manualDropis gone.crates/services/src/metrics/consts.rs: the three metric names above.Unchanged:
ORG_LIMIT_CACHE_TTL_SECS = 300and the org-limit cache,DEFAULT_CONCURRENT_LIMIT,invalidate_org_concurrent_limit, all limit semantics. The counter is still per instance.Compatibility with #975 (fleet concurrency leases). #975 keeps the local counter as its
ConcurrentSlot::Local/Shadowed { local }fallback and keepsConcurrentSlotGuard+disarm(). On rebase,Local(Arc<AtomicU32>)becomesLocal(ConcurrencySlot)andrelease_localbecomes a plain drop;ConcurrentRequestGuardalready moves to aslotfield in that PR. The metric names here (cloud_api.concurrent_slots.*) deliberately do not collide with #975'scloud_api.concurrency.*.Second-leak audit (requested). Every guarded path was traced and no early release or lost slot was found:
disarm()is covered by the armed guard, and the span fromdisarm()to theInterceptStreamliteral contains no.await, so it cannot be cancelled there. Provider retry/fallback happens entirely before the stream is returned (retry_with_fallback_caps); there is no mid-stream provider swap./v1/chat/completionsand/v1/completionsmove theInterceptStreamintoBody::from_streamunbuffered, so the slot lives exactly as long as the client body./v1/responsestakes one slot per agent turn and drops it promptly on client disconnect (send failure breaks the loop)./v1/messages: the guard is moved intoNativeUsageStreambefore the first await and released infinish_billingon EOF, error or drop; the non-streaming branch holds it until the body is fully read.count_tokensgenerates nothing and correctly takes no slot.resolve_and_get_model) returns the canonicalmodel.id, so aliases and canonical names share one counter.n > 1is one slot, one provider call.audio_transcriptionwraps the provider future intokio::time::timeoutin place; nothing is spawned that could outlive the guard.Gaps found that are not leaks and are left for a follow-up because they are policy changes (they would introduce 429s on paths that never had admission control):
POST /v1/images/generations,POST /v1/images/edits, and the/v1/responsesimage branch call the provider pool directly and never take a slot; the auto-redact PII classification issued inside/v1/chat/completions(auto_redact::detect) also reaches the pool without a slot. Also pre-existing in/v1/responses: a client disconnect is only detected when a text delta fails to send; failures from reasoning or citation deltas are logged and ignored, so a request that is still emitting only reasoning keeps its slot until the provider stream ends. Correctly counted, just released late. Minor: the audio route keys the slot on the canonicalmodel.idbut sendsrequest.model(possibly an alias) upstream; the count is right, only the outbound name differs.Validation
Two independent reviews of the commit: an adversarial review of the registry and every guard site (no bug found; admission and release are atomic under one lock, no cancellation window between
disarm()and theInterceptStreamliteral, no double release, registry growth bounded by live requests), and a system-level review confirming exactly oneCompletionServiceImpland therefore one registry per process (OHTTP loopback re-enters the same router), every capped route taking its slot, the streaming body owning theInterceptStreamuntil the client goes away, and the admin PATCH invalidating the org-limit cache immediately.Tests added (all Tokio paused-time where time matters):
concurrency::tests::limit_holds_for_requests_held_past_600s_and_900sandtests::service_limit_holds_for_requests_held_past_600s_and_900s(throughCompletionServiceImpl::try_acquire_concurrent_slotwith a pinned org limit): fill the limit, advance 601 s and then past 1000 s, the next acquire is still rejected; release one, exactly one more is admitted.concurrency::tests::stress_never_exceeds_limit_and_ends_at_zero: 64 tasks × 50 iterations on 4 worker threads, limit 8; observed in-flight never exceeds 8, admitted + rejected == attempts, registry ends at 0 in-use and 0 keys.tests::dropping_intercept_stream_midway_releases_slot,tests::provider_error_before_first_chunk_releases_slot,tests::successful_stream_holds_slot_until_dropped(provesdisarm()handed the slot to the stream rather than leaking it),tests::panic_in_guarded_scope_releases_slot,tests::panic_in_spawned_task_releases_slot,tests::test_intercept_stream_releases_slot_on_drop.tests::streaming_cap_holds_past_600s_through_create_chat_completion_stream: through the real public entry point with the mock provider, three never-polled streams fill a limit of 3; after 700 s and again after 1000 s of paused time the next request is rejected withRateLimitExceeded; dropping one stream admits exactly one more; dropping all leaves the registry empty.concurrency::tests::idle_key_is_reclaimed_and_restarts_from_zero_without_touching_other_keys,concurrency::tests::test_concurrent_limit_different_orgs_and_models_independent.concurrency::tests::sweep_reports_long_held_slots_onceandconcurrency::tests::slot_monitor_records_slot_histograms(metrics carry the environment tag only).Commands run locally on the pinned toolchain (1.92.0):
Rollout Notes
cloud_api.concurrent_slots.max_per_key≤ the highest configured org limit (64 unless raised), andcloud_api.concurrent_slots.in_use≤ that limit × number of (org, model) pairs active.cloud_api.concurrent_slots.over_thresholdstays at 0 and no "Concurrent request slot held longer than the leak threshold" warnings appear; if they do, the org/model IDs in the log point at the leaking path.