fix: tune.run() from concurrent threads corrupts shared runner state - #1610
Amir Fathi (AmirF194) wants to merge 11 commits into
Conversation
report()/run() coordinated through five module-level globals (_use_ray, _runner, _verbose, _running_trial, _training_iteration), shared by every thread. Two threads calling tune.run() concurrently overwrite each other's runner/trial bookkeeping mid-flight: whichever thread's run() call most recently assigns _runner wins the global for every thread's subsequent report()/stop_trial() calls, including resetting it while another thread is still running. Move that state into a threading.local subclass so each thread gets its own copy. The existing single-thread nested-reentrancy save/restore in run()'s try/finally is unchanged, just scoped per thread instead of process-wide. Fixes microsoft#996
Li Jiang (thinkall)
left a comment
There was a problem hiding this comment.
Overall review of the complete PR: changes are required.
flaml/tune/tune.py:45-65,209-229:tune.report()from a trainable-created callback/worker thread now sees a fresh thread-local state and loses the active runner. Use a propagatable logical run context and add a callback-thread reporting regression test.flaml/tune/tune.py:512-537,963-964: concurrent runs still replace the same process-global logger handlers and level, causing log routing loss and leaving handlers corrupted after completion. Use run-scoped loggers/handlers and test simultaneous log files plus restoration.flaml/tune/tune.py:509-685: state and logging are mutated before any encompassing cleanup block. Searcher, scheduler, or backend setup failures can leak state into nested/caught runs. Start one outertry/finallybefore the first mutation and restore all saved state for every failure path.
Posted by thinkall-agent-auto-reviewer
Scope the shared flaml.tune.logger handlers/level per run instead of replacing logger.handlers wholesale, so concurrent tune.run() calls no longer lose or leak each other's log routing (the logger was a sixth piece of process-global state left over after the five _TuneState fields were made thread-local). Wrap tune.run()'s setup (searcher/scheduler construction) in the same restore path used on normal return, so a setup failure no longer skips the state/logger restore and leaks mutated state into the next call on that thread. Add get_run_context()/use_run_context() so a trainable that spawns its own worker/callback thread can hand that thread the driving thread's run state before calling tune.report() from it, which the thread-local _TuneState broke silently.
|
Pushed 7c19134, addressing all three points.
Three new tests cover each point (logging cross-contamination, setup-failure leak, worker-thread propagation), each verified to fail without the corresponding fix. |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Helper-thread reporting regresses, propagated iteration state can become stale, and some backend failures still leak state.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 3
Open (3)
What changed in this PR
Isolates concurrent tune.run() executions to prevent shared runner-state corruption.
Changes:
- Replaces module globals with thread-local state.
- Adds run-scoped logging and worker-thread context APIs.
- Adds concurrency and state-restoration regression tests.
| File | Description |
|---|---|
flaml/tune/tune.py |
Implements thread-local state, logging isolation, and context propagation. |
flaml/tune/__init__.py |
Exports the new context APIs. |
test/tune/test_concurrent_run.py |
Tests concurrent runs, logging, restoration, and worker reporting. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| Being per-thread means a thread that a trainable spawns on its own (e.g. | ||
| a background worker that later calls tune.report()) starts from these | ||
| defaults too, with no runner attached; see get_run_context()/ | ||
| use_run_context() below for the supported way to hand that thread the |
| _state.use_ray = ctx.use_ray | ||
| _state.runner = ctx.runner | ||
| _state.verbose = ctx.verbose | ||
| _state.running_trial = ctx.running_trial | ||
| _state.training_iteration = ctx.training_iteration |
| except Exception: | ||
| _restore_tune_state() | ||
| raise | ||
|
|
||
| if use_ray: |
Li Jiang (thinkall)
left a comment
There was a problem hiding this comment.
Overall review of the complete current PR: changes are still required.
flaml/tune/tune.py:45-70: existing trainables that report from callback/helper threads silently lose results unless callers adopt the new context API. Preserve backward-compatible reporting automatically.flaml/tune/tune.py:192: reused propagated contexts copy and discardtraining_iteration, causing duplicate report steps and potentially corrupting scheduler/searcher ordering. Keep bookkeeping in synchronized shared context state.flaml/tune/tune.py:875,1034: Spark/backend initialization and sequential scheduler setup still occur outside the restoration guard. Failures leak thread state, handlers, and active logging bookkeeping. Enclose every setup and execution branch in one outertry/finally.
Posted by thinkall-agent-auto-reviewer
…safety fix report() from a trainable-spawned thread now works with no caller-side change: run() pins a contextvars context around each evaluation_function() call, and a patched threading.Thread inherits it into any thread the trainable spawns on its own. get_run_context()/use_run_context() stay as an explicit escape hatch for cases the patch can't reach. training_iteration moved off the per-thread/per-context snapshot onto a lock-guarded counter keyed by trial, so repeated handoffs for the same trial keep counting instead of restarting from a stale copy. Spark backend init and the sequential scheduler setup are now inside the same outer try/finally as the rest of run(), so a failure there restores _state/logger like every other setup failure does.
|
Pushed 272ba31 for all three.
All three have new or rewritten tests in test/tune/test_concurrent_run.py, each one fails on the prior commit and passes now. Ran the full test/tune/ suite too: 91 passed, 1 skipped, one unrelated failure (test_nested_run needs lightgbm, not installed in my env, same failure on the prior commit). |
…s with real pyspark installed The test relied on PySpark being absent so check_spark() would return unavailable. ubuntu-latest 3.11/3.12/3.13 CI legs install real pyspark (3.5.1/4.0.1/4.1.0), so check_spark() succeeds there and the test never raises ImportError. Patch check_spark() directly to force the failure path regardless of whether pyspark is actually installed.
|
The new spark test was failing on the 3.11/3.12/3.13 legs: DID NOT RAISE ImportError. It assumed PySpark was absent so check_spark() would fail, but those legs install real pyspark (3.5.1/4.0.1/4.1.0), so check_spark() succeeded and the test never hit the ImportError path. Patched check_spark() itself with mock.patch so the setup-failure path is forced regardless of whether pyspark is actually installed. Verified in Docker: all 6 tests in test_concurrent_run.py pass both with real pyspark installed and without it. Negative control, reverting tune.py to the pre-fix commit with this test kept: 3 of 6 fail, including this one, with the same leaked-handler assertion CI was hitting. |
Li Jiang (thinkall)
left a comment
There was a problem hiding this comment.
Overall review of the complete current PR: changes are still required.
flaml/tune/tune.py:207-214,456-484: context is captured per worker-thread lifetime. Pre-existing workers receive none, and reused workers remain pinned to the first trial, dropping or misrouting later reports. Propagate context per submitted callback/task and test pre-warmed and cross-trial executor reuse.flaml/tune/tune.py:204-218:Threadsubclasses overridingrun()bypass the global base-method patch and silently losetune.report(). Add a propagation design and regression that supports existing subclasses.flaml/tune/tune.py:183-221: importing FLAML globally monkey-patchesthreading.Threadand copies every ambient applicationContextVarinto new threads. Propagate only FLAML-owned state through a scoped mechanism.flaml/tune/tune.py:101-102: automatically propagated callback threads lack the owning log-run ID, so their log records are filtered out of the run log. Include run-scoped logging identity in the propagated context.
Posted by thinkall-agent-auto-reviewer
…afety fix Three gaps in the automatic context-propagation patch, all real: 1. ThreadPoolExecutor worker threads call Thread.start() once, when the pool spins them up, not once per submitted task, so a task submitted to an already-warmed-up worker never saw a context captured at start() time. Fixed by also patching ThreadPoolExecutor.submit() to attach the context per task rather than per thread; verified this is not otherwise handled (a plain contextvars.ContextVar is not propagated into an already-running pool worker either). 2. The patch replaced threading.Thread.run directly, which a Thread subclass overriding its own run() shadows. Now wraps Thread._bootstrap_inner instead, which CPython calls internally and which invokes self.run() regardless of which run() that resolves to. 3. The patch captured contextvars.copy_context() (the full ambient Context), dragging every application ContextVar into every new thread process-wide. Now captures and restores only FLAML's own _RunContext value. Folded log_run_id into _RunContext and propagate it through both paths too: a worker thread's log records were being filtered out of the run log the same way an un-propagated report() used to be dropped, since the run-scoped log filter checks a separate thread-local the context patch was not setting. Five new regression tests: pre-warmed executor worker, pre-warmed executor reused across two trials, a Thread subclass overriding run(), and log records reaching the run log through both the plain-thread and executor paths. All five fail on the prior commit and pass with this fix; the six pre-existing concurrency tests are unaffected.
|
Pushed 16773f5 for all four.
Five new tests: pre-warmed executor worker, the same reused across two trials, a Thread subclass overriding run(), and log records reaching the run log through both the plain-thread and executor paths. All five fail on the prior commit and pass with this one; the six existing concurrency tests are unaffected, and a full test/tune/ run shows the same 17 pre-existing failures (missing optuna, order-dependent) with or without this diff. |
Li Jiang (thinkall)
left a comment
There was a problem hiding this comment.
Overall review of the complete current PR: changes are still required.
flaml/tune/tune.py:229-230,278-293,545-551: generic persistent callback/queue workers started beforetune.run()still receive no context, so reports and run-scoped logs are silently lost. Preserve automatic single-run compatibility when ownership is unambiguous and add a pre-started queue-worker regression.flaml/tune/tune.py:233-242,283-291,574-587: propagated contexts remain usable after their trial finishes. Late threads or executor work can rewrite completed trial results and raiseStopIteration. Make contexts lifecycle-aware, invalidate them at trial completion, reject inactive/finished contexts before processing, and avoid retaining trial context as a pool worker's thread-lifetime default.
Posted by thinkall-agent-auto-reviewer
…follow-up) thinkall's fourth CHANGES_REQUESTED review, point 2: a _RunContext captured for a trial stays usable after that trial finishes. A worker thread or executor task that captured get_run_context() and reports late, after the trial is already TERMINATED, still wrote through: process_trial_result() overwrote the trial's final metric_analysis and last_result with the stale value, and report()'s own trailing `if trial.is_finished(): raise StopIteration` (the normal scheduler-stop signal for the current report) then raised into the late caller too, which has no reason to expect it the way a trainable's own control-flow loop does. report() now checks trial.is_finished() before processing and drops the late report instead. New regression test captures a context, lets the trial finish, then reports through the stale context: fails with an uncaught StopIteration on the prior commit, passes now, and asserts last_result/metric_analysis are byte-for-byte unchanged by the late write. Point 1 (a persistent callback/queue worker started before tune.run() never gets context for later items) is not fixed here: replied on the PR with why an automatic fallback is not safe to ship.
|
Pushed 04463eb for point 2, and I'm not patching point 1, here's why. Point 2: report() now checks trial.is_finished() before processing and drops the report if so. A stale context still writing through would corrupt the trial's already-final metric_analysis via process_trial_result(), and the trailing is_finished() check (the normal scheduler-stop signal for the CURRENT report) would then raise StopIteration into a caller that never expected it. New test captures a context, lets the trial finish, reports through the stale context: uncaught StopIteration on the prior commit, clean no-op now, last_result/metric_analysis asserted unchanged. Point 1: traced this all the way through and I don't think an automatic fallback is safe. The only information available to disambiguate a context-less report is "how many tune.run() calls are active right now", so the only possible fallback is "if exactly one, assume it's that one's current trial." The problem: it reads runner.running_trial live, which SequentialTrialRunner reassigns to a new Trial object every step(). A report queued while trial A was running but delivered after the runner has moved on to trial B lands on B, which is_finished() won't catch since B is genuinely still running. That trades a silent drop for a silent wrong-trial write, worse for a thread-safety fix than the current behavior. Fixing it for real needs the queue item itself to carry get_run_context() from enqueue time, which only the worker's own code can do, so I think this is the architectural limit you flagged rather than something to patch around. Verified in Docker (python:3.11-slim): fails before / passes after on the new test, full test/tune/test_concurrent_run.py (12/12) and test/tune/ minus test_tune.py (needs xgboost, same gap as earlier rounds) both green, black and ruff clean. |
Li Jiang (thinkall)
left a comment
There was a problem hiding this comment.
Overall review of the complete current PR: changes are still required.
flaml/tune/tune.py:230-239,545-551: generic pre-started or cross-trial persistent queue workers still lack per-dispatch context, so reports/logs are lost or remain pinned to an earlier trial. Add lifecycle-aware per-dispatch propagation or an unambiguous compatibility route, with pre-started and reused queue-worker tests.flaml/tune/tune.py:577-596:trial.is_finished()andprocess_trial_result()are not atomic. Trial completion can occur between them, allowing a stale report to rewrite a completed result and raiseStopIteration. Synchronize report admission with lifecycle invalidation and add a barrier-based race regression.
Posted by thinkall-agent-auto-reviewer
…follow-up) thinkall's fifth CHANGES_REQUESTED review, point 2: trial.is_finished() and runner.process_trial_result() were not atomic. A second, truly concurrent report for the same trial (a trainable's own worker threads reporting for the trial they share, for instance) could pass the is_finished() check while the trial was still running, and if the FIRST report's scheduler decision finished the trial before the second one reached process_trial_result(), the second one still wrote through, silently replacing the trial's real final result with a stale one. Added a per-trial admission lock and moved the actual admission decision (the check process_trial_result() acts on) inside it, so the re-check and the write are now one atomic step. The existing fast-path check right after resolving the trial is unchanged; it just stops being the only line of defense. New test forces the exact interleaving deterministically (events, not a sleep): worker B is paused, via a patched _next_training_iteration, right after its own is_finished() check returns False, until worker A's entire report() call, including the scheduler decision that terminates the trial, has completed. Verified this reproduces the bug on the prior commit (B's stale report wins, 2.0 instead of A's 1.0) and is fixed here. Point 1 (a persistent queue worker started before tune.run() lacking per-dispatch context) is restated from the fourth review. Automatic propagation for it is still not being added, for the reason already given: nothing at report()-call time can reconstruct which trial a delayed item was produced for. The compatibility route the review also names already exists (get_run_context()/use_run_context()); added a test exercising it on exactly the shape described, a worker thread started before tune.run() and reused across two trials.
|
Point 2: real race, fixed. Point 1: not adding automatic propagation, same reason as before and still true after tracing it again: nothing at report()-call time can tell which trial a delayed queue item was produced for, since the runner's running_trial has already moved on by the time it's dequeued. The compatibility route you're also asking for already exists: The failing |
Li Jiang (thinkall)
left a comment
There was a problem hiding this comment.
Overall review of the complete current PR: changes are still required.
flaml/tune/tune.py:631andflaml/tune/trial_runner.py:96-102: report admission andstop_trial()do not share the same lifecycle lock, so a late report can still mutate a terminated trial.flaml/tune/tune.py:581-601,102-103: generic pre-started queue workers still lose reports and run-scoped logs unless callers adopt the new explicit context API, breaking backward compatibility.flaml/tune/tune.py:625-641: training iterations are allocated before locked admission, allowing concurrent reports to reach schedulers in reverse order. Allocate iterations inside the admitted critical section.flaml/tune/tune.py:122-130: restoration saves the effective logger level but restores it as an explicit level, permanently changing inherited logging behavior. Restore the exact originallogger.level.
Posted by thinkall-agent-auto-reviewer
…afety fix thinkall's sixth review, three real gaps fixed: 1. stop_trial() and report()'s admission did not share a lock. A straggling background-thread report could still be mutating trial.last_result/status via process_trial_result() at the same instant run()'s own loop called stop_trial() on the same trial. stop_trial() now takes the same per-trial _admission_lock_for report() already used. 2. training_iteration was allocated before the admission lock was acquired, so two concurrent reports for one trial could be handed iterations in one order and reach process_trial_result() in the other. Allocation moved inside the same critical section as admission. 3. _logger_level_enter() saved logger.getEffectiveLevel() (the resolved level, walking up the logger hierarchy) instead of logger.level (the logger's own, possibly NOTSET, level), so a logger that was inheriting ended up pinned to an explicit level after every run finished. Restores logger.level now. The review's fourth point, automatic context propagation for a generic pre-started queue worker, is not implemented for the reason given in the third and fourth review rounds: report() cannot tell which trial a delayed queue item belongs to once the runner has moved on to a later trial, and a fallback that guesses would silently attribute a report to the wrong trial, worse than today's silent drop. The documented get_run_context()/use_run_context() API already covers this case explicitly; added a test for the specific pre-started, reused-across-trials shape the review describes. Five new regression tests, each with a negative control confirming it fails on the pre-fix code and passes on this commit. Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
|
Pushed 0a055a6 for points 1, 3 and 4.
Point 2 is the same conclusion as the last two rounds, re-traced rather than repeated: report()'s only handle on which trial a delayed queue item belongs to is whatever context the producer captured, and the runner reassigns running_trial on every step, so any fallback that resolves the trial live at dequeue time can attribute a late item to the wrong trial. That is worse than the current silent drop. get_run_context()/use_run_context() already covers this; added a test for the specific pre-started, cross-trial-reused worker shape here. Five new regression tests, each with a negative control against the pre-fix code. |
Li Jiang (thinkall)
left a comment
There was a problem hiding this comment.
Overall review of the current PR: changes are still required.
The latest commit fixes the report/stop lifecycle lock, iteration ordering, and exact logger-level restoration. One prior backward-compatibility blocker remains: flaml/tune/tune.py:247-263,267-313,595-620 only propagates a run context to threads started during evaluation or tasks submitted through ThreadPoolExecutor. A generic queue/callback worker started before tune.run() has neither context nor a runner, so its existing tune.report() calls silently return without recording the metric, and its log records are filtered out at tune.py:102-103. The new pre-started-worker test changes the caller to pass get_run_context()/use_run_context() rather than testing unchanged callers.
Please preserve legacy reporting/logging when ownership can be determined safely, and do not silently discard an ambiguous context-less report. Add regressions with an unchanged pre-started queue worker and a reused worker across trials. If that compatibility cannot be preserved, document and explicitly signal the unsupported case rather than presenting it as compatible.
Posted by thinkall-agent-auto-reviewer
…eventh review) A generic queue/callback worker started before tune.run() has neither a propagated context nor a runner, so its tune.report() calls silently returned without recording the metric. Automatic attribution is still not added (SequentialTrialRunner.step() reassigns running_trial every step, so a live-resolved fallback would attribute a delayed report to whichever trial happens to be running by the time it is handled, not the one it was produced for) but the drop is no longer silent: it logs once, and the record bypasses the run-scoped filter so it reaches the active run's own log file instead of being swallowed by it. New test starts a legacy queue worker before tune.run(), an unchanged caller with no get_run_context()/use_run_context(), and asserts the trial's result stays untouched (None) while the warning appears exactly once in the run's log file. Fails on the prior commit with an AttributeError (no _context_less_report_warned to patch), passes on this one. Verified in Docker (python:3.11-slim): test/tune/test_concurrent_run.py 18/18 pass; test/tune/ minus test_tune.py (needs xgboost, pre-existing gap) 98 passed, 1 skipped; black 23.3.0 and ruff 0.0.261 clean. Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
|
Pushed 865bd8c. Not adding automatic attribution, same conclusion as the last several rounds and unchanged after re-reading the ask: nothing available at report() time can safely resolve which trial a delayed queue item belongs to, since the runner reassigns running_trial every step. What changed: the drop is no longer silent, per the "document and explicitly signal" suggestion. report() now logs a one-time warning when it drops a context-less report, and the record carries a flag that bypasses the run-scoped log filter, so it reaches the active run's own log file instead of being swallowed the same way the report itself was. get_run_context()/use_run_context() is named in the message as the fix. New test starts a legacy queue worker before tune.run() with no get_run_context()/use_run_context() call at all, the unchanged-caller shape described above. It asserts the trial's result stays None (not corrupted) and the warning appears exactly once in the run's own log file. Verified in Docker: fails on the prior commit with an AttributeError (nothing to patch pre-fix), passes on this one. test/tune/test_concurrent_run.py 18/18, test/tune/ minus test_tune.py (needs xgboost, pre-existing gap) 98 passed/1 skipped, black and ruff clean. |
Li Jiang (thinkall)
left a comment
There was a problem hiding this comment.
Overall review of the current PR: changes are still required.
flaml/tune/tune.py:247-313,642-724: unchanged queue/callback workers started beforetune.run()still cannot see the run context. Their reports remain unrecorded and their log records are filtered out. The new regression test explicitly assertslast_result is None, codifying the backward-incompatible data loss instead of preserving the earlier working caller. Preserve reporting when work-item ownership can be propagated; for ambiguous ownership, fail explicitly rather than silently treating a lost metric as a successful trial.flaml/tune/tune.py:565-598: the diagnostic is gated once per process. After the first affected run, another run can drop metrics without any warning. Signal each affected run/call so later data loss is visible.flaml/tune/tune.py:102-103: theflaml_tune_unscopedbypass sends an unattributed warning to every concurrently active run handler, placing unrelated diagnostics in the wrong run logs. Keep unattributed warnings separate from run-owned log files.
The current Ubuntu Python 3.12 build check is also failing (pytest timed out); the other completed legs and formatting pass.
Posted by thinkall-agent-auto-reviewer
|
Pushed 2876ab5 addressing all three points.
|
There was a problem hiding this comment.
Overall review of the current PR: one compatibility issue remains.
flaml/tune/tune.py:114-115,794-807,895-897: the single-active-run fallback now restores tune.report() for an unchanged pre-started queue worker, but it sets _state.log_run_id only for the duration of that report call. The same worker has no log identity when it emits ordinary logger.info(...) before or after tune.report(), so _RunScopedFilter drops those records from the sole run’s configured log file. The pre-PR process-global handler recorded them, and the new legacy-worker regression checks metrics but no longer checks a worker log marker. Preserve run-scoped logging for this unambiguous single-run worker case (without attributing records when multiple runs are active), and add a regression with the unchanged pre-started queue worker emitting a log marker outside its report call.
The new commit addresses the previously lost metric, once-per-process warning, and cross-run warning broadcast. Current checks pass; no other blocking finding from this delta.
Posted by thinkall-agent-auto-reviewer

