@@ -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" )
79116class 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+
118183class 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+
489604class TestTwoActivities :
490605 def test_first_schedules (self ) -> None :
491606 outcome = replay (TwoActivities , [], [])
0 commit comments