Skip to content

Commit 34112b1

Browse files
Enable call suspend-resume from durable client (#483)
* Add API syntax support for suspend-resume * initial commit * add new line at the end of file * add unit tests for suspend-resume * add suspend/resume history event * remove empty line * remove duplicate caused by emrge * update by comment --------- Co-authored-by: Julio Arroyo <[email protected]>
1 parent b30d0a6 commit 34112b1

File tree

6 files changed

+213
-2
lines changed

6 files changed

+213
-2
lines changed

azure/durable_functions/models/DurableOrchestrationClient.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,3 +709,73 @@ async def rewind(self,
709709
else:
710710
ex_msg = "Received unexpected payload from the durable-extension: " + str(response)
711711
raise Exception(ex_msg)
712+
713+
async def suspend(self, instance_id: str, reason: str) -> None:
714+
"""Suspend the specified orchestration instance.
715+
716+
Parameters
717+
----------
718+
instance_id : str
719+
The ID of the orchestration instance to suspend.
720+
reason: str
721+
The reason for suspending the instance.
722+
723+
Raises
724+
------
725+
Exception:
726+
When the suspend call failed with an unexpected status code
727+
728+
Returns
729+
-------
730+
None
731+
"""
732+
request_url = f"{self._orchestration_bindings.rpc_base_url}instances/{instance_id}/" \
733+
f"suspend?reason={quote(reason)}"
734+
response = await self._post_async_request(request_url, None)
735+
switch_statement = {
736+
202: lambda: None, # instance is suspended
737+
410: lambda: None, # instance completed
738+
404: lambda: f"No instance with ID '{instance_id}' found.",
739+
}
740+
741+
has_error_message = switch_statement.get(
742+
response[0],
743+
lambda: f"The operation failed with an unexpected status code {response[0]}")
744+
error_message = has_error_message()
745+
if error_message:
746+
raise Exception(error_message)
747+
748+
async def resume(self, instance_id: str, reason: str) -> None:
749+
"""Resume the specified orchestration instance.
750+
751+
Parameters
752+
----------
753+
instance_id : str
754+
The ID of the orchestration instance to query.
755+
reason: str
756+
The reason for resuming the instance.
757+
758+
Raises
759+
------
760+
Exception:
761+
When the resume call failed with an unexpected status code
762+
763+
Returns
764+
-------
765+
None
766+
"""
767+
request_url = f"{self._orchestration_bindings.rpc_base_url}instances/{instance_id}/" \
768+
f"resume?reason={quote(reason)}"
769+
response = await self._post_async_request(request_url, None)
770+
switch_statement = {
771+
202: lambda: None, # instance is resumed
772+
410: lambda: None, # instance completed
773+
404: lambda: f"No instance with ID '{instance_id}' found.",
774+
}
775+
776+
has_error_message = switch_statement.get(
777+
response[0],
778+
lambda: f"The operation failed with an unexpected status code {response[0]}")
779+
error_message = has_error_message()
780+
if error_message:
781+
raise Exception(error_message)

azure/durable_functions/models/OrchestrationRuntimeStatus.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@ class OrchestrationRuntimeStatus(Enum):
2727

2828
Pending = 'Pending'
2929
"""The orchestration instance has been scheduled but has not yet started running."""
30+
31+
Suspended = 'Suspended'
32+
"""The orchestration instance has been suspended and may go back to running at a later time."""

azure/durable_functions/models/history/HistoryEventType.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ class HistoryEventType(IntEnum):
2323
CONTINUE_AS_NEW = 16
2424
GENERIC_EVENT = 17
2525
HISTORY_STATE = 18
26+
EXECUTION_SUSPENDED = 19
27+
EXECUTION_RESUMED = 20

tests/conftest.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ def get_binding_string():
3939
"{text}&taskHub="
4040
f"{TASK_HUB_NAME}&connection=Storage&code={AUTH_CODE}",
4141
"purgeHistoryDeleteUri": f"{BASE_URL}/instances/INSTANCEID?taskHub="
42-
f"{TASK_HUB_NAME}&connection=Storage&code={AUTH_CODE}"
42+
f"{TASK_HUB_NAME}&connection=Storage&code={AUTH_CODE}",
43+
"suspendPostUri": f"{BASE_URL}/instances/INSTANCEID/suspend?reason="
44+
"{text}&taskHub="
45+
f"{TASK_HUB_NAME}&connection=Storage&code={AUTH_CODE}",
46+
"resumePostUri": f"{BASE_URL}/instances/INSTANCEID/resume?reason="
47+
"{text}&taskHub="
48+
f"{TASK_HUB_NAME}&connection=Storage&code={AUTH_CODE}",
4349
},
4450
"rpcBaseUrl": RPC_BASE_URL
4551
}

