44from typing import Optional
55
66from temporalio import activity , workflow
7- from temporalio .client import Client
87
98from resource_locking .sem_workflow import AssignedResource , SEMAPHORE_WORKFLOW_ID , \
10- ReleaseRequest , AcquireRequest , SemaphoreWorkflowInput , SEMAPHORE_WORKFLOW_TYPE
9+ ReleaseRequest , AcquireRequest , HandoffRequest
1110
1211
1312@dataclass
@@ -24,47 +23,63 @@ async def load(input: LoadActivityInput) -> None:
2423
2524@dataclass
2625class LoadWorkflowInput :
26+ # If set, this workflow will fail after the "first", "second", or "third" activity.
2727 iteration_to_fail_after : Optional [str ]
2828
29+ # If True, this workflow will continue as new after the third activity. The next iteration will run three more
30+ # activities, but will not continue as new. This lets us exercise the handoff logic.
31+ should_continue_as_new : bool
32+
33+ # Used to transfer resource ownership between iterations during continue_as_new
34+ already_owned_resource : Optional [str ]
35+
2936class FailWorkflowException (Exception ):
3037 pass
3138
3239MAX_RESOURCE_WAIT_TIME = timedelta (minutes = 5 )
3340
41+ def has_timeout (timeout : Optional [timedelta ]) -> bool :
42+ return timeout is not None and timeout > timedelta (0 )
43+
3444@workflow .defn (
3545 failure_exception_types = [FailWorkflowException ]
3646)
3747class LoadWorkflow :
3848
3949 def __init__ (self ):
40- self .assigned_resource = None
50+ self .assigned_resource : Optional [ str ] = None
4151
4252 @workflow .signal (name = "assign_resource" )
4353 def handle_assign_resource (self , input : AssignedResource ):
4454 self .assigned_resource = input .resource
4555
4656 @workflow .run
4757 async def run (self , input : LoadWorkflowInput ):
48- if workflow .info ().run_timeout is not None :
58+ workflow .info ()
59+ if has_timeout (workflow .info ().run_timeout ):
4960 # See "locking" comment below for rationale
50- raise FailWorkflowException (f"LoadWorkflow cannot have a run_timeout" )
51- if workflow .info ().execution_timeout is not None :
52- raise FailWorkflowException (f"LoadWorkflow cannot have an execution_timeout" )
61+ raise FailWorkflowException (f"LoadWorkflow cannot have a run_timeout (found { workflow . info (). run_timeout } ) " )
62+ if has_timeout ( workflow .info ().execution_timeout ) :
63+ raise FailWorkflowException (f"LoadWorkflow cannot have an execution_timeout (found { workflow . info (). execution_timeout } ) " )
5364
5465 sem_handle = workflow .get_external_workflow_handle (SEMAPHORE_WORKFLOW_ID )
5566
56- # Ask for a resource...
5767 info = workflow .info ()
58- await sem_handle .signal ("acquire_resource" , AcquireRequest (info .workflow_id , info .run_id ))
59-
60- # ...and wait for the answer
68+ if input .already_owned_resource is None :
69+ await sem_handle .signal ("acquire_resource" , AcquireRequest (info .workflow_id , info .run_id ))
70+ else :
71+ # If we continued as new, we already have a resource. We need to transfer ownership from our predecessor to
72+ # ourselves.
73+ await sem_handle .signal ("handoff_resource" , HandoffRequest (input .already_owned_resource , info .workflow_id , info .continued_run_id , info .run_id ))
74+
75+ # Both branches above should cause us to receive an "assign_resource" signal.
6176 await workflow .wait_condition (lambda : self .assigned_resource is not None , timeout = MAX_RESOURCE_WAIT_TIME )
6277 if self .assigned_resource is None :
6378 raise FailWorkflowException (f"No resource was assigned after { MAX_RESOURCE_WAIT_TIME } " )
6479
65- # From this point forward, we own the resource. Note that this is a lock, not a lease! Our finally block needs
66- # to run to free up the resource if an activity fails. This is why we asserted the lack of workflow-level
67- # timeouts above - they would prevent the finally block from running if there was a timeout.
80+ # From this point forward, we own the resource. Note that this is a lock, not a lease! Our finally block will
81+ # free up the resource if an activity fails. This is why we asserted the lack of workflow-level timeouts
82+ # above - the finally block wouldn't run if there was a timeout.
6883 try :
6984 for iteration in ["first" , "second" , "third" ]:
7085 await workflow .execute_activity (
@@ -76,5 +91,17 @@ async def run(self, input: LoadWorkflowInput):
7691 if iteration == input .iteration_to_fail_after :
7792 workflow .logger .info (f"Failing after iteration { input .iteration_to_fail_after } " )
7893 raise FailWorkflowException ()
94+
95+ if input .should_continue_as_new :
96+ next_input = LoadWorkflowInput (
97+ iteration_to_fail_after = input .iteration_to_fail_after ,
98+ should_continue_as_new = False ,
99+ already_owned_resource = self .assigned_resource ,
100+ )
101+ workflow .continue_as_new (next_input )
79102 finally :
80- await sem_handle .signal ("release_resource" , ReleaseRequest (self .assigned_resource , info .workflow_id , info .run_id ))
103+ # Only release the resource if we didn't continue-as-new. workflow.continue_as_new raises to halt workflow
104+ # execution, but the code in this finally block will still run. It wouldn't successfully send the signal...
105+ # the if statement just avoids some warnings in the log.
106+ if not input .should_continue_as_new :
107+ await sem_handle .signal ("release_resource" , ReleaseRequest (self .assigned_resource , info .workflow_id , info .run_id ))
0 commit comments