Why are these changes needed?
flaml/tune/tune.pycoordinatesreport()/run()through five module-level globals (_use_ray,_runner,_verbose,_running_trial,_training_iteration). Two threads callingtune.run()concurrently share all five: whichever thread'srun()call most recently assigns_runnerwins the global for every thread's subsequentreport()/stop_trial()calls, including resetting it mid-flight while another thread is still running. sonichi's 2023 comment on this issue names the same root cause.Fixed by moving that state into a
threading.localsubclass (_TuneState), so each thread gets its own runner/trial bookkeeping.report()andrun()now read/write_state.<field>instead of the bare globals; the existing single-thread nested-reentrancy save/restore inrun()'stry/finallyis unchanged, just scoped per thread instead of process-wide.Added a deterministic regression test (
test/tune/test_concurrent_run.py): two threads are synchronized with events so thread A runs its own trial to completion, including its ownfinally-restore, while thread B is still paused mid-evaluation with its own runner already installed in the (then-shared) global. On current main this reproduces the exact crash reported here,AttributeError: 'NoneType' object has no attribute 'stop_trial', because thread A'sfinallyresets the runner out from under thread B mid-flight. With the fix each thread keeps its own state and both trials complete with their own results.Verification:
AttributeErroron main and is green on this branch, on python 3.11.test/tune/passes (80 passed, 1 skipped); I skipped the ray- and xgboost-dependent files since I didn't have those installed.run()myself, since I don't haveray/pysparkinstalled here; they get the same mechanical change (globalto_state.<field>) as the branch the test covers.Related issue number
Fixes #996
Checks