Skip to content

Fix/ollama health monitoring and timeouts - #25

Open
Ayan-josh-05 wants to merge 7 commits into
refactor/consolidate-backend-servicesfrom
fix/ollama-health-monitoring-and-timeouts
Open

Fix/ollama health monitoring and timeouts#25
Ayan-josh-05 wants to merge 7 commits into
refactor/consolidate-backend-servicesfrom
fix/ollama-health-monitoring-and-timeouts

Conversation

@Ayan-josh-05

Copy link
Copy Markdown

fix: keep field-mapping and translation usable when Ollama is down or cold-loading

The problem

Both field_mapping_poc and translation call the same local Ollama instance. Two things went wrong, and they made each other worse:

1. A stopped Ollama looked identical to a slow one.

The health probe had no connect timeout, so when Ollama wasn't running, the probe just hung.

/health stayed stuck on "initializing" forever, and /map and /translate kept accepting requests that queued up behind a call that was never going to return — each one holding the service's single request lock.

2. Timing out a request made the model load slower.

Ollama treats the client closing a connection as a cancellation.

If our timeout fired while the model was cold-loading (2–4 minutes on CPU), Ollama aborted the load. The retry then started a fresh load, which timed out again — creating a loop that never let the model finish loading.

The fix

Split the timeout into two phases instead of one flat number.

Connect timeout: 5s

Either Ollama is listening or it isn't. This can't abort a model load, because if the socket connected, the load hasn't started yet.

Read timeout: 600s

A cold load or a long document has to be waited out, never raced. This is a last-resort circuit breaker for a wedged server, not a per-request deadline.

Probe in two steps so each status means one thing

  • /api/tags just lists model files on disk. It's cheap and can't trigger a load. This answers: "Is the server up?"
  • A real one-token chat ping answers: "Is the model ready?"

That gives three honest statuses:

  • unreachable — Server is down
  • initializing — Server is up, model hasn't answered yet (usually cold-loading)
  • ok — Model is loaded and responding

Previously, a single probe couldn't tell the first two states apart, so a perfectly healthy service dipped to "initializing" during every ping.

Reject early when Ollama is genuinely down

/map, /translate/text, and /translate/files now return 503 with Retry-After: 30 before taking the request lock, so callers fail fast instead of piling up.

This fires only on unreachablenot on "anything that isn't ok".

initializing requests do succeed if you let them wait, and rejecting them would turn "slow" into "broken".

Changes by area

field_mapping_poc

Brought up to the same standard translation already had:

  • Added the background health monitor and a real /health. It used to return a hardcoded {"status": "healthy"} that told you nothing about Ollama.
  • Default num_ctx raised from 819232768 to match translation. Ollama treats a different num_ctx as a different runtime, so the mismatch was forcing a full model reload (~2 min) every time the two services took turns calling it.
  • The monitor now starts on a half-interval offset, so the two services' pings land staggered instead of hitting the shared model at the same moment.
  • Read timeouts are no longer retried — we already waited the full window, and the timeout just aborted whatever Ollama was loading, so a retry is strictly worse.
  • Connect timeouts are still retried: they fail fast and Ollama may just be mid-restart.

translation

Closed the gaps found while porting the pattern across:

  • Model calls now go through a configured client with the connect/read split above, instead of the untimed module-level chat(). Without any read bound, a wedged Ollama held _translate_lock until the process was restarted.
  • Added the two-phase probe and the fail-fast gate.
  • Pulled the retry/backoff logic out into _backoff() (shared by both failure paths, was previously duplicated).

Small fixes

  • surya-inference/entrypoint.sh: mkdir -p "$MODEL_DIR" before using it.
  • frontend/.env.example: 80008080, the actual gateway port.
  • Deleted field_mapping_poc/.env.example — stale. It listed DATABASE_URL, ENCRYPTION_KEY, and DEBUG, none of which that service reads. The root .env.example is the real one.
  • cancel() + await in Surya OCR warm-up shutdown keeps the asyncio side tidy by ensuring there is no unfinished task left when the event loop closes. It also makes shutdown deterministic from the async side and keeps the implementation consistent with stop_monitoring().

Cold load

Restart Ollama, then immediately call /map.

/health should report initializing and the request should succeed after the load finishes (~3 min) rather than being rejected or timing out.

Shutdown

docker compose down

Notes for reviewers

  • 600s is a deliberate range, not a guess. It has to sit above a real cold load (~215s measured) but not too far past the gateway's 300s proxy timeout. Past that point nobody is waiting for the answer, but the call is still holding the lock. It's env-overridable for slower hosts.
  • One caveat on translation: a long document can legitimately generate for a long time (num_predict=16384 at ~3.5 tok/s on CPU). A large enough document could hit the 600s read ceiling and get cut off mid-answer. If long translations start failing right around 600s, OLLAMA_TIMEOUT_SECONDS is the knob — Ollama isn't broken.

Ayan-josh-05 and others added 7 commits September 1, 2026 16:04
A POST /map request hung for ~15 minutes and returned 500. Ollama was
reload-thrashing: field-mapping asked for num_ctx=8192 while translation
used 32768, and Ollama treats a different context size as a different model
runtime, so the two evicted each other's. OLLAMA_KEEP_ALIVE=-1 does not
help — it keeps one runtime resident, not both.

