fix(pyamber): stop EndWorker from consuming straggler messages - #6522
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 77.31% 77.32% +0.01%
- Complexity 3524 3525 +1
============================================
Files 1161 1161
Lines 45920 45921 +1
Branches 5099 5099
============================================
+ Hits 35501 35507 +6
+ Misses 8839 8836 -3
+ Partials 1580 1578 -2
*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 | 400 | 0.244 | 23,366/31,063/31,063 us | 🔴 +16.0% / 🔴 +87.2% |
| 🔴 | bs=100 sw=10 sl=64 | 810 | 0.495 | 120,620/164,700/164,700 us | 🔴 +27.8% / 🔴 +51.0% |
| ⚪ | bs=1000 sw=10 sl=64 | 927 | 0.566 | 1,081,108/1,125,436/1,125,436 us | ⚪ within ±5% / 🔴 +7.9% |
Baseline details
Latest main 52584a8 from same runner
| config | metric | PR | latest main | 7d avg | Δ latest | Δ 7d |
|---|---|---|---|---|---|---|
| bs=10 sw=10 sl=64 | throughput | 400 tuples/sec | 431 tuples/sec | 754.55 tuples/sec | -7.2% | -47.0% |
| bs=10 sw=10 sl=64 | MB/s | 0.244 MB/s | 0.263 MB/s | 0.461 MB/s | -7.2% | -47.0% |
| bs=10 sw=10 sl=64 | p50 | 23,366 us | 20,144 us | 12,816 us | +16.0% | +82.3% |
| bs=10 sw=10 sl=64 | p95 | 31,063 us | 33,537 us | 16,594 us | -7.4% | +87.2% |
| bs=10 sw=10 sl=64 | p99 | 31,063 us | 33,537 us | 19,806 us | -7.4% | +56.8% |
| bs=100 sw=10 sl=64 | throughput | 810 tuples/sec | 844 tuples/sec | 969.38 tuples/sec | -4.0% | -16.4% |
| bs=100 sw=10 sl=64 | MB/s | 0.495 MB/s | 0.515 MB/s | 0.592 MB/s | -3.9% | -16.3% |
| bs=100 sw=10 sl=64 | p50 | 120,620 us | 117,601 us | 103,584 us | +2.6% | +16.4% |
| bs=100 sw=10 sl=64 | p95 | 164,700 us | 128,921 us | 109,097 us | +27.8% | +51.0% |
| bs=100 sw=10 sl=64 | p99 | 164,700 us | 128,921 us | 117,304 us | +27.8% | +40.4% |
| bs=1000 sw=10 sl=64 | throughput | 927 tuples/sec | 936 tuples/sec | 1,004 tuples/sec | -1.0% | -7.6% |
| bs=1000 sw=10 sl=64 | MB/s | 0.566 MB/s | 0.571 MB/s | 0.613 MB/s | -0.9% | -7.6% |
| bs=1000 sw=10 sl=64 | p50 | 1,081,108 us | 1,072,500 us | 1,002,357 us | +0.8% | +7.9% |
| bs=1000 sw=10 sl=64 | p95 | 1,125,436 us | 1,124,987 us | 1,046,463 us | +0.0% | +7.5% |
| bs=1000 sw=10 sl=64 | p99 | 1,125,436 us | 1,124,987 us | 1,073,661 us | +0.0% | +4.8% |
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,500.17,200,128000,400,0.244,23365.76,31062.73,31062.73
1,100,10,64,20,2467.66,2000,1280000,810,0.495,120620.48,164699.62,164699.62
2,1000,10,64,20,21565.47,20000,12800000,927,0.566,1081108.49,1125435.86,1125435.86Per 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.
Yicong-Huang
left a comment
There was a problem hiding this comment.
🟡 0 must-fix · 1 advisory · 1 polish — the fix is correct, matches the Scala EndHandler, and is well-tested; both notes are minor.
Design & architecture (1)
test_end_worker_handler.py:42— fixture bypassesControlHandler.__init__via__new__for no gain (advisory, see inline)
Polish: 1 quick touch-up (see inline comment).
Verification trace
Traced the failure path end to end: AsyncRPCServer.receive converts a handler exception into a ControlError reply, AsyncRPCClient.fulfillPromise turns it into a failed future, and RegionExecutionManager.terminateWorkersWithRetry re-sends EndWorker after killRetryDelay (bounded by maxTerminationAttempts) — so the raise has exactly the claimed effect and mirrors the Scala EndHandler's Future.exception. Also confirmed InternalQueue.size() and the old is_empty() read the same total_count (the guard's counting basis is unchanged — only the destructive get() is gone), and the tests use a real InternalQueue, so the non-consumption assertions genuinely observe the old bug.
…y wording - test fixture: construct EndWorkerHandler(SimpleNamespace(...)) directly; ControlHandler.__init__ only stashes context and never calls super().__init__(), so the __new__ bypass had nothing to bypass (same pattern as test_initialize_executor_handler.py). - log + comment: the coordinator retries on a fixed killRetryDelay (bounded by maxTerminationAttempts) and does not watch the queue, so say "a later coordinator retry succeeds once the queue has drained" instead of implying it retries in response to the drain.
Resolves the overlap with apache#6522 (stop EndWorker from consuming straggler messages): keep apache#6522's size()-based check and never-drop guarantee, layer the ack-only leniency on top (all-ReturnInvocation backlog completes with a warning; anything else is re-queued and fails the RPC). Test file is the union: apache#6522's five straggler cases plus the two ack-leniency cases.
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.EndWorkerAssertionErrorFix.
end_worker_handler.pyreads the queued count once viainput_queue.size()and branches on it, so nothing is consumed. When the queue is non-empty it logs the pending count and raisesRuntimeError("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-sendsEndWorkeron a fixedkillRetryDelay(bounded bymaxTerminationAttempts), succeeding once the queue has drained — the exact Python analogue of the ScalaEndHandler'sFuture.exception:The local is annotated as
InternalQueuebecausesize()lives onInternalQueue, not the baseIQueueinterface. No new inspection API (e.g.peek()) is added —InternalQueuestays non-peekable, likequeue.Queue— so the change is confined to the handler plus its new test.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.Ran locally:
cd amber && pytest -m "not integration"on the touched test file 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)