fix(python): release the GIL across engine evaluation - #56
Conversation
intercept() held the CPython GIL for the whole of Runtime::evaluate. That is harmless for a pure Rego manifest, which is evaluated in process in microseconds, and wrong for any manifest with an llm, endpoint or classifier annotator, because evaluation then performs a blocking HTTP request to the classifier and waits for the reply with the lock held. Every thread in the process stops for that round trip, not only the one being governed. Annotators on a point are dispatched in sequence, so the stall is their sum. Measured with a 300 ms classifier: a spin thread got 1.77%, 1.90% and 1.87% of its normal progress across three runs; with this change it gets 97% to 107%. Under a Python web server the old behaviour presents as tail latency on endpoints that have nothing to do with the governed request. The same omission covered interceptor_new, which reads the manifest from disk and follows its extends chain, possibly over the network. The sibling entry points policy_activate, policy_activate_from_memory and policy_evaluate already detach, and policy_evaluate does so while Python host dispatchers may be called back, so the callback path is not new: a dispatcher, policy dispatcher or telemetry sink written in Python re-acquires through Python::attach. The regression test serves the classifier from a thread of the same interpreter. Holding the GIL starves that thread, so the annotator times out and the point fails closed with runtime_error:annotation_failed; releasing it lets the label reach the policy. It is deterministic rather than a timing ratio, which CI runners under load have made flaky before. Confirmed failing on the unfixed binding and passing on the fixed one. Refs #44 Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
MohammadHaroonAbuomar
left a comment
There was a problem hiding this comment.
Reviewed at a4056ce, with local builds of both this head and its parent to compare behaviour.
Reproduced the defect and the fix. Rebuilt the binding from the parent commit: with a 400 ms classifier served in-process, a background counter thread made 0.06% of its control progress during intercept() — the call blocked 8.2 s until the annotator timeout because the server thread was starved, then failed closed with runtime_error:annotation_failed. At this head the same probe gives ~95% progress and the verdict settles from the classifier's answer. test_gil_release.py fails on the parent exactly as the description says, and its starvation-based design (deterministic, no timing threshold) is a good fit for this repo's loaded-runner history.
Error paths unchanged. Diffed exception class and message before/after for: missing manifest, malformed manifest YAML, unknown limits key, non-mapping limits, non-object context, unparseable context. Identical. PyErr construction inside the detached closures is lazy in pyo3, so building the error without the GIL is sound, and pyo3's detach guard reattaches on unwind.
Callback paths under the detached region. Probed a Python annotator dispatcher driven by 8 threads × 20 intercept() calls each: 160 dispatches reattached via Python::attach across 8 distinct threads, no deadlock, all verdicts correct. A raising dispatcher still fails closed (runtime_error:annotation_failed). A reentrant dispatcher that calls intercept() on another interceptor from inside the callback (attach nested in detach) also completes correctly. The detach closures capture only owned/Arc data; resolve_limits is the one Python-reading step and is correctly ordered before the detach.
Streaming untouched, no lock-ordering hazard. The Mutex<StreamSession> entry points never detach and the detached regions here never take that lock, so no GIL-vs-mutex inversion is introduced.
Checks run here: maturin editable build; pytest sdk/python/tests 88 passed; ruff format --check and ruff check clean; cargo fmt --check and cargo clippy --all-targets -D warnings clean. The branch is one commit behind main (the dependabot rust-group bump); the merge is textually clean and cargo check --locked passes on the merge result, so a branch update should be routine.
One non-blocking inline note on error precedence in interceptor_new.
MohammadHaroonAbuomar
left a comment
There was a problem hiding this comment.
Approved. Adversarial review posted earlier came back clean: detachment ordering sound, dispatcher reentrancy and error shapes verified, and the concurrency payoff empirically demonstrated. The non-blocking precedence note stands as an observation only.
|
Copilot resolve the merge conflicts in this pull request |
…detach # Conflicts: # CHANGELOG.md Co-authored-by: liamcrumm <14815298+liamcrumm@users.noreply.github.com>
Done in c28dda2. The only conflict was CHANGELOG.md: this branch's Net diff against main is still the three intended files. Verified on the merge result:
One note: The |
MohammadHaroonAbuomar
left a comment
There was a problem hiding this comment.
Re-approving after the merge-from-main update; the resolution is identical to the union merge verified locally (unreleased GIL entry above the released 0.4.0-alpha.3 section). CI green on the merged head.
Fixes #44.
Problem
intercept()held the CPython GIL for the whole ofRuntime::evaluate.For a pure Rego manifest that is harmless, since evaluation happens in process and finishes in microseconds. It stops being harmless as soon as a manifest declares an
llm,endpointorclassifierannotator, because evaluation then makes a blocking HTTP request to the classifier and waits for the reply with the lock still held. Every thread in the process stops for that round trip, not only the one being governed.interceptor_newhad the same omission. It reads the manifest from disk and follows itsextendschain, which may fetch over the network.Measurement
A background thread does nothing but increment a counter. The control is
time.sleep(0.3), which releases the GIL. The classifier takes 300 ms. Repro is in #44.Under
agent-framework-core, an asyncio task asking to wake every 5 ms did not run for 313 ms with one 300 ms annotator bound toinput. Annotators on a point dispatch in sequence, so two of them cost 604 ms. Timingintercept()directly gave 302, 302 and 303 ms for one annotator, and 604, 605 and 604 ms for two.For a Python service the old behaviour presented as tail latency on endpoints unrelated to the governed request, which is a slow thing to attribute to a policy engine.
Change
py.detacharound the engine call inintercept, and around manifest load plus runtime construction ininterceptor_new.This is not a new pattern.
policy_activate,policy_activate_from_memoryandpolicy_evaluatealready detach, andpolicy_activatecarries a comment giving this reason.policy_evaluatealso detaches while Python host dispatchers may be called back into, so the callback path is proven in tree: an annotator dispatcher, policy dispatcher or telemetry sink written in Python re-acquires throughPython::attach, unchanged by this PR.Anything that reads a Python object still happens before the lock drops. In
interceptor_newthat isresolve_limits, now explicitly ordered ahead of the detach with a comment saying why.Test
sdk/python/tests/test_gil_release.pyserves the classifier from a thread of the same interpreter.That design carries the assertion. Holding the GIL starves the server thread, so the annotator times out and the point fails closed with
runtime_error:annotation_failed. Releasing it lets the label reach the policy, which denies withannotator_ran. There is no ratio and no threshold, given this repo's history with timing assertions on loaded runners.Confirmed to fail on the defect, by stashing the
lib.rschange and rebuilding:The 4.77 s is the annotator's own 4000 ms timeout expiring, which is what starvation looks like from outside.
Review round
The error precedence note is correct, and no code changed for it. Moving the manifest read into the detached closure also moves it after
perf_telemetryandlimitsvalidation, so a call carrying both an unreadable manifest and an invalidperf_telemetrynow reports the perf error first. Single-fault exception classes and messages are unchanged, and validating the cheap Python-reading arguments before dropping the lock is the intended order, so the precedence change is recorded rather than reverted.The branch is updated to main.
Verification
pytest sdk/python/testsruff format --check,ruff checkcargo fmt --checkcargo clippy --all-targets -D warningscargo check --locked --workspaceOther bindings
Node and .NET are untouched. Whether either has an equivalent problem, a synchronous binding blocking the Node event loop in particular, is unmeasured and therefore unclaimed here.
Ordering
Independent of #55, the release bump. Either can land first. Both belong in
0.4.0-alpha.3if the tag has not been cut, since this changes runtime behaviour for every Python host that uses annotators.