Skip to content

Fix merge_message crash loop on nodes missing optional TRAPI fields - #153

Merged
maximusunc merged 1 commit into
mainfrom
claude/session-d6yl98
Sep 2, 2026
Merged

Fix merge_message crash loop on nodes missing optional TRAPI fields#153
maximusunc merged 1 commit into
mainfrom
claude/session-d6yl98

Conversation

@maximusunc

Copy link
Copy Markdown
Collaborator

The merge_message worker was logging the same traceback dozens of times a second, on repeat, for the same two queries:

File "/app/shepherd_utils/shared.py", line 984, in merge_kgraph
  if value["name"]:

KeyError: 'name'

merge_kgraph subscripted name, categories and attributes on nodes and attributes/sources on edges. All of those are optional in TRAPI, and a subservice is free to omit one or send it as null -- is_support_edge right above already guards for exactly that on attributes. So one node without a name on an id already in the accumulator raised out of the process-pool child and took down the entire batch merge. Read them with .get() instead.

That alone was a bad callback; what made it a crash loop is the failure path. A failed merge re-enqueued its wake task immediately and unconditionally, and re-enqueueing mints a new stream message, so Redis' times_delivered (what the reclaim poison-pill breaker reads) resets to 1 every time and could never trip. A deterministic failure therefore spun: merge, raise, re-enqueue, with no backoff, for as long as the query lived -- burning a pool slot and never finishing the query.

So the failure path now backs off exponentially between attempts, carrying the attempt count in the task fields, and after merge_max_attempts (3) consecutive failures discards the batch it cannot merge -- clearing it from the ready index and callbacks table -- then re-enqueues with the counter reset so the query merges everything else and finishes. BrokenProcessPool goes through the same breaker: a batch that OOM-kills the child kills it again on every retry.

_clear_batch moves to module level alongside the new handler so both are unit-testable.

Claude-Session: https://claude.ai/code/session_011Ms8RqVffNEzs3X1Qz15To

The merge_message worker was logging the same traceback dozens of times a
second, on repeat, for the same two queries:

    File "/app/shepherd_utils/shared.py", line 984, in merge_kgraph
      if value["name"]:
  KeyError: 'name'

merge_kgraph subscripted `name`, `categories` and `attributes` on nodes and
`attributes`/`sources` on edges. All of those are optional in TRAPI, and a
subservice is free to omit one or send it as null -- `is_support_edge` right
above already guards for exactly that on `attributes`. So one node without a
`name` on an id already in the accumulator raised out of the process-pool
child and took down the entire batch merge. Read them with .get() instead.

That alone was a bad callback; what made it a crash loop is the failure path.
A failed merge re-enqueued its wake task immediately and unconditionally, and
re-enqueueing mints a new stream message, so Redis' `times_delivered` (what
the reclaim poison-pill breaker reads) resets to 1 every time and could never
trip. A deterministic failure therefore spun: merge, raise, re-enqueue, with
no backoff, for as long as the query lived -- burning a pool slot and never
finishing the query.

So the failure path now backs off exponentially between attempts, carrying
the attempt count in the task fields, and after merge_max_attempts (3)
consecutive failures discards the batch it cannot merge -- clearing it from
the ready index and callbacks table -- then re-enqueues with the counter
reset so the query merges everything else and finishes. BrokenProcessPool
goes through the same breaker: a batch that OOM-kills the child kills it
again on every retry.

_clear_batch moves to module level alongside the new handler so both are
unit-testable.

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

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.47619% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.91%. Comparing base (07f18b3) to head (c7e0954).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
workers/merge_message/worker.py 84.61% 3 Missing and 1 partial ⚠️
Files with missing lines Coverage Δ
shepherd_utils/config.py 95.53% <100.00%> (+0.12%) ⬆️
shepherd_utils/shared.py 80.84% <100.00%> (+0.91%) ⬆️
workers/merge_message/worker.py 70.40% <84.61%> (+1.79%) ⬆️

... 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 4f7d4bc...c7e0954. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@maximusunc
maximusunc merged commit 3958a2e into main Sep 2, 2026
2 checks passed
@maximusunc
maximusunc deleted the claude/session-d6yl98 branch September 2, 2026 13:01
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