Notes from a 2 AM On-Call: Ollama's Model Hot-Swap Race Condition Under Concurrent Requests
The alert fired at 2:14 AM: inference latency spiked to 8+ seconds on our production Ollama cluster serving DeepSeek and Qwen models. No OOM kills, no CPU throttling, no network saturation. Just silent hangs followed by timeout errors from downstream clients.
The Failure Symptom
2025-01-23T02:14:37Z ERROR router: context deadline exceeded serving inference request
2025-01-23T02:14:38Z WARN model loader: attempted load during active unload operation
2025-01-23T02:14:39Z ERROR llama_cpp: model pointer nil after successful load check
The pattern was clear in our traces: requests hitting Ollama's /api/generate endpoint would hang indefinitely when a model hot-swap was triggered (switching from deepseek-coder:6.7b to qwen2.5:7b via the API). About 30% of concurrent requests would enter a zombie state—goroutines blocked on channel sends that never completed.
The Red Herring
Ollama's documentation suggests model switching is safe during active inference. The official tutorial shows sequential API calls:
curl http://localhost:11434/api/generate -d '{"model": "qwen2.5:7b", ...}'
Works flawlessly in isolation. But under real production load—40+ concurrent requests/sec with client SDKs maintaining persistent connections—the model registry state machine hit an unguarded race window.
The naive assumption: Ollama's internal model manager serializes load/unload operations. Reality: the registry's models map[string]*Model was being read during iteration while another goroutine was deleting entries during unload.
The Root Cause & Runnable Fix
Diving into Ollama's server/routes.go and llm/llama.go, the issue surfaced in the model lifecycle coordinator. When switching models:
- New model load starts (acquires GPU memory)
- Concurrent inference requests still reference old model pointer
- Unload triggers—sets
model = nilwithout waiting for in-flight requests - Registry map mutated during concurrent range iteration
The production-tested fix required wrapping the model registry with a sync.RWMutex and adding ref-counting to prevent premature unloads:
// Hardened model registry with safe concurrent access
type ModelRegistry struct {
mu sync.RWMutex
models map[string]*modelHandle
}
type modelHandle struct {
model *llama.Model
refCount int32
unloading atomic.Bool
}
func (r *ModelRegistry) Get(name string) (*llama.Model, func()) {
r.mu.RLock()
defer r.mu.RUnlock()
handle, ok := r.models[name]
if !ok || handle.unloading.Load() {
return nil, func() {}
}
atomic.AddInt32(&handle.refCount, 1)
release := func() {
if atomic.AddInt32(&handle.refCount, -1) == 0 {
// Safe to unload—no in-flight requests
}
}
return handle.model, release
}
func (r *ModelRegistry) Unload(name string) error {
r.mu.Lock()
handle, ok := r.models[name]
if !ok {
r.mu.Unlock()
return ErrModelNotFound
}
handle.unloading.Store(true)
r.mu.Unlock()
// Busy-wait with timeout for ref count to drain
timeout := time.After(5 * time.Second)
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
if atomic.LoadInt32(&handle.refCount) == 0 {
r.mu.Lock()
delete(r.models, name)
r.mu.Unlock()
return handle.model.Close()
}
select {
case <-timeout:
return ErrUnloadTimeout
case <-ticker.C:
}
}
}
After patching our forked Ollama instance with this guard:
Benchmark results (100 concurrent clients, model switch every 30s):
Before: p99 latency 8.2s, 32% timeout rate
After: p99 latency 340ms, 0% timeout rate
When Benchmarking Multi-Model Routing Topologies
When stress-testing Ollama as an upstream model provider behind a relay gateway (like routing through https://b-lost.com/v1 as a fallback endpoint in our FastAPI aggregator), model switch latency becomes critical. Our topology:
# Load balancer config with Ollama + fallback relay
upstreams = [
{"base_url": "http://ollama-1.internal:11434", "weight": 0.7},
{"base_url": "http://ollama-2.internal:11434", "weight": 0.2},
{"base_url": "https://b-lost.com/v1", "weight": 0.1} # Fallback for overflow
]
Under this architecture, Ollama's model switch race condition caused cascade failures—requests queued during unload would timeout, triggering aggressive retries that DDOSed the fallback relay. The ref-counted unload ensured clean handoffs.
Invariant Rule
Never expose stateful resource cleanup (model unload, connection pool drain, cache eviction) without ref-counting or generational markers when the resource is shared across concurrent goroutines.
In Go's concurrent model management: if sync.Map or bare map[string]*Resource is mutated during iteration or accessed post-deletion, wrap it with sync.RWMutex and drain in-flight references before finalizing cleanup. The 50ms polling loop is inelegant but correct—Ollama's C++ llama.cpp bindings don't expose safe async cleanup hooks.
The full patch reduced our production P99 latency by 96% and eliminated timeout-triggered fallback storms. Model hot-swapping is now safe under sustained concurrent load.
Top comments (0)