Skip to content
Open
81 changes: 79 additions & 2 deletions backend/api_v2/deployment_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
from utils.constants import Account, CeleryQueue
from utils.local_context import StateStore
from workflow_manager.endpoint_v2.destination import DestinationConnector
from workflow_manager.endpoint_v2.result_cache_utils import ResultCacheUtils
from workflow_manager.endpoint_v2.source import SourceConnector
from workflow_manager.utils.pipeline_utils import PipelineUtils
from workflow_manager.workflow_v2.dto import ExecutionResponse
from workflow_manager.workflow_v2.enums import ExecutionStatus
from workflow_manager.workflow_v2.execution import WorkflowExecutionServiceHelper
Expand Down Expand Up @@ -293,7 +295,14 @@ def execute_workflow(
logger.exception(f"Failed to mark execution {execution_id} as ERROR")

# Async job never started — release the rate limit slot and clean up.
APIDeploymentRateLimiter.release_slot(api.organization, str(execution_id))
# str(...organization_id), NOT the model instance: release_slot formats
# its argument into the Redis key, and acquire_slot built that key from
# str(organization.organization_id). Passing the instance ZREMs a
# non-member — it returns 0 and raises nothing, so the slot silently
# stays held for the full TTL. Same trap as undispatched_sweep.py:245.
APIDeploymentRateLimiter.release_slot(
str(api.organization.organization_id), str(execution_id)
)
DestinationConnector.delete_api_storage_dir(
workflow_id=workflow_id, execution_id=execution_id
)
Expand All @@ -306,6 +315,72 @@ def execute_workflow(
)
).data

# Staging rejected every file, so there is nothing to dispatch. The worker
# short-circuits an empty file set without writing a status back, which
# would strand this execution in PENDING — terminalise it here instead.
if not hash_values_of_files:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Low] [Lens 15] — The two fixes for the same scenario disagree on total_files

The worker sets total_files=0 (workers/api-deployment/tasks.py:230); this branch leaves it at the creation-time len(file_objs) (deployment_helper.py:241). An all-rejected run therefore lands COMPLETED with total_files=1 and zero file executions.

Cosmetic in the API response (which reads the result cache), but the executions list shows a completed run whose counts do not add up.

Suggested fix — have update_execution_completed zero the count, or accept a total_files argument, so both paths agree.

# Isolate the DB write the way the staging-failure path above does, so
# the rate limit slot and staging dir are released even if it raises.
execution = None
try:
execution = WorkflowExecutionServiceHelper.update_execution_completed(
str(execution_id),
total_files=len(file_objs),
failed_files=len(file_objs),
)
except Exception:
logger.exception(f"Failed to mark execution {execution_id} as COMPLETED")

APIDeploymentRateLimiter.release_slot(
str(api.organization.organization_id), str(execution_id)
)
DestinationConnector.delete_api_storage_dir(
workflow_id=workflow_id, execution_id=execution_id
)
Comment on lines +321 to +339

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[High] [Lens 3 · 8] — this branch can raise before its own cleanup runs

Failure mode. update_execution_completed catches only WorkflowExecution.DoesNotExist (execution.py:393-401). Any other DB failure — OperationalError, a statement or lock timeout on the select_for_update inside update_execution (models/execution.py:418-423), a deadlock, a dropped connection — propagates out of execute_workflow, so line 315 and lines 316-318 never run. The org's rate-limit slot stays held for the full 6h TTL and throttles every other API-deployment call for that org, the staging dir is never deleted, the row stays PENDING, and the caller gets a 500 with no execution id to poll.

Evidence. The sibling block immediately above (lines 289-308) deliberately isolates its DB write in an inner try/except with logger.exception so that cleanup always runs, and has a regression test pinning exactly that: test_staging_failure_cleanup_survives_db_marking_error (tests/test_deployment_helper.py:75-91). The new path copies the shape but not the guard, and has no equivalent test.

Suggested fix. Wrap the update_execution_completed call in its own try/except Exception: logger.exception(...) so release_slot and delete_api_storage_dir always execute, and add the mirror-image test.

