fix(mp): release stage thread pools so evaluate() stops leaking workers - #587
Open
twang126 wants to merge 2 commits into
Open
fix(mp): release stage thread pools so evaluate() stops leaking workers#587twang126 wants to merge 2 commits into
twang126 wants to merge 2 commits into
Conversation
twang126
requested review from
IsmailMehdi,
helloeve and
prernakakkar-google
as code owners
September 2, 2026 00:51
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
MPRunner had no way to release its ThreadPoolExecutor. CPython pool workers have no idle timeout, so once started they park on the pool's work queue until the interpreter exits. Every evaluator builds its pools inside evaluate(), and every orchestrator builds a fresh evaluator per call, so each call added threads to a process that never restarts between requests in the server. StreamingOrchestrator is the worst case: eval_service hands each arriving input to evaluate_item, which constructs a new Evaluator and therefore four new pools for a dataset of one item. Measured on the streaming shape, 500 eval cases leaked 2000 threads and 47 MB of RSS; with the pools released it is 0 threads and 96 KB. A long-lived pod reaches RLIMIT_NPROC after roughly 33k cases and then fails to start new threads. Add MPRunner.shutdown() plus context-manager support, and release the pools from a finally block in Evaluator, AgentEvaluator, CortadoEvaluator, and DataEngineeringAgentEvaluator. The three agent evaluators now build their runner in evaluate() rather than __init__, since a runner is spent once shut down. Evaluator's sqlexec pool keeps its queued work instead of cancelling it. The dispatch loop takes a connection off db_queue before submitting each SQLExecWork and only SQLExecWork.run returns it, so a cancelled item would strand one; StreamingOrchestrator shares a single db_queue across inputs, where that would shrink the pool permanently.
twang126
force-pushed
the
mprunner-shutdown
branch
from
September 2, 2026 00:54
cc5cd2d to
e2ca149
Compare
Exiting a `with` block called shutdown() with its own defaults, which cancel queued work and return without waiting. That inverts Executor.__exit__ and breaks the very pattern the class docstring advertises: a caller who submits more items than the pool has workers would lose the queued ones silently. Exit now drains and waits; a caller that needs to abandon work in progress calls shutdown() directly. Also stop test_shutdown_cancels_queued_work from waiting on a cancelled future. Cancelling through shutdown() leaves the future CANCELLED rather than CANCELLED_AND_NOTIFIED, so concurrent.futures.wait never sees it finish and burned the full 30s timeout. The suite drops from 45s to 14s.
Collaborator
|
/gcbrun |
prernakakkar-google
approved these changes
Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
MPRunnerhad no way to release itsThreadPoolExecutor. CPython pool workers have no idle timeout, so once started they park on the pool's internal work queue until the interpreter exits.Every evaluator builds its pools inside
evaluate(), and every orchestrator builds a fresh evaluator per call, so each call permanently added threads to a process that never restarts between requests in the server.StreamingOrchestratoris the worst case.eval_service.pyhands each arriving eval input toevaluate_item, which constructs a newEvaluatorand therefore four new stage pools for a dataset of one item — four threads leaked per eval case, for the life of the pod.Measurement
Simulating the streaming shape (500 cases × 4 stage pools × 1 submit each):
About 4 threads and 95 KB per eval case, growing without bound.
RLIMIT_NPROCon the dev workstation is 131072, which one long-lived pod reaches at roughly 33k eval cases and then dies withcan't start new thread.Change
MPRunner.shutdown(wait=False, cancel_futures=True)plus__enter__/__exit__, and a class docstring stating that the caller owns the pool's lifetime.Evaluator,AgentEvaluator,CortadoEvaluator, andDataEngineeringAgentEvaluatorrelease their pools from afinallyblock, so a failing pipeline releases them too.evaluate()rather than__init__, because a runner is spent once shut down and these evaluators must tolerate being run again.DataEngineeringAgentEvaluatorthe shutdown runs before_archive_workspace_to_gcs, so on the error path queued scenarios stop mutating the Dataform workspace while it is being zipped.Evaluator's sqlexec pool is the one exception: it shuts down withcancel_futures=False. The dispatch loop takes a connection offdb_queuebefore submitting eachSQLExecWork, and onlySQLExecWork.runreturns it, so cancelling a queued item would strand a connection.StreamingOrchestratorshares onedb_queueacross inputs, where that would shrink the pool for every later input.Scope
Deliberately limited to the pools that actually hold threads.
InteractEvaluatorandDataAgentEvaluatoralso construct runners, but their loops run every work item inline viawork.run()and never submit, so those pools have started no threads and are left alone.This does not raise concurrent scenarios per pod — the leaked threads are idle and hold no pool slot, DB connection, or GIL. It bounds thread and memory growth over a pod's lifetime. It also does not address timed-out work that keeps running, or the connection acquired on the dispatch thread; both are tracked separately.
Tests
New
test/mprunner_test.py(8 tests) covers workers persisting until shutdown, context-manager release on both the normal and exception paths, shutdown not blocking on a hung work item, queued work being cancelled by default and kept undercancel_futures=False, andEvaluator.evaluatereleasing all four stage pools with the sqlexec pool exempt from cancellation.pytest test/mprunner_test.py test/evaluator_test.py test/robustness_test.py test/agentevaluator_test.py test/test_eval_case_timeout.py→ 34 passed.Review with
git diff -w; most of the raw line count is re-indentation from wrappingEvaluator.evaluate's body intry:.