a dead socket is not a pod refusing the pipeline (v0.4.6)

A pooled keep-alive connection that a pod closes while idle fails on its
next use:

  pod OCR at https://.../v1/ocr is unreachable: ('Connection aborted.',
  RemoteDisconnected('Remote end closed connection without response'))

_post_page_to_pod turned every transport failure into OcrError(503), 503 is
in _POD_CANNOT_RUN_PIPELINE, so one dead TCP connection was read as "these
pods cannot run the OCR pipeline" and set the whole pool aside for five
minutes. It happened twelve times in four hours on a production run: the
GPU-less orchestrator rasterised every page of every document for those
windows and climbed to 40 GB at ~0.8 GB/min, while sixteen rented A40s sat
idle and willing.

An ANSWER may condemn an image; a socket may not. So:

* PodUnreachable, a distinct error for "nothing came back", which never sets
  the pool aside. A pod that ANSWERS 404/501/503 still does.
* One page is retried on a fresh connection (nothing was sent, so there is no
  side effect) and then on another pod — but a pod is dropped from the pool
  only after it fails to answer /healthz. Writing a pod off because an idle
  connection was reused has cost seven healthy cards before.
* A timeout is NOT retried: the pod may be ninety seconds into a 130-second
  read, and sending the page again doubles the work on that card.
* An active cooldown is no longer extended. It used to be, silently: every
  further refusal pushed the expiry out while only the first one printed, so
  a five-minute window could last twenty with nothing in the log.

Two more ways to end up local, both of which were SILENT:

* The gate compared the request's engine against the three VLM engine names.
  /v1/ocr has always also accepted the MODEL a caller knows in place of the
  engine driving it — resolve_engine maps the Surya-2 checkpoint to 'surya'
  and the catalogue advertises those ids as aliases — so a caller naming the
  model got no pod-side pipeline and no message. The switch has to be read
  the way a READ reads it, for the same reason it has to be read the way a
  LAUNCH reads it.
* Serve mode not 'model', an empty model id, an engine with no pod pipeline,
  a standing refusal: each returned "" or None without a word. The log of the
  run that prompted this holds 8,278 successful pod reads and nothing at all
  explaining the thousands of documents in between. Every one of them now
  says which way the decision went, rate limited rather than said once,
  because a cooldown is minutes and a run is hours.

And, since reading a page here means rasterising here, ocr.pod_pipeline:

  prefer  (default) wait up to ocr.pod_pipeline_wait_s for a pod, then read
          it here — a pool with nothing free is usually one still booting.
          A timed-out wait is not paid again for five minutes, so the ceiling
          cannot become a per-page tax.
  auto    read it here at once (0.4.1-0.4.5 behaviour).
  require never rasterise on this box: 503, saying why.

Tests: 32 new (tests/test_ocr_pod_pipeline_policy.py) covering the alias, all
four silent routes, the three policies and the transport/answer line.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnfkxBpcZ1v6ypRFFyhrVM
parent be2eef1a
......@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here.
__version__ = "0.4.5"
__version__ = "0.4.6"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -4136,6 +4136,8 @@ def build_settings_dict(c, gpu_cards):
"surya_dpi": c.ocr.surya_dpi,
"max_concurrency": c.ocr.max_concurrency,
"max_pages": c.ocr.max_pages,
"pod_pipeline": c.ocr.pod_pipeline,
"pod_pipeline_wait_s": c.ocr.pod_pipeline_wait_s,
"max_resident_mb": c.ocr.max_resident_mb,
"page_window": c.ocr.page_window,
"raster_workers": c.ocr.raster_workers,
......@@ -4987,6 +4989,14 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
if "max_concurrency" in d: o.max_concurrency = _i("max_concurrency", o.max_concurrency, 1)
# 0 = derive from the cgroup limit or host RAM; see codai/ocr/budget.py
if "max_pages" in d: o.max_pages = _i("max_pages", o.max_pages, 0)
if "pod_pipeline" in d:
_pp = str(d.get("pod_pipeline") or "").strip().lower()
o.pod_pipeline = _pp if _pp in ("auto", "prefer", "require") else o.pod_pipeline
if "pod_pipeline_wait_s" in d:
try:
o.pod_pipeline_wait_s = max(0.0, float(d.get("pod_pipeline_wait_s") or 0))
except (TypeError, ValueError):
pass
if "max_resident_mb" in d: o.max_resident_mb = _i("max_resident_mb", o.max_resident_mb, 0)
if "page_window" in d: o.page_window = _i("page_window", o.page_window, 0)
if "raster_workers" in d: o.raster_workers = _i("raster_workers", o.raster_workers, 0)
......
......@@ -1131,6 +1131,25 @@
over-long document is refused in milliseconds with a 413 naming its page
count &mdash; never a silent or empty read.</span>
</div>
<div class="form-group" style="flex:1;min-width:170px">
<label class="form-label">When no pod can take a page</label>
<select id="s-ocr-pod-pipeline" class="form-input">
<option value="prefer">Wait for a pod, then read it here</option>
<option value="auto">Read it here straight away</option>
<option value="require">Refuse the page (never read it here)</option>
</select>
<span class="form-hint">Only applies where the pods&rsquo; image carries the OCR
pipeline. Reading a page here means <b>rasterising</b> here, which is what every
OCR memory incident has begun with &mdash; so the default waits for a pod first,
and says in the log whenever it gives up and reads one locally anyway.</span>
</div>
<div class="form-group" style="flex:1;min-width:130px">
<label class="form-label">Wait for a pod (s)</label>
<input type="number" id="s-ocr-pod-pipeline-wait" class="form-input" min="0" placeholder="60">
<span class="form-hint">How long to wait before giving up on a pod. Covers a pool
that is momentarily full, not a cold one: after a timeout the next pages go
straight through for five minutes rather than paying this per page.</span>
</div>
<div class="form-group" style="flex:1;min-width:140px">
<label class="form-label">Max resident pages (MB)</label>
<input type="number" id="s-ocr-max-resident-mb" class="form-input" min="0" placeholder="0">
......@@ -2255,6 +2274,8 @@ async function loadSettings(){
document.getElementById('s-ocr-surya-dpi').value = ocr.surya_dpi ?? 250;
document.getElementById('s-ocr-max-concurrency').value = ocr.max_concurrency ?? 64;
document.getElementById('s-ocr-max-pages').value = ocr.max_pages ?? 0;
document.getElementById('s-ocr-pod-pipeline').value = ocr.pod_pipeline ?? 'prefer';
document.getElementById('s-ocr-pod-pipeline-wait').value = ocr.pod_pipeline_wait_s ?? 60;
document.getElementById('s-ocr-max-resident-mb').value = ocr.max_resident_mb ?? 0;
document.getElementById('s-ocr-page-window').value = ocr.page_window ?? 0;
document.getElementById('s-ocr-raster-workers').value = ocr.raster_workers ?? 0;
......@@ -2560,6 +2581,8 @@ async function saveSettings(silent){
surya_dpi: parseInt(document.getElementById('s-ocr-surya-dpi').value) || 0,
max_concurrency: parseInt(document.getElementById('s-ocr-max-concurrency').value) || 64,
max_pages: parseInt(document.getElementById('s-ocr-max-pages').value) || 0,
pod_pipeline: document.getElementById('s-ocr-pod-pipeline').value || 'prefer',
pod_pipeline_wait_s: parseFloat(document.getElementById('s-ocr-pod-pipeline-wait').value) || 0,
max_resident_mb: parseInt(document.getElementById('s-ocr-max-resident-mb').value) || 0,
page_window: parseInt(document.getElementById('s-ocr-page-window').value) || 0,
raster_workers: parseInt(document.getElementById('s-ocr-raster-workers').value) || 0,
......
......@@ -1048,6 +1048,29 @@ class OcrConfig:
# It must never look like an empty or failed read -- see the "a degraded
# page still answers 200" trap this subsystem already carries.
max_pages: int = 0
# What to do when the model's pods are the ones that run the OCR pipeline
# (their image carries the rasteriser) but no pod can take a page.
#
# auto -- read it here at once. What 0.4.1-0.4.5 did, kept because an
# install with a GPU of its own may genuinely prefer it.
# prefer -- wait up to `pod_pipeline_wait_s` for a pod, then read it
# here. The default, because a pool with nothing free this
# second is usually a pool whose pods are still booting.
# require -- never rasterise on this box: answer 503 and say why.
#
# The default is not a performance setting, it is a memory one. A GPU-less
# orchestrator that quietly takes the pipeline back holds every page it
# renders: measured at ~0.8 GB/min and 36 GB on a 125 GB box, with sixteen
# rented cards idle, because a caller named the model instead of the engine
# and the gate said nothing. Waiting a minute is cheaper than that in every
# install where the pods exist at all.
pod_pipeline: str = "prefer"
# How long `prefer` (and `require`) wait for a pod before giving up on one.
# A pod takes minutes to boot, so this is deliberately NOT long enough to
# cover a cold pool: it covers a pool that is momentarily full. After a
# wait times out the next pages go straight through for a cooldown rather
# than paying the ceiling one page at a time.
pod_pipeline_wait_s: float = 60.0
# How many pages of one document are rasterised at a time. The document is read
# in windows, so residency is O(window) instead of O(document): a 400-page
# filing costs the same as an 8-page one. 0 = follow `max_concurrency` (capped
......@@ -2053,6 +2076,8 @@ class ConfigManager:
"max_concurrency": self.config.ocr.max_concurrency,
"max_resident_mb": self.config.ocr.max_resident_mb,
"max_pages": self.config.ocr.max_pages,
"pod_pipeline": self.config.ocr.pod_pipeline,
"pod_pipeline_wait_s": self.config.ocr.pod_pipeline_wait_s,
"page_window": self.config.ocr.page_window,
"raster_workers": self.config.ocr.raster_workers,
"lang": self.config.ocr.lang,
......
This diff is collapsed.
......@@ -305,16 +305,24 @@ def _fake_placement(monkeypatch, kind="pod", on_choose=None):
def test_no_pod_means_the_local_pipeline(monkeypatch):
"""`placement.choose` answering "local" means nothing is rented. The whole
document is then read locally -- not half one way and half the other, which
is the hardest kind of result to explain."""
is the hardest kind of result to explain.
With `ocr.pod_pipeline = auto`, which is this immediate fallback: the
shipped default waits for a pod first (see
tests/test_ocr_pod_pipeline_policy.py), because reading here means
rasterising here."""
class _Auto(_Cfg):
pod_pipeline = "auto"
mgr = om.OcrManager()
mgr._require_cfg = lambda: _Cfg()
mgr._require_cfg = lambda: _Auto()
mgr._sem = asyncio.Semaphore(4)
_fake_placement(monkeypatch, kind="local")
out = asyncio.run(mgr._read_on_pods(
_pdf(3), "d.pdf", "application/pdf", "m",
dpi=250, page_opts=None, detect=None, cfg=_Cfg()))
dpi=250, page_opts=None, detect=None, cfg=_Auto()))
assert out is None
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment