Skip to content

fix(llm): bound total in-flight LLM requests, not one analyzer's fan-out - #401

Open
Mark2Mac wants to merge 1 commit into
NVIDIA:mainfrom
Mark2Mac:fix/global-llm-concurrency
Open

fix(llm): bound total in-flight LLM requests, not one analyzer's fan-out#401
Mark2Mac wants to merge 1 commit into
NVIDIA:mainfrom
Mark2Mac:fix/global-llm-concurrency

Conversation

@Mark2Mac

Copy link
Copy Markdown
Contributor

Closes #387.

The knob does not bound what it says it bounds

SKILLSPECTOR_MAX_LLM_CONCURRENCY creates its semaphore inside one analyzer's fan-out:

if max_concurrency is None:
    max_concurrency = resolve_max_concurrency()
sem = asyncio.Semaphore(max_concurrency)     # llm_analyzer_base.py

The analyzers are separate graph nodes and graph.py fans out to them in parallel, so each one
gets its own semaphore. The docstring says users on rate-limited endpoints "can set it to 1 to
serialize requests"; they cannot.

It is not merely ineffective — it multiplies. Peak in-flight requests, counted by instrumenting
ainvoke and running analyzers concurrently on this branch and on main (29b0dc8, v2.9.6):

LLM analyzers limit peak on main peak with this PR
2 1 2 1
2 2 4 2
4 1 4 1
4 2 8 2
4 10 (the default) 8 8 — unchanged

Four is the number that matters: mcp_tool_poisoning, semantic_security_discovery,
semantic_developer_intent and semantic_quality_policy all extend LLMAnalyzerBase, and the
graph opens them together. So =1 puts four requests on the wire and =2 puts eight.

The last row is the control: at the default of 10 the peak is identical before and after,
because the ceiling is above the work. Users who never touch the variable see no change.

The behaviour on a rate-limited endpoint is the one reported in #387 (free-tier
build.nvidia.com: 1 of 4 analyzers completing at every setting, the three failures arriving
together as 429); that measurement is from the issue, not repeated here.
metadata.llm_degraded reports the partial coverage honestly, so the scan does not lie — but
the one knob offered to fix it cannot.

The change

One semaphore per event loop, per resolved limit, shared by every analyzer:

  • keying by loop keeps unrelated loops independent (tests, repeated CLI invocations) and the
    weak key drops the entry with the loop;
  • keying by limit as well means a caller resolving a different value gets its own semaphore
    instead of replacing one other coroutines are currently holding;
  • an explicit max_concurrency= argument stays local to its call. Callers that pass a number
    are asking for a fan-out width, not for a share of the process-wide budget, and the existing
    tests rely on that isolation. The documented "an explicit argument still wins" keeps holding.

No change to defaults: with the variable unset the behaviour is the same ceiling as before,
now applied once instead of once per analyzer.

Tests

Three tests, the first two verified red against the unmodified module (assert 4 == 2 on the
second, which is the defect stated as a number). They use two analyzers rather than four: two is
enough to make the bound observable, and it keeps the test fast:

test what it pins
test_two_analyzers_respect_one_global_slot limit 1 means one request in flight, process-wide
test_limit_of_two_allows_two_across_analyzers the bound is a ceiling, not a serialization
test_explicit_argument_still_bounds_only_its_own_call the documented escape hatch survives

The counting mock yields control with await asyncio.sleep(0.02) inside the invocation, so a
second in-flight request has the chance to be observed — without it the assertion would pass on
a serialization the code does not actually provide.

Gates reproduced locally

ruff check and ruff format --check clean; docker build + tests/docker/smoke.sh pass,
including the GitHub URL scan. Unit suite 2211 passed / 14 skipped / 4 xfailed, with the same
single unrelated failure as any loaded run of main
(test_mcp_stdio_initialize_registers_scan_skill, hardcoded 15-second handshake budget);
it passes on this branch and on an untouched main once the machine is quiet.

Merged with #386 on top of main the two apply cleanly and the 204 tests covering the areas
both touch pass together.

`SKILLSPECTOR_MAX_LLM_CONCURRENCY` creates its semaphore inside a single analyzer's
batch fan-out. The analyzers are separate graph nodes and the workflow fans out to
them in parallel, so each one gets its own semaphore and the process puts N x limit
requests on the wire.

It is not merely ineffective, it multiplies. Peak in-flight requests measured with two
analyzers running concurrently:

    limit 1  ->  2 in flight
    limit 2  ->  4 in flight

The docstring says users on rate-limited endpoints "can set it to 1 to serialize
requests"; they cannot. On a free-tier endpoint this is the difference between one
analyzer completing and all four: the extra requests arrive together and come back 429.

This shares one semaphore per event loop, per resolved limit. Keying by loop keeps
unrelated loops independent (tests, repeated CLI invocations) and the weak key drops
the entry with the loop; keying by limit as well means a caller that resolves a
different value gets its own semaphore instead of replacing one other coroutines are
currently holding.

An explicit `max_concurrency=` argument stays local to its call. Callers that pass a
number are asking for a fan-out width, not for a share of the process-wide budget, and
the existing tests rely on that isolation.

Defaults are unchanged: with the variable unset the ceiling is the same as before, now
applied once instead of once per analyzer.

Closes NVIDIA#387

Signed-off-by: Marco Macrì <62335226+Mark2Mac@users.noreply.github.com>
] = weakref.WeakKeyDictionary()


def _shared_semaphore(limit: int) -> asyncio.Semaphore:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the graph registers these analyzers as synchronous nodes, each node reaches run_async(), which calls asyncio.run() (and may do so in separate LangGraph worker threads). That gives each analyzer a different event loop, so this WeakKeyDictionary returns different semaphores and the original N × limit burst remains in the real graph. The new tests gather two analyzers on one artificial loop and therefore cannot catch this. Please coordinate at a truly shared layer (or convert the graph nodes to share one async loop) and add a graph-level regression exercising the actual analyzer nodes.

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

Requesting changes. The proposed limiter is keyed by event loop, but the production graph runs synchronous analyzer nodes that each enter run_async() / asyncio.run() and therefore do not share that loop or semaphore. The real graph can still issue N × limit requests. Please address the inline blocker and add a regression through the actual graph/analyzer execution path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SKILLSPECTOR_MAX_LLM_CONCURRENCY does not bound total in-flight LLM requests

2 participants