Skip to content

Commit 2a0feca

Browse files
Sagas conformance: Python reverse compensation stays waiting after first refund (#213)
1 parent 983d9ca commit 2a0feca

2 files changed

Lines changed: 142 additions & 3 deletions

File tree

src/durable_workflow/workflow.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,10 +1884,34 @@ def _workflow_sequence(payload: Mapping[str, Any]) -> int | None:
18841884
return None
18851885

18861886

1887+
def _activity_type_from_payload(payload: Mapping[str, Any]) -> str | None:
1888+
activity_type = _optional_str(payload.get("activity_type"))
1889+
if activity_type is not None:
1890+
return activity_type
1891+
1892+
activity_name = _optional_str(payload.get("activity_name"))
1893+
if activity_name is not None:
1894+
return activity_name
1895+
1896+
activity_payload = payload.get("activity")
1897+
if isinstance(activity_payload, Mapping):
1898+
return (
1899+
_optional_str(activity_payload.get("activity_type"))
1900+
or _optional_str(activity_payload.get("activity_name"))
1901+
or _optional_str(activity_payload.get("type"))
1902+
or _optional_str(activity_payload.get("name"))
1903+
)
1904+
1905+
return None
1906+
1907+
18871908
def _recorded_step_details(payload: Mapping[str, Any]) -> dict[str, Any]:
18881909
details: dict[str, Any] = {}
1910+
activity_type = _activity_type_from_payload(payload)
1911+
if activity_type is not None:
1912+
details["activity_type"] = activity_type
1913+
18891914
for key in (
1890-
"activity_type",
18911915
"workflow_type",
18921916
"child_workflow_type",
18931917
"timer_kind",
@@ -2024,7 +2048,7 @@ def _activity_failed_from_payload(payload: Mapping[str, Any]) -> ActivityFailed:
20242048

20252049
return ActivityFailed(
20262050
message or "activity failed",
2027-
activity_type=_optional_str(payload.get("activity_type")),
2051+
activity_type=_activity_type_from_payload(payload),
20282052
activity_execution_id=_optional_str(payload.get("activity_execution_id")),
20292053
activity_attempt_id=_optional_str(payload.get("activity_attempt_id")),
20302054
failure_id=_optional_str(payload.get("failure_id")),

tests/test_replay.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,43 @@ def run(self, ctx: WorkflowContext, order_id: str): # type: ignore[no-untyped-d
7575
return {"charged": True}
7676

7777

78+
@workflow.defn(name="reverse-compensation-saga")
79+
class ReverseCompensationSaga:
80+
def run(self, ctx: WorkflowContext, payload: dict): # type: ignore[type-arg]
81+
completed: list[str] = []
82+
compensations: list[str] = []
83+
steps = [
84+
("reserve_flight", "cancel_flight"),
85+
("reserve_hotel", "cancel_hotel"),
86+
("charge_card", "refund_card"),
87+
("send_confirmation", ""),
88+
]
89+
90+
try:
91+
for action, compensation in steps:
92+
yield ctx.schedule_activity(action, [payload])
93+
completed.append(action)
94+
if compensation:
95+
compensations.append(compensation)
96+
except ActivityFailed:
97+
if not compensations:
98+
raise
99+
for compensation in reversed(compensations):
100+
yield ctx.schedule_activity(compensation, [payload])
101+
completed.append(compensation)
102+
return {
103+
"status": "compensated",
104+
"activity_log": completed,
105+
"compensations": compensations,
106+
}
107+
108+
return {
109+
"status": "completed",
110+
"activity_log": completed,
111+
"compensations": compensations,
112+
}
113+
114+
78115
@workflow.defn(name="activity-failure-payload-inspector")
79116
class ActivityFailurePayloadInspector:
80117
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
@@ -115,6 +152,34 @@ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
115152
raise ValueError("something went wrong")
116153

117154

155+
def _activity_completed_event(sequence: int, activity_type: str) -> dict:
156+
return {
157+
"event_type": "ActivityCompleted",
158+
"payload": {
159+
"sequence": sequence,
160+
"activity_type": activity_type,
161+
"payload_codec": "json",
162+
"result": serializer.encode({"activity": activity_type}, codec="json"),
163+
},
164+
}
165+
166+
167+
def _activity_failed_event(sequence: int, activity_type: str) -> dict:
168+
return {
169+
"event_type": "ActivityFailed",
170+
"payload": {
171+
"sequence": sequence,
172+
"activity_type": activity_type,
173+
"failure_id": f"failure-{sequence}",
174+
"failure_category": "activity",
175+
"exception_type": "RuntimeError",
176+
"exception_class": "builtins.RuntimeError",
177+
"message": f"{activity_type} planned saga failure before forward effect",
178+
"non_retryable": True,
179+
},
180+
}
181+
182+
118183
class TestSimpleReturn:
119184
def test_non_generator_completes(self) -> None:
120185
outcome = replay(SimpleReturn, [], [])
@@ -215,7 +280,7 @@ def test_failed_activity_is_thrown_into_workflow_for_compensation(self) -> None:
215280
{
216281
"event_type": "ActivityFailed",
217282
"payload": {
218-
"activity_type": "charge-card",
283+
"activity_name": "charge-card",
219284
"activity_execution_id": "act-1",
220285
"activity_attempt_id": "attempt-1",
221286
"failure_id": "failure-1",
@@ -486,6 +551,56 @@ def test_schedule_activity_rejects_incoherent_timeout_envelopes(
486551
cmd.to_server_command("default-queue")
487552

488553

554+
class TestReverseCompensationSaga:
555+
def test_resumes_reverse_compensation_after_first_refund(self) -> None:
556+
payload = {"scenario_id": "reverse-compensation"}
557+
history = [
558+
_activity_completed_event(1, "reserve_flight"),
559+
_activity_completed_event(2, "reserve_hotel"),
560+
_activity_completed_event(3, "charge_card"),
561+
_activity_failed_event(4, "send_confirmation"),
562+
_activity_completed_event(5, "refund_card"),
563+
]
564+
565+
outcome = replay(ReverseCompensationSaga, history, [payload], payload_codec="json")
566+
567+
assert len(outcome.commands) == 1
568+
cmd = outcome.commands[0]
569+
assert isinstance(cmd, ScheduleActivity)
570+
assert cmd.activity_type == "cancel_hotel"
571+
assert cmd.arguments == [payload]
572+
573+
def test_completes_reverse_compensation_without_duplicates(self) -> None:
574+
payload = {"scenario_id": "reverse-compensation"}
575+
history = [
576+
_activity_completed_event(1, "reserve_flight"),
577+
_activity_completed_event(2, "reserve_hotel"),
578+
_activity_completed_event(3, "charge_card"),
579+
_activity_failed_event(4, "send_confirmation"),
580+
_activity_completed_event(5, "refund_card"),
581+
_activity_completed_event(6, "cancel_hotel"),
582+
_activity_completed_event(7, "cancel_flight"),
583+
]
584+
585+
outcome = replay(ReverseCompensationSaga, history, [payload], payload_codec="json")
586+
587+
assert len(outcome.commands) == 1
588+
cmd = outcome.commands[0]
589+
assert isinstance(cmd, CompleteWorkflow)
590+
assert cmd.result == {
591+
"status": "compensated",
592+
"activity_log": [
593+
"reserve_flight",
594+
"reserve_hotel",
595+
"charge_card",
596+
"refund_card",
597+
"cancel_hotel",
598+
"cancel_flight",
599+
],
600+
"compensations": ["cancel_flight", "cancel_hotel", "refund_card"],
601+
}
602+
603+
489604
class TestTwoActivities:
490605
def test_first_schedules(self) -> None:
491606
outcome = replay(TwoActivities, [], [])

0 commit comments

Comments
 (0)