fix(galleryop): make admitted operations queryable and survive a failed op#11044
Merged
Conversation
…ed op Two lifecycle defects observed on a 2-replica distributed cluster. The install endpoints mint a job UUID, hand the operation to an unbuffered channel, and answer HTTP 200 immediately. The gallery worker is a single goroutine that processes operations serially, and the first status write happens inside modelHandler/backendHandler — i.e. only once the worker actually starts the work. An operation queued behind a running install therefore had no status at all: GET /models/jobs/<uuid> answered "could not find any status for ID" and GET /models/jobs did not list it, so the endpoint reported success for work nothing could observe. On the paths that sent directly rather than from a goroutine, the same unbuffered channel blocked the HTTP handler for the whole duration of the in-flight install, which is how a replica came to accept no /models/apply at all while /readyz stayed green. Admission now goes through EnqueueModelOp/EnqueueBackendOp, which publish a "queued" status before handing the operation over, so a job ID is queryable from the instant it is handed out. Delivery selects on the operation's context, so cancelling a still-queued operation releases the delivery goroutine instead of stranding it on a send that will never be received, and an operation the worker never accepts becomes a terminal failure rather than a silent leak. The worker also had no panic containment. A panic in any handler propagated out of the single consumer goroutine and killed the process, taking every queued operation with it; it is now contained to the operation that caused it. The two ignored galleryStore.Create errors are logged, and the model and backend delete endpoints now run under the same ID they hand back — they previously ran under an empty ID and returned a status URL for a job that could never have a status. Second, an operation orphaned by a controller replaced mid-download kept reporting phase=downloading, processed=false, error=none while nothing was downloading. The PostgreSQL side does recover on its own (FindDuplicate ignores rows untouched for 30 minutes and CleanStale marks them failed), but the reaper only ever corrected the database. The in-memory statuses map that GET /models/jobs/<id> and /api/operations actually read was never corrected, so every replica kept serving the frozen tick indefinitely. ReapStaleOperations now reconciles the in-memory copy with the reap. Note that operation ownership is still not tracked: gallery_operations has a FrontendID column that nothing writes, so a live operation and one whose owner died are distinguished only by a 30-minute staleness timeout. Narrowing that window needs a lease/heartbeat mechanism and is out of scope here. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
localai-org-maint-bot
force-pushed
the
fix/gallery-op-lifecycle
branch
from
July 22, 2026 18:04
1d57345 to
2595528
Compare
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.
Observed on a live 2-replica distributed cluster
Sequence A — the install endpoint reported success for work nothing could observe
POST /models/apply {"id":"localai@longcat-video-avatar-1.5"}→ job0bae4174, ran to ~19%, then failed with a transient read error (materialize primary model artifact: failed to read "https://huggingface.co/.../diffusion_pytorch_model-00003-of-00006.safetensors").POST /models/applycalls each returned HTTP 200 with a fresh UUID (a2893eed,cd884437,f8876d41).GET /models/jobs/<uuid>for each returned HTTP 500could not find any status for ID.GET /models/jobslisted only0bae4174. The three later UUIDs were never in the map.Earlier in the same session one replica also stopped answering
POST /models/applyentirely (connection closed, no response) whileGET /readyzkept returning 200.Sequence B — an op orphaned by a controller restart claimed "downloading" forever
A controller replaced mid-download left the op reporting
phase=downloading,processed=false,error=none,progress=13.8%while nothing was downloading (du -sbflat for 25+ minutes). Every retry for that model was rejected withalready being processed by another instance (op <id>)until the op was manually cancelled. This happened three times.Mechanism
The admission handlers mint the job UUID, hand the op to an unbuffered channel, and answer 200 immediately. The worker (
GalleryService.Start) is a single goroutine consuming both channels serially, and the first status write happens insidemodelHandler/backendHandler— only once the worker actually starts the work.So an op queued behind a running install has no status by construction, which is symptoms 3 and 4 exactly. On the paths that sent directly rather than from a goroutine, the same unbuffered channel blocked the HTTP handler for the entire duration of the in-flight install — that is the replica that accepted no
/models/applywhile/readyzstayed green.The worker also had no panic containment: a panic in any handler propagated out of the single consumer goroutine and killed the process, taking every queued op with it.
For B, the PostgreSQL side does recover on its own —
FindDuplicateignores rows untouched for 30 minutes andCleanStalemarks them failed, on a 15-minute tick. But the reaper only ever corrected the database. The in-memorystatusesmap thatGET /models/jobs/<id>and/api/operationsactually read was never corrected, so every replica kept serving the frozen tick indefinitely, on both the originating replica and any peer that received it over NATS.Changes
EnqueueModelOp/EnqueueBackendOppublish aqueuedstatus before handing the op over, so a job ID is queryable from the instant it is handed out. All HTTP admission paths now go through them.ReapStaleOperationsreconciles the in-memory status with the reap (GalleryStore.ListStale).galleryStore.Createerrors are logged.Not addressed
Operation ownership is still not tracked.
gallery_operationshas aFrontendIDcolumn that nothing writes, so a live op and one whose owner died are distinguished only by the 30-minute staleness timeout. Narrowing that window needs a lease/heartbeat mechanism — out of scope here, noted for follow-up.Testing
New Ginkgo specs in
core/services/galleryop/enqueue_test.go, each written failing on behaviour first:nilstatus)ReapStaleOperationsmarks the reaped op failed in memory, not just in the storepanic: boom, which is the production failure modego build ./core/... ./pkg/...exit 0.go test ./core/services/galleryop/ ./core/services/distributed/ ./core/http/endpoints/localai/ ./core/http/routes/exit 0. Scopedgolangci-linton the touched packages: 0 issues. Fullmake lintfails on a pre-existingcore/http/endpoints/ollama/helpers_internal_test.gotypecheck error that reproduces on a pristineorigin/mastercheckout and is unrelated to this change.The transient network failure that triggered the original sequence is not reproducible locally, so the fix is verified against the mechanism established in code rather than against the live cluster.
🤖 Generated with Claude Code
https://claude.ai/code/session_0156CbKpDh8qbAYzJZJDZ2yk