@@ -88,14 +88,15 @@ def test_rewind_preserves_successful_results():
8888 rewind should re-execute only the failed activity while the successful
8989 result is replayed from history."""
9090 call_tracker : dict [str , int ] = {"first" : 0 , "second" : 0 }
91+ should_fail_second = True
9192
9293 def first_activity (_ : task .ActivityContext , input : str ) -> str :
9394 call_tracker ["first" ] += 1
9495 return f"first:{ input } "
9596
9697 def second_activity (_ : task .ActivityContext , input : str ) -> str :
9798 call_tracker ["second" ] += 1
98- if call_tracker [ "second" ] == 1 :
99+ if should_fail_second :
99100 raise RuntimeError ("Temporary failure" )
100101 return f"second:{ input } "
101102
@@ -120,7 +121,8 @@ def orchestrator(ctx: task.OrchestrationContext, input: str):
120121 assert state is not None
121122 assert state .runtime_status == client .OrchestrationStatus .FAILED
122123
123- # Rewind – second_activity will now succeed on retry.
124+ # Fix second_activity so it now succeeds, then rewind.
125+ should_fail_second = False
124126 c .rewind_orchestration (instance_id , reason = "retry" )
125127 state = c .wait_for_orchestration_completion (instance_id , timeout = 30 )
126128
@@ -130,8 +132,8 @@ def orchestrator(ctx: task.OrchestrationContext, input: str):
130132 assert state .failure_details is None
131133 # first_activity should NOT be re-executed – its result is replayed.
132134 assert call_tracker ["first" ] == 1
133- # second_activity was called twice (once failed, once succeeded).
134- assert call_tracker ["second" ] = = 2
135+ # second_activity was called at least twice (once failed, once succeeded).
136+ assert call_tracker ["second" ] > = 2
135137
136138
137139def test_rewind_not_found ():
@@ -210,6 +212,118 @@ def parent_orchestrator(ctx: task.OrchestrationContext, input: str):
210212 assert sub_call_count == 2
211213
212214
215+ def test_rewind_purged_sub_orchestration ():
216+ """A purged sub-orchestration is re-run when the parent is rewound.
217+
218+ Flow: parent orchestrator -> calls sub-orchestrator -> sub-orchestrator
219+ fails -> parent fails -> client purges the sub-orchestration -> client
220+ rewinds the parent -> parent re-schedules the sub-orchestration which
221+ now succeeds -> parent completes.
222+ """
223+ child_call_count = 0
224+
225+ def child_activity (_ : task .ActivityContext , input : str ) -> str :
226+ nonlocal child_call_count
227+ child_call_count += 1
228+ if child_call_count == 1 :
229+ raise RuntimeError ("Child failure" )
230+ return f"child:{ input } "
231+
232+ def child_orchestrator (ctx : task .OrchestrationContext , input : str ):
233+ result = yield ctx .call_activity (child_activity , input = input )
234+ return result
235+
236+ def parent_orchestrator (ctx : task .OrchestrationContext , input : str ):
237+ result = yield ctx .call_sub_orchestrator (
238+ child_orchestrator , input = input , instance_id = "sub-orch-to-purge" )
239+ return f"parent:{ result } "
240+
241+ with DurableTaskSchedulerWorker (host_address = endpoint , secure_channel = True ,
242+ taskhub = taskhub_name , token_credential = None ) as w :
243+ w .add_orchestrator (parent_orchestrator )
244+ w .add_orchestrator (child_orchestrator )
245+ w .add_activity (child_activity )
246+ w .start ()
247+
248+ c = DurableTaskSchedulerClient (host_address = endpoint , secure_channel = True ,
249+ taskhub = taskhub_name , token_credential = None )
250+ instance_id = c .schedule_new_orchestration (
251+ parent_orchestrator , input = "data" )
252+ state = c .wait_for_orchestration_completion (instance_id , timeout = 30 )
253+
254+ # Parent should fail because child failed.
255+ assert state is not None
256+ assert state .runtime_status == client .OrchestrationStatus .FAILED
257+
258+ # Purge the sub-orchestration so it must be completely re-run.
259+ c .purge_orchestration ("sub-orch-to-purge" )
260+
261+ # Rewind the parent – child will be re-scheduled and succeed.
262+ c .rewind_orchestration (instance_id , reason = "purge and retry" )
263+ state = c .wait_for_orchestration_completion (instance_id , timeout = 30 )
264+
265+ assert state is not None
266+ assert state .runtime_status == client .OrchestrationStatus .COMPLETED
267+ assert state .serialized_output == json .dumps ("parent:child:data" )
268+ assert child_call_count == 2
269+
270+
271+ def test_rewind_does_not_rerun_successful_activities ():
272+ """Successful activities must not be re-executed during rewind.
273+
274+ The orchestration calls two activities in sequence. The first
275+ succeeds and the second fails. After rewind, only the failed
276+ activity is retried; the successful activity's result is replayed
277+ from history and its body is never called again.
278+ """
279+ success_call_count = 0
280+ fail_call_count = 0
281+
282+ def success_activity (_ : task .ActivityContext , input : str ) -> str :
283+ nonlocal success_call_count
284+ success_call_count += 1
285+ return f"ok:{ input } "
286+
287+ def fail_activity (_ : task .ActivityContext , input : str ) -> str :
288+ nonlocal fail_call_count
289+ fail_call_count += 1
290+ if fail_call_count == 1 :
291+ raise RuntimeError ("Temporary failure" )
292+ return f"recovered:{ input } "
293+
294+ def orchestrator (ctx : task .OrchestrationContext , input : str ):
295+ r1 = yield ctx .call_activity (success_activity , input = input )
296+ r2 = yield ctx .call_activity (fail_activity , input = input )
297+ return [r1 , r2 ]
298+
299+ with DurableTaskSchedulerWorker (host_address = endpoint , secure_channel = True ,
300+ taskhub = taskhub_name , token_credential = None ) as w :
301+ w .add_orchestrator (orchestrator )
302+ w .add_activity (success_activity )
303+ w .add_activity (fail_activity )
304+ w .start ()
305+
306+ c = DurableTaskSchedulerClient (host_address = endpoint , secure_channel = True ,
307+ taskhub = taskhub_name , token_credential = None )
308+ instance_id = c .schedule_new_orchestration (orchestrator , input = "v" )
309+ state = c .wait_for_orchestration_completion (instance_id , timeout = 30 )
310+
311+ assert state is not None
312+ assert state .runtime_status == client .OrchestrationStatus .FAILED
313+
314+ # Rewind – only the failed activity should be retried.
315+ c .rewind_orchestration (instance_id , reason = "retry" )
316+ state = c .wait_for_orchestration_completion (instance_id , timeout = 30 )
317+
318+ assert state is not None
319+ assert state .runtime_status == client .OrchestrationStatus .COMPLETED
320+ assert state .serialized_output == json .dumps (["ok:v" , "recovered:v" ])
321+ # The successful activity must have been called exactly once.
322+ assert success_call_count == 1
323+ # The failing activity was called twice (once failed, once succeeded).
324+ assert fail_call_count == 2
325+
326+
213327def test_rewind_without_reason ():
214328 """Rewind should work when no reason is provided."""
215329 call_count = 0
0 commit comments