Skip to content

UN-4318 [FEAT] Show stuck executions and let owners abort running ones - #2415

Draft
kirtimanmishrazipstack wants to merge 1 commit into
mainfrom
UN-4318-stuck-executions-abort
Draft

kirtimanmishrazipstack wants to merge 1 commit into
mainfrom
UN-4318-stuck-executions-abort

Conversation

@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor

What

  • GET execution/?stuck=true narrows the execution list to stuck runs, and each row gets stuck_since.
  • GET execution/stuck-count/ returns the stuck runs per entity (API, ETL, TASK, WF) and the threshold.
  • POST execution/abort/ stops up to 100 runs and returns one outcome per id: aborted, already_finished or not_permitted.

Why

  • The Frontend v1 executions page needs to show stuck runs and abort them (PB-T08).

How

  • A run is stuck when it is PENDING or EXECUTING and neither the run nor its files have changed for longer than the threshold.
  • Owners of the run's deployment, pipeline or workflow can abort it, and so can org admins and service accounts. Abort sets the run and its open file rows to STOPPED and records an execution.aborted audit event.
  • Abort stops a run between files; the file being processed finishes. Workers check the run's status before each file through a new internal workflow-execution/<id>/status/ endpoint, and stop only on STOPPED.
  • The contract is in backend/workflow_manager/execution/README.md.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No. Without stuck, the list returns the same response as before (tested), and the other existing endpoints are unchanged.
  • Workers make one extra status call per file. If it fails, the file is processed as today. A run that hits ERROR mid-batch carries on as today.

Database Migrations

  • None.

Env Config

  • STUCK_EXECUTION_THRESHOLD_SECONDS (default 9000), also added to backend/sample.env.

Relevant Docs

  • backend/workflow_manager/execution/README.md

Related Issues or PRs

  • UN-4318 (sub-tasks UN-4548 to UN-4552), epic UN-4257

Dependencies Versions

  • None.

Notes on Testing

  • Deploy the backend before the workers. A new worker talking to an old backend gets a 404 from the status check and keeps processing, as today.
  • Backend execution, workflow_v2 and file_execution tests: 216 passed. Workers: 1467 passed, 184 skipped (they need local Postgres/Redis; none are from this PR). The new tests all ran: 11 stuck, 13 abort, 9 contract, 18 worker.

Screenshots

Checklist

I have read and understood the Contribution Guidelines.

🤖 Generated with Claude Code

- GET execution/?stuck=true and execution/stuck-count/ for PENDING/EXECUTING
  runs idle past STUCK_EXECUTION_THRESHOLD_SECONDS (default 9000).
- POST execution/abort/ stops runs the caller owns (or any run, for org
  admins and service accounts), with a per-id outcome and an audit event.
- Workers check the run's status before each file and stop once it is
  STOPPED; the file in progress finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@kirtimanmishrazipstack kirtimanmishrazipstack left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #2415 review — UN-4318 [FEAT] Show stuck executions and let owners abort running ones

Mode: INITIAL · Head: 4bca922 · Merge base: 8765a4d · Base: main (freshened this run) · 23 files, 1638 lines, count matches GitHub · Rubric: unstract 2.1.80

Signals: lenses 4, 6, 7, 8, 16, 17 raised (new endpoints and permission checks, select_for_update, new serializers, a worker-to-backend call, README and docstrings, a new threshold setting).

Verdict — REQUEST CHANGES

Summary — Critical: 0 · High: 4 · Medium: 15 · Low: 8 · Lenses run: 19/19 (4 N/A, 1 Not covered; see checklist)

Security deep-dive: done
boundary-sweep: 12 sinks traced, clean

Findings are numbered F1 to F27 and appear as inline comments on this review.

One cause sits behind F1, F2, F6, F7 and F8. This PR adds the first request-path writer of STOPPED (abort.py:L127-L129). The worker and API code around it was written assuming a terminal row means the reaper already finished the run. Patching each site is possible; the structural fix is to make STOPPED a real outcome that the callback, the skip path and the API view all handle.

Unanchored findings

  • [Low] The PR body still lists "sub-tasks UN-4548 to UN-4552" under Related Issues; the ticket's session rules say to remove sub-task numbers from the description.

