Skip to content

Commit 4198c81

Browse files
nitrobass24claude
andcommitted
Fix #571: drain dead worker queues before closing them
WorkerSupervisor.recreate_if_dead() swapped in a fresh worker and then closed the dead worker's result queues without draining them, so extract/ validate work the worker finished just before crashing was silently dropped (the sole consumer, ModelUpdater, read the fresh worker's empty queues after the swap). The supervisor now drains the dead worker's completed/failed queues into a carry-over buffer before _reap_dead() closes them, and surfaces them on the next pop_completed()/pop_failed() — exactly once, ahead of the fresh worker's results. ModelUpdater reads completed/failed through the supervisor (statuses stay on .worker, display-only). Hardening from adversarial review: - Read the live worker before clearing the buffer, so a failed live read does not drop the carry-over. - try/finally guarantees the dead worker's queues are still closed (FDs freed) even if draining raises. Tests: reproduce the drop, exactly-once surfacing, rapid-restart accumulation, per-queue drain-failure isolation, buffer survival on a failing live read, and reap-on-drain-failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 55be236 commit 4198c81

3 files changed

Lines changed: 199 additions & 18 deletions

File tree

src/python/controller/model_updater.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,18 @@ def __init__(
7878
self._move_retry_counts: dict[str, int] = {}
7979

8080
def update(self) -> None:
81-
# Grab the latest extract results (shared)
81+
# Grab the latest extract results. Completed/failed go through the
82+
# supervisor (not .worker) so results buffered from a dead worker during
83+
# recreation are surfaced exactly once (#571); statuses are display-only
84+
# and read straight off the live worker.
8285
latest_extract_statuses = self._extract_process.worker.pop_latest_statuses()
83-
latest_extracted_results = self._extract_process.worker.pop_completed()
84-
latest_failed_extractions = self._extract_process.worker.pop_failed()
86+
latest_extracted_results = self._extract_process.pop_completed()
87+
latest_failed_extractions = self._extract_process.pop_failed()
8588

86-
# Grab the latest validate results (shared)
89+
# Grab the latest validate results (same #571 contract as extract).
8790
latest_validate_statuses = self._validate_process.worker.pop_latest_statuses()
88-
latest_validated_results = self._validate_process.worker.pop_completed()
89-
latest_failed_validations = self._validate_process.worker.pop_failed()
91+
latest_validated_results = self._validate_process.pop_completed()
92+
latest_failed_validations = self._validate_process.pop_failed()
9093

9194
# Process each pair context's scan results and LFTP status
9295
for pc in self._pair_contexts:

src/python/controller/worker_supervisor.py

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,27 @@
2121

2222
import logging
2323
from collections.abc import Callable
24-
from typing import Any, Generic, TypeVar
24+
from typing import Any, Generic, Protocol, TypeVar, cast
2525

2626
from common import AppProcess
2727

2828
W = TypeVar("W", bound=AppProcess)
2929

3030

31+
class _ResultReader(Protocol):
32+
"""The result-queue drain surface shared by the Extract/Validate workers.
33+
34+
These live on the concrete workers rather than the ``AppProcess`` base (the
35+
completed/failed result types differ between Extract and Validate), so the
36+
supervisor reaches them via a cast when draining a dead worker before its
37+
queues are closed (#571).
38+
"""
39+
40+
def pop_completed(self) -> list[Any]: ...
41+
42+
def pop_failed(self) -> list[Any]: ...
43+
44+
3145
class WorkerSupervisor(Generic[W]):
3246
"""Owns the current live worker plus a factory that builds a fresh one.
3347
@@ -52,6 +66,11 @@ def __init__(self, factory: Callable[[], W], logger: logging.Logger, feature: st
5266
# to the same cross-process logging queue before it is started.
5367
self._mp_log_queue: Any = None
5468
self._mp_log_level: int | None = None
69+
# Results drained from a dead worker during recreation, surfaced on the
70+
# next pop_completed()/pop_failed() so finished work emitted just before
71+
# a crash is not silently dropped when its queues close (#571).
72+
self._buffered_completed: list[Any] = []
73+
self._buffered_failed: list[Any] = []
5574

5675
@property
5776
def worker(self) -> W:
@@ -93,18 +112,51 @@ def close_queues(self) -> None:
93112
def propagate_exception(self) -> None:
94113
self._worker.propagate_exception()
95114

115+
def pop_completed(self) -> list[Any]:
116+
"""Drain completed results, prepended with any buffered from a dead worker.
117+
118+
Readers must go through the supervisor (not ``self.worker``) so results a
119+
worker emitted just before crashing — captured by :meth:`_drain_dead`
120+
during recreation — are surfaced exactly once, ahead of the fresh
121+
worker's own results (#571). ModelUpdater drains this in the same
122+
controller cycle as recreation, so the carry-over is picked up promptly.
123+
124+
The live worker is read *before* the buffer is cleared so that a failure
125+
reading the live worker does not drop the carry-over: the buffer is left
126+
intact and surfaces on a later cycle instead of being lost.
127+
"""
128+
fresh = cast(_ResultReader, self._worker).pop_completed()
129+
buffered = self._buffered_completed
130+
self._buffered_completed = []
131+
return buffered + fresh
132+
133+
def pop_failed(self) -> list[Any]:
134+
"""Drain failed results, prepended with any buffered from a dead worker (#571).
135+
136+
See :meth:`pop_completed` for the carry-over contract, including the
137+
read-live-before-clearing-the-buffer ordering that keeps the carry-over
138+
from being lost if the live read fails.
139+
"""
140+
fresh = cast(_ResultReader, self._worker).pop_failed()
141+
buffered = self._buffered_failed
142+
self._buffered_failed = []
143+
return buffered + fresh
144+
96145
# --- delegated worker methods (dynamic) ---
97146

98147
def __getattr__(self, item: str) -> Any:
99148
"""Delegate the worker's own methods to the current live instance.
100149
101-
Covers the result-queue readers (``pop_latest_statuses``/
102-
``pop_completed``/``pop_failed``) and the request-dispatch methods
103-
(``extract``/``validate``) — the worker surface that differs between
104-
ExtractProcess and ValidateProcess and so cannot be typed on a single
105-
``Generic[W]`` supervisor. Callers that need the concrete return types
106-
read them through :attr:`worker` (e.g. ``supervisor.worker.pop_completed()``);
107-
ModelUpdater does this.
150+
Covers the display-only status reader (``pop_latest_statuses``) and the
151+
request-dispatch methods (``extract``/``validate``) — the worker surface
152+
that differs between ExtractProcess and ValidateProcess and so cannot be
153+
typed on a single ``Generic[W]`` supervisor. Callers that need the
154+
concrete status type read it through :attr:`worker` (e.g.
155+
``supervisor.worker.pop_latest_statuses()``); ModelUpdater does this. The
156+
completed/failed readers are *not* dynamic — they are the explicit
157+
:meth:`pop_completed`/:meth:`pop_failed` above so they can carry over a
158+
dead worker's buffered results (#571), and ModelUpdater calls those
159+
through the supervisor rather than via :attr:`worker`.
108160
109161
Only invoked for attributes not defined on the supervisor itself, so the
110162
explicit delegations above always win. ``_worker`` is set in
@@ -144,10 +196,37 @@ def recreate_if_dead(self) -> bool:
144196
# Swap before reaping the old worker so a failure releasing the dead
145197
# worker's queues still leaves the supervisor pointing at the live one.
146198
self._worker = fresh
147-
self._reap_dead(dead)
199+
# Capture any finished results still in the dead worker's queues before
200+
# _reap_dead() closes them, so they are not silently dropped (#571). The
201+
# finally guarantees the dead worker's queues are still closed (FDs
202+
# freed) even in the unlikely event draining itself raises.
203+
try:
204+
self._drain_dead(dead)
205+
finally:
206+
self._reap_dead(dead)
148207
self._logger.info("%s worker process restarted as %s", self._feature, fresh.name)
149208
return True
150209

210+
def _drain_dead(self, dead: W) -> None:
211+
"""Buffer results the dead worker emitted before crashing (#571).
212+
213+
Runs before :meth:`_reap_dead` closes the dead worker's queues. Each
214+
queue is drained in isolation and best-effort: a worker mid-crash may
215+
raise on a read, and a drain failure must never block the restart. The
216+
results are *extended* onto the buffers (not assigned) so repeated
217+
restarts before a consumer drain accumulate rather than clobber. They
218+
surface on the next :meth:`pop_completed`/:meth:`pop_failed`.
219+
"""
220+
reader = cast(_ResultReader, dead)
221+
try:
222+
self._buffered_completed.extend(reader.pop_completed())
223+
except Exception:
224+
self._logger.debug("Error draining dead %s worker completions during restart", self._feature, exc_info=True)
225+
try:
226+
self._buffered_failed.extend(reader.pop_failed())
227+
except Exception:
228+
self._logger.debug("Error draining dead %s worker failures during restart", self._feature, exc_info=True)
229+
151230
def _is_dead(self) -> bool:
152231
"""True if the worker was started and has since exited."""
153232
try:

src/python/tests/unittests/test_controller/test_worker_supervisor.py

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,100 @@ def test_subsequent_dispatch_goes_to_fresh_worker(self):
143143
self.replacement.pop_completed.assert_called_once()
144144
self.initial.extract.assert_not_called()
145145

146+
def test_recreate_drains_dead_worker_results_before_close(self):
147+
# #571: results the dead worker emitted just before crashing must be
148+
# surfaced through the supervisor, not silently dropped when its queues
149+
# are closed. Buffer them on the dead worker, kill it, recreate.
150+
c1, c2 = MagicMock(name="completed-1"), MagicMock(name="completed-2")
151+
f1 = MagicMock(name="failed-1")
152+
self.initial.pop_completed.return_value = [c1, c2]
153+
self.initial.pop_failed.return_value = [f1]
154+
self.initial.is_alive.return_value = False
155+
self.initial.exitcode = 1
156+
157+
created = self.sup.recreate_if_dead()
158+
self.assertTrue(created)
159+
160+
# The dead worker was drained before close_queues() closed its FDs.
161+
self.initial.close_queues.assert_called_once()
162+
# The buffered results are surfaced exactly once via the supervisor,
163+
# ahead of the (empty) fresh worker's results.
164+
self.assertEqual([c1, c2], self.sup.pop_completed())
165+
self.assertEqual([f1], self.sup.pop_failed())
166+
# And only once: a second drain yields just the fresh worker's results.
167+
self.assertEqual([], self.sup.pop_completed())
168+
self.assertEqual([], self.sup.pop_failed())
169+
170+
def test_rapid_restarts_accumulate_buffered_results(self):
171+
# Two deaths before the consumer drains must accumulate, not clobber (#571).
172+
w1 = _make_worker("W-1", alive=False, exitcode=1)
173+
w1.pop_completed.return_value = ["a"]
174+
w2 = _make_worker("W-2", alive=False, exitcode=1)
175+
w2.pop_completed.return_value = ["b"]
176+
w3 = _make_worker("W-3", alive=True, exitcode=None)
177+
sup: WorkerSupervisor = WorkerSupervisor(
178+
factory=_FakeFactory([w1, w2, w3]), logger=self.logger, feature="Extract"
179+
)
180+
181+
sup.recreate_if_dead() # w1 dies -> buffer ["a"], swap to w2
182+
sup.recreate_if_dead() # w2 dies -> buffer ["a", "b"], swap to w3
183+
184+
self.assertIs(w3, sup.worker)
185+
self.assertEqual(["a", "b"], sup.pop_completed())
186+
187+
def test_drain_failure_is_isolated_and_does_not_block_restart(self):
188+
# A worker mid-crash may raise on one queue read; the restart must still
189+
# happen and the other queue's results must still be surfaced (#571).
190+
self.initial.is_alive.return_value = False
191+
self.initial.exitcode = 1
192+
self.initial.pop_completed.side_effect = RuntimeError("queue broken")
193+
self.initial.pop_failed.return_value = ["f"]
194+
195+
created = self.sup.recreate_if_dead()
196+
197+
self.assertTrue(created)
198+
self.assertIs(self.replacement, self.sup.worker)
199+
self.initial.close_queues.assert_called_once()
200+
# The unreadable completed queue is skipped; the readable failed queue
201+
# is still surfaced.
202+
self.assertEqual([], self.sup.pop_completed())
203+
self.assertEqual(["f"], self.sup.pop_failed())
204+
205+
def test_buffered_results_survive_a_failing_live_read(self):
206+
# #571: if reading the fresh worker raises (e.g. it too crashed and its
207+
# queue is closed), the carry-over buffer must NOT be cleared/lost — it
208+
# has to survive to a later cycle once a healthy worker can be read.
209+
self.initial.pop_completed.return_value = ["carried"]
210+
self.initial.is_alive.return_value = False
211+
self.initial.exitcode = 1
212+
self.sup.recreate_if_dead() # buffers ["carried"], swaps to replacement
213+
214+
# The fresh worker's own read blows up on the next drain.
215+
self.replacement.pop_completed.side_effect = ValueError("Queue is closed")
216+
with self.assertRaises(ValueError):
217+
self.sup.pop_completed()
218+
219+
# Buffer was preserved despite the failed read; once the worker recovers,
220+
# the carried result is still surfaced exactly once.
221+
self.replacement.pop_completed.side_effect = None
222+
self.replacement.pop_completed.return_value = ["fresh"]
223+
self.assertEqual(["carried", "fresh"], self.sup.pop_completed())
224+
self.assertEqual(["fresh"], self.sup.pop_completed())
225+
226+
def test_reap_runs_even_if_draining_raises(self):
227+
# Defense in depth: if draining the dead worker unexpectedly raises (e.g.
228+
# the logger itself fails inside _drain_dead), its queues must still be
229+
# closed so file descriptors are freed (#571).
230+
self.initial.is_alive.return_value = False
231+
self.initial.exitcode = 1
232+
self.initial.pop_completed.side_effect = RuntimeError("queue broken")
233+
self.logger.debug.side_effect = RuntimeError("logging down")
234+
235+
with self.assertRaises(RuntimeError):
236+
self.sup.recreate_if_dead()
237+
238+
self.initial.close_queues.assert_called_once()
239+
146240
def test_recreate_survives_dead_worker_close_failure(self):
147241
# Releasing the dead worker's queues must not abort the swap.
148242
self.initial.is_alive.return_value = False
@@ -230,13 +324,18 @@ def test_pipeline_recreate_rewires_updater_reads(self):
230324
sup_logger.warning.assert_called()
231325
sup_logger.info.assert_called()
232326

233-
# ModelUpdater.update() now drains the FRESH worker's queues, not the
234-
# dead one's — the swap re-wired the updater through the shared supervisor.
327+
# ModelUpdater.update() reads completed/failed through the shared
328+
# supervisor, so the swap re-wires it to the fresh worker; statuses are
329+
# read straight off the fresh worker.
235330
updater.update()
236331
replacement.pop_latest_statuses.assert_called()
237332
replacement.pop_completed.assert_called()
238333
replacement.pop_failed.assert_called()
239-
initial.pop_completed.assert_not_called()
334+
# The dead worker is drained exactly once during recreation (#571) so its
335+
# last-moment results are surfaced rather than dropped, and is not read
336+
# again by the updater afterward.
337+
initial.pop_completed.assert_called_once()
338+
initial.pop_failed.assert_called_once()
240339

241340
def test_pipeline_dispatch_after_recreate_targets_fresh_worker(self):
242341
logger = MagicMock()

0 commit comments

Comments
 (0)