Fix/ollama health monitoring and timeouts - #25
Open
Ayan-josh-05 wants to merge 7 commits into
Open
Conversation
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>
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.
fix: keep field-mapping and translation usable when Ollama is down or cold-loading
The problem
Both
field_mapping_pocandtranslationcall 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.
/healthstayed stuck on"initializing"forever, and/mapand/translatekept 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/tagsjust lists model files on disk. It's cheap and can't trigger a load. This answers: "Is the server up?"That gives three honest statuses:
unreachable— Server is downinitializing— Server is up, model hasn't answered yet (usually cold-loading)ok— Model is loaded and respondingPreviously, 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/filesnow return503withRetry-After: 30before taking the request lock, so callers fail fast instead of piling up.This fires only on
unreachable— not on "anything that isn't ok".initializingrequests do succeed if you let them wait, and rejecting them would turn "slow" into "broken".Changes by area
field_mapping_pocBrought up to the same standard translation already had:
/health. It used to return a hardcoded{"status": "healthy"}that told you nothing about Ollama.num_ctxraised from8192→32768to match translation. Ollama treats a differentnum_ctxas a different runtime, so the mismatch was forcing a full model reload (~2 min) every time the two services took turns calling it.translationClosed the gaps found while porting the pattern across:
chat(). Without any read bound, a wedged Ollama held_translate_lockuntil the process was restarted._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:8000→8080, the actual gateway port.field_mapping_poc/.env.example— stale. It listedDATABASE_URL,ENCRYPTION_KEY, andDEBUG, none of which that service reads. The root.env.exampleis the real one.Cold load
Restart Ollama, then immediately call
/map./healthshould reportinitializingand the request should succeed after the load finishes (~3 min) rather than being rejected or timing out.Shutdown
Notes for reviewers
num_predict=16384at ~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_SECONDSis the knob — Ollama isn't broken.