DEV Community

Cover image for Notes from a 2 AM On-Call: Ollama's Model Hot-Swap Race Condition Under Concurrent Requests
Kevin Tian
Kevin Tian

Posted on

Notes from a 2 AM On-Call: Ollama's Model Hot-Swap Race Condition Under Concurrent Requests

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
Enter fullscreen mode Exit fullscreen mode

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", ...}'
Enter fullscreen mode Exit fullscreen mode

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:

  1. New model load starts (acquires GPU memory)
  2. Concurrent inference requests still reference old model pointer
  3. Unload triggers—sets model = nil without waiting for in-flight requests
  4. 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:
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
]
Enter fullscreen mode Exit fullscreen mode

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)