fix(dspy): guard finetune worker set_result against cancelled job - #92
Conversation
Greptile SummaryThis PR attempts to prevent fine-tuning worker threads from writing results to cancelled
Confidence Score: 4/5This PR is not yet safe to merge because concurrent cancellation can still trigger the worker-thread failure it is intended to fix. The caller and worker operate concurrently, while Files Needing Attention: dspy/clients/lm.py Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant Worker
participant Future as TrainingJob/Future
Worker->>Future: cancelled()
Future-->>Worker: false
Caller->>Future: cancel()
Future-->>Caller: transitions to CANCELLED
Worker->>Future: set_result(...)
Future-->>Worker: InvalidStateError
Reviews (1): Last reviewed commit: "fix(dspy): guard finetune worker set_res..." | Re-trigger Greptile |
| if not job.cancelled(): | ||
| job.set_result(lm) |
There was a problem hiding this comment.
Cancellation race still remains
A caller can cancel the job after cancelled() returns False but before set_result() acquires the Future's lock. Because these are separate operations, set_result() then raises InvalidStateError. This affects both the success path here and the error path at lines 398–399, so the worker thread can still crash in the situation this PR is intended to fix. Completion must handle cancellation atomically or tolerate set_result() losing the race.
Warning
GitHub issue creation failed
Detail attempted to publish this bug to GitHub, but the issue could not be created. This fix PR was created without that issue, and missing tracker references are shown as
Unknown issue.You can review and merge this PR normally. Please review your tracker integration settings before the next publish run.
Detail bug report: View on Detail
📝 Changes Description
This MR/PR contains the following changes:
dspy/clients/lm.py—LM._run_finetune_jobnow guards bothjob.set_result(...)calls withif not job.cancelled():, fixing aconcurrent.futures.InvalidStateErrorworker-thread crash whenTrainingJob.cancel()is invoked during a fine-tune.tests/clients/test_openai_provider.py— new regression tests covering the finetune success path, error path, and the two cancel timing windows.Bug:
LM._run_finetune_jobnever callsset_running_or_notify_cancel(), so theTrainingJobFuturestaysPENDINGfor the entire remote fine-tune.TrainingJobOpenAI.cancel()(a public method on theTrainingJobreturned byLM.finetune()) transitions theFuturetoCANCELLEDviasuper().cancel(). When the worker later reachesjob.set_result(lm)(success path) orjob.set_result(err)(error path),concurrent.futuresraisesInvalidStateError. In the error path this secondInvalidStateErroris raised inside theexceptclause, so it escapes the worker thread and kills it; the caller'sjob.result()then surfacesCancelledError. Anycancel()invoked while the remote job is still running deterministically produces this crash.Fix: Guard the two
set_resultcalls so they are skipped when the job was cancelled. The caller'sresult()then raisesCancelledErrorcleanly, and noInvalidStateErrorescapes the worker thread. The success and error paths for non-cancelled jobs are unchanged.This is the minimal fix recommended in the bug report; it does not address the separate, theoretical "post-cancel remote-job creation" cost concern (Window 2a), which the report explicitly defers pending live-account assessment.
Closes Unknown issue
✅ Contributor Checklist
uv run ruff checkanduv run ruff format --checkpass on the changed files;uv run pytestpasses.fix(dspy): guard finetune worker set_result against cancelled jobAI-assisted contribution disclaimer: Per
CONTRIBUTING.md§"AI-Generated Contributions", this change was prepared by Detail: Automatic Fixes. I understand and have verified every line: the fix is a two-line guard matching theconcurrent.futuresFuturecancellation protocol, and the tests reproduce the crash deterministically via the realLM.finetune→_run_finetune_job→OpenAIProvider.finetune→TrainingJobOpenAI.cancelpaths with only theopenai.*SDK boundary mocked. The bug was reproduced before fixing and confirmed fixed after.Testing summary:
tests/clients/test_openai_provider.py, 4 tests) — Drive the real production worker paths with theopenai.*SDK boundary mocked;threading.Eventpairs deterministically pin the worker at each timing window. All 4 pass.dspy.LMviaresult().result().result()raisesCancelledError.upload_data(provider_file_id not yet set) — no uncaught exception,result()raisesCancelledError.InvalidStateError('CANCELLED: <TrainingJobOpenAI ... state=cancelled>')escaping the worker thread (the exact defect), confirming the tests are genuine regression guards.ruff checkandruff format --checkpass on the changed files; existingtests/clients(159 passed, 22 credential-gated skips, pre-existing) andtests/teleprompt/test_bootstrap_finetune.py(5 passed) show no regressions; full default-gated CI-equivalent suite (pytest -m 'not extra and not deno') passed (1255 passed, 252 skipped, 2 xfailed).TODO(enhance): We should listen for keyboard interruptsatdspy/clients/lm.py:382-383plans to wire Ctrl-C toTrainingJob.cancel(). A standalone script exercising the real worker (mocked SDK boundary) invokedcancel()during polling and confirmed the worker exits cleanly withcancelled()True, no uncaught exception, andresult()raisesCancelledError— not versioned, as it duplicates the polling-window regression test.Procedure not verified:
OPENAI_API_KEYwith fine-tuning access. No credentials are present in this environment (envhas noOPENAI_API_KEY;~/.config/openaidoes not exist;openai.OpenAI()construction raisesopenai.OpenAIError: Missing credentials...). This procedure would submit a real fine-tune, invokejob.cancel()during polling, and confirm the OpenAI dashboard shows the job cancelled and that noInvalidStateErrortraceback is emitted. The deterministic mocked tests exercise the identical worker state-machine code paths and are the substantive verification of the crash fix; the live test would additionally confirm the remote OpenAI job is cancelled, which isTrainingJobOpenAI.cancel's own responsibility, not the worker guard's.Automatic Fixes PRs can be configured here.