fix(quantization): record job progress without an attached listener (#11874) - #11881
Open
Anai-Guo wants to merge 1 commit into
Open
fix(quantization): record job progress without an attached listener (#11874)#11881Anai-Guo wants to merge 1 commit into
Anai-Guo wants to merge 1 commit into
Conversation
…udler#11874) A quantization job that runs with no client on its progress stream stays "queued" forever, in the API and in state.json, while the finished artifact sits on disk. state.json was written once by StartJob, and the only code that advanced a job afterwards lived inside the stream callback of StreamProgress, so job state depended on somebody watching it. The backend's progress stream cannot simply gain a second reader: each job owns one queue.Queue and QuantizationProgress pops from it, so two consumers split the updates rather than both seeing them. The stream has to be opened exactly once per job. StartJob now starts watchProgress on the application context (the request context is done as soon as the handler returns). That goroutine is the single reader: it applies each update to the job -- in the cross-replica store and in state.json, terminal statuses still winning over late updates -- and republishes it in-process. StreamProgress becomes a pure reader over that fan-out and no longer loads a backend or opens a stream. A client attaching to a job that has already finished, including one hydrated from disk after a restart, gets a final event built from the stored job instead of blocking. Two paths used to end a client's stream by breaking the gRPC connection and now release it explicitly: StopJob kills the backend, so it publishes the stopped event itself; and a stream that ends without a terminal update means the backend is gone, so the job is recorded as failed rather than left running forever. Signed-off-by: Tai An <antai12232931@outlook.com>
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.
Fixes #11874.
The bug
A quantization job that runs with no client attached to its progress stream stays
queuedforever — in the API and instate.json— while the finished artifact sits on disk.state.jsonis written once, bysaveJobStateinStartJob, and the only code that ever advanced a job afterwards lived inside the stream callback ofStreamProgress:StartJobkicks the backend off over gRPC and returns. Nothing consumes the backend's progress stream until an SSE client callsStreamProgress, so with nobody watching, no transition is ever recorded. Job state depended on an observer.Why the obvious fix needs care
The issue suggests having the job runner record transitions and letting
StreamProgressbe a pure reader. That is the right shape, but simply starting a second consumer alongsideStreamProgresswould break the case that works today.The backend's progress stream is single-consumer and destructive. In
backend/python/llama-cpp-quantization/backend.py, each job owns onequeue.Queue:_send_progressdoesjob.progress_queue.put(update)andQuantizationProgressdoesjob.progress_queue.get(timeout=1.0)— it pops. Two readers of that stream do not each see every update; they split them. So a background watcher running next to a live SSE reader would steal half the events from the UI.That rules out "add a watcher and leave
StreamProgressas-is". The stream has to be opened exactly once per job.The change
core/services/quantization/service.goonly:StartJobstartswatchProgresson the application context (not the request context, which is done the moment the HTTP handler returns). That goroutine is the single reader of the backend stream for the job's lifetime.applyProgressUpdate— the state-recording half of the oldStreamProgresscallback, unchanged in behaviour (terminal statuses still win over late updates) — now runs from the watcher, so transitions land in the SyncedMap and instate.jsonwhether or not anyone is attached.StreamProgressbecomes a pure reader: it subscribes to an in-process fan-out and returns when a terminal event arrives orctxis done. It no longer loads a backend or opens a gRPC stream.loadJobsFromDiskmarksstoppedand which have no watcher.Two paths that used to end a client's stream by breaking the gRPC connection now need an explicit release, since the client is no longer holding that connection:
StopJobkills the backend process, so no terminal update will ever reach the watcher — it publishes thestoppedevent itself.failedand that is published, rather than leaving it in a running state forever — the same failure mode this change exists to prevent. Skipped whenctxis already done, so a shutdown still leaves jobs forloadJobsFromDiskto report as stopped.No schema, API, or route changes; the SSE payload is byte-for-byte the same
QuantizationProgressEvent.Tests
Seven specs added to
core/services/quantization/service_test.go(white-box, no backend needed — the existing suite already drives the service with a nil model loader):advances job state and rewrites state.json with no subscriber attached— the reported failure, asserted withprogressSubsempty:GetJobreportscompletedand the on-diskstate.jsonis rewritten with the status and output file.does not let a late update overwrite a terminal status— pins the existing terminal-state guard that moved with the code.delivers one update to every attached subscriber— the fan-out that replaces per-client gRPC streams.unsubscribing removes the job's entry once the last client leaves— no map growth per finished job.returns a final event immediately for a job that already finished— the attach-after-completion / post-restart path.releases an attached client when the job is stopped— the stop path that no longer ends by breaking a gRPC connection.streams published events to a client until a terminal status arrives— end of stream on terminal status.gofmt -landgo veton the package are clean.Scope of verification: the specs above and the package build/vet are what I actually ran. I did not re-run the end-to-end reproduction against a live
llama-cpp-quantizationbackend — the single-consumer queue behaviour is read frombackend.pyas quoted above, and the failure mode is the one in the issue report.🤖 Generated with Claude Code