# ADR-034: Ollama in production — deployment, keep-alive pinning, and corrected init recipe

**Date:** 2026-06-06
**Status:** Accepted
**Deciders:** Marcel Raddatz
**Relates to:** #758 (bug), #759 (fix), #737 (NL search infrastructure)
**Corrects:** ADR-028 §10–§11 (init recipe and readiness probe)

---

## Context

ADR-028 introduced Ollama as a Docker Compose service for NL search and documented
its topology, graceful-degradation contract, and memory budget. Two defects survived
that work and only surfaced once NL search reached staging (#758):

1. **Ollama was added only to the dev `docker-compose.yml`.** Staging and production
   deploy from the self-contained `docker-compose.prod.yml`, which had no `ollama`
   service. The backend defaults to `app.ollama.base-url: http://ollama:11434`, so its
   client bean was active and resolved to a non-existent host → `ResourceAccessException`
   → HTTP 503 on every NL search.
2. **The init recipe documented in ADR-028 §10 never worked.** The `ollama/ollama` image
   `ENTRYPOINT` is `ollama`, so a bare `command: sh -c "…"` ran as `ollama sh -c "…"`
   (`unknown command "sh"`), and the image ships **no curl**, so the curl-based readiness
   loop and the curl healthcheck could never pass.

This ADR records the production deployment decision and the corrected operational
contract. It is also the durable record of *why* `OLLAMA_KEEP_ALIVE=-1` is set, so a
future maintainer does not "optimize" it away and reintroduce the cold-load 503.

---

## Decisions

### 1. Ollama is a first-class production service

`docker-compose.prod.yml` now defines `ollama` + `ollama-model-init` + the
`ollama-models` volume, mirroring the dev stack. The graceful-degradation contract from
ADR-028 §3 is preserved: `backend` has **no** hard `depends_on` on `ollama`, so an absent
or unhealthy Ollama still yields a clean 503 rather than blocking backend startup.

### 2. Corrected init recipe (supersedes ADR-028 §10)

The init container overrides the image entrypoint to a shell and probes readiness with
`ollama list` (not curl, which the image lacks):

```sh
ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && \
  (ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)
```

```yaml
entrypoint: ["/bin/sh", "-c"]
```

The pull is **guarded by a grep on the cached model list**. A model already on the volume
exits clean without any registry round-trip. This makes re-up offline-safe: a host reboot
during a registry/network blip can no longer fail init (which, via
`condition: service_completed_successfully`, would otherwise block the `ollama` service
and take NL search down until the registry was reachable again). The same recipe is used
in dev and prod — one mental model.

### 3. Healthcheck uses `ollama list` (supersedes ADR-028 §11 probe)

```yaml
healthcheck:
  test: ["CMD", "ollama", "list"]
```

`ollama list` hits the local API and exits non-zero when the server is down — the correct
probe for a curl-less image. The `start_period: 60s` rationale from ADR-028 §11 still holds.

### 4. `OLLAMA_KEEP_ALIVE=-1` — pin the model in memory

```yaml
environment:
  OLLAMA_KEEP_ALIVE: "-1"
```

By default Ollama evicts an idle model after ~5 minutes. The next query then pays a
cold-load penalty that exceeds the backend read timeout, producing an NL search 503 after
any idle period. Pinning the model (`-1` = never unload) keeps warm-path latency
predictable (~18 s on CPU). **Do not remove this** without re-introducing the post-idle
cold-load 503.

### 5. Read timeout raised 30 → 60 s

`app.ollama.timeout-seconds` is raised from 30 to 60 (`application.yaml`, mirrored in
`DEPLOYMENT.md`). Warm CPU inference is ~18 s; the higher ceiling absorbs the one cold
model load on the first query after an Ollama (re)start, before §4's pin takes hold.

**Implicit NFR made explicit:** NL search shall return a result or a 503 within 60 s; the
cold-start path immediately after an Ollama restart is the only path that approaches this
ceiling.

### 6. Hard-OOM trade-off (refines ADR-028 §2)

`memswap_limit == mem_limit` (both `${OLLAMA_MEM_LIMIT:-8g}`) disables swap for the
container. Combined with §4's pinned model, a memory-pressure event is a **hard OOM-kill,
not graceful latency degradation**. This is deliberate — swap-thrashing an LLM is worse
than a clean restart — but it means the 8 GB envelope is a real ceiling. `qwen2.5-7B-q4`
plus its KV cache under load sits close enough to 8 GB that this needs a Prometheus
memory alert on the `ollama` container before it bites in production (tracked as
observability follow-up, not in this PR).

---

## Consequences

### Positive

- NL search works on staging/production, not just dev — the actual deploy artifact now
  matches the documented architecture.
- Re-up is offline-safe: a cached model never depends on registry reachability.
- The keep-alive pin and timeout ceiling make NL search latency predictable on CPU.

### Risks and operational implications

- **Hard OOM under memory pressure** (§6): a Prometheus alert on `ollama` container memory
  is required before this is load-bearing in prod. Tracked as an observability follow-up.
- **Unauthenticated inference** relies entirely on `archiv-net` isolation (ADR-028 §7/§12,
  unchanged). Sending an `Authorization` header from `RestClientOllamaClient` is a separate
  durable hardening item, tracked outside this PR.
- ADR-028 §10–§11 describe a recipe that never functioned; this ADR is the authoritative
  init/healthcheck contract going forward.