Confidence: High.

api_results = ResultCacheUtils.get_api_results(
workflow_id=str(workflow_id), execution_id=str(execution_id)
)
if execution is not None:
# Separate try blocks on purpose: these are independent obligations,
# and sharing one would let a failed acknowledgement silence the
# notification that subscribers depend on.
try:
# This response carries the results, so mark them consumed the way
# the synchronous dispatch path does — otherwise a follow-up
# GET /status serves them a second time with 200 instead of 406.
WorkflowHelper.set_result_acknowledge(execution)
except Exception:
logger.exception(
f"Failed to acknowledge results for execution {execution_id}"
)

try:
# Terminalising here bypasses WorkflowHelper, which is what
# normally notifies API deployment subscribers on a terminal
# status. Without this an all-rejected run alerts nobody.
PipelineUtils.update_pipeline_status(
pipeline_id=pipeline_id, workflow_execution=execution
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
except Exception:
logger.exception(
f"Failed to notify subscribers for execution {execution_id}"
)

# Report the stored status rather than asserting COMPLETED: the row may
# be missing, or the terminal guard may have refused the change. Claiming
# success here would only hide the stranded execution behind a 200 that a
# follow-up GET /status then contradicts.
return APIExecutionResponseSerializer(
ExecutionResponse(
workflow_id=workflow_id,
execution_id=execution_id,
execution_status=(
execution.status if execution else ExecutionStatus.ERROR.value
),
result=api_results,
)
).data
Comment on lines +373 to +382

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[High] [Lens 3 · 10] — the response asserts COMPLETED whether or not the status write landed

Failure mode. There are three ways the call on line 314 returns normally without the row reaching COMPLETED:

  1. row missing — execution.py:399-401 logs and returns None;
  2. row vanished under the lock — models/execution.py:424-425, if locked is None: return, silent;
  3. row already terminal with a different value — models/execution.py:520-535 refuses, logs a warning, returns ([], False).

The return value is discarded and execution_status on line 323 is a hardcoded literal rather than the row's actual status. The API then answers COMPLETED while a follow-up GET /status/<execution_id> reads the DB and returns PENDING — the stranded-execution bug this PR exists to fix, now concealed behind a success response instead of being visible.

update_execution_completed was given a WorkflowExecution | None return type to carry exactly this signal, and no caller reads it.

Suggested fix. Bind the result: if it is None, or its status is not COMPLETED, log at error level and return the row's real status (or ERROR) rather than claiming COMPLETED.

Confidence: High.


try:
result = WorkflowHelper.execute_workflow_async(
workflow_id=workflow_id,
Expand Down Expand Up @@ -352,7 +427,9 @@ def execute_workflow(
# Dispatch failures are marked ERROR internally by execute_workflow_async;
# post-dispatch failures (enrichment/config) must not overwrite a running
# execution's status, so only release the slot and clean up storage here.
APIDeploymentRateLimiter.release_slot(api.organization, str(execution_id))
APIDeploymentRateLimiter.release_slot(
str(api.organization.organization_id), str(execution_id)
)

# Clean up storage
DestinationConnector.delete_api_storage_dir(
Expand Down
193 changes: 193 additions & 0 deletions backend/api_v2/tests/test_deployment_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def _api() -> MagicMock:
api = MagicMock()
api.workflow.id = "wf-1"
api.id = "pipe-1"
api.organization.organization_id = "org-uuid-1"
return api


Expand Down Expand Up @@ -89,3 +90,195 @@ def test_staging_failure_cleanup_survives_db_marking_error(collaborators) -> Non
# Cleanup still runs even though error-marking raised.
collaborators["APIDeploymentRateLimiter"].release_slot.assert_called_once()
collaborators["DestinationConnector"].delete_api_storage_dir.assert_called_once()


@pytest.fixture
def staging_rejects_everything():
"""Patch execute_workflow's collaborators; staging returns no dispatchable files."""
with mock.patch.multiple(
dh,
WorkflowExecutionServiceHelper=mock.DEFAULT,
SourceConnector=mock.DEFAULT,
DestinationConnector=mock.DEFAULT,
APIDeploymentRateLimiter=mock.DEFAULT,
WorkflowHelper=mock.DEFAULT,
ResultCacheUtils=mock.DEFAULT,
PipelineUtils=mock.DEFAULT,
Tag=mock.DEFAULT,
logger=mock.DEFAULT,
) as mocks:
execution_row = MagicMock()
execution_row.id = "exec-123"
mocks[
"WorkflowExecutionServiceHelper"
].create_workflow_execution.return_value = execution_row
mocks["SourceConnector"].add_input_file_to_api_storage.return_value = {}
mocks["ResultCacheUtils"].get_api_results.return_value = [
{"file": "evil.pdf", "status": "Failed", "error": "unsupported MIME type"}
]
completed_row = MagicMock()
completed_row.status = "COMPLETED"
mocks[
"WorkflowExecutionServiceHelper"
].update_execution_completed.return_value = completed_row
yield mocks


def test_all_files_rejected_completes_without_dispatch(
staging_rejects_everything,
) -> None:
"""A request whose every file is rejected must reach a terminal status.

The worker short-circuits an empty file set without writing a status back, so
dispatching one strands the execution in PENDING and the caller polls forever.
"""
mocks = staging_rejects_everything
# A non-empty upload whose staging result is empty. Passing [] instead would
# leave the branch satisfied by `not file_objs` too, and the original bug -
# dispatching a request whose files were all rejected - would pass this test.
response = dh.DeploymentHelper.execute_workflow(
organization_name="org",
api=_api(),
file_objs=[MagicMock()],
timeout=-1,
)
Comment on lines +139 to +144

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[High] [Lens 13] — the guard's predicate is unpinned; the original bug can be reintroduced with the suite green

Failure mode. This test passes file_objs=[] with SourceConnector fully mocked, so it exercises the zero-files-uploaded path, not the zero-files-staged path the guard exists for. Nothing in the suite asserts that a non-empty upload whose staging result is empty takes the short-circuit, and nothing asserts the short-circuit does not fire when staging returns files.

Evidence (mutants run against the branch, then reverted):

  • if not hash_values_of_files: -> if not file_objs: at deployment_helper.py:3133/3 pass. That mutant is the production bug verbatim: one HTML file uploaded, staging rejects it and returns {}, file_objs is non-empty, control falls through to execute_workflow_async, execution stranded in PENDING.
  • if not hash_values_of_files: -> if True: — the whole backend/api_v2/tests/ suite is identical to baseline (48 passed).
  • Control: deleting the update_execution_completed call does fail this test, so it pins the branch body, not the branch condition.

Also worth noting: assert response["result"][0]["status"] == "Failed" on line 148 reads back the fixture's own literal from line 115, so it proves the branch forwards the cache verbatim, not what source.py writes.

Suggested fix. Pass a non-empty file_objs (a bare MagicMock() suffices — with SourceConnector mocked, the only read is len(file_objs) at deployment_helper.py:243) so the two cases become distinguishable, and add a sibling test with add_input_file_to_api_storage.return_value = {"good.pdf": MagicMock()} asserting execute_workflow_async is called and update_execution_completed is not. Parametrising timeout over {-1, 10} closes the untested synchronous path at negligible cost.

Confidence: High (mutants executed).


# Nothing is dispatched...
mocks["WorkflowHelper"].execute_workflow_async.assert_not_called()
# ...the row is terminalised here instead of being left PENDING, and the
# counters are written so the run does not read back as a clean success...
mocks[
"WorkflowExecutionServiceHelper"
].update_execution_completed.assert_called_once_with(
"exec-123", total_files=1, failed_files=1
)
# ...the slot and staging dir are released. The slot must be released by org
# id string: release_slot formats its argument into the Redis key, so passing
# the model instance removes a non-member and silently holds the slot.
mocks["APIDeploymentRateLimiter"].release_slot.assert_called_once_with(
"org-uuid-1", "exec-123"
)
mocks["DestinationConnector"].delete_api_storage_dir.assert_called_once()
# ...and the caller still sees why each file failed.
assert response["execution_status"] == "COMPLETED"
assert response["result"][0]["file"] == "evil.pdf"
assert response["result"][0]["status"] == "Failed"


def test_all_files_rejected_acknowledges_and_notifies(
staging_rejects_everything,
) -> None:
"""The early return owes the caller what the dispatch path would have done.

It hands back the results in its own response and reaches a terminal status
without going through WorkflowHelper, so both the acknowledgement and the
subscriber notification have to happen here or they happen nowhere.
"""
mocks = staging_rejects_everything
completed_row = mocks[
"WorkflowExecutionServiceHelper"
].update_execution_completed.return_value

dh.DeploymentHelper.execute_workflow(
organization_name="org",
api=_api(),
file_objs=[MagicMock()],
timeout=-1,
)

# Results were served in this response, so a later GET /status must 406
# rather than serve them again.
mocks["WorkflowHelper"].set_result_acknowledge.assert_called_once_with(completed_row)
# PipelineUtils is the only dispatcher of API deployment notifications.
mocks["PipelineUtils"].update_pipeline_status.assert_called_once_with(
pipeline_id="pipe-1", workflow_execution=completed_row
)


def test_notification_survives_a_failed_acknowledgement(
staging_rejects_everything,
) -> None:
"""Acknowledgement and notification are independent obligations.

Sharing one try block would let a failed acknowledgement silence the
notification that subscribers depend on.
"""
mocks = staging_rejects_everything
mocks["WorkflowHelper"].set_result_acknowledge.side_effect = Exception("db is down")

response = dh.DeploymentHelper.execute_workflow(
organization_name="org",
api=_api(),
file_objs=[MagicMock()],
timeout=-1,
)

mocks["PipelineUtils"].update_pipeline_status.assert_called_once()
assert response["execution_status"] == "COMPLETED"


def test_all_files_rejected_still_responds_if_notification_fails(
staging_rejects_everything,
) -> None:
"""A failing webhook must not turn a handled rejection into a 500."""
mocks = staging_rejects_everything
mocks["PipelineUtils"].update_pipeline_status.side_effect = Exception("webhook down")

response = dh.DeploymentHelper.execute_workflow(
organization_name="org",
api=_api(),
file_objs=[MagicMock()],
timeout=-1,
)

assert response["execution_status"] == "COMPLETED"
assert response["result"][0]["file"] == "evil.pdf"


def test_files_staged_successfully_are_dispatched(staging_rejects_everything) -> None:
"""The short-circuit must not fire when staging did return files.

Sibling to the test above: together they pin the branch to the staging result
rather than to the upload list.
"""
mocks = staging_rejects_everything
mocks["SourceConnector"].add_input_file_to_api_storage.return_value = {
"good.pdf": MagicMock()
}

dh.DeploymentHelper.execute_workflow(
organization_name="org",
api=_api(),
file_objs=[MagicMock()],
timeout=-1,
)

mocks["WorkflowHelper"].execute_workflow_async.assert_called_once()
mocks["WorkflowExecutionServiceHelper"].update_execution_completed.assert_not_called()


def test_all_files_rejected_cleanup_survives_db_marking_error(
staging_rejects_everything,
) -> None:
"""A failing status write must not strand the slot or the staging dir.

update_execution_completed only catches DoesNotExist, so a lock timeout or a
dropped connection propagates; without isolation the org's rate limit slot
stays held for its full TTL and throttles every other call for that org.
"""
mocks = staging_rejects_everything
mocks[
"WorkflowExecutionServiceHelper"
].update_execution_completed.side_effect = Exception("db is down")

response = dh.DeploymentHelper.execute_workflow(
organization_name="org",
api=_api(),
file_objs=[MagicMock()],
timeout=-1,
)

mocks["APIDeploymentRateLimiter"].release_slot.assert_called_once()
mocks["DestinationConnector"].delete_api_storage_dir.assert_called_once()
# The row never reached COMPLETED, so the response must not claim it did.
assert response["execution_status"] == "ERROR"
Loading
Loading