Skip to content

Claude/worker diagnosis g2v5cw#129

Merged
maximusunc merged 5 commits into
mainfrom
claude/worker-diagnosis-g2v5cw
Jul 15, 2026
Merged

Claude/worker diagnosis g2v5cw#129
maximusunc merged 5 commits into
mainfrom
claude/worker-diagnosis-g2v5cw

Conversation

@maximusunc

Copy link
Copy Markdown
Collaborator

No description provided.

claude and others added 5 commits July 15, 2026 15:20
A single oversized sort_results_score response was OOM-killed (an
uncatchable SIGKILL) mid-load. Because the worker died before acking, the
message stayed pending in the Redis stream and was re-delivered to every
restart via reclaim, turning one bad task into a ~2-hour CrashLoopBackOff
for the whole queue. Two layered fixes:

1. Delivery-count circuit breaker (always on). reclaim_orphaned now
   surfaces each candidate's Redis Streams times_delivered via an optional
   out-param, and get_tasks dead-letters (ack + route to finish_query with
   ERROR) any reclaimed message delivered >= max_task_deliveries times
   without completing, instead of retrying it forever. Bounds any poison
   pill -- OOM, segfault, hard crash -- to N attempts. Configurable via
   max_task_deliveries (default 5; 0 disables).

2. Pre-flight response-size guard (opt-in). enforce_response_size_limit
   checks a response's stored (compressed) blob size with a cheap Redis
   STRLEN before get_message, and raises ResponseTooLargeError -- which the
   existing run_task_lifecycle turns into a clean finish_query ERROR --
   before the memory-expanding decompress/parse/sort ever runs. Turns an
   uncatchable OOM SIGKILL into a catchable failure with no crash. Wired
   into sort_results_score; other workers can adopt the same one-liner.
   Configurable via max_response_size (default 0 / disabled).

_discard_unprocessable_task is refactored onto a shared _terminate_task
helper; behavior is unchanged. Adds unit tests for the delivery-count
population, the get_tasks dead-letter path, the size guard, and the worker
raising before load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018oXSgLFH28b4KTU7m6wnno
…essed size

Follow-up on the OOM: the 320MB figure is the *uncompressed* response. The
worker dies during the load (before "Returning sorted results"), because
decode_message decompresses to the full JSON bytes and orjson parses that
into a Python object tree ~5-6x larger for TRAPI-shaped data (measured 5.7x);
the tree and the decompressed bytes coexist during the parse, so peak is
roughly uncompressed x 6-7 plus the process baseline -- ~2.5GB for a 320MB
response, over a 2Gi limit.

- sort_results_score now sorts in place (list.sort, not sorted()) instead of
  allocating a second copy of the results list and of every result's analyses
  list -- wasted peak on exactly the payloads big enough to matter. The
  results list is rebound at the end so a response that arrived without a
  results field still gets one (behavior preserved), without a copy.

- The size guard now gates on the response's *uncompressed* size (the number
  the /response endpoint returns), read cheaply from the zstd frame header via
  a GETRANGE of a few bytes -- no fetch, no decompress -- instead of the
  compressed STRLEN. This is the size that actually drives peak memory, so the
  configured limit is intuitive: for a 2Gi pod, cap around 200-250M. Adds
  get_response_size; keeps get_blob_size for the compressed length.

The dominant cost (parse expansion + the decompressed bytes held alongside the
tree during orjson.loads) is intrinsic to an all-at-once parse and can't be
removed without streaming decode; the in-place sort removes the duplicates
that are ours to remove. Tests updated/added for the uncompressed-size read,
the in-place sort, and the missing-results-key path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018oXSgLFH28b4KTU7m6wnno
Apply the in-place, no-duplicate approach from sort_results_score to the
other workers that transform the large TRAPI response so peak memory tracks
one copy of the payload, not two:

- filter_results_top_n: del results[n:] in place instead of binding a
  results[:n] slice. The slice allocated a second list and, worse, kept the
  dropped results (and everything they reference) alive until the function
  returned -- i.e. through the save. Deleting the tail frees them before the
  re-encode.
- filter_analyses_top_n: del result["analyses"][n:] in place, same reasoning,
  per result.
- filter_kgraph_orphans (shared): delete orphan nodes/edges/auxiliary_graphs
  from the existing dicts in place instead of rebuilding each with a
  comprehension, which held the full original dict and the filtered copy
  simultaneously for the largest structure in the response. Snapshot the keys
  to delete first to avoid mutating during iteration; the empty
  auxiliary_graphs key is still created when absent to preserve behavior.
- merge_message: check the original query's presence with a cheap EXISTS
  (new db.message_exists) instead of loading, decompressing and parsing the
  whole query blob just to discard it.

example_score and filter_kgraph_orphans' worker already mutated in place;
aragorn_score/sipr deepcopies are algorithmic and left untouched.

Adds tests for message_exists and the filter workers' missing-results path.

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

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.87879% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 51.99%. Comparing base (97d5fcb) to head (f86de24).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
shepherd_utils/db.py 76.92% 4 Missing and 2 partials ⚠️
shepherd_utils/shared.py 95.23% 0 Missing and 1 partial ⚠️
workers/merge_message/worker.py 0.00% 1 Missing ⚠️
Files with missing lines Coverage Δ
shepherd_utils/config.py 94.38% <100.00%> (+0.33%) ⬆️
shepherd_utils/reclaim.py 73.21% <100.00%> (+0.99%) ⬆️
workers/filter_analyses_top_n/worker.py 65.85% <100.00%> (ø)
workers/filter_results_top_n/worker.py 65.00% <100.00%> (+0.89%) ⬆️
workers/sort_results_score/worker.py 74.41% <100.00%> (+1.24%) ⬆️
shepherd_utils/shared.py 75.30% <95.23%> (+3.40%) ⬆️
workers/merge_message/worker.py 66.73% <0.00%> (+0.28%) ⬆️
shepherd_utils/db.py 60.92% <76.92%> (+1.14%) ⬆️

... 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 f73292d...f86de24. 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 9df6b13 into main Jul 15, 2026
2 checks passed
@maximusunc
maximusunc deleted the claude/worker-diagnosis-g2v5cw branch July 15, 2026 18:28
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