|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import asyncio |
3 | 4 | import uuid |
4 | 5 | from contextlib import contextmanager |
5 | 6 | from dataclasses import dataclass |
6 | 7 | from datetime import timedelta |
7 | | -from enum import Enum |
| 8 | +from enum import Enum, IntEnum |
8 | 9 | from typing import Any, Iterator, Mapping, Optional |
9 | 10 | from unittest.mock import patch |
10 | 11 |
|
|
17 | 18 | OutboundInterceptor, |
18 | 19 | StartWorkflowUpdateWithStartInput, |
19 | 20 | WithStartWorkflowOperation, |
| 21 | + WorkflowExecutionStatus, |
20 | 22 | WorkflowUpdateFailedError, |
21 | 23 | WorkflowUpdateHandle, |
22 | 24 | WorkflowUpdateStage, |
23 | 25 | ) |
24 | 26 | from temporalio.common import ( |
25 | 27 | WorkflowIDConflictPolicy, |
| 28 | + WorkflowIDReusePolicy, |
26 | 29 | ) |
27 | 30 | from temporalio.exceptions import ApplicationError, WorkflowAlreadyStartedError |
28 | 31 | from temporalio.service import RPCError, RPCStatusCode, ServiceCall |
@@ -859,3 +862,266 @@ async def __call__( |
859 | 862 | assert err.value.status == RPCStatusCode.INTERNAL |
860 | 863 | assert err.value.message == "empty details" |
861 | 864 | assert len(err.value.grpc_status.details) == 0 |
| 865 | + |
| 866 | + |
| 867 | +class ExecutionBehavior(IntEnum): |
| 868 | + COMPLETES = 0 |
| 869 | + BLOCKS = 1 |
| 870 | + |
| 871 | + |
| 872 | +@workflow.defn |
| 873 | +class WorkflowWithUpdate: |
| 874 | + def __init__(self) -> None: |
| 875 | + self._unblock_workflow = asyncio.Event() |
| 876 | + self._unblock_update = asyncio.Event() |
| 877 | + |
| 878 | + @workflow.run |
| 879 | + async def run(self, behavior: ExecutionBehavior) -> str: |
| 880 | + if behavior == ExecutionBehavior.BLOCKS: |
| 881 | + await self._unblock_workflow.wait() |
| 882 | + return str(workflow.uuid4()) |
| 883 | + |
| 884 | + @workflow.update(unfinished_policy=workflow.HandlerUnfinishedPolicy.ABANDON) |
| 885 | + async def update(self, behavior: ExecutionBehavior) -> str: |
| 886 | + if behavior == ExecutionBehavior.BLOCKS: |
| 887 | + await self._unblock_update.wait() |
| 888 | + return str(workflow.uuid4()) |
| 889 | + |
| 890 | + @workflow.signal |
| 891 | + async def unblock_workflow(self): |
| 892 | + self._unblock_workflow.set() |
| 893 | + |
| 894 | + @workflow.signal |
| 895 | + async def unblock_update(self): |
| 896 | + self._unblock_update.set() |
| 897 | + |
| 898 | + |
| 899 | +@pytest.mark.parametrize( |
| 900 | + "workflow_behavior_name", |
| 901 | + [ExecutionBehavior.COMPLETES.name, ExecutionBehavior.BLOCKS.name], |
| 902 | +) |
| 903 | +@pytest.mark.parametrize( |
| 904 | + "id_conflict_policy_name", |
| 905 | + [ |
| 906 | + WorkflowIDConflictPolicy.USE_EXISTING.name, |
| 907 | + WorkflowIDConflictPolicy.FAIL.name, |
| 908 | + ], |
| 909 | +) |
| 910 | +@pytest.mark.parametrize( |
| 911 | + "id_reuse_policy_name", |
| 912 | + [ |
| 913 | + WorkflowIDReusePolicy.ALLOW_DUPLICATE.name, |
| 914 | + WorkflowIDReusePolicy.REJECT_DUPLICATE.name, |
| 915 | + ], |
| 916 | +) |
| 917 | +async def test_update_with_start_always_attaches_to_completed_update( |
| 918 | + env: WorkflowEnvironment, |
| 919 | + workflow_behavior_name: str, |
| 920 | + id_conflict_policy_name: str, |
| 921 | + id_reuse_policy_name: str, |
| 922 | +): |
| 923 | + """ |
| 924 | + A workflow exists and contains a completed update. An update-with-start sent for that workflow ID and that |
| 925 | + update ID attaches to the update if workflow is running. If the workflow is closed then it attaches iff |
| 926 | + the update is completed. The behavior is unaffected by the conflict policy or id reuse policy (so, for |
| 927 | + example, we attach to an update in an existing workflow even if the conflict policy is FAIL). |
| 928 | + """ |
| 929 | + if env.supports_time_skipping: |
| 930 | + pytest.skip("TODO: make update_with_start tests pass under Java test server") |
| 931 | + client = env.client |
| 932 | + id_conflict_policy = WorkflowIDConflictPolicy[id_conflict_policy_name] |
| 933 | + id_reuse_policy = WorkflowIDReusePolicy[id_reuse_policy_name] |
| 934 | + workflow_behavior = ExecutionBehavior[workflow_behavior_name] |
| 935 | + shared_workflow_id = f"workflow-id-{uuid.uuid4()}" |
| 936 | + shared_update_id = f"update-id-{uuid.uuid4()}" |
| 937 | + async with new_worker(client, WorkflowWithUpdate) as worker: |
| 938 | + |
| 939 | + def start_op(): |
| 940 | + return WithStartWorkflowOperation( |
| 941 | + WorkflowWithUpdate.run, |
| 942 | + workflow_behavior, |
| 943 | + id=shared_workflow_id, |
| 944 | + task_queue=worker.task_queue, |
| 945 | + id_conflict_policy=id_conflict_policy, |
| 946 | + id_reuse_policy=id_reuse_policy, |
| 947 | + ) |
| 948 | + |
| 949 | + start_op_1 = start_op() |
| 950 | + update_result_1 = await client.execute_update_with_start_workflow( |
| 951 | + WorkflowWithUpdate.update, |
| 952 | + ExecutionBehavior.COMPLETES, |
| 953 | + id=shared_update_id, |
| 954 | + start_workflow_operation=start_op_1, |
| 955 | + ) |
| 956 | + wf_handle_1 = await start_op_1.workflow_handle() |
| 957 | + assert (await wf_handle_1.describe()).status == ( |
| 958 | + WorkflowExecutionStatus.COMPLETED |
| 959 | + if workflow_behavior == ExecutionBehavior.COMPLETES |
| 960 | + else WorkflowExecutionStatus.RUNNING |
| 961 | + ) |
| 962 | + |
| 963 | + # Whether or not the workflow closed, the update exists in the last workflow run and is completed, so |
| 964 | + # we attach to it. |
| 965 | + |
| 966 | + start_op_2 = start_op() |
| 967 | + update_result_2 = await client.execute_update_with_start_workflow( |
| 968 | + WorkflowWithUpdate.update, |
| 969 | + ExecutionBehavior.COMPLETES, |
| 970 | + id=shared_update_id, |
| 971 | + start_workflow_operation=start_op_2, |
| 972 | + ) |
| 973 | + wf_handle_2 = await start_op_2.workflow_handle() |
| 974 | + assert wf_handle_1.first_execution_run_id == wf_handle_2.first_execution_run_id |
| 975 | + assert update_result_1 == update_result_2 |
| 976 | + |
| 977 | + |
| 978 | +@pytest.mark.parametrize( |
| 979 | + "id_conflict_policy_name", |
| 980 | + [ |
| 981 | + WorkflowIDConflictPolicy.USE_EXISTING.name, |
| 982 | + WorkflowIDConflictPolicy.FAIL.name, |
| 983 | + ], |
| 984 | +) |
| 985 | +@pytest.mark.parametrize( |
| 986 | + "id_reuse_policy_name", |
| 987 | + [ |
| 988 | + WorkflowIDReusePolicy.ALLOW_DUPLICATE.name, |
| 989 | + WorkflowIDReusePolicy.REJECT_DUPLICATE.name, |
| 990 | + ], |
| 991 | +) |
| 992 | +async def test_update_with_start_attaches_to_non_completed_update_in_running_workflow( |
| 993 | + env: WorkflowEnvironment, |
| 994 | + id_conflict_policy_name: str, |
| 995 | + id_reuse_policy_name: str, |
| 996 | +): |
| 997 | + """ |
| 998 | + A workflow exists and is running and contains a non-completed update. An update-with-start sent for that |
| 999 | + workflow ID and that update ID attaches to the update. The behavior is unaffected by the conflict policy |
| 1000 | + or id reuse policy (so, for example, we attach to the update in an existing workflow even if the conflict |
| 1001 | + policy is FAIL). |
| 1002 | + """ |
| 1003 | + if env.supports_time_skipping: |
| 1004 | + pytest.skip("TODO: make update_with_start tests pass under Java test server") |
| 1005 | + client = env.client |
| 1006 | + id_conflict_policy = WorkflowIDConflictPolicy[id_conflict_policy_name] |
| 1007 | + id_reuse_policy = WorkflowIDReusePolicy[id_reuse_policy_name] |
| 1008 | + shared_workflow_id = f"workflow-id-{uuid.uuid4()}" |
| 1009 | + shared_update_id = f"update-id-{uuid.uuid4()}" |
| 1010 | + async with new_worker(client, WorkflowWithUpdate) as worker: |
| 1011 | + |
| 1012 | + def start_op(): |
| 1013 | + return WithStartWorkflowOperation( |
| 1014 | + WorkflowWithUpdate.run, |
| 1015 | + ExecutionBehavior.BLOCKS, |
| 1016 | + id=shared_workflow_id, |
| 1017 | + task_queue=worker.task_queue, |
| 1018 | + id_conflict_policy=id_conflict_policy, |
| 1019 | + id_reuse_policy=id_reuse_policy, |
| 1020 | + ) |
| 1021 | + |
| 1022 | + start_op_1 = start_op() |
| 1023 | + update_handle_1 = await client.start_update_with_start_workflow( |
| 1024 | + WorkflowWithUpdate.update, |
| 1025 | + ExecutionBehavior.BLOCKS, |
| 1026 | + id=shared_update_id, |
| 1027 | + start_workflow_operation=start_op_1, |
| 1028 | + wait_for_stage=WorkflowUpdateStage.ACCEPTED, |
| 1029 | + ) |
| 1030 | + wf_handle_1 = await start_op_1.workflow_handle() |
| 1031 | + assert (await wf_handle_1.describe()).status == WorkflowExecutionStatus.RUNNING |
| 1032 | + |
| 1033 | + # The workflow is running with the update not-completed. We will attach to the update. |
| 1034 | + |
| 1035 | + start_op_2 = start_op() |
| 1036 | + |
| 1037 | + update_handle_2 = await client.start_update_with_start_workflow( |
| 1038 | + WorkflowWithUpdate.update, |
| 1039 | + ExecutionBehavior.COMPLETES, |
| 1040 | + id=shared_update_id, |
| 1041 | + start_workflow_operation=start_op_2, |
| 1042 | + wait_for_stage=WorkflowUpdateStage.ACCEPTED, |
| 1043 | + ) |
| 1044 | + wf_handle_2 = await start_op_2.workflow_handle() |
| 1045 | + assert wf_handle_1.first_execution_run_id == wf_handle_2.first_execution_run_id |
| 1046 | + await wf_handle_1.signal(WorkflowWithUpdate.unblock_update) |
| 1047 | + assert (await update_handle_1.result()) == (await update_handle_2.result()) |
| 1048 | + |
| 1049 | + |
| 1050 | +@pytest.mark.parametrize( |
| 1051 | + "id_conflict_policy_name", |
| 1052 | + [ |
| 1053 | + WorkflowIDConflictPolicy.USE_EXISTING.name, |
| 1054 | + WorkflowIDConflictPolicy.FAIL.name, |
| 1055 | + ], |
| 1056 | +) |
| 1057 | +@pytest.mark.parametrize( |
| 1058 | + "id_reuse_policy_name", |
| 1059 | + [ |
| 1060 | + WorkflowIDReusePolicy.ALLOW_DUPLICATE.name, |
| 1061 | + WorkflowIDReusePolicy.REJECT_DUPLICATE.name, |
| 1062 | + ], |
| 1063 | +) |
| 1064 | +async def test_update_with_start_does_not_attach_to_non_completed_update_in_closed_workflow( |
| 1065 | + env: WorkflowEnvironment, |
| 1066 | + id_conflict_policy_name: str, |
| 1067 | + id_reuse_policy_name: str, |
| 1068 | +): |
| 1069 | + """ |
| 1070 | + A workflow exists but is closed and contains a non-completed update. An update-with-start sent for that workflow |
| 1071 | + ID and that update ID does not attach to the update. If the id reuse policy is ALLOW_DUPLICATE then a new |
| 1072 | + workflow is started and the update is issued. |
| 1073 | + """ |
| 1074 | + if env.supports_time_skipping: |
| 1075 | + pytest.skip("TODO: make update_with_start tests pass under Java test server") |
| 1076 | + client = env.client |
| 1077 | + id_conflict_policy = WorkflowIDConflictPolicy[id_conflict_policy_name] |
| 1078 | + id_reuse_policy = WorkflowIDReusePolicy[id_reuse_policy_name] |
| 1079 | + shared_workflow_id = f"workflow-id-{uuid.uuid4()}" |
| 1080 | + shared_update_id = f"update-id-{uuid.uuid4()}" |
| 1081 | + async with new_worker(client, WorkflowWithUpdate) as worker: |
| 1082 | + |
| 1083 | + def start_op(): |
| 1084 | + return WithStartWorkflowOperation( |
| 1085 | + WorkflowWithUpdate.run, |
| 1086 | + ExecutionBehavior.COMPLETES, |
| 1087 | + id=shared_workflow_id, |
| 1088 | + task_queue=worker.task_queue, |
| 1089 | + id_conflict_policy=id_conflict_policy, |
| 1090 | + id_reuse_policy=id_reuse_policy, |
| 1091 | + ) |
| 1092 | + |
| 1093 | + start_op_1 = start_op() |
| 1094 | + await client.start_update_with_start_workflow( |
| 1095 | + WorkflowWithUpdate.update, |
| 1096 | + ExecutionBehavior.BLOCKS, |
| 1097 | + id=shared_update_id, |
| 1098 | + start_workflow_operation=start_op_1, |
| 1099 | + wait_for_stage=WorkflowUpdateStage.ACCEPTED, |
| 1100 | + ) |
| 1101 | + wf_handle_1 = await start_op_1.workflow_handle() |
| 1102 | + assert ( |
| 1103 | + await wf_handle_1.describe() |
| 1104 | + ).status == WorkflowExecutionStatus.COMPLETED |
| 1105 | + |
| 1106 | + # The workflow closed with the update not-completed. We will start a new workflow and issue the update |
| 1107 | + # iff reuse_policy is ALLOW_DUPLICATE. Conflict policy is irrelevant. |
| 1108 | + |
| 1109 | + start_op_2 = start_op() |
| 1110 | + |
| 1111 | + async def _do_update() -> Any: |
| 1112 | + return await client.execute_update_with_start_workflow( |
| 1113 | + WorkflowWithUpdate.update, |
| 1114 | + ExecutionBehavior.COMPLETES, |
| 1115 | + id=shared_update_id, |
| 1116 | + start_workflow_operation=start_op_2, |
| 1117 | + ) |
| 1118 | + |
| 1119 | + if id_reuse_policy == WorkflowIDReusePolicy.ALLOW_DUPLICATE: |
| 1120 | + await _do_update() |
| 1121 | + wf_handle_2 = await start_op_2.workflow_handle() |
| 1122 | + assert ( |
| 1123 | + wf_handle_1.first_execution_run_id != wf_handle_2.first_execution_run_id |
| 1124 | + ) |
| 1125 | + elif id_reuse_policy == WorkflowIDReusePolicy.REJECT_DUPLICATE: |
| 1126 | + with pytest.raises(WorkflowAlreadyStartedError): |
| 1127 | + await _do_update() |
0 commit comments