Skip to content

UN-3016 [FIX] Record why an execution failed; stop dispatching skipped files - #2256

Open
hari-kuriakose wants to merge 7 commits into
mainfrom
worktree-un3016-fix
Open

UN-3016 [FIX] Record why an execution failed; stop dispatching skipped files#2256
hari-kuriakose wants to merge 7 commits into
mainfrom
worktree-un3016-fix

Conversation

@hari-kuriakose

Copy link
Copy Markdown
Contributor

UN-3016 — an execution failed with no explanation

A customer (Moody's) execution ended in ERROR with nothing telling the user why. Root-caused from code and then corroborated against the actual prod row:

Field Value
status ERROR
error_message (blank)
total_files 1
file ...Application.xlsm (application/vnd.ms-excel.sheet.macroenabled.12)
file_hash temp-hash-9d260345dd444238a58eb42ab562bcd7

The prod logs for this run are long gone (30-day retention vs a 283-day-old incident), so the DB row is the evidence.

What was happening

The temp-hash- prefix is written in exactly one place — the backend MIME-skip branch — which pins the mechanism:

  1. The backend correctly rejected the .xlsm and took the skip branch, which deliberately never writes the file's bytes. But it returned the file with is_executed=True and a fake hash.
  2. Nothing downstream filters on is_executed, so the file was counted in total_files and dispatched to a worker anyway.
  3. The worker failed on a file that was never staged. Both halves of the stored error are bare paths — what FileNotFoundError(path) stringifies to.
  4. failed_files == total_files (1 == 1) escalated the execution to ERROR.
  5. _determine_execution_status_unified returned only a status and counts — no reason string — so every call site passed error_message=None. WorkflowExecution.update_execution() guards on if error:, so error_message was never written.

Verified present at 0da9da5a8, the commit that was HEAD the day the ticket was filed — pre-existing, not a regression.

Changes

  1. Executions now record why they failed. _determine_execution_status_unified returns a reason built from the per-file errors already aggregated in aggregated_results; all call sites pass it through, including notifications. Capped to the CharField(256) that otherwise truncates silently.
  2. A skipped file is no longer dispatched. Unsupported files are excluded from the staged set rather than returned with a fake hash. If every file is skipped, the request fails with a 400 naming the types instead of dispatching an empty execution that would finish as a vacuous success.
  3. Workers' AllowedFileTypes was missing XLSM while the backend had it. The two MIME lists are now identical, so a file the API accepts cannot be rejected again inside the worker.

PGMQ

All three apply to the PGMQ stack. It is not a separate callback path — it shares _process_batch_callback_core and process_batch_callback_api with is_pg as a kwarg flag, so both patched call sites cover it. Staging is backend-side and transport-agnostic, and the PG stack has no MIME list of its own. Its own error paths ([pg-poison-drop], [pg-barrier-abort], [reaper-recovery]) already wrote descriptive reasons and never had this defect.

Tests

workers/tests/test_un3016_execution_error.py — 9 passing: the real incident error shape, the never-blank guarantee, the 256-char column fit, and a regression guard that no caller reintroduces error_message=None.

Reviewer notes

  • The full worker suite was not run locally — it needs the repo's unstract.core package, absent from this checkout. The 3→4 tuple change on _determine_execution_status_unified is the thing to watch; the two PG tests touching it mock with assert_not_called, so they should be unaffected, but CI is the real check.
  • Ruff was not run (not installed here). Files compile.
  • Known, deliberately not fixed here: the per-file error text is still a bare path pair built by interpolating two FileNotFoundError paths. This PR makes the execution record it rather than a blank — an improvement, not a cure. _handle_null_execution_result also still publishes nothing to the UI; proven not to be this incident's mechanism, so left alone. Both warrant a follow-up.
  • XLSM was absent from the backend enum at incident time and has since been added, so this customer's file would be accepted today. The other two defects remained for any other unsupported type, which is why this is not just an enum entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_0197gRFp4QFxu2VzxhTEozGn

hari-kuriakose and others added 5 commits August 29, 2026 23:09
…d files

A customer (Moody's) execution ended in ERROR with no explanation. Verified
against the prod row for 30b84e4a-8675-4e93-a235-8d5dbac89be8:
status=ERROR, error_message='' (blank), total_files=1.

Three distinct defects, fixed here:

1. Execution errors were recorded blank.
   _determine_execution_status_unified() decided ERROR purely from failure
   counts and returned no reason, so all three call sites passed
   error_message=None. WorkflowExecution.update_execution() guards on
   `if error:`, so error_message was never written. It now returns a reason
   summarised from the per-file errors already present in aggregated_results,
   capped to the CharField(256) that otherwise truncates silently.

2. A file skipped for an unsupported MIME type was still handed to a worker.
   The skip deliberately never wrote the file's bytes, but returned it with
   is_executed=True and a "temp-hash-<uuid>" sentinel. Nothing downstream
   filters on is_executed, so a worker ran, failed on the missing file, and
   produced the opaque "Execution: <path>; Destination: <path>" error seen in
   the incident row (that string is built by interpolating two FileNotFoundError
   paths, not by mangling a MIME message). Skipped files are now excluded from
   the staged set; if every file is skipped the request fails with a 400 naming
   the unsupported types instead of dispatching an empty execution.

3. The workers' AllowedFileTypes lacked XLSM while the backend had it.
   The two MIME lists are now identical, so a file the API accepts cannot be
   rejected again inside the worker.

Tests: workers/tests/test_un3016_execution_error.py, 9 passing — covers the
real incident error shape, the never-blank guarantee, the 256-char column fit,
and a regression guard that no caller reintroduces error_message=None.

Note: XLSM was absent from the backend enum at the time of the incident and has
since been added, so the customer's .xlsm would be accepted today; the defects
above remain for any other unsupported type.
Remediation of review findings on PR #2256.

F1 (High): the new UnsupportedMimeTypeError raised by
add_input_file_to_api_storage escaped the try/except in
WorkflowViewSet.execute that owns delete_api_storage_dir, so a partial
stage could leave written files behind. The staging call now has its own
handler mirroring the one at the end of the method. The sibling caller
(api_v2/deployment_helper.py:279) was already guarded.

F2 (High): test_no_caller_passes_a_hardcoded_none_error asserted
"error_message=None" not in the whole 1900-line tasks.py. It matched
nothing at the sites it meant to guard and would trip on any unrelated
keyword default. Now an AST check scoped to _process_batch_callback_core
and process_batch_callback_api. Mutation-checked: reintroducing
error_message=None at process_batch_callback_api fails the test with
that call site's line number.

F4 (Medium): _EXECUTION_ERROR_MAX_LENGTH now names its authority,
EXECUTION_ERROR_LENGTH in backend workflow_v2/models/execution.py,
following the convention in workflow_v2/undispatched_sweep.py.

Adversarial verification also found two comments I had written asserting
mechanisms the code does not have (the storage dir is a computed path,
not a mkdir; the AST check does not catch positional/indirected None).
Both false claims deleted rather than reworded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
…rce shape

Behaviour-preserving cleanup, run once after the review verdict settled.

- views.py: drop the second cleanup handler added in the previous commit and
  stage inside the existing try instead. That handler's guard is exactly the
  staging condition, so one handler now covers both paths rather than two
  copies of the same contract.

- test_un3016_execution_error.py: import callback.tasks directly instead of
  extracting the helper with ast/exec. The docstring's claim that importing
  pulls in an unusable celery runtime is false — conftest loads .env.test
  before collection, and test_pg_callback_duplicate_guard.py already imports
  the module at module level. Verified by running the import under pytest.
  test_status_function_returns_a_reason is now behavioural: it calls
  _determine_execution_status_unified and asserts the reason is non-blank.
  The old version asserted only that every return was a 4-tuple, which would
  have passed with an always-None fourth element — i.e. it could not detect
  the very defect it was named for. Mutation-checked: forcing
  error_message = None now fails the test.

- _summarize_file_errors: errors is keyed by file name, so entries are
  distinct by construction and the `entry not in seen` dedup could never
  fire. Removed, along with the duplicate early return it guarded.

- source.py: skipped_files was a dict never used as a mapping; now a list of
  pre-formatted entries.

Tests: 1298 passed, 132 skipped. test_pg_reaper.py deselected — it needs a
live Postgres on 127.0.0.1:5432 and hangs identically on unmodified HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
_determine_execution_status_unified marks ERROR from two places, and a blank
error_message from either one is the UN-3016 defect. Only the
failed_files == total_files branch was covered; the timeout branch
(files expected, no batch result came back) had no test at all — mutating its
error_message to None left the suite green.

Adds test_timeout_failure_also_returns_a_reason, which mocks the api_client
so get_workflow_execution reports total_files=3 with an empty
file_batch_results, driving has_timeout_failure. Mutation-checked: forcing
that branch's error_message to None now fails the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
@hari-kuriakose

Copy link
Copy Markdown
Contributor Author

Note: partial file skips are silent to the API caller — deliberately not fixed here

Flagged during review and waived, not overlooked. Recording the reasoning so it is stated rather than rediscovered later.

The behaviour. The guard added in this PR is at backend/workflow_manager/endpoint_v2/source.py:1294:

if skipped_files and not file_hashes:
    raise UnsupportedMimeTypeError(
        "No files could be processed. Unsupported file type(s): " + ", ".join(skipped_files)
    )

The and not file_hashes means this fires only when every uploaded file is rejected. On a partial skip — say 10 files uploaded, 2 unsupported — the execution proceeds over the surviving 8 and returns 200. Nothing in the response names the 2 that were dropped; the reason exists only in the server log written just above (workflow_log.log_error). A caller has to count results to notice anything is missing.

Why it is not fixed in this PR.

  1. Surfacing skipped files in the response is a response-contract change. That is out of scope for this fix and wants its own review, since anything parsing the execution response could be affected.
  2. It is pre-existing, and this PR strictly improves it. Previously the unsupported file was returned with is_executed=True and a temp hash; nothing downstream filtered on is_executed, so the worker ran it anyway, failed on the missing file, and the whole execution died with an opaque Execution: <path>; Destination: <path> error. So the prior behaviour was one bad file kills the entire run, confusingly. This PR makes it one bad file is skipped and the rest succeed.

The asymmetry worth knowing about. Total failure is now loud and explicit; partial failure is quiet. That is a deliberate trade, not an oversight — but it does mean a user can get a successful run over fewer files than they uploaded without the response saying so.

Suggested follow-up: report skipped files to the caller, e.g. as a skipped_files field on the execution response, so a partial skip is as visible as a total one.

Reviewed with unstract:lite-remediation; finding F3, waived.

hari-kuriakose and others added 2 commits August 31, 2026 23:12
PR #2256's workers half had 8 tests; its backend half had none. Six tests
close that gap, all DB-free (the ORM boundary and file storage are patched,
so no Postgres is needed).

source.py — add_input_file_to_api_storage:
- partial skip returns only the supported files, and does NOT raise. A
  rejected file alongside a runnable one is simply absent from the mapping;
  the request proceeds with what can be run (UN-4055 tracks whether that
  should remain the behaviour, so this pins it rather than asserting a raise).
- a total skip raises UnsupportedMimeTypeError naming every skipped file and
  its MIME type, instead of dispatching an empty execution that would report
  a vacuous success.
- an empty request returns {} without raising: the guard is on something
  having been skipped, not merely on the mapping being empty.
- an accepted file's FileHash carries the sha256 of the staged bytes. This is
  characterisation only — it held before the fix and no mutation of the fix
  makes it fail; it documents what a returned entry looks like, which is what
  makes the rejected file's absence meaningful.

views.py — WorkflowViewSet.execute:
- a staging failure reaches delete_api_storage_dir, so a partial stage does
  not leave written files behind.
- a request that staged nothing does not attempt that cleanup.

Files are real SimpleUploadedFile objects, not mocks: a MagicMock's
content_type is never in AllowedFileTypes, so a mocked "supported" file would
silently take the skip branch and the test would pass for the wrong reason.

Mutation-checked, each mutation reverted and the restore verified on disk:
- re-adding the rejected file to file_hashes with a temp hash -> partial-skip
  and total-skip tests fail
- dropping "and not file_hashes" from the raise condition -> partial-skip
  test fails (it now raises on a request that has runnable files)
- dropping "skipped_files and" from the raise condition -> empty-request test
  fails
- deleting the raise block -> total-skip test fails
- stripping the filenames from the error message -> total-skip test fails
- moving staging back outside the try -> staging-failure test fails
- dropping the has_uploads guard on cleanup -> no-cleanup test fails

Five of the six tests are pinned by a mutation; the sha256 test is the
characterisation case noted above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQhqNqCwFcXZZ7cQU6HxUE
@hari-kuriakose
hari-kuriakose marked this pull request as ready for review August 31, 2026 19:48
@sonarqubecloud

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR prevents unsupported API uploads from being dispatched without staged bytes and records bounded, nonblank reasons when worker callbacks finalize executions as failed.

  • Unsupported files are excluded from worker fan-out, while all-unsupported requests return a descriptive client error.
  • Staging now occurs inside the execution cleanup boundary.
  • Callback status determination propagates file-error or timeout summaries to execution updates and notifications.
  • Worker MIME support is aligned with the backend by adding XLSM.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete changed-code defect remaining after review.

The staging, dispatch filtering, callback tuple propagation, execution update, and notification paths remain aligned, and the apparent lifecycle concerns are either handled by existing callers, explicitly intentional, or pre-existing.

Important Files Changed

Filename Overview
backend/workflow_manager/endpoint_v2/source.py Filters unsupported uploads out of the staged mapping and raises a descriptive error when no processable files remain.
backend/workflow_manager/workflow_v2/views.py Moves upload staging into the existing cleanup boundary so staging failures remove execution-scoped API storage.
workers/callback/tasks.py Adds bounded execution-error summaries and propagates them through both callback finalization and notification paths.
workers/shared/enums/file_types.py Adds the XLSM MIME type to align worker validation with backend acceptance.

Sequence Diagram

sequenceDiagram
    participant C as API client
    participant B as Backend staging
    participant W as File worker
    participant K as Callback
    participant E as Execution record
    C->>B: Upload files
    B->>B: Filter unsupported MIME types
    alt No supported files
        B-->>C: 400 with unsupported types
    else Supported files remain
        B->>W: Dispatch only staged files
        W-->>K: Per-file results
        K->>K: Determine final status and reason
        K->>E: Persist counts, status, and error message
        K-->>C: Notify with failure reason when applicable
    end
Loading

Reviews (1): Last reviewed commit: "Merge branch 'main' into worktree-un3016..." | Re-trigger Greptile

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
e2e-api-deployment e2e 3 0 0 0 20.9
e2e-coowners e2e 1 0 0 0 1.6
e2e-etl e2e 1 0 0 0 8.3
e2e-login e2e 2 0 0 0 1.3
e2e-prompt-studio e2e 1 0 0 0 4.4
e2e-smoke e2e 2 0 0 0 1.4
e2e-workflow e2e 1 0 0 0 16.6
integration-backend integration 310 0 0 26 49.5
integration-connectors integration 1 0 0 7 8.8
integration-workers integration 157 0 0 1 54.2
unit-backend unit 1164 0 0 1 33.3
unit-connectors unit 63 0 0 0 8.8
unit-core unit 33 0 0 0 1.0
unit-platform-service unit 15 0 0 0 2.0
unit-rig unit 117 0 0 0 4.6
unit-runner unit 5 0 0 0 3.7
unit-sdk1 unit 563 0 0 0 24.2
unit-workers unit 1407 0 0 1 120.8
TOTAL 3846 0 0 36 365.4

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

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