Repository navigation
Never start a queued task that is cancelled or stopped; two queues for the process backend - #508
Conversation
… talk to the process runner through one queue per direction Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
payno
left a comment
There was a problem hiding this comment.
Looks good but this was an hard one.
I didn't went deeply into the tests.
| def try_start(self) -> Optional[StartDenial]: | ||
| """Mark the task as started unless it was denied. Returns the denial.""" | ||
| return self._gate.open() | ||
|
|
There was a problem hiding this comment.
This seems a bit weird because it uses _gate that is defined in parent class TaskController and there is no other operation using the gate or try_start in the children class.
There was a problem hiding this comment.
- In-process controller (sync, thread): the runner is in the same process, so it calls
controller.try_start()directly. - Process controller: the runner is in another process, so it asks over the queue, and the controller calls
self._gate.open()in_allow_start.
The process controller opens the gate too, in _allow_start, when the runner asks over the queue. Only in-process runners can call it directly, so try_start lives here.
| future.cancel() | ||
| return True |
There was a problem hiding this comment.
I would expects that we return the result of the future.cancel no ?
| future.cancel() | |
| return True | |
| return future.cancel() |
There was a problem hiding this comment.
No: if a worker already picked up the task, future.cancel() returns False, but the gate already denied the start, so the task is cancelled anyway. future.cancel() only completes a still-queued future early.
|
|
||
|
|
||
| class StartGate: | ||
| """Decides once whether a task may start.""" |
There was a problem hiding this comment.
| """Decides once whether a task may start.""" | |
| """Decides once whether a task may start. | |
| The gate is shared by two sides, each calling one method: | |
| - the runner, which executes the task, calls `open` right before creating | |
| the task to ask whether it may start it. | |
| - the caller, which controls the task, calls `deny` to prevent the task from | |
| starting. | |
| The first of the two to take the lock makes the decision. | |
| """ |
There was a problem hiding this comment.
Added, shortened. In the process backend the controller calls open for the runner, so I wrote "the side executing the task" instead of "the runner".
| return self._denial | ||
|
|
||
| def deny(self, denial: StartDenial) -> bool: | ||
| """Prevent the task from starting. |
There was a problem hiding this comment.
| """Prevent the task from starting. | |
| """Prevent the task from starting. Called by the side controlling the task. |
| return True | ||
|
|
||
| def open(self) -> Optional[StartDenial]: | ||
| """Let the task start unless it was denied. Returns the denial.""" |
There was a problem hiding this comment.
| """Let the task start unless it was denied. Returns the denial.""" | |
| """Let the task start unless it was denied. Called by the side executing | |
| the task, right before creating it. | |
| Returns the denial, or `None` when the task may start. | |
| """ |
| raise NotImplementedError | ||
| if self._gate.deny(StartDenial.STOPPED): | ||
| return True | ||
| if not self._gate.started: |
There was a problem hiding this comment.
Was not that direct to me. Nevertheless the comment might be over-killed. I let you judge.
| if not self._gate.started: | |
| # case task already started or already denied | |
| if not self._gate.started: |
There was a problem hiding this comment.
Added, placed on the return False: past deny(), not started means denied before.
| raise NotImplementedError | ||
|
|
||
| def stop_requested(self) -> bool: | ||
| """Return True if the stop request prevented the start or reached the task.""" |
There was a problem hiding this comment.
| """Return True if the stop request prevented the start or reached the task.""" | |
| """Return True if the stop request prevented the start or reached the task.""" | |
| # Stopped before starting: the gate holds the denial, nothing was sent (set self._gate.denial). | |
| # Stopped while running: the stop request was sent to the task (set self._stop_sent). |
There was a problem hiding this comment.
Added, without the parentheses.
| cancelled). Manager queues are FIFO, so draining up to the sentinel | ||
| delivers all of them. | ||
| """ | ||
| if self._relay_thread is None: |
There was a problem hiding this comment.
Not related to the PR but re-reading the classes this was not obvious
| if self._relay_thread is None: | |
| # Guard in case never watched. Should never happen. | |
| if self._relay_thread is None: |
| name = task_class.__name__ | ||
| if denial is StartDenial.STOPPED: | ||
| return TaskStoppedError(f"Task {name} was stopped before it started") | ||
| raise ValueError(f"Start denial {denial} is not handled") |
There was a problem hiding this comment.
raise is done from raise _denial_error(self._task_class, denial). So from this and the docstring I would expect more something like:
| raise ValueError(f"Start denial {denial} is not handled") | |
| return ValueError(f"Start denial {denial} is not handled") |
| def _read_to_runner_queue(): | ||
| try: | ||
| while True: | ||
| message = self._to_runner_queue.get() | ||
| if message is ToRunner.REQUEST_STOP: | ||
| stop_event.set() | ||
| elif message is ToRunner.END: | ||
| return | ||
| except self._TRANSIENT_ERRORS: | ||
| pass |
There was a problem hiding this comment.
nested function + a try + a while starts to get difficult to read. I would remove at least one.
| def _read_to_runner_queue(): | |
| try: | |
| while True: | |
| message = self._to_runner_queue.get() | |
| if message is ToRunner.REQUEST_STOP: | |
| stop_event.set() | |
| elif message is ToRunner.END: | |
| return | |
| except self._TRANSIENT_ERRORS: | |
| pass | |
| def _relay_stop_requests() -> None: | |
| """Set `stop_event` on stop requests until `ToRunner.END`.""" | |
| while True: | |
| message = self._to_runner_queue.get() | |
| if message is ToRunner.REQUEST_STOP: | |
| stop_event.set() | |
| elif message is ToRunner.END: | |
| return | |
| def _read_to_runner_queue() -> None: | |
| """Set `stop_event` on stop requests until `ToRunner.END` or the manager shuts down.""" | |
| try: | |
| _relay_stop_requests() | |
| except self._TRANSIENT_ERRORS: | |
| pass |
Maybe the function could also be moved to private at the class scope. No strong opinion there.
There was a problem hiding this comment.
Moved to a private method instead of splitting it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e14a18a to
ec30e17
Compare
…turns False Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er never watched Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sing it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #514
Stack (split from #501, merge in order):
PR summary
cancel()/request_stop()that reaches a queued task prevents it from starting.cancel():result()raisesCancelledError.request_stop():result()raises the newTaskStoppedError.aboutToStartorstarted.False. On a finished task they do nothing.Events and the single events queue are replaced by two queues._progress.py→_messages.py. The endpoints are named controller and runner.concurrent.futures.ProcessPoolExecutor(pebble comes later).AI Disclosure
🤖 Generated with Claude Code