tests/models/test_DurableOrchestrationBindings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,15 @@ def test_extracts_purge_history_delete_uri(binding_info):
5454
"=Storage&code=AUTH_CODE")
5555
assert expected_url == binding_info.management_urls[
5656
"purgeHistoryDeleteUri"]
57+
58+
def test_extracts_suspend_post_uri(binding_info):
59+
expected_url = replace_stand_in_bits(
60+
"BASE_URL/instances/INSTANCEID/suspend?reason={"
61+
"text}&taskHub=TASK_HUB_NAME&connection=Storage&code=AUTH_CODE")
62+
assert expected_url == binding_info.management_urls["suspendPostUri"]
63+
64+
def test_extracts_resume_post_uri(binding_info):
65+
expected_url = replace_stand_in_bits(
66+
"BASE_URL/instances/INSTANCEID/resume?reason={"
67+
"text}&taskHub=TASK_HUB_NAME&connection=Storage&code=AUTH_CODE")
68+
assert expected_url == binding_info.management_urls["resumePostUri"]

tests/models/test_DurableOrchestrationClient.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,15 @@ def test_create_check_status_response(binding_string):
137137
"purgeHistoryDeleteUri":
138138
r"http://test_azure.net/runtime/webhooks/durabletask/instances/"
139139
r"2e2568e7-a906-43bd-8364-c81733c5891e"
140-
r"?taskHub=TASK_HUB_NAME&connection=Storage&code=AUTH_CODE"
140+
r"?taskHub=TASK_HUB_NAME&connection=Storage&code=AUTH_CODE",
141+
"suspendPostUri":
142+
r"http://test_azure.net/runtime/webhooks/durabletask/instances/"
143+
r"2e2568e7-a906-43bd-8364-c81733c5891e/suspend"
144+
r"?reason={text}&taskHub=TASK_HUB_NAME&connection=Storage&code=AUTH_CODE",
145+
"resumePostUri":
146+
r"http://test_azure.net/runtime/webhooks/durabletask/instances/"
147+
r"2e2568e7-a906-43bd-8364-c81733c5891e/resume"
148+
r"?reason={text}&taskHub=TASK_HUB_NAME&connection=Storage&code=AUTH_CODE"
141149
}
142150
for key, _ in http_management_payload.items():
143151
http_management_payload[key] = replace_stand_in_bits(http_management_payload[key])
@@ -610,3 +618,113 @@ async def test_rewind_with_no_rpc_endpoint(binding_string):
610618
await client.rewind(INSTANCE_ID, REASON)
611619
ex_message = str(ex.value)
612620
assert ex_message == expected_exception_str
621+
622+
@pytest.mark.asyncio
623+
async def test_post_202_suspend(binding_string):
624+
raw_reason = 'stuff and things'
625+
reason = 'stuff%20and%20things'
626+
mock_request = MockRequest(
627+
expected_url=f"{RPC_BASE_URL}instances/{TEST_INSTANCE_ID}/suspend?reason={reason}",
628+
response=[202, None])
629+
client = DurableOrchestrationClient(binding_string)
630+
client._post_async_request = mock_request.post
631+
632+
result = await client.suspend(TEST_INSTANCE_ID, raw_reason)
633+
assert result is None
634+
635+
636+
@pytest.mark.asyncio
637+
async def test_post_410_suspend(binding_string):
638+
raw_reason = 'stuff and things'
639+
reason = 'stuff%20and%20things'
640+
mock_request = MockRequest(
641+
expected_url=f"{RPC_BASE_URL}instances/{TEST_INSTANCE_ID}/suspend?reason={reason}",
642+
response=[410, None])
643+
client = DurableOrchestrationClient(binding_string)
644+
client._post_async_request = mock_request.post
645+
646+
result = await client.suspend(TEST_INSTANCE_ID, raw_reason)
647+
assert result is None
648+
649+
650+
@pytest.mark.asyncio
651+
async def test_post_404_suspend(binding_string):
652+
raw_reason = 'stuff and things'
653+
reason = 'stuff%20and%20things'
654+
mock_request = MockRequest(
655+
expected_url=f"{RPC_BASE_URL}instances/{TEST_INSTANCE_ID}/suspend?reason={reason}",
656+
response=[404, MESSAGE_404])
657+
client = DurableOrchestrationClient(binding_string)
658+
client._post_async_request = mock_request.post
659+
660+
with pytest.raises(Exception):
661+
await client.suspend(TEST_INSTANCE_ID, raw_reason)
662+
663+
664+
@pytest.mark.asyncio
665+
async def test_post_500_suspend(binding_string):
666+
raw_reason = 'stuff and things'
667+
reason = 'stuff%20and%20things'
668+
mock_request = MockRequest(
669+
expected_url=f"{RPC_BASE_URL}instances/{TEST_INSTANCE_ID}/suspend?reason={reason}",
670+
response=[500, MESSAGE_500])
671+
client = DurableOrchestrationClient(binding_string)
672+
client._post_async_request = mock_request.post
673+
674+
with pytest.raises(Exception):
675+
await client.suspend(TEST_INSTANCE_ID, raw_reason)
676+
677+
@pytest.mark.asyncio
678+
async def test_post_202_resume(binding_string):
679+
raw_reason = 'stuff and things'
680+
reason = 'stuff%20and%20things'
681+
mock_request = MockRequest(
682+
expected_url=f"{RPC_BASE_URL}instances/{TEST_INSTANCE_ID}/resume?reason={reason}",
683+
response=[202, None])
684+
client = DurableOrchestrationClient(binding_string)
685+
client._post_async_request = mock_request.post
686+
687+
result = await client.resume(TEST_INSTANCE_ID, raw_reason)
688+
assert result is None
689+
690+
691+
@pytest.mark.asyncio
692+
async def test_post_410_resume(binding_string):
693+
raw_reason = 'stuff and things'
694+
reason = 'stuff%20and%20things'
695+
mock_request = MockRequest(
696+
expected_url=f"{RPC_BASE_URL}instances/{TEST_INSTANCE_ID}/resume?reason={reason}",
697+
response=[410, None])
698+
client = DurableOrchestrationClient(binding_string)
699+
client._post_async_request = mock_request.post
700+
701+
result = await client.resume(TEST_INSTANCE_ID, raw_reason)
702+
assert result is None
703+
704+
705+
@pytest.mark.asyncio
706+
async def test_post_404_resume(binding_string):
707+
raw_reason = 'stuff and things'
708+
reason = 'stuff%20and%20things'
709+
mock_request = MockRequest(
710+
expected_url=f"{RPC_BASE_URL}instances/{TEST_INSTANCE_ID}/resume?reason={reason}",
711+
response=[404, MESSAGE_404])
712+
client = DurableOrchestrationClient(binding_string)
713+
client._post_async_request = mock_request.post
714+
715+
with pytest.raises(Exception):
716+
await client.resume(TEST_INSTANCE_ID, raw_reason)
717+
718+
719+
@pytest.mark.asyncio
720+
async def test_post_500_resume(binding_string):
721+
raw_reason = 'stuff and things'
722+
reason = 'stuff%20and%20things'
723+
mock_request = MockRequest(
724+
expected_url=f"{RPC_BASE_URL}instances/{TEST_INSTANCE_ID}/resume?reason={reason}",
725+
response=[500, MESSAGE_500])
726+
client = DurableOrchestrationClient(binding_string)
727+
client._post_async_request = mock_request.post
728+
729+
with pytest.raises(Exception):
730+
await client.resume(TEST_INSTANCE_ID, raw_reason)

0 commit comments

Comments
 (0)