SEP-1835: Keep the outcome the sync resolved when a run is stopped - #1377
Open
marcuscruz-percona wants to merge 5 commits into
Open
SEP-1835: Keep the outcome the sync resolved when a run is stopped#1377marcuscruz-percona wants to merge 5 commits into
marcuscruz-percona wants to merge 5 commits into
Conversation
BaseExecutor.stop_task synced the task history against the backend and then unconditionally stamped STOPPED and a fresh finished_at, discarding whatever the sync had just derived. A stop landing on a run that had already exited was recorded as an operator action, its real finish time (and therefore its duration) was lost, and PMM received two contradictory terminal annotations. Persist what the sync resolved instead: a terminal status and its finish time stand as they are, STOPPED is stamped only for a run left non-terminal, finished_at is filled only when unset, and exactly one terminal annotation is emitted, naming the status that was persisted.
marcuscruz-percona
requested review from
a team,
peter-o-addo and
yyyyyyyan
as code owners
August 19, 2026 21:00
Contributor
There was a problem hiding this comment.
Pull request overview
Corrects task stopping so executor-resolved terminal outcomes and finish times are preserved, with one matching PMM annotation.
Changes:
- Preserves terminal status and
finished_atafter synchronization. - Adds Nomad, Celery, and base-executor regression coverage.
- Documents the user-visible correction.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
app/tasks/execution/models.py |
Preserves synchronized terminal outcomes during stops. |
tests/app/tasks/execution/test_models.py |
Covers status, timestamp, annotation, and failure behavior. |
tests/app/tasks/execution/executors/nomad/test_models.py |
Reproduces the Nomad failure race. |
tests/app/tasks/execution/executors/celery/test_models.py |
Ensures Celery stops still terminate rows. |
changelog.d/SEP-1835.fixed.md |
Records the user-visible fix. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
BaseExecutor.stop_tasksynced the task history against the backend and then unconditionally stampedSTOPPEDwith a freshfinished_at, discarding the outcome the sync had just derived. It now persists what the sync resolved: a terminal status and the finish time that came with it stand as they are,STOPPEDis stamped only for a run the sync left non-terminal, andfinished_atis filled only when it is still unset.FAILED(fromsync_task_history) and a contradictingSTOPPED.finished_atcase, backend-stop failure, an end-to-end Nomad reproduction (failed allocation + stop), and a Celery non-regression that the stop still terminates the row.Why it was wrong
The stop route only accepts a
RUNNINGrow (app/tasks/routes.py), so the reproduction window is exactly "the payload already exited, but the periodic sync has not moved the row out ofRUNNINGyet". In that window:STOPPED— history and UI reported an operator action while the PagerDuty incident (raised insidesync_task_history, before the overwrite) said the run failed.finished_atfrom the allocation'sModifyTime; the stop path replaced it with the stop-request wall clock, which also corruptsTaskHistory.duration.STOPPEDfrom the stop path.The Nomad executor already implements the opposite contract deliberately —
_apply_terminal_statusdowngrades toSTOPPEDonly when the derived status is notFAILED, "so the failure is not relabelled as an operator action". The base class threw that decision away one frame up: the guard existed and was overwritten by its own caller. Who wins between the stop and the run's own result stays owned by the executor's derivation, which is already correct; the base class now just records it.The change
Notes on the details:
TaskHistoryStatusEnum.is_terminal()rather than a hand-listed set of statuses the stop path may keep._TERMINAL_STATUS_EVENT_MAPis then indexed (not.get()), so a future terminal status added without an annotation event fails loudly instead of being silently relabelledSTOPPED. A new test pinsset(_TERMINAL_STATUS_EVENT_MAP) == {s for s in TaskHistoryStatusEnum if s.is_terminal()}so that drift is caught in CI rather than at runtime.finished_atis filled only where the sync did not establish one. This is load-bearing beyond preservation:_sync_task_historyresolvesLOSTonJobNotFoundErrorwithout stamping a finish time, so leaving itNonewould produce a terminal row with noduration.STOPPED" to "the sync already emitted a terminal event", and the emitted event is read beforeTaskHistoryManager.saveso it does not depend on what the post-save refresh returns.CeleryExecutor._sync_task_historyreturns the record unchanged, so under Celery the row is stillRUNNINGwhenstop_taskruns and the removed assignment was the only thing making it terminal. Thenot sync_resolved_itbranch is what keeps every Celery stop from wedging inRUNNING;test_stop_reaches_stopped_statuscovers it explicitly.Test changes
tests/app/tasks/execution/test_models.py—TestStopTasknow drives aStopStubExecutorwhose backend stop and executor-specific sync report a configured state, sostop_taskruns against real persistence (realTaskHistoryManager.save/session.refresh, realsync_task_historyincluding its own annotation) with PMM as the only patched boundary. That replaces four near-identicalfake_syncclosures plus a three-patch harness repeated per test. New cases:FAILED,SUCCESS,LOST,STALE) is preserved, verified aftersession.rollback()+ refetch, with a single annotation naming the derived event and neverSTOPPED;finished_atsurvives instead of being replaced by the stop-request wall clock;LOSTshape);RUNNINGand the sync returns terminal, the stop path owns the only emit and it names the persisted outcome;RUNNING.nomad/test_models.pyadds the end-to-end reproduction (failed allocation withModifyTime, jobdead/Stop: true, Nomad as the only mocked boundary): the row persistsFAILED,finished_atcomes fromModifyTime, and PMM hearsFAILEDonce.celery/test_models.pyadds the wedge-in-RUNNINGguard.TestStopTaskRegression(theMissingGreenletguard on the explicitsession.refreshbeforeschedule_annotation) is untouched and still passes.Out of scope
RUNNING-only precondition.STOPPEDby this defect — the derived status was never written anywhere, so it cannot be recovered after the fact. Called out in the changelog fragment.No auth or authorization surface changes: the route keeps its dependency and its
RUNNING-only precondition, so no new caller can reach the new branch. This is record-correctness only.Tested
make test— 9897 passed, 424 skippedmake lint— cleanPOST /history/{id}/stop/inside the window before the periodic sync, then read the row back:statusisfailed,finished_atis the payload exit time,durationis the real run length, and PMM shows oneFAILEDannotation for the run.stoppedwith a finish time and a singleSTOPPEDannotation.Checklist
make test)make run-pre-commit)make makemigrations) — N/A, no model changeschangelog.d/if the change is user-facing (make changelog-add), or confirmed N/A