Lens checklist

  1. Spec & intent: See findings F1, F2
  2. Architectural fit & precedent: See findings F8
  3. Correctness & edge cases: See findings F1, F2, F3, F7, F8, F9, F10, F11, F23
  4. Security: Clean
  5. Data integrity & migrations: See findings F8
  6. Concurrency: See findings F3
  7. API & contract compatibility: See findings F1, F6, F12, F21, F22
  8. Reliability & resilience: See findings F9
  9. Performance & cost: See findings F11
  10. Observability: Clean
  11. Operational safety: Clean
  12. LLM/agent-specific: N/A — no model calls, prompts or tools in the diff
  13. Testing: Not covered — Postgres and Redis unreachable, so 30 of 33 new backend tests did not run; gaps found by reading are F4, F5, F14 to F19, F24 to F27
  14. Dependencies & build: N/A — no dependency, lockfile or build file in the diff
  15. Code quality: Clean
  16. Doc & comment accuracy: See findings F2, F12, F13, F20
  17. Cross-document rule consistency: See findings F2, F20
  18. On-prem deployment experience: N/A — no chart, on-prem values, schema or on-prem build path in the diff
  19. Scaling & workload lifecycle: N/A — no concurrency, autoscaling, shutdown, broker or pool setting in the diff

Assessed by the orchestrator, not an agent: 4, 5, 6, 8, 11, 12, 14, 15's gate, 17, 18, 19 and lens 7's consumer search. Lens 1's PR-title check passes against CONTRIBUTION_GUIDE.md.

Notes behind those lines:

  • Lens 4: read the abort permission path, API-key tiers (read keys cannot POST) and tenant scoping. The security scan found nothing at confidence >= 8. That scan is precision-biased and excludes DoS, rate limits and docs.
  • Lens 7 consumer search: grepped the endpoint paths and stuck_since in sibling unstract-cloud and unstract-sdk, in frontend/ on this branch and on origin/main, and read unstract-python-client for F6. The new public endpoints have no consumer yet (UN-4366 is pending). The internal status endpoint's consumer is this PR's worker, and the route resolves.
  • Lens 15 gate: ruff 0.3.4 (pinned) with the hook's --force-exclude is clean on check and format, matching pre-commit.ci. mypy on the three worker files: 145 errors, none on lines this PR added. mypy on backend files was not run: it is not installed in backend/.venv.
  • Lens 17: the branch is 10 commits behind origin/main and merges without conflict. The merge-result sweep covered 13 rule-carrying identifiers; its hits are this branch's own definitions, plus the stale sites in F2 and F20. facts.sh's own pass covered the first 40 of 108 identifiers.
  • Boundary sweep: run and file messages, audit target name, summary and metadata, the abort, count, list and internal status responses, and three worker warnings. Only ids, statuses, constants, names already shown to that caller, and errors from this app's own backend reach them.

Open questions for the author

  • Should files that finished in a STOPPED run count toward usage? F1 and F2 both depend on the answer.
  • Does a storage lifecycle rule sweep the execution directories? If so, the document-retention part of F2 shrinks.
  • Does SoftTimeLimitExceeded fire on the PG queue path? If not, that part of F9 falls away.
  • What should a re-run do with a file the aborted run is still processing? F3 needs this decision.

Assumptions made

  • No abort flow was run end to end. Backend behaviour is from reading, and from probes with mocks where stated.
  • The enterprise auth plugin, cloud overrides of REST_FRAMEWORK and ingress exposure of /internal/ are outside this repo and were not checked.
  • The spec is the Confluence page linked from UN-4318 (PB-T08, version 5).

cost: agents=7 wall=19:24 lenses-examined=14 of 19 files=23 lines=1638

