fix(pyamber): stop EndWorker from consuming straggler messages#6522
fix(pyamber): stop EndWorker from consuming straggler messages#6522aglinxinyuan wants to merge 3 commits into
Conversation
EndWorkerHandler logged its "unprocessed messages" warning through input_queue.get(), a destructive read: with exactly one straggler the message was silently dropped and EndWorker acknowledged success; with more, one message was still destroyed and the RPC failed with a bare AssertionError, so each coordinator retry that hit the guard ate another queued message. Mirror the Scala EndHandler instead: log via a non-destructive peek() (added to InternalQueue) and raise, so the RPC layer replies with a ControlError and the coordinator's terminateWorkersWithRetry retries once the queue has drained, with no messages lost. Tests live under amber/src/test/python per the current layout: a new test_end_worker_handler.py (the Python analogue of EndHandlerSpec) and peek() cases appended to the existing test_internal_queue.py.
Automated Reviewer SuggestionsBased on the
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a teardown race in the Python worker where EndWorkerHandler could destructively consume (“drop”) pending input-queue messages while checking for stragglers, causing incorrect successful acks and/or misleading failures during coordinator retries.
Changes:
- Make
EndWorkerHandlerlog pending work via a non-destructivepeek()and fail the RPC with a clearRuntimeErrorinstead of consuming viaget()and asserting. - Add
InternalQueue.peek()(delegating toLinkedBlockingMultiQueue.peek()) to support non-destructive inspection. - Add focused unit tests covering
peek()semantics and theEndWorkerretry protocol behavior (fail while stragglers exist; succeed once drained).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| amber/src/main/python/core/architecture/handlers/control/end_worker_handler.py | Switch EndWorker guard from destructive get()+assert to peek()+RuntimeError to preserve straggler messages and participate in retry semantics. |
| amber/src/main/python/core/models/internal_queue.py | Add InternalQueue.peek() API to expose non-destructive “next element” inspection. |
| amber/src/test/python/core/architecture/handlers/control/test_end_worker_handler.py | New tests validating EndWorker rejects stragglers without consuming them and succeeds after the queue drains. |
| amber/src/test/python/core/models/test_internal_queue.py | New tests validating peek() returns None on empty, is non-destructive, and matches the next get() across sub-queues. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6522 +/- ##
============================================
+ Coverage 70.57% 70.58% +0.01%
Complexity 3425 3425
============================================
Files 1146 1146
Lines 45062 45062
Branches 4960 4960
============================================
+ Hits 31802 31807 +5
+ Misses 11613 11608 -5
Partials 1647 1647
*This pull request uses carry forward flags. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
| config | throughput | MB/s | latency | max Δ latest / 7d | |
|---|---|---|---|---|---|
| 🔴 | bs=10 sw=10 sl=64 | 375 | 0.229 | 25,188/34,364/34,364 us | 🔴 +10.2% / 🔴 +123.7% |
| 🔴 | bs=100 sw=10 sl=64 | 796 | 0.486 | 123,099/146,256/146,256 us | 🔴 +9.7% / 🔴 +38.0% |
| ⚪ | bs=1000 sw=10 sl=64 | 913 | 0.557 | 1,098,399/1,138,550/1,138,550 us | ⚪ within ±5% / 🔴 +12.9% |
Baseline details
Latest main 39db110 from same runner
| config | metric | PR | latest main | 7d avg | Δ latest | Δ 7d |
|---|---|---|---|---|---|---|
| bs=10 sw=10 sl=64 | throughput | 375 tuples/sec | 407 tuples/sec | 770.44 tuples/sec | -7.9% | -51.3% |
| bs=10 sw=10 sl=64 | MB/s | 0.229 MB/s | 0.248 MB/s | 0.47 MB/s | -7.7% | -51.3% |
| bs=10 sw=10 sl=64 | p50 | 25,188 us | 22,848 us | 12,688 us | +10.2% | +98.5% |
| bs=10 sw=10 sl=64 | p95 | 34,364 us | 34,755 us | 15,365 us | -1.1% | +123.7% |
| bs=10 sw=10 sl=64 | p99 | 34,364 us | 34,755 us | 19,670 us | -1.1% | +74.7% |
| bs=100 sw=10 sl=64 | throughput | 796 tuples/sec | 837 tuples/sec | 1,004 tuples/sec | -4.9% | -20.7% |
| bs=100 sw=10 sl=64 | MB/s | 0.486 MB/s | 0.511 MB/s | 0.613 MB/s | -4.9% | -20.7% |
| bs=100 sw=10 sl=64 | p50 | 123,099 us | 118,606 us | 99,406 us | +3.8% | +23.8% |
| bs=100 sw=10 sl=64 | p95 | 146,256 us | 133,284 us | 105,989 us | +9.7% | +38.0% |
| bs=100 sw=10 sl=64 | p99 | 146,256 us | 133,284 us | 119,372 us | +9.7% | +22.5% |
| bs=1000 sw=10 sl=64 | throughput | 913 tuples/sec | 918 tuples/sec | 1,035 tuples/sec | -0.5% | -11.8% |
| bs=1000 sw=10 sl=64 | MB/s | 0.557 MB/s | 0.561 MB/s | 0.631 MB/s | -0.7% | -11.8% |
| bs=1000 sw=10 sl=64 | p50 | 1,098,399 us | 1,089,807 us | 973,037 us | +0.8% | +12.9% |
| bs=1000 sw=10 sl=64 | p95 | 1,138,550 us | 1,125,114 us | 1,015,887 us | +1.2% | +12.1% |
| bs=1000 sw=10 sl=64 | p99 | 1,138,550 us | 1,125,114 us | 1,047,206 us | +1.2% | +8.7% |
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,533.24,200,128000,375,0.229,25187.79,34364.13,34364.13
1,100,10,64,20,2512.35,2000,1280000,796,0.486,123098.85,146256.22,146256.22
2,1000,10,64,20,21914.01,20000,12800000,913,0.557,1098398.99,1138550.32,1138550.32Per review feedback: keep InternalQueue non-peekable (parity with queue.Queue) and keep the handler typed as IQueue. The EndWorker guard does not need the next message's content — it logs the pending count via the IQueue interface (len()/is_empty()) and raises, so no InternalQueue internals are exposed. Also fix the warning text to say EndWorker rather than EndHandler. - end_worker_handler.py: revert annotation to IQueue; the warning now references EndWorker and reports the pending count; the guard no longer calls peek() or get(). - internal_queue.py: remove InternalQueue.peek() and the now-unused Optional import. - test_internal_queue.py: drop the three peek tests.
…t once The local was annotated IQueue (which declares neither __len__ nor size), so len(input_queue) tripped static analysis. Type it as the actual InternalQueue and read queued_count = size() once, branching on it instead of the redundant is_empty()+len() double-check.
What changes were proposed in this PR?
Root cause. The Python worker's
EndWorkerHandlerguard logs its "unprocessed messages" warning throughinput_queue.get()— a destructive, blocking read that removes a pending message — and thenassert input_queue.is_empty(). With exactly one straggler message the straggler is silently dropped andEndWorkeris acknowledged as success; with two or more, one message is still destroyed and the RPC fails with a bareAssertionError— and because the coordinator retriesEndWorker, every retry that hits the guard eats another queued message until the last one is dropped under a success ack.EndWorkerAssertionErrorChanges:
end_worker_handler.py: log via a non-destructivepeek()and raiseRuntimeError("worker still has unprocessed messages")instead of consuming a message and asserting. The raise rides the existing failure path —AsyncRPCServer.receiveconverts a handler exception into aControlErrorreply,AsyncRPCClient.fulfillPromiseon the coordinator turns it into a failed future, andRegionExecutionManager.terminateWorkersWithRetryre-sendsEndWorkerafter the queue drains — the exact Python analogue of the ScalaEndHandler'sFuture.exception:internal_queue.py: exposeInternalQueue.peek(), delegating to the existing non-destructiveLinkedBlockingMultiQueue.peek()(mirrors what the ScalaEndHandlerreads viainputMessageQueue.peek()).Any related issues, documentation, discussions?
Closes #6521
How was this PR tested?
TDD — the tests were written first and fail against the unfixed handler (with 1 straggler: no exception is raised and the message vanishes; with ≥ 2:
AssertionErrorinstead of a clean RPC failure):src/test/python/core/architecture/handlers/control/test_end_worker_handler.py: acks on an empty queue; fails the RPC with one straggler; does not consume the straggler; keeps all messages with two stragglers; acks again once the queue drains (the retry protocol end-to-end). This is the Python analogue of the ScalaEndHandlerSpec.src/test/python/core/models/test_internal_queue.py:peek()on an empty queue returnsNone;peek()is non-destructive under repeated calls;peek()always surfaces exactly the element the nextget()returns across sub-queues (deliberately not pinning control-before-data ordering — the registration-order quirk is already documented by this file'sxfail).Ran locally:
cd amber && pytest -m "not integration"on the touched test files plus the full unit suite, andruff check src/main/python src/test/python && ruff format --check src/main/python src/test/python.Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Fable 5)