Repository navigation
UN-4318 [FEAT] Show stuck executions and let owners abort running ones - #2415
kirtimanmishrazipstack wants to merge 1 commit into
Conversation
- 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>
|
kirtimanmishrazipstack
left a comment
There was a problem hiding this comment.
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
- Spec & intent: See findings F1, F2
- Architectural fit & precedent: See findings F8
- Correctness & edge cases: See findings F1, F2, F3, F7, F8, F9, F10, F11, F23
- Security: Clean
- Data integrity & migrations: See findings F8
- Concurrency: See findings F3
- API & contract compatibility: See findings F1, F6, F12, F21, F22
- Reliability & resilience: See findings F9
- Performance & cost: See findings F11
- Observability: Clean
- Operational safety: Clean
- LLM/agent-specific: N/A — no model calls, prompts or tools in the diff
- 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
- Dependencies & build: N/A — no dependency, lockfile or build file in the diff
- Code quality: Clean
- Doc & comment accuracy: See findings F2, F12, F13, F20
- Cross-document rule consistency: See findings F2, F20
- On-prem deployment experience: N/A — no chart, on-prem values, schema or on-prem build path in the diff
- 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 (
readkeys 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_sincein siblingunstract-cloudandunstract-sdk, infrontend/on this branch and onorigin/main, and readunstract-python-clientfor 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-excludeis clean on check and format, matchingpre-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 inbackend/.venv. - Lens 17: the branch is 10 commits behind
origin/mainand 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
SoftTimeLimitExceededfire 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_FRAMEWORKand 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_errortests onlyerroror"ERROR", so POST returns 200 with a null or partial result, while GET on the same run returns 422. The consumerunstract-python-client/src/unstract/api_deployments/client.py:L92,L389-L391does 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-L767passes no error. - Suggested fix: use
ExecutionStatus.is_failure(execution_status)inhas_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_idadds the file to the active set.clear_history_for_workflowclears these markers; abort does not. - Suggested fix: call
cleanup_active_file_cachefor 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) |
There was a problem hiding this comment.
[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 throughbackend/workflow_manager/execution/abort.py:L127-L129andworkers/file_processing/tasks.py:L768-L770; contradicted doc atbackend/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 defaultMAX_PARALLEL_FILE_BATCHES=1there 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 ofupdate_workflow_execution_statusat L397 is discarded.test_later_completion_cannot_undo_the_abortasserts 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, orworkflow_execution.get("status")in the API path). The mapping atL127then gives FAILURE and cleanup still runs. Delete README L119. Billing needs a decision:_track_subscription_usage_if_availablecommits 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_unifiedreturned 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( |
There was a problem hiding this comment.
[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
remainingnever 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, unlikeworkers/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.pyhas 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_barrierwith 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) |
There was a problem hiding this comment.
[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:L1364filters executions onstatus__in=["PENDING", "EXECUTING"], andL1393filters 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: |
There was a problem hiding this comment.
[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_onere-reads by pk with no org filter, so_abortableis the only tenant boundary on this write. If itsfor_userbase 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.pyorstuck_abort_base.py. ReplacingvisiblewithWorkflowExecution.objects.all()atabort.py:L90survives all 13 abort tests (traced). - Suggested fix: create an org-b workflow and run as
test_shared_execution_access.py:L181does, abort it as an admin and as a service account, and assertnot_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): |
There was a problem hiding this comment.
[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_sincevalues, count query budget, abort outcomes and permission matrix, audit row, file-row updates and internal status 404s. CI gives no cover: thetestjob 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:
pyteston the three backend files gave 3 passed, 30 setup errors (connection refused on localhost:5432).workers/tests/test_abort_between_files.pygave 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-backendruns, 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 |
There was a problem hiding this comment.
[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() |
There was a problem hiding this comment.
[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: |
There was a problem hiding this comment.
[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: |
There was a problem hiding this comment.
[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. |
There was a problem hiding this comment.
[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
left a comment
There was a problem hiding this comment.
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
- 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.
- Does a storage lifecycle rule expire execution directories? That decides how much the leftover directories in #2 matter.
- 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?
- Should a remaining file already in
skipped_already_completedcount 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 bybackend/workflow_manager/execution/abort.py:L127 -
Failure mode: The sync wait loop exits on STOPPED.
posttreats only an error or ERROR as failure, so it returns 200, whilegetreturns 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, matchingget, 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 bybackend/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:L255and cleared attasks.py:L1527.filter_pipeline.py:L655-L657compares only the execution id and never checks whether that run has ended. -
Suggested fix: Call
cleanup_active_file_cachefor the batch's files in the_TerminalExecutionSkipbranch. -
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): |
There was a problem hiding this comment.
[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_filesdiffers fromtotal_files. The backend refuses that write, so the run stays STOPPED, but the callback carries on with COMPLETED. The pipeline'slast_run_statusbecomes SUCCESS, webhooks send a success status, and subscribers who asked for failures only get that success payload too. At the defaultMAX_PARALLEL_FILE_BATCHES=1the 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(theelsesets COMPLETED),L1494andL1536(used for pipeline and notifications),backend/workflow_manager/internal_views.py:L612-L630(the refused write).README.md:L119says "The pipeline's last-run status is not changed", andL156-L158does 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, andupdate_statusaccepts 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_filesthrough 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( |
There was a problem hiding this comment.
[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
remainingnever 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-L1393returns before_barrier_pg_decrement._cleanup_execution_resourcesis called only fromworkers/callback/tasks.py:L1512andL1779. 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-L1312andL1383-L1388("the reaper has by definition already torn the barrier down"),workers/file_processing/tasks.py:L299-L301. The skip's log attasks.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: |
There was a problem hiding this comment.
[High] [Lens 13] — No test covers abort's tenant boundary
- Location:
backend/workflow_manager/execution/tests/test_abort_executions.py:L182 - Failure mode:
for_useratabort.py:L90is the only org scoping on this path, because_abort_onere-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_404uses"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 givesaborted; the real code answersnot_permitted. - Suggested fix: Create a run in a second real org. Assert
not_permittedand 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): |
There was a problem hiding this comment.
[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. Removeatomic()and every abort returns 500 in production. Both pass all 33 tests. No test aborts twice either, thoughREADME.md:L140promises retries are safe. - Evidence: Probes on real connections: with a writer committing COMPLETED mid-abort, real code gives
already_finishedand the unlocked mutant givesaborted. In autocommit, real code gives 200 and the mutant givesTransactionManagementError. The pattern to copy is intest_update_status_locking.py(_lock_statements,ConcurrentStatusWriteTests). - Suggested fix: Assert one
FOR NO KEY UPDATEwithCaptureQueriesContext. Add aTransactionTestCasefor 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: |
There was a problem hiding this comment.
[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@actionatinternal_views.py:L520passes 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( |
There was a problem hiding this comment.
[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_classreturns 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 grepon main found no generator.
| execution_log_export, | ||
| name="execution-log-export", | ||
| ), | ||
| path( |
There was a problem hiding this comment.
[Low] [Lens 7] — stuck-count.json and abort.json return 500
- Location:
backend/workflow_manager/execution/urls.py:L32-L37 format_suffix_patternspassesformat=to handlers that do not accept it, and theTypeErrorfires before any abort runs. Move these two paths outside the wrapper, or add the kwarg.- Verification: VERIFIED — ran it for
stuck-count;aborthas the same signature and was read only.
| return stopped | ||
|
|
||
|
|
||
| def _mark_file_stopped(api_client: InternalAPIClient, file_execution_id: str) -> None: |
There was a problem hiding this comment.
[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 atworkers/tests/test_abort_between_files.py:L110raises aRuntimeErrorthe client cannot produce, and its unspecced mock would not catch a renamed kwarg. Checkresponse.success, log the file id, and usecreate_autospec. - Verification: VERIFIED — read the client; mutant for the rename.
| """ | ||
| try: | ||
| status = api_client.get_workflow_execution_status(execution_id) | ||
| except Exception as error: |
There was a problem hiding this comment.
[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_terminalin the same file.except Exceptionalso hides bugs behind a per-file warning. Log missing or unknown values and narrow the catch toInternalAPIClientError. - Verification: VERIFIED — read
_make_requestand_parse_response.
| ) | ||
| # Past the default 9000 s threshold, and well inside it. | ||
| OLD = timedelta(hours=3) | ||
| RECENT = timedelta(minutes=1) |
There was a problem hiding this comment.
[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=30fails 3 tests; pin it withoverride_settings. The 100-id limit test derives its limit from the constant, soMAX_ABORT_IDS = 5passes while the README says 100. - Verification: VERIFIED — ran both in a scratch copy.



What
GET execution/?stuck=truenarrows the execution list to stuck runs, and each row getsstuck_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_finishedornot_permitted.Why
How
execution.abortedaudit event.workflow-execution/<id>/status/endpoint, and stop only on STOPPED.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)
stuck, the list returns the same response as before (tested), and the other existing endpoints are unchanged.Database Migrations
Env Config
STUCK_EXECUTION_THRESHOLD_SECONDS(default 9000), also added tobackend/sample.env.Relevant Docs
backend/workflow_manager/execution/README.mdRelated Issues or PRs
Dependencies Versions
Notes on Testing
Screenshots
Checklist
I have read and understood the Contribution Guidelines.
🤖 Generated with Claude Code