Unanchored findings

  • backend/api_v2/api_deployment_views.py:174 (outside the diff hunk): [Medium] [Lens 7] — F6 An API caller waiting on POST gets HTTP 200 when its run is aborted; polling the same run gets 422
  • Location: backend/api_v2/api_deployment_views.py:L174
  • Failure mode: the sync wait exits on STOPPED and builds a response with no error. has_error tests only error or "ERROR", so POST returns 200 with a null or partial result, while GET on the same run returns 422. The consumer unstract-python-client/src/unstract/api_deployments/client.py:L92, L389-L391 does not list STOPPED as in progress, so a caller reads this as finished successfully. Other external callers could not be identified.
  • Evidence: has_error = response.get("error") or execution_status == "ERROR"; workflow_helper.py:L762-L767 passes no error.
  • Suggested fix: use ExecutionStatus.is_failure(execution_status) in has_error.
  • Confidence: High on the status codes, Medium on how often an abort meets a live sync wait.
  • Verification: VERIFIED — read the view, the sync wait and the sibling client. Not executed.
  • workers/file_processing/tasks.py:332 (outside the diff hunk): [Medium] [Lens 3] — F7 A re-run after abort silently leaves out queued-batch files until the active-file marker expires
  • Location: workers/file_processing/tasks.py:L332-L339, L1527, workers/shared/processing/filter_pipeline.py:L655-L657
  • Failure mode: markers are created at discovery and removed only in pre-create. A queued batch skipped after abort never reaches pre-create, so its markers survive. The re-run's filter sees a different execution id and drops those files, with only a log line. The TTL is 300s by default and can be set up to 9000s.
  • Evidence: cached_exec_id != execution_id adds the file to the active set. clear_history_for_workflow clears these markers; abort does not.
  • Suggested fix: call cleanup_active_file_cache for the batch's files on the terminal-skip path.
  • Confidence: High
  • Verification: VERIFIED — read marker creation, removal and the filter. Not executed.

for file_number, file_item in enumerate(files, 1):
# Stop between files once the run is aborted.
if _is_execution_stopped(api_client, context.execution_id):
_stop_remaining_files(context, files[file_number - 1 :], result)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 3, 7] — F1 The callback reports an aborted run as COMPLETED: pipeline SUCCESS, COMPLETED webhook, final COMPLETED log

  • Location: workers/callback/tasks.py:L338-L344, L397, L1494, L1536, L1783, L1805; reached through backend/workflow_manager/execution/abort.py:L127-L129 and workers/file_processing/tasks.py:L768-L770; contradicted doc at backend/workflow_manager/execution/README.md:L119
  • Failure mode: abort a live batch of 10 after 1 file finishes. The batch returns 1 ok and 9 failed, the last decrement fires the callback, and because failed is not equal to total it computes COMPLETED. The backend refuses the write with a 200 skipped, the callback ignores that, and it carries on with COMPLETED: last_run_status=SUCCESS, run count + 1, a COMPLETED webhook and a final COMPLETED log, while the row stays STOPPED with 9 files unprocessed. If no file had finished, the same path sends ERROR and pipeline FAILURE. With the default MAX_PARALLEL_FILE_BATCHES=1 there is no queued batch, so this is what every live abort does.
  • Evidence: tasks.py:L342-L344 "Some or all files succeeded ... mark as COMPLETED"; the return of update_workflow_execution_status at L397 is discarded. test_later_completion_cannot_undo_the_abort asserts only the row status. README L119 says the pipeline's last-run status is not changed; README L156-L158 says it can be.
  • Suggested fix: in both callback paths, let a STOPPED row override the computed status (context.execution_status, or workflow_execution.get("status") in the API path). The mapping at L127 then gives FAILURE and cleanup still runs. Delete README L119. Billing needs a decision: _track_subscription_usage_if_available commits only on COMPLETED (L1312), so files that really finished in a STOPPED run need an explicit rule.
  • Confidence: High. High rather than Critical because the execution row stays correct and nothing is lost, but customers' webhooks receive a false status.
  • Verification: VERIFIED — read the status computation, the discarded response and all downstream uses; a mock probe of _determine_execution_status_unified returned COMPLETED for 1 ok, 9 failed. Not run end to end.

