Repository navigation
Conversation
528ab59 to
10b490d
Compare
|
Correction to my own earlier framing: the "an unobserved/defaulted exit code overriding an already-confirmed terminal state" explanation in the current docstring is inference I hadn't actually verified. I built live instrumentation and reproduced the crash under load — full writeup with tracebacks and the apiserver-side timeline is in #65708 (comment). Short version: the real, reachable case is One addition since I first posted this: the other duplicate write in that same trace (a retried Holding off on any further changes here pending review. Drafted-by: Claude Code (Sonnet 5); reviewed by @seanmuth before posting |
10b490d to
86a3f75
Compare
|
Pushed a squashed rewrite addressing this round of review:
Full suite green (211 passed, 1 skipped/platform-only) plus mypy clean after each change. Drafted-by: Claude Sonnet 5 (no human review before posting) |
kaxil
left a comment
There was a problem hiding this comment.
Re-reviewed at 86a3f75. Everything from the earlier rounds is addressed and the precedence change is correct in every (_terminal_state, _exit_code, _should_retry) combination I traced. Approving.
Three things to take or leave, none worth another round:
supervisor.py:1813-1816 states the 409 unconditionally, but it only holds for should_retry=False. With retries the pre-fix code resolved to UP_FOR_RETRY, which is in STATES_SENT_DIRECTLY, so finish() was never reached. The SKIPPED bullet below already branches on exactly this. Separately, 1807 and 1823-1824 scope the rule to "reported via message", but the code checks _terminal_state is not None, which also catches SERVER_TERMINATED set at 1778 by _send_heartbeat_if_needed with no subprocess message.
test_supervisor.py:4208 and :4245 say _exit_code = 1 comes "from a SIGTERM kill", but signal deaths are negative (1279/1285 compare against -signal), and the overtime path ends at -9 since the child's SIGTERM handler at task_runner.py:1563 returns and force=True escalates. A positive 1 with a terminal state already set is reachable via finalize() at 2460, inside the try whose except Exception exits 1 at 2475.
The body undersells the fix by one case. TaskState(FAILED) with should_retry=True is reachable via task_runner.py:1648, 1667 and 1885, all of which emit FAILED regardless of retry eligibility. On base a later non-zero exit turned that into UP_FOR_RETRY, so nothing was written and the row stayed running.
A subprocess can report a terminal state via message (SucceedTask, TaskState(SKIPPED), etc.) and then exit with a genuinely non-zero code afterward -- e.g. an OOM kill, or `_handle_process_overtime_if_needed()` sending SIGTERM once `_terminal_state` is already set. `final_state` previously derived its result from the exit code whenever one was observed, ignoring an already-confirmed terminal state. The clearest reachable case is SUCCESS: `SucceedTask` writes the row directly and clears the pending-message slot on success, so a later non-zero exit code re-triggers `update_task_state_if_needed()` -> `.finish()` against a row `succeed()` already wrote, which 409s. A terminal state can also be set directly by the supervisor rather than reported by the subprocess (SERVER_TERMINATED via `_send_heartbeat_if_needed`), so the precedence applies to any already-set `_terminal_state`, not only ones delivered by message -- this subsumes needing SERVER_TERMINATED/UP_FOR_RETRY called out as special cases, since every terminal state now gets the same precedence. Rebased over apache#73249/apache#73253's rework of pending-message delivery: those changes mean TaskState-reported outcomes (SKIPPED, FAILED, etc.) are now normally dispatched using the message's own state directly, without consulting `final_state` at all while a message is pending, so this precedence is defense-in-depth for that path rather than the reachable bug it was in the pre-rework code -- the SUCCESS case above remains the live, reachable one this fixes. Confirmed via live instrumentation against a reproduced crash, see apache#65708 (comment).
86a3f75 to
617d920
Compare
|
Addressed the three optional nits from the approval review, and rebased over #73249/#73253 in the process, worth a second look on the latter given your approval predates those merges. The three nits:
The rebase: #73249 restructured how 294 passed (up from 211 pre-rebase, picking up #73249/#73253's own new tests), 1 skipped (platform-only), mypy clean. Drafted-by: Claude Sonnet 5 (reviewed by @seanmuth) |
A subprocess can report a terminal state via message (SucceedTask, TaskState(SKIPPED),
etc.) and then exit with a genuinely non-zero code afterward -- e.g. an OOM kill, or
_handle_process_overtime_if_needed()sending SIGTERM once_terminal_stateis alreadyset.
Supervisor.final_statepreviously derived its result from the exit code wheneverone was observed, ignoring an already-confirmed terminal state in that case.
The clearest reachable case is SUCCESS:
SucceedTaskwrites the row directly(
_send_terminal_state_msg) and clears the pending-message slot on success, so a laternon-zero exit code re-triggers
update_task_state_if_needed()->.finish()against arow
succeed()already wrote, which 409s against the server and crashes tasksupervision. The same precedence gap is also reachable for
TaskState(FAILED)withretries enabled (
should_retry=True): the worker legitimately reports FAILED, but alater non-zero exit resolves to UP_FOR_RETRY on the old code, so nothing gets written
and the row is left stuck
RUNNINGinstead ofFAILED.Fix: trust
self._terminal_statewhenever it's already set, and only fall back toderiving the state from the exit code when no terminal state was ever set at all. This
is a reordering of
final_state's existing branches, purely local, no additionalnetwork/DB round-trip.
Rebased over #73249 / #73253's rework of pending-message delivery. Those changes
mean
TaskState-reported outcomes (SKIPPED, FAILED, etc.) are now normally dispatchedin
update_task_state_if_needed()using the message's own state directly, withoutconsulting
final_stateat all while a message is pending -- so for that path, thisprecedence is now defense-in-depth rather than the reachable bug it was before that
rework. The SUCCESS case above remains fully live and reachable regardless. This also
subsumes
final_state's existing special-case handling of SERVER_TERMINATED andUP_FOR_RETRY (added by #73253) under one general rule: every terminal state gets the
same precedence, not just those two.
Root-caused via a live instrumented burst-fanout repro that reproduced the
SUCCESScase end to end (full write-up: #65708 (comment)).
This addresses the underlying ordering bug rather than only swallowing its symptom (the
separate, already-merged #63355 idempotency check on the API side remains a good
complementary hardening for the retried-
succeed()case it covers).closes: #65708
Was generative AI tooling used to co-author this PR?
Generated-by: Claude Sonnet 5 following the guidelines