- Align num_ctx to 32768 so both services share one resident runtime.
- Replace the hardcoded {"status": "healthy"} /health with a real
  background monitor reporting ok/initializing/unreachable. It runs a
  two-phase probe — /api/tags for "is the server up", then a chat ping for
  "can the model answer" — so a cold load is distinguishable from an
  outage, and status never flaps while a probe is in flight. The first
  probe is offset 15s so its pings interleave with translation's.
- Bound the probe's connect phase (5s) but leave its read unbounded. A read
  timeout firing mid-load makes Ollama abort the load; a connect timeout
  cannot, because an established socket means loading already started.
  Leaving connect unbounded pinned status at "initializing" indefinitely
  against a stopped Ollama.
- Retune the request timeout to a 600s read ceiling: above the measured
  ~215s cold load, but not far past the gateway's own 300s, after which the
  call only holds _map_lock with nobody waiting for the answer.
- Stop retrying read timeouts. ReadTimeout specifically, not
  TimeoutException — ConnectTimeout subclasses that and fails fast, so it
  is still worth retrying.
- Fail fast with 503 + Retry-After when Ollama is known-down, checked
  before _map_lock so concurrent callers do not queue behind a doomed
  request. Deliberately only on "unreachable": "initializing" requests do
  succeed if allowed to wait out the load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comparing the two services' GET /health showed translation carried the same
monitor bugs already fixed in field-mapping, plus an unbounded request path.
Nothing gated on its status, so the monitor issues were reporting-accuracy
problems — but /health misled precisely when you would be looking at it.

- Two-phase probe (/api/tags, then the chat ping), so "server down" and
  "server up, model loading" are actually distinguishable rather than both
  landing on whatever the previous value was.
- Stop resetting status to "initializing" on every loop iteration. A healthy
  service was dipping to "initializing" for each ping's duration (measured
  400ms-1.3s) every 30s.
- Bound the connect phase (5s), leaving read generous. Previously the ping
  used the untimed module-level chat(), so a connection that hung rather
  than being cleanly refused pinned status indefinitely — observed taking
  60s+ with no failure ever logged.
- Move translate() onto the same client. Without a connect bound it could
  block forever holding _translate_lock, wedging the endpoint for every
  later caller until a restart.
- Add a 600s read ceiling for an Ollama that accepts the connection but
  never replies, so it cannot hold the lock indefinitely either. Note this
  margin is thinner than field-mapping's: num_predict is 16384 here and CPU
  generation runs ~3.5 tok/s, so a long document could legitimately cross
  600s. Raise OLLAMA_TIMEOUT_SECONDS if long translations start failing at
  that mark.
- Fail fast with 503 + Retry-After when Ollama is known-down, on both
  /translate/text and /translate/files, matching field-mapping. It reads
  the default domain's service because api_server.py only starts the
  monitor on that one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stop_monitoring() called cancel() and returned immediately. cancel() only
schedules a CancelledError into the task, so the monitor could still be
pending when the event loop closed, producing "Task was destroyed but it is
pending!". Await the task after cancelling, suppressing the CancelledError
that the await re-raises.

Applies to both services — field-mapping's monitor was ported from
translation's, so both carried the same pattern.

Verified against a negative control: with the old cancel-only behaviour the
task is still pending when stop_monitoring() returns; with the fix it is
settled and cancelled. Also exercised via a real container restart, with no
pending-task warnings in the shutdown logs.

Not addressed here: if the loop is parked in `await asyncio.to_thread(
self._ping)`, cancelling abandons the await but cannot interrupt the worker
thread, which runs until the blocking HTTP call returns. That is inherent to
blocking I/O in a thread; the read timeout on _client bounds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The monitor ran one cheap probe (/api/tags) and one expensive one (a real
model ping) on a single shared schedule, so it was routinely asleep when
Ollama's state actually changed — in both directions:

- After a stop, the shared failure counter escalated to the 120s backoff
  (a window sized for the model ping), so a restarted Ollama went
  unnoticed for up to 2 minutes.
- While healthy, the loop slept the full 30s recheck interval in one go,
  so a stopped Ollama kept reporting "ok" for up to 30s and the /map and
  /translate fail-fast gates waved requests through that whole time.

Split the two schedules. The reachability probe polls every
OLLAMA_HEALTH_RETRY_SECONDS (5s) indefinitely and never escalates — it is
a TCP connect to a local port that fails instantly when refused, so the
escalating backoff bought nothing and cost detection latency. The model
ping keeps its 30s healthy cadence and its escalating backoff on failure,
now tracked by its own counter so a down server can't feed it.

The loop no longer sleeps the recheck interval in one go: it ticks at the
fast interval and skips the ping until it is due, so it cannot sleep
through Ollama disappearing. Detection is ~5s in both directions.

Log the unreachable warning on status transitions only — at a 5s cadence,
per-attempt logging would put a line in the log every few seconds for the
length of an outage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant