Skip to content

Claude/omnicorp worker pool error ytpcn2#119

Merged
maximusunc merged 5 commits into
mainfrom
claude/omnicorp-worker-pool-error-ytpcn2
Jul 10, 2026
Merged

Claude/omnicorp worker pool error ytpcn2#119
maximusunc merged 5 commits into
mainfrom
claude/omnicorp-worker-pool-error-ytpcn2

Conversation

@maximusunc

Copy link
Copy Markdown
Collaborator

No description provided.

claude added 5 commits July 10, 2026 16:32
A single pool child dying abruptly -- almost always an OOM kill while
overlaying a very large message -- puts the shared ProcessPoolExecutor
into a permanently broken state. From then on every run_in_executor call
raises BrokenProcessPool ("A process in the process pool was terminated
abruptly while the future was running or pending"), not just the task
whose child died. Because poll_for_tasks reused one executor for the
worker's whole lifetime, one oversized query wedged the worker into
failing every subsequent task until the pod restarted.

Wrap the executor in a PoolManager that catches the breakage, tears the
dead pool down, and stands up a fresh one so the worker recovers on its
next task. Replacement is guarded so the burst of concurrent
BrokenProcessPool errors (every in-flight future fails at once) swaps the
pool only once. The triggering task still fails cleanly via
run_task_lifecycle; only the poisoning of later tasks is fixed.

Also add an opt-in OMNICORP_MAX_TASKS_PER_CHILD knob to recycle pool
children under memory pressure so peak resident size from a big message
is returned to the OS instead of accumulating across tasks. Left unset by
default since it forces the spawn start method.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiiFCcToq8LgiEeaQhQM5e
aragorn_score and arax_rank had the same wedged-worker vulnerability just
fixed in aragorn.omnicorp: poll_for_tasks created a single
ProcessPoolExecutor reused for the worker's whole lifetime, so one child
dying abruptly (a kernel OOM kill on a large message) put the executor
into a permanently broken state and every subsequent task failed with "A
process in the process pool was terminated abruptly while the future was
running or pending" until the pod restarted.

Extract the self-healing wrapper introduced for omnicorp into a shared
shepherd_utils.process_pool.ProcessPoolManager and route all three CPU-
bound workers through it. The manager catches BrokenProcessPool, tears the
dead pool down, and stands up a fresh one so the worker recovers on its
next task; the triggering task still fails cleanly via run_task_lifecycle.

Surveyed the remaining workers: merge_message already recovers via its own
holder + _recreate_executor path; score_paths and monitor/alerts use
thread pools, which a task crash cannot permanently poison. No other
worker is affected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiiFCcToq8LgiEeaQhQM5e
The callback endpoint buffers each subservice's posted TRAPI response into
memory before decoding it. An oversized payload can OOM the server (and,
once merged, the downstream CPU-bound workers). Reject bodies larger than a
configurable limit with 413 before the whole body is read.

_read_body_within_limit checks the declared Content-Length first (so
well-behaved clients are rejected without buffering anything), then reads
the stream in chunks and aborts the moment the running total crosses the
limit, so a missing or dishonest Content-Length can't force an unbounded
buffer. Enforced in the shared callback() so every ARA's /callback route is
covered.

The limit is set via callback_max_request_size (Kubernetes-style sizes like
"100MB"/"512Mi" or a plain byte count), default 100MB; 0 disables it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiiFCcToq8LgiEeaQhQM5e
A callback only leaves the per-query running set (the postgres callbacks
table that get_running_callbacks reads) once merge_message has processed
it. When /callback rejects an oversized payload with 413 it never reaches
merge_message, so the callback stayed "running" and the lookup worker
blocked waiting for it until its whole-query timeout.

Call remove_callback_id on the 413 path so the rejected callback is dropped
from the running set immediately and the lookup proceeds with whatever else
came back, exactly as it already does when a lookup POST fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiiFCcToq8LgiEeaQhQM5e
/callback enqueues one wake task per callback and adds the callback to the
query's ready set. The first worker to grab the query lock becomes the
holder and drains the whole ready set; every other wake task for that query
is a loser. A loser used to re-enqueue itself after merge_contention_backoff
(0.1s) whenever its callback was still pending, so during a single burst
merge each contended callback bounced through the stream ~10x/second while
the holder drained. The shared get_tasks helper logs "Doing task <payload>"
at INFO on every redelivery, and re-enqueued wakes carry the same
callback_id/query_id, so the logs filled with hundreds of near-identical
"Doing task" lines for what was really one callback being retried -- no
duplicate merging, just wake-task churn.

Have losers simply ack and drop: the holder already drains the ready set to
empty, so a loser has nothing to add. To cover the narrow race where a
callback lands after the holder's final drain pass but before it releases
the lock, the holder now re-checks the ready set once after unlocking and
kicks a single wake if anything remains. Holder crashes are still recovered
by reclaim (60s min-idle) once the 45s lock TTL lapses. Net effect: at most
one re-enqueue per drain instead of a storm, and one "Doing task" per
callback instead of hundreds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiiFCcToq8LgiEeaQhQM5e
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.34694% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.43%. Comparing base (2aa25ee) to head (ee4593f).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
workers/aragorn_omnicorp/worker.py 35.71% 8 Missing and 1 partial ⚠️
workers/merge_message/worker.py 0.00% 8 Missing ⚠️
shepherd_utils/process_pool.py 84.61% 5 Missing and 1 partial ⚠️
workers/arax_rank/worker.py 0.00% 4 Missing ⚠️
shepherd_server/base_routes.py 88.00% 2 Missing and 1 partial ⚠️
workers/aragorn_score/worker.py 50.00% 2 Missing ⚠️
Files with missing lines Coverage Δ
shepherd_utils/config.py 93.75% <100.00%> (+4.41%) ⬆️
workers/aragorn_score/worker.py 89.92% <50.00%> (ø)
shepherd_server/base_routes.py 33.52% <88.00%> (+33.52%) ⬆️
workers/arax_rank/worker.py 0.00% <0.00%> (ø)
shepherd_utils/process_pool.py 84.61% <84.61%> (ø)
workers/merge_message/worker.py 65.59% <0.00%> (-0.29%) ⬇️
workers/aragorn_omnicorp/worker.py 72.11% <35.71%> (-1.53%) ⬇️

... and 1 file with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 57a4dc1...ee4593f. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@maximusunc
maximusunc merged commit 7dae192 into main Jul 10, 2026
2 checks passed
@maximusunc
maximusunc deleted the claude/omnicorp-worker-pool-error-ytpcn2 branch July 10, 2026 18:35
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.

2 participants