execution_id, AbortOutcome.ALREADY_FINISHED, execution.status
)
previous_status = execution.status
execution.update_execution(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 3, 17] — F2 When a batch is still queued or the worker is dead, the callback never fires: no cleanup, webhook or usage commit, and the pipeline stays INPROGRESS

  • Location: workers/queue_backend/pg_barrier.py:L1382-L1393, workers/file_processing/tasks.py:L299-L301, L336
  • Made stale by: backend/workflow_manager/execution/abort.py:L127-L129
  • Failure mode: a batch picked up after the abort takes the terminal skip and bypasses the barrier decrement, so remaining never reaches 0. A dead worker gives the same result, and that is this feature's main use. Lost: _cleanup_execution_resources (the execution directory with the customer's documents), every webhook, the usage commit for finished files, and the pipeline status, which stays INPROGRESS until the next run. The barrier row waits for the 2.5h stuck timeout, then the reaper deletes it with a warning. The API orchestrator has no terminal guard (workers/api-deployment/tasks.py:L489, unlike workers/general/tasks.py:L304), so an API run aborted while PENDING still arms a barrier and dispatches batches that all skip.
  • Evidence: pg_barrier.py:L1383 "the reaper has by definition already torn the barrier down" is now false, and it is the stated reason for the bypass. abort.py has no barrier call. _callback_already_ran (workers/callback/tasks.py:L1391-L1394) says a STOPPED run's first callback must run "incl. resource cleanup". README L156 covers only the opposite case.
  • Suggested fix: for STOPPED, omit the skip marker so the decrement runs, after F1 so the callback reports STOPPED. A dead worker needs abort itself to finalize the pipeline status and cleanup, or a written waiver. Correct the three stale comments and the skip log line, and add this to README Limits.
  • Confidence: High on the flow, Medium on document retention. A storage lifecycle rule would reduce that part; see open questions.
  • Verification: VERIFIED — read the guard, skip result, barrier bypass and reaper; a mock probe of run_batch_with_barrier with the marker set called the decrement 0 times. Not run end to end.

stopped = (
WorkflowFileExecution.objects.filter(workflow_execution=execution)
.exclude(status__in=ExecutionStatus.terminal_values())
.update(status=ExecutionStatus.STOPPED.value, execution_error=ABORT_ERROR_MESSAGE)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 6, 3] — F3 Re-running right after an abort can process the in-flight file twice and write to the destination twice

  • Location: backend/workflow_manager/execution/abort.py:L127-L141, backend/workflow_manager/internal_views.py:L1364-L1366, L1393, workers/file_processing/tasks.py:L1527
  • Failure mode: abort leaves the current file running but marks the run and that file's row STOPPED at once. The active-file check needs both to be PENDING/EXECUTING, so the file now reads as free. Its Redis marker was already removed at pre-create, and file history has no entry until the file finishes. A manual re-run or the next scheduled run picks up the file while the old worker is still on it: double LLM spend and duplicate rows in a DB destination. The destination lock does not help because it is keyed per execution.
  • Evidence: internal_views.py:L1364 filters executions on status__in=["PENDING", "EXECUTING"], and L1393 filters file rows the same way. README Limits does not mention this.
  • Suggested fix: unsure of the best shape. One option: abort leaves recently touched EXECUTING file rows alone and the check counts them as active regardless of run status, with a staleness bound so a dead worker cannot block forever. At minimum, document it and warn on re-run, with a written waiver.
  • Confidence: Medium. A run with an ETL DB destination and a slow file would raise it. High rather than Medium because the damage is silent, reaches customer destinations and follows the natural next action.
  • Verification: VERIFIED — read every guard on the path. Not reproduced at runtime.

outcome = self._outcomes(self._abort(self.member, execution.id))
self.assertEqual(outcome[str(execution.id)], ("aborted", "STOPPED"))

def test_org_admin_can_abort_any_run(self) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 13] — F4 No test stops an org admin or service account aborting another org's run

  • Location: backend/workflow_manager/execution/tests/test_abort_executions.py:L175-L186, backend/workflow_manager/execution/abort.py:L89-L95
  • Failure mode: _abort_one re-reads by pk with no org filter, so _abortable is the only tenant boundary on this write. If its for_user base were lost, an admin or service account could stop another tenant's runs and the suite would stay green. The code is correct today; the guard is untested on a new mutating endpoint.
  • Evidence: grep finds no second org in test_abort_executions.py or stuck_abort_base.py. Replacing visible with WorkflowExecution.objects.all() at abort.py:L90 survives all 13 abort tests (traced).
  • Suggested fix: create an org-b workflow and run as test_shared_execution_access.py:L181 does, abort it as an admin and as a service account, and assert not_permitted, status unchanged and no audit row.
  • Confidence: High
  • Verification: VERIFIED — grep run; mutant traced by reading, because Postgres is unreachable.

RECENT = timedelta(minutes=1)


class StuckAbortTestBase(GroupSharingTestBase):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 13] — F5 30 of the 33 new backend tests could not run here: Postgres and Redis unreachable

  • Location: backend/workflow_manager/execution/tests/stuck_abort_base.py:L32
  • Failure mode: nothing in this review shows these tests pass at this head. Their absence masks the stuck filter, stuck_since values, count query budget, abort outcomes and permission matrix, audit row, file-row updates and internal status 404s. CI gives no cover: the test job is skipped while the PR is a draft. The PR body's "216 passed" could not be confirmed. The implementation session reports all 33 passed with Postgres up on the code that went into this head; that is a report from another session, not re-run here.
  • Evidence: pytest on the three backend files gave 3 passed, 30 setup errors (connection refused on localhost:5432). workers/tests/test_abort_between_files.py gave 18 passed; those use mocks and need no services.
  • Suggested fix: run these files against live Postgres and Redis, or mark the PR ready so integration-backend runs, before relying on them.
  • Confidence: High
  • Verification: VERIFIED — the pytest run and gh pr checks 2415.

error,
)
return False
stopped: bool = status == ExecutionStatus.STOPPED.value

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 3] — F23 A 200 with no status fails open without a log — workers/file_processing/tasks.py:L703. Warn when the status is None.



def test_stops_after_the_status_flips_to_stopped() -> None:
api_client = mock.Mock()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 13] — F24 The worker tests' mock.Mock() has no spec, so a misnamed argument survives — workers/tests/test_abort_between_files.py:L83. Use create_autospec(InternalAPIClient, instance=True).

data: dict[str, Any] = response.data
return data

def test_stuck_run_is_listed_with_stuck_since_and_counted(self) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 13] — F25 "Later of" is half pinned: no case has a run newer than its files — backend/workflow_manager/execution/tests/test_stuck_executions.py:L36-L47. Add that case.

ids = [row["id"] for row in response.data["results"]]
self.assertEqual(ids, [str(run.id)])

def test_stuck_rows_add_only_stuck_since(self) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 13] — F26 Only stuck=true is checked for stuck_since, not 1 or other case — backend/workflow_manager/execution/tests/test_execution_contract.py:L82-L87. Add both.

"tenant_account_v2.organization_member_service."
"OrganizationMemberService.is_user_organization_admin"
)
# Past the default 9000 s threshold, and well inside it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 13] — F27 Tests rely on the ambient 9000s threshold — backend/workflow_manager/execution/tests/stuck_abort_base.py:L27-L29. Use @override_settings.

@kirtimanmishrazipstack kirtimanmishrazipstack left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UN-4318 [FEAT] Show stuck executions and let owners abort running ones. Head 4bca92285, merge base 8765a4dcb, 23 files, 1638 lines (count matches GitHub). Base fetched this run. The branch is 10 commits behind main and merges cleanly.

Verdict — REQUEST CHANGES

Summary — Critical: 0 · High: 5 · Medium: 9 · Low: 6 · Lenses run: 19/19

Security deep-dive: done. No findings at confidence >= 8; this is a precision-biased scan, not a clean bill of health.

boundary-sweep: 11 sinks traced, clean.

Tests run with real Postgres and Redis: backend 33 passed plus 21 subtests, workers 18 passed. MinIO and RabbitMQ were not started. The wider totals in the PR body (216 and 1467) were not re-run.

The numbered findings are in the inline comments.

Unanchored findings

  • [Low] The PR body still lists "sub-tasks UN-4548 to UN-4552"; remove them.

Lens checklist

  • Lens 1: See findings #1, #2
  • Lens 2: Clean
  • Lens 3: See findings #1, #2, #7, #8, #9, #18, #19
  • Lens 4: Clean
  • Lens 5: See findings #7
  • Lens 6: See findings #2, #4
  • Lens 7: See findings #6, #10, #16, #17
  • Lens 8: Clean
  • Lens 9: Clean
  • Lens 10: Clean
  • Lens 11: Clean
  • Lens 12: N/A — no model calls, prompts or agents touched
  • Lens 13: See findings #3, #4, #5, #13, #14, #18, #20
  • Lens 14: N/A — no dependency manifest or build file changed; new imports are existing dependencies
  • Lens 15: Clean
  • Lens 16: See findings #2, #10, #11, #12, #15
  • Lens 17: See findings #11
  • Lens 18: N/A — no chart, on-prem values, on-prem settings or on-prem build path touched
  • Lens 19: N/A — no worker concurrency, autoscaling, grace or pool setting touched

Assessed by the orchestrator: lenses 4, 5, 6, 8, 11, 12, 14, 17, 18, 19, and lens 7's consumer search.

Lens 7 search: frontend/src and the cloud repo have no caller of the new endpoints yet; the planned consumer is the Logs page ticket, UN-4366. The internal status route's consumer is the worker in this repo.

Lens 15 gate, pinned versions, check-only, on the changed files: ruff 0.3.4 check and format with the hook's --force-exclude, pycln 2.6.0 and markdownlint-cli 0.42.0 all pass. pyupgrade was not run because it has no check mode.

Lens 17: the merge-result sweep exited 1. Its hits outside this diff are the audit README and actions.py lines; only platform_audit/README.md:L200 disagrees.

Open questions for the author

  1. What should the callback's usage step do for finished files of an aborted run? It acts only on COMPLETED, so returning STOPPED in fix #1 changes that.
  2. Does a storage lifecycle rule expire execution directories? That decides how much the leftover directories in #2 matter.
  3. The spec names owners and admins; this PR also lets write-tier platform keys abort. A ticket comment on the audit recorder mentions platform keys as an actor. Is this intended?
  4. Should a remaining file already in skipped_already_completed count as failed when a batch stops? The main loop does not count it (tasks.py:L809), and the stop path does (L727).

Assumptions made

  • #1 is held at High, not Critical, because the run row stays correct and abort is a deliberate user action. If a webhook drives an irreversible downstream load, it should go up.
  • #2 is rated High rather than Medium because it covers the feature's main target and leaves customer documents in storage.
  • Findings #3, #4 and #5 are test gaps; today's code behaved correctly in each probe.

Unanchored findings

  • backend/api_v2/api_deployment_views.py:173 (outside the diff hunk): [Medium] [Lens 7] — A sync API caller gets HTTP 200 for an aborted run

  • Location: backend/api_v2/api_deployment_views.py:L173-L174, made reachable by backend/workflow_manager/execution/abort.py:L127

  • Failure mode: The sync wait loop exits on STOPPED. post treats only an error or ERROR as failure, so it returns 200, while get returns 422 for the same run. An integration keying on 2xx treats the aborted document as processed. It needs an abort inside the request's timeout, and the body does say STOPPED.

  • Evidence: has_error = response.get("error") or execution_status == "ERROR".

  • Suggested fix: Treat STOPPED as an error in post, matching get, and note it in the README.

  • Confidence: High. Searched this repo and the cloud repo; external API callers have no source here, so what they do with a 200 is not covered.

  • Verification: VERIFIED — read both handlers and workflow_helper.py:L741-L766.

  • workers/file_processing/tasks.py:336 (outside the diff hunk): [Medium] [Lens 3] — Re-running straight after an abort can silently drop files

  • Location: workers/file_processing/tasks.py:L336-L339, made reachable by backend/workflow_manager/execution/abort.py:L127

  • Failure mode: Active-file markers are cleared only during pre-create, which a skipped batch never reaches. If a batch was still queued at abort, a re-run within the marker's lifetime treats those files as active elsewhere and leaves them out. The lifetime defaults to 300s and can be raised to 9000s.

  • Evidence: Markers are written at workers/shared/processing/file_discovery.py:L255 and cleared at tasks.py:L1527. filter_pipeline.py:L655-L657 compares only the execution id and never checks whether that run has ended.

  • Suggested fix: Call cleanup_active_file_cache for the batch's files in the _TerminalExecutionSkip branch.

  • Confidence: Medium. Aborting a run with a queued batch and then re-running would confirm it.

  • Verification: VERIFIED — read the write, cleanup and filter paths. Not run end to end.

# Process each file - handle list, tuple, and dictionary formats
for file_number, file_item in enumerate(files, 1):
# Stop between files once the run is aborted.
if _is_execution_stopped(api_client, context.execution_id):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 3, 1] — An aborted run with any finished file is reported as a success

  • Location: workers/file_processing/tasks.py:L768-L770
  • Failure mode: A user aborts mid-batch after at least one file finished. The batch counts its remaining files as failed and decrements the barrier, so the callback runs. The callback never reads the run's real status and computes COMPLETED, because failed_files differs from total_files. The backend refuses that write, so the run stays STOPPED, but the callback carries on with COMPLETED. The pipeline's last_run_status becomes SUCCESS, webhooks send a success status, and subscribers who asked for failures only get that success payload too. At the default MAX_PARALLEL_FILE_BATCHES=1 the running batch is always the last, so this is the normal case for a mid-run abort. The spec says an aborted run is "marked aborted".
  • Evidence: workers/callback/tasks.py:L338-L344 (the else sets COMPLETED), L1494 and L1536 (used for pipeline and notifications), backend/workflow_manager/internal_views.py:L612-L630 (the refused write). README.md:L119 says "The pipeline's last-run status is not changed", and L156-L158 does not say the reported outcome is success. No test covers the callback on this path.
  • Suggested fix: In _determine_execution_status_unified, return STOPPED when the fetched run is STOPPED. The pipeline mapping already turns STOPPED into FAILURE, and update_status accepts an equal status, so the file counts get written too. This changes what the callback's usage step does, see the first open question. Correct the README lines.
  • Confidence: High
  • Verification: VERIFIED — traced by reading from _stop_remaining_files through the callback, the pipeline endpoint and the notification payload. Not run end to end.

