Skip to content

Commit f4129a2

Browse files
committed
fix(testing): handle workflow failure states correctly
1 parent 4bbb9f2 commit f4129a2

6 files changed

Lines changed: 154 additions & 8 deletions

File tree

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/execution.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,9 @@ def complete_success(self, result: str | None, now: datetime | None = None) -> N
406406
self.close_status = ExecutionStatus.SUCCEEDED
407407
self._end_execution(OperationStatus.SUCCEEDED, now)
408408

409-
def complete_fail(self, error: ErrorObject, now: datetime | None = None) -> None:
409+
def complete_fail(
410+
self, error: ErrorObject | None, now: datetime | None = None
411+
) -> None:
410412
"""Complete execution with failure (DecisionType.FAIL_WORKFLOW_EXECUTION)."""
411413
self.result = DurableExecutionInvocationOutput(
412414
status=InvocationStatus.FAILED, error=error

packages/aws-durable-execution-sdk-python-testing/src/aws_durable_execution_sdk_python_testing/executor.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,9 +1285,7 @@ def _validate_invocation_response_and_store(
12851285
)
12861286
raise InvalidParameterValueException(msg_failed_result)
12871287
logger.info("[%s] Execution failed", execution_arn)
1288-
self._complete_workflow(
1289-
execution_arn, result=None, error=response.error
1290-
)
1288+
self._fail_workflow(execution_arn, response.error)
12911289

12921290
case InvocationStatus.SUCCEEDED:
12931291
if response.error is not None:
@@ -1591,7 +1589,7 @@ def _complete_workflow(
15911589
else:
15921590
self.complete_execution(execution_arn, result)
15931591

1594-
def _fail_workflow(self, execution_arn: str, error: ErrorObject):
1592+
def _fail_workflow(self, execution_arn: str, error: ErrorObject | None):
15951593
"""Fail workflow with terminal state validation."""
15961594
execution = self._store.load(execution_arn)
15971595

@@ -1671,8 +1669,8 @@ def complete_execution(self, execution_arn: str, result: str | None = None) -> N
16711669
raise IllegalStateException(msg)
16721670
self._complete_events(execution_arn=execution_arn)
16731671

1674-
def fail_execution(self, execution_arn: str, error: ErrorObject) -> None:
1675-
"""Fail execution with error (FAIL_WORKFLOW_EXECUTION decision)."""
1672+
def fail_execution(self, execution_arn: str, error: ErrorObject | None) -> None:
1673+
"""Fail execution with optional error (FAIL_WORKFLOW_EXECUTION decision)."""
16761674
logger.error("[%s] Completing execution with error: %s", execution_arn, error)
16771675
execution: Execution = self._store.load(execution_arn=execution_arn)
16781676
execution.complete_fail(error=error, now=self._clock.now())
@@ -1688,7 +1686,7 @@ def on_completed(self, execution_arn: str, result: str | None = None) -> None:
16881686
"""Complete execution successfully. Observer method triggered by notifier."""
16891687
self.complete_execution(execution_arn, result)
16901688

1691-
def on_failed(self, execution_arn: str, error: ErrorObject) -> None:
1689+
def on_failed(self, execution_arn: str, error: ErrorObject | None) -> None:
16921690
"""Fail execution. Observer method triggered by notifier."""
16931691
self.fail_execution(execution_arn, error)
16941692

packages/aws-durable-execution-sdk-python-testing/tests/event_factory_test.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,41 @@ def test_create_execution_failed():
158158
assert event.execution_failed_details.error.payload.message == "Execution failed"
159159

160160

161+
def test_create_execution_failed_without_error_payload():
162+
from aws_durable_execution_sdk_python.execution import (
163+
DurableExecutionInvocationOutput,
164+
InvocationStatus,
165+
)
166+
167+
operation = create_mock_operation("op-1", status=OperationStatus.FAILED)
168+
operation.end_timestamp = datetime.now(UTC)
169+
170+
error_result = DurableExecutionInvocationOutput(
171+
status=InvocationStatus.FAILED,
172+
error=None,
173+
)
174+
context = EventCreationContext.create(
175+
operation=operation,
176+
event_id=3,
177+
durable_execution_arn="arn:test",
178+
start_input=StartDurableExecutionInput(
179+
account_id="123",
180+
function_name="test",
181+
function_qualifier="$LATEST",
182+
execution_name="test",
183+
execution_timeout_seconds=300,
184+
execution_retention_period_days=7,
185+
),
186+
result=error_result,
187+
include_execution_data=True,
188+
)
189+
event = Event.create_execution_event(context)
190+
191+
assert event.event_type == "ExecutionFailed"
192+
assert event.execution_failed_details.error is not None
193+
assert event.execution_failed_details.error.payload is None
194+
195+
161196
def test_create_execution_timed_out():
162197
from aws_durable_execution_sdk_python.execution import (
163198
DurableExecutionInvocationOutput,

packages/aws-durable-execution-sdk-python-testing/tests/execution_test.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,26 @@ def test_complete_fail():
507507
assert execution.result.error == error
508508

509509

510+
def test_complete_fail_without_error():
511+
"""Test complete_fail preserves a missing error payload."""
512+
start_input = StartDurableExecutionInput(
513+
account_id="123456789012",
514+
function_name="test-function",
515+
function_qualifier="$LATEST",
516+
execution_name="test-execution",
517+
execution_timeout_seconds=300,
518+
execution_retention_period_days=7,
519+
invocation_id="test-invocation-id",
520+
)
521+
execution = Execution("test-arn", start_input, [Mock()])
522+
523+
execution.complete_fail(None)
524+
525+
assert execution.is_complete is True
526+
assert execution.result.status == InvocationStatus.FAILED
527+
assert execution.result.error is None
528+
529+
510530
def test_find_operation_exists():
511531
"""Test find_operation method when operation exists."""
512532
start_input = StartDurableExecutionInput(

packages/aws-durable-execution-sdk-python-testing/tests/executor_invariants_test.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
* Gate-release: after a sequence of transient invocation failures,
2222
the observed InvocationState is never INVOKING.
2323
* Idempotent replay byte-equivalence.
24+
* Child context failures remain operation-local and do not force a
25+
successful root invocation to fail.
2426
"""
2527

2628
from __future__ import annotations
@@ -52,6 +54,7 @@
5254
)
5355
from aws_durable_execution_sdk_python_testing.execution import (
5456
Execution,
57+
ExecutionStatus,
5558
OperationPaginatorState,
5659
)
5760
from aws_durable_execution_sdk_python_testing.executor import Executor, InvocationState
@@ -927,6 +930,58 @@ def test_checkpoint_returns_full_delta_in_one_response():
927930
assert [op.operation_id for op in r2.new_execution_state.operations] == []
928931

929932

933+
def test_context_failed_checkpoint_does_not_force_root_failure():
934+
"""A caught child context failure must not force root execution failure."""
935+
executor, store, arn, token = _make_executor()
936+
error = ErrorObject.from_message("context failed")
937+
938+
executor.checkpoint_execution(
939+
execution_arn=arn,
940+
checkpoint_token=token,
941+
updates=[
942+
OperationUpdate(
943+
operation_id="context-1",
944+
operation_type=OperationType.CONTEXT,
945+
action=OperationAction.START,
946+
name="context-1",
947+
),
948+
OperationUpdate(
949+
operation_id="context-1",
950+
operation_type=OperationType.CONTEXT,
951+
action=OperationAction.FAIL,
952+
name="context-1",
953+
error=error,
954+
),
955+
],
956+
)
957+
958+
checkpointed_execution = store.load(arn)
959+
_, context_op = checkpointed_execution.find_operation("context-1")
960+
assert context_op.operation_type == OperationType.CONTEXT
961+
assert context_op.status == OperationStatus.FAILED
962+
assert context_op.context_details is not None
963+
assert context_op.context_details.error == error
964+
965+
response = DurableExecutionInvocationOutput(
966+
status=InvocationStatus.SUCCEEDED, result="handled"
967+
)
968+
executor._validate_invocation_response_and_store( # noqa: SLF001
969+
arn, response, checkpointed_execution
970+
)
971+
972+
final_execution = store.load(arn)
973+
assert final_execution.is_complete is True
974+
assert final_execution.close_status == ExecutionStatus.SUCCEEDED
975+
assert final_execution.result is not None
976+
assert final_execution.result.status == InvocationStatus.SUCCEEDED
977+
assert final_execution.result.result == "handled"
978+
979+
_, context_op = final_execution.find_operation("context-1")
980+
assert context_op.status == OperationStatus.FAILED
981+
assert context_op.context_details is not None
982+
assert context_op.context_details.error == error
983+
984+
930985
def test_validate_invocation_response_raises_on_already_complete():
931986
"""Covers the defensive is_complete check in
932987
_validate_invocation_response_and_store. Direct invocation with a

packages/aws-durable-execution-sdk-python-testing/tests/executor_test.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,42 @@ def test_should_complete_workflow_with_error_when_invocation_fails(
310310
mock_fail.assert_called_once_with("test-arn", failed_response.error)
311311

312312

313+
def test_validate_invocation_response_failed_without_error_still_fails():
314+
"""FAILED without an error must fail (not succeed), preserving the null error."""
315+
316+
store = InMemoryExecutionStore()
317+
executor = Executor(store, Mock(), Mock(), Mock())
318+
319+
start_input = StartDurableExecutionInput(
320+
account_id="123456789012",
321+
function_name="test-function",
322+
function_qualifier="$LATEST",
323+
execution_name="test-execution",
324+
execution_timeout_seconds=300,
325+
execution_retention_period_days=7,
326+
invocation_id="test-invocation-id",
327+
)
328+
execution = Execution.new(start_input)
329+
execution.start()
330+
store.save(execution)
331+
332+
response = DurableExecutionInvocationOutput(
333+
status=InvocationStatus.FAILED, error=None
334+
)
335+
336+
executor._validate_invocation_response_and_store( # noqa: SLF001
337+
execution.durable_execution_arn, response, execution
338+
)
339+
340+
stored = store.load(execution.durable_execution_arn)
341+
assert stored.is_complete is True
342+
assert stored.close_status is not None
343+
assert stored.close_status.value == "FAILED"
344+
assert stored.result is not None
345+
assert stored.result.status == InvocationStatus.FAILED
346+
assert stored.result.error is None
347+
348+
313349
def test_should_complete_workflow_with_result_when_invocation_succeeds(
314350
executor, mock_store, mock_scheduler, mock_invoker, start_input
315351
):

0 commit comments

Comments
 (0)