execution_id, AbortOutcome.ALREADY_FINISHED, execution.status
)
previous_status = execution.status
execution.update_execution(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 3, 6, 16, 1] — When a worker is dead or a batch is still queued, the callback never runs for an aborted run

  • Location: backend/workflow_manager/execution/abort.py:L127-L130
  • Failure mode: Abort sets STOPPED and leaves the barrier row in place. A batch picked up later hits the terminal check and returns without decrementing, and a dead worker never decrements at all. So remaining never reaches 0, the callback never fires, and the reaper later deletes only the barrier row. This covers the feature's main target, a stuck run. Only the callback does these things, so they are lost:
    • Directory cleanup: the execution directory, which holds an API run's uploaded documents, stays in storage.
    • Usage tracking: the callback's usage step does not run for finished files.
    • Webhooks: subscribers hear nothing.
    • Pipeline status: stays INPROGRESS until the next run.
  • Evidence: workers/queue_backend/pg_barrier.py:L1382-L1393 returns before _barrier_pg_decrement. _cleanup_execution_resources is called only from workers/callback/tasks.py:L1512 and L1779. The spec says abort "releases queued work for that execution", and README Limits covers only the case where the callback does run. Comments that this makes false: pg_barrier.py:L1310-L1312 and L1383-L1388 ("the reaper has by definition already torn the barrier down"), workers/file_processing/tasks.py:L299-L301. The skip's log at tasks.py:L336-L339 ("stale/redelivered after reaper-recovery") will mislead on-call. The reaper's ERROR path has the same gap today; this PR gives users a direct route into it.
  • Suggested fix: Decide what finalizes a stopped run. A skip caused by STOPPED could still decrement, so the last batch fires the callback. A dead-worker run never decrements, so the reaper's already-terminal branch also has to trigger cleanup for STOPPED runs. I am unsure which split is simplest; check it against the barrier's zero-row path. Then correct the comments and the README.
  • Confidence: High on the flow, Medium on how long storage lasts (see open questions).
  • Verification: VERIFIED — read _run_batch_stages, run_batch_with_barrier, _recover_one_barrier, and the callback. Not run on a multi-batch execution.

event = PlatformAuditEvent.objects.get(action=Action.EXECUTION_ABORTED)
self.assertEqual(event.actor, self.admin)

def test_service_account_can_abort(self) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 13] — No test covers abort's tenant boundary

  • Location: backend/workflow_manager/execution/tests/test_abort_executions.py:L182
  • Failure mode: for_user at abort.py:L90 is the only org scoping on this path, because _abort_one re-reads the row by id alone. If it regresses, an admin or service account in one org can stop another org's runs with a green suite. Today's code is correct. Separately, test_other_org_unknown_and_malformed_ids_are_404 uses "some-other-org", which does not exist, so it never tests a real second org.
  • Evidence: With visible = WorkflowExecution.objects.all(), all 33 tests pass. A probe with a real second org then gives aborted; the real code answers not_permitted.
  • Suggested fix: Create a run in a second real org. Assert not_permitted and an unchanged status for the admin and the service account, and use that org in the status 404 case.
  • Confidence: High
  • Verification: VERIFIED — mutant in a scratch copy, plus a two-org probe on real and mutated code.

RECENT = timedelta(minutes=1)


class StuckAbortTestBase(GroupSharingTestBase):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 13] — The abort row lock and per-id transaction cannot fail any test

  • Location: backend/workflow_manager/execution/tests/stuck_abort_base.py:L32
  • Failure mode: Everything runs inside TestCase's transaction. Remove the lock and a run that finishes during the abort is overwritten and reported as aborted. Remove atomic() and every abort returns 500 in production. Both pass all 33 tests. No test aborts twice either, though README.md:L140 promises retries are safe.
  • Evidence: Probes on real connections: with a writer committing COMPLETED mid-abort, real code gives already_finished and the unlocked mutant gives aborted. In autocommit, real code gives 200 and the mutant gives TransactionManagementError. The pattern to copy is in test_update_status_locking.py (_lock_statements, ConcurrentStatusWriteTests).
  • Suggested fix: Assert one FOR NO KEY UPDATE with CaptureQueriesContext. Add a TransactionTestCase for the race and an autocommit abort, and an abort-twice case.
  • Confidence: High
  • Verification: VERIFIED — mutants plus concurrent and autocommit probes.

response.data, {"id": str(execution.id), "status": "STOPPED"}
)

def test_other_org_unknown_and_malformed_ids_are_404(self) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 13] — Nothing pins the status route, and the worker fails open if it breaks

  • Location: backend/workflow_manager/execution/tests/test_execution_contract.py:L104
  • Failure mode: The tests bind the view directly, so the router is never involved, and the worker test pins only its own URL string. If the route breaks, every status check gets a 404 and every file is processed. The abort API still answers aborted, so the stop between files silently does nothing, with only a worker warning. The route works today.
  • Evidence: Renaming url_path="status" or removing the @action at internal_views.py:L520 passes all 33 backend tests. A real request through the middleware returned 200 for the right org and 404 for another.
  • Suggested fix: Assert resolve(f"/internal/v1/workflow-execution/{id}/status/").func.actions == {"get": "execution_status"}, or send one request through the test client.
  • Confidence: High
  • Verification: VERIFIED — mutants in a scratch copy, plus a real-stack probe.

return WorkflowExecution.objects.for_user(request.user).filter(id=obj.id).exists()


@extend_schema_view(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 7] — stuck_since is in no schema component

  • Location: backend/workflow_manager/execution/views/execution.py:L40-L54
  • The spec asks for a typed contract, but at schema time get_serializer_class returns the plain serializer, so the field exists only in prose. Nothing generates types from these routes today. Simplest fix: say the README is the contract.
  • Verification: VERIFIED — generated the schema; git grep on main found no generator.

execution_log_export,
name="execution-log-export",
),
path(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 7] — stuck-count.json and abort.json return 500

  • Location: backend/workflow_manager/execution/urls.py:L32-L37
  • format_suffix_patterns passes format= to handlers that do not accept it, and the TypeError fires before any abort runs. Move these two paths outside the wrapper, or add the kwarg.
  • Verification: VERIFIED — ran it for stuck-count; abort has the same signature and was read only.

return stopped


def _mark_file_stopped(api_client: InternalAPIClient, file_execution_id: str) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 3, 13] — The except in _mark_file_stopped never fires on a real failure

  • Location: workers/file_processing/tasks.py:L707-L718
  • The real client catches errors and returns APIResponse(success=False), and the return value is ignored. The test at workers/tests/test_abort_between_files.py:L110 raises a RuntimeError the client cannot produce, and its unspecced mock would not catch a renamed kwarg. Check response.success, log the file id, and use create_autospec.
  • Verification: VERIFIED — read the client; mutant for the rename.

"""
try:
status = api_client.get_workflow_execution_status(execution_id)
except Exception as error:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 3] — The status check is silent on a missing status and catches programming errors

  • Location: workers/file_processing/tasks.py:L695-L704
  • A 2xx with an unusable body reads as "not stopped" with no log, unlike _raise_if_execution_terminal in the same file. except Exception also hides bugs behind a per-file warning. Log missing or unknown values and narrow the catch to InternalAPIClientError.
  • Verification: VERIFIED — read _make_request and _parse_response.

)
# Past the default 9000 s threshold, and well inside it.
OLD = timedelta(hours=3)
RECENT = timedelta(minutes=1)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 13] — Small test pins

  • Location: backend/workflow_manager/execution/tests/stuck_abort_base.py:L27-L29, backend/workflow_manager/execution/tests/test_abort_executions.py:L195
  • Running with STUCK_EXECUTION_THRESHOLD_SECONDS=30 fails 3 tests; pin it with override_settings. The 100-id limit test derives its limit from the constant, so MAX_ABORT_IDS = 5 passes while the README says 100.
  • Verification: VERIFIED — ran both in a scratch copy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant