operaton-external-task-client-python3 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/__init__.py +0 -0
- examples/bpmn_error_example.py +75 -0
- examples/correlate_message.py +11 -0
- examples/event_subprocess_example.py +50 -0
- examples/examples_auth_basic/__init__.py +0 -0
- examples/examples_auth_basic/fetch_and_execute.py +31 -0
- examples/examples_auth_basic/get_process_instance.py +12 -0
- examples/examples_auth_basic/start_process.py +15 -0
- examples/examples_auth_basic/task_handler_example.py +44 -0
- examples/fetch_and_execute.py +30 -0
- examples/get_process_instance.py +12 -0
- examples/retry_task_example.py +58 -0
- examples/start_process.py +14 -0
- examples/task_handler_example.py +44 -0
- examples/tasks_example.py +36 -0
- operaton/__init__.py +0 -0
- operaton/client/__init__.py +0 -0
- operaton/client/async_external_task_client.py +171 -0
- operaton/client/engine_client.py +180 -0
- operaton/client/external_task_client.py +166 -0
- operaton/client/tests/__init__.py +0 -0
- operaton/client/tests/test_async_external_task_client.py +128 -0
- operaton/client/tests/test_async_external_task_client_auth.py +42 -0
- operaton/client/tests/test_async_external_task_client_bearer.py +43 -0
- operaton/client/tests/test_engine_client.py +228 -0
- operaton/client/tests/test_engine_client_auth.py +231 -0
- operaton/client/tests/test_engine_client_bearer.py +237 -0
- operaton/client/tests/test_external_task_client.py +17 -0
- operaton/client/tests/test_external_task_client_auth.py +19 -0
- operaton/client/tests/test_external_task_client_bearer.py +24 -0
- operaton/external_task/__init__.py +0 -0
- operaton/external_task/async_external_task_executor.py +91 -0
- operaton/external_task/async_external_task_worker.py +181 -0
- operaton/external_task/external_task.py +173 -0
- operaton/external_task/external_task_executor.py +88 -0
- operaton/external_task/external_task_worker.py +92 -0
- operaton/external_task/tests/__init__.py +0 -0
- operaton/external_task/tests/test_async_external_task_executor.py +139 -0
- operaton/external_task/tests/test_async_external_task_worker.py +129 -0
- operaton/external_task/tests/test_external_task.py +106 -0
- operaton/external_task/tests/test_external_task_executor.py +200 -0
- operaton/external_task/tests/test_external_task_worker.py +147 -0
- operaton/process_definition/__init__.py +0 -0
- operaton/process_definition/process_definition_client.py +123 -0
- operaton/process_definition/tests/__init__.py +0 -0
- operaton/process_definition/tests/test_process_definition_client.py +181 -0
- operaton/utils/__init__.py +0 -0
- operaton/utils/auth_basic.py +28 -0
- operaton/utils/auth_bearer.py +28 -0
- operaton/utils/log_utils.py +31 -0
- operaton/utils/response_utils.py +35 -0
- operaton/utils/tests/test_auth_basic.py +30 -0
- operaton/utils/tests/test_auth_bearer.py +27 -0
- operaton/utils/tests/test_response_utils.py +43 -0
- operaton/utils/tests/test_utils.py +21 -0
- operaton/utils/utils.py +14 -0
- operaton/variables/__init__.py +0 -0
- operaton/variables/properties.py +27 -0
- operaton/variables/tests/test_properties.py +20 -0
- operaton/variables/tests/test_variables.py +60 -0
- operaton/variables/variables.py +45 -0
- operaton_external_task_client_python3-1.0.0.dist-info/METADATA +258 -0
- operaton_external_task_client_python3-1.0.0.dist-info/RECORD +66 -0
- operaton_external_task_client_python3-1.0.0.dist-info/WHEEL +5 -0
- operaton_external_task_client_python3-1.0.0.dist-info/licenses/LICENSE +201 -0
- operaton_external_task_client_python3-1.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from operaton.variables.properties import Properties
|
|
2
|
+
from operaton.variables.variables import Variables
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ExternalTask:
|
|
6
|
+
def __init__(self, context):
|
|
7
|
+
self._context = context
|
|
8
|
+
self._variables = Variables(context.get("variables", {}))
|
|
9
|
+
self._task_result = TaskResult.empty_task_result(task=self)
|
|
10
|
+
self._extProperties = Properties(context.get("extensionProperties", {}))
|
|
11
|
+
|
|
12
|
+
def get_worker_id(self):
|
|
13
|
+
return self._context["workerId"]
|
|
14
|
+
|
|
15
|
+
def get_process_instance_id(self):
|
|
16
|
+
return self._context["processInstanceId"]
|
|
17
|
+
|
|
18
|
+
def get_variables(self):
|
|
19
|
+
return self._variables.to_dict()
|
|
20
|
+
|
|
21
|
+
def get_extension_properties(self) -> dict:
|
|
22
|
+
return self._extProperties.to_dict()
|
|
23
|
+
|
|
24
|
+
def get_task_id(self):
|
|
25
|
+
return self._context["id"]
|
|
26
|
+
|
|
27
|
+
def get_activity_id(self):
|
|
28
|
+
return self._context["activityId"]
|
|
29
|
+
|
|
30
|
+
def get_topic_name(self):
|
|
31
|
+
return self._context["topicName"]
|
|
32
|
+
|
|
33
|
+
def get_variable(self, variable_name, with_meta=False):
|
|
34
|
+
return self._variables.get_variable(variable_name, with_meta=with_meta)
|
|
35
|
+
|
|
36
|
+
def get_extension_property(self, property_name) -> str:
|
|
37
|
+
return self._extProperties.get_property(property_name)
|
|
38
|
+
|
|
39
|
+
def get_tenant_id(self):
|
|
40
|
+
return self._context.get("tenantId", None)
|
|
41
|
+
|
|
42
|
+
def get_business_key(self):
|
|
43
|
+
return self._context.get("businessKey", None)
|
|
44
|
+
|
|
45
|
+
def get_task_result(self):
|
|
46
|
+
return self._task_result
|
|
47
|
+
|
|
48
|
+
def set_task_result(self, task_result):
|
|
49
|
+
self._task_result = task_result
|
|
50
|
+
|
|
51
|
+
def complete(self, global_variables={}, local_variables={}):
|
|
52
|
+
self._task_result = TaskResult.success(self, global_variables, local_variables)
|
|
53
|
+
return self._task_result
|
|
54
|
+
|
|
55
|
+
def failure(self, error_message, error_details, max_retries, retry_timeout):
|
|
56
|
+
retries = self._calculate_retries(max_retries)
|
|
57
|
+
self._task_result = TaskResult.failure(
|
|
58
|
+
self,
|
|
59
|
+
error_message=error_message,
|
|
60
|
+
error_details=error_details,
|
|
61
|
+
retries=retries,
|
|
62
|
+
retry_timeout=retry_timeout,
|
|
63
|
+
)
|
|
64
|
+
return self._task_result
|
|
65
|
+
|
|
66
|
+
def _calculate_retries(self, max_retries):
|
|
67
|
+
retries = self._context.get("retries", None)
|
|
68
|
+
retries = int(retries - 1) if retries and retries >= 1 else max_retries
|
|
69
|
+
return retries
|
|
70
|
+
|
|
71
|
+
def bpmn_error(self, error_code, error_message, variables={}):
|
|
72
|
+
self._task_result = TaskResult.bpmn_error(
|
|
73
|
+
self,
|
|
74
|
+
error_code=error_code,
|
|
75
|
+
error_message=error_message,
|
|
76
|
+
variables=variables,
|
|
77
|
+
)
|
|
78
|
+
return self._task_result
|
|
79
|
+
|
|
80
|
+
def __str__(self):
|
|
81
|
+
return f"{self._context}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TaskResult:
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
task,
|
|
88
|
+
success=False,
|
|
89
|
+
global_variables={},
|
|
90
|
+
local_variables={},
|
|
91
|
+
bpmn_error_code=None,
|
|
92
|
+
error_message=None,
|
|
93
|
+
error_details={},
|
|
94
|
+
retries=0,
|
|
95
|
+
retry_timeout=300000,
|
|
96
|
+
):
|
|
97
|
+
self.task = task
|
|
98
|
+
self.success_state = success
|
|
99
|
+
self.global_variables = global_variables
|
|
100
|
+
self.local_variables = local_variables
|
|
101
|
+
self.bpmn_error_code = bpmn_error_code
|
|
102
|
+
self.error_message = error_message
|
|
103
|
+
self.error_details = error_details
|
|
104
|
+
self.retries = retries
|
|
105
|
+
self.retry_timeout = retry_timeout
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def success(cls, task, global_variables, local_variables={}):
|
|
109
|
+
return TaskResult(
|
|
110
|
+
task,
|
|
111
|
+
success=True,
|
|
112
|
+
global_variables=global_variables,
|
|
113
|
+
local_variables=local_variables,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def failure(cls, task, error_message, error_details, retries, retry_timeout):
|
|
118
|
+
return TaskResult(
|
|
119
|
+
task,
|
|
120
|
+
success=False,
|
|
121
|
+
error_message=error_message,
|
|
122
|
+
error_details=error_details,
|
|
123
|
+
retries=retries,
|
|
124
|
+
retry_timeout=retry_timeout,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def bpmn_error(cls, task, error_code, error_message, variables={}):
|
|
129
|
+
return TaskResult(
|
|
130
|
+
task,
|
|
131
|
+
success=False,
|
|
132
|
+
bpmn_error_code=error_code,
|
|
133
|
+
error_message=error_message,
|
|
134
|
+
global_variables=variables,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def empty_task_result(cls, task):
|
|
139
|
+
return TaskResult(task, success=False)
|
|
140
|
+
|
|
141
|
+
def is_success(self):
|
|
142
|
+
return (
|
|
143
|
+
self.success_state
|
|
144
|
+
and self.bpmn_error_code is None
|
|
145
|
+
and self.error_message is None
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def is_failure(self):
|
|
149
|
+
return (
|
|
150
|
+
not self.success_state
|
|
151
|
+
and self.error_message is not None
|
|
152
|
+
and not self.is_bpmn_error()
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def is_bpmn_error(self):
|
|
156
|
+
return not self.success_state and self.bpmn_error_code
|
|
157
|
+
|
|
158
|
+
def get_task(self):
|
|
159
|
+
return self.task
|
|
160
|
+
|
|
161
|
+
def __str__(self):
|
|
162
|
+
if self.is_success():
|
|
163
|
+
return f"success: task_id={self.task.get_task_id()}, global_variables={self.global_variables}, local_variables={self.local_variables}"
|
|
164
|
+
elif self.is_failure():
|
|
165
|
+
return (
|
|
166
|
+
f"failure: task_id={self.task.get_task_id()}, "
|
|
167
|
+
f"error_message={self.error_message}, error_details={self.error_details}, "
|
|
168
|
+
f"retries={self.retries}, retry_timeout={self.retry_timeout}"
|
|
169
|
+
)
|
|
170
|
+
elif self.is_bpmn_error():
|
|
171
|
+
return f"bpmn_error: task_id={self.task.get_task_id()}, error_code={self.bpmn_error_code}"
|
|
172
|
+
|
|
173
|
+
return "empty_task_result"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from operaton.utils.log_utils import log_with_context
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExternalTaskExecutor:
|
|
9
|
+
|
|
10
|
+
def __init__(self, worker_id, external_task_client):
|
|
11
|
+
self.worker_id = worker_id
|
|
12
|
+
self.external_task_client = external_task_client
|
|
13
|
+
|
|
14
|
+
def execute_task(self, task, action):
|
|
15
|
+
topic = task.get_topic_name()
|
|
16
|
+
task_id = task.get_task_id()
|
|
17
|
+
self._log_with_context(f"Executing external task for Topic: {topic}", task_id=task_id)
|
|
18
|
+
task_result = action(task)
|
|
19
|
+
# in case task result is not set inside action function, set it in task here
|
|
20
|
+
task.set_task_result(task_result)
|
|
21
|
+
self._handle_task_result(task_result)
|
|
22
|
+
return task_result
|
|
23
|
+
|
|
24
|
+
def _handle_task_result(self, task_result):
|
|
25
|
+
task = task_result.get_task()
|
|
26
|
+
topic = task.get_topic_name()
|
|
27
|
+
task_id = task.get_task_id()
|
|
28
|
+
if task_result.is_success():
|
|
29
|
+
self._handle_task_success(task_id, task_result, topic)
|
|
30
|
+
elif task_result.is_bpmn_error():
|
|
31
|
+
self._handle_task_bpmn_error(task_id, task_result, topic)
|
|
32
|
+
elif task_result.is_failure():
|
|
33
|
+
self._handle_task_failure(task_id, task_result, topic)
|
|
34
|
+
else:
|
|
35
|
+
err_msg = f"task result for task_id={task_id} must be either complete/failure/BPMNError"
|
|
36
|
+
self._log_with_context(err_msg, task_id=task_id, log_level='warning')
|
|
37
|
+
raise Exception(err_msg)
|
|
38
|
+
|
|
39
|
+
def _strip_long_variables(self, variables):
|
|
40
|
+
"""remove value of complex variables for the dict"""
|
|
41
|
+
if not variables:
|
|
42
|
+
return variables
|
|
43
|
+
cleaned = {}
|
|
44
|
+
for k, v in variables.items():
|
|
45
|
+
if isinstance(v, dict) and v.get("type", "") in ("File", "Bytes"):
|
|
46
|
+
cleaned[k] = {**v, "value": "..."}
|
|
47
|
+
else:
|
|
48
|
+
cleaned[k] = v
|
|
49
|
+
return cleaned
|
|
50
|
+
|
|
51
|
+
def _handle_task_success(self, task_id, task_result, topic):
|
|
52
|
+
self._log_with_context(f"Marking task complete for Topic: {topic}", task_id)
|
|
53
|
+
if self.external_task_client.complete(task_id, task_result.global_variables, task_result.local_variables):
|
|
54
|
+
self._log_with_context(f"Marked task completed - Topic: {topic} "
|
|
55
|
+
f"global_variables: {self._strip_long_variables(task_result.global_variables)} "
|
|
56
|
+
f"local_variables: {self._strip_long_variables(task_result.local_variables)}", task_id)
|
|
57
|
+
else:
|
|
58
|
+
self._log_with_context(f"Not able to mark task completed - Topic: {topic} "
|
|
59
|
+
f"global_variables: {self._strip_long_variables(task_result.global_variables)} "
|
|
60
|
+
f"local_variables: {self._strip_long_variables(task_result.local_variables)}", task_id)
|
|
61
|
+
raise Exception(f"Not able to mark complete for task_id={task_id} "
|
|
62
|
+
f"for topic={topic}, worker_id={self.worker_id}")
|
|
63
|
+
|
|
64
|
+
def _handle_task_failure(self, task_id, task_result, topic):
|
|
65
|
+
self._log_with_context(f"Marking task failed - Topic: {topic} task_result: {task_result}", task_id)
|
|
66
|
+
if self.external_task_client.failure(task_id, task_result.error_message, task_result.error_details,
|
|
67
|
+
task_result.retries, task_result.retry_timeout):
|
|
68
|
+
self._log_with_context(f"Marked task failed - Topic: {topic} task_result: {task_result}", task_id)
|
|
69
|
+
else:
|
|
70
|
+
self._log_with_context(f"Not able to mark task failure - Topic: {topic}", task_id=task_id)
|
|
71
|
+
raise Exception(f"Not able to mark failure for task_id={task_id} "
|
|
72
|
+
f"for topic={topic}, worker_id={self.worker_id}")
|
|
73
|
+
|
|
74
|
+
def _handle_task_bpmn_error(self, task_id, task_result, topic):
|
|
75
|
+
bpmn_error_handled = self.external_task_client.bpmn_failure(task_id, task_result.bpmn_error_code,
|
|
76
|
+
task_result.error_message,
|
|
77
|
+
task_result.global_variables)
|
|
78
|
+
if bpmn_error_handled:
|
|
79
|
+
self._log_with_context(f"BPMN Error Handled: {bpmn_error_handled} "
|
|
80
|
+
f"Topic: {topic} task_result: {task_result}")
|
|
81
|
+
else:
|
|
82
|
+
self._log_with_context(f"Not able to mark BPMN error - Topic: {topic}", task_id=task_id)
|
|
83
|
+
raise Exception(f"Not able to mark BPMN Error for task_id={task_id} "
|
|
84
|
+
f"for topic={topic}, worker_id={self.worker_id}")
|
|
85
|
+
|
|
86
|
+
def _log_with_context(self, msg, task_id=None, log_level='info', **kwargs):
|
|
87
|
+
context = {"WORKER_ID": self.worker_id, "TASK_ID": task_id}
|
|
88
|
+
log_with_context(msg, context=context, log_level=log_level, **kwargs)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from operaton.client.external_task_client import ExternalTaskClient, ENGINE_LOCAL_BASE_URL
|
|
4
|
+
from operaton.external_task.external_task import ExternalTask
|
|
5
|
+
from operaton.external_task.external_task_executor import ExternalTaskExecutor
|
|
6
|
+
from operaton.utils.log_utils import log_with_context
|
|
7
|
+
from operaton.utils.auth_basic import obfuscate_password
|
|
8
|
+
from operaton.utils.utils import get_exception_detail
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExternalTaskWorker:
|
|
12
|
+
DEFAULT_SLEEP_SECONDS = 300
|
|
13
|
+
|
|
14
|
+
def __init__(self, worker_id, base_url=ENGINE_LOCAL_BASE_URL, config=None):
|
|
15
|
+
config = config if config is not None else {} # To avoid to have a mutable default for a parameter
|
|
16
|
+
self.worker_id = worker_id
|
|
17
|
+
self.client = ExternalTaskClient(self.worker_id, base_url, config)
|
|
18
|
+
self.executor = ExternalTaskExecutor(self.worker_id, self.client)
|
|
19
|
+
self.config = config
|
|
20
|
+
self._log_with_context(f"Created new External Task Worker with config: {obfuscate_password(self.config)}")
|
|
21
|
+
|
|
22
|
+
def subscribe(self, topic_names, action, process_variables=None, variables=None):
|
|
23
|
+
while True:
|
|
24
|
+
self._fetch_and_execute_safe(topic_names, action, process_variables, variables)
|
|
25
|
+
|
|
26
|
+
self._log_with_context("Stopping worker") # Fixme: This code seems to be unreachable?
|
|
27
|
+
|
|
28
|
+
def _fetch_and_execute_safe(
|
|
29
|
+
self, topic_names, action, process_variables=None, variables=None
|
|
30
|
+
):
|
|
31
|
+
try:
|
|
32
|
+
self.fetch_and_execute(topic_names, action, process_variables, variables)
|
|
33
|
+
except NoExternalTaskFound:
|
|
34
|
+
self._log_with_context(f"no External Task found for Topics: {topic_names}, "
|
|
35
|
+
f"Process variables: {process_variables}", topic=topic_names)
|
|
36
|
+
except BaseException as e:
|
|
37
|
+
sleep_seconds = self._get_sleep_seconds()
|
|
38
|
+
self._log_with_context(f'error fetching and executing tasks: {get_exception_detail(e)} '
|
|
39
|
+
f'for topic(s)={topic_names} with Process variables: {process_variables}. '
|
|
40
|
+
f'retrying after {sleep_seconds} seconds', exc_info=True)
|
|
41
|
+
time.sleep(sleep_seconds)
|
|
42
|
+
|
|
43
|
+
def fetch_and_execute(self, topic_names, action, process_variables=None, variables=None):
|
|
44
|
+
self._log_with_context(f"Fetching and Executing external tasks for Topics: {topic_names} "
|
|
45
|
+
f"with Process variables: {process_variables}")
|
|
46
|
+
resp_json = self._fetch_and_lock(topic_names, process_variables, variables)
|
|
47
|
+
tasks = self._parse_response(resp_json, topic_names, process_variables)
|
|
48
|
+
if len(tasks) == 0:
|
|
49
|
+
raise NoExternalTaskFound(f"no External Task found for Topics: {topic_names}, "
|
|
50
|
+
f"Process variables: {process_variables}")
|
|
51
|
+
self._execute_tasks(tasks, action)
|
|
52
|
+
|
|
53
|
+
def _fetch_and_lock(self, topic_names, process_variables=None, variables=None):
|
|
54
|
+
self._log_with_context(f"Fetching and Locking external tasks for Topics: {topic_names} "
|
|
55
|
+
f"with Process variables: {process_variables}")
|
|
56
|
+
return self.client.fetch_and_lock(topic_names, process_variables, variables)
|
|
57
|
+
|
|
58
|
+
def _parse_response(self, resp_json, topic_names, process_variables):
|
|
59
|
+
tasks = []
|
|
60
|
+
if resp_json:
|
|
61
|
+
for context in resp_json:
|
|
62
|
+
task = ExternalTask(context)
|
|
63
|
+
tasks.append(task)
|
|
64
|
+
|
|
65
|
+
tasks_count = len(tasks)
|
|
66
|
+
self._log_with_context(f"{tasks_count} External task(s) found for "
|
|
67
|
+
f"Topics: {topic_names}, Process variables: {process_variables}")
|
|
68
|
+
return tasks
|
|
69
|
+
|
|
70
|
+
def _execute_tasks(self, tasks, action):
|
|
71
|
+
for task in tasks:
|
|
72
|
+
self._execute_task(task, action)
|
|
73
|
+
|
|
74
|
+
def _execute_task(self, task, action):
|
|
75
|
+
try:
|
|
76
|
+
self.executor.execute_task(task, action)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
self._log_with_context(f'error when executing task: {get_exception_detail(e)}',
|
|
79
|
+
topic=task.get_topic_name(), task_id=task.get_task_id(),
|
|
80
|
+
log_level='error', exc_info=True)
|
|
81
|
+
raise e
|
|
82
|
+
|
|
83
|
+
def _log_with_context(self, msg, topic=None, task_id=None, log_level='info', **kwargs):
|
|
84
|
+
context = {"WORKER_ID": str(self.worker_id), "TOPIC": topic, "TASK_ID": task_id}
|
|
85
|
+
log_with_context(msg, context=context, log_level=log_level, **kwargs)
|
|
86
|
+
|
|
87
|
+
def _get_sleep_seconds(self):
|
|
88
|
+
return self.config.get("sleepSeconds", self.DEFAULT_SLEEP_SECONDS)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class NoExternalTaskFound(Exception):
|
|
92
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import AsyncMock
|
|
3
|
+
|
|
4
|
+
from operaton.client.async_external_task_client import AsyncExternalTaskClient
|
|
5
|
+
from operaton.external_task.async_external_task_executor import AsyncExternalTaskExecutor
|
|
6
|
+
from operaton.external_task.external_task import ExternalTask, TaskResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncExternalTaskExecutorTest(unittest.IsolatedAsyncioTestCase):
|
|
10
|
+
|
|
11
|
+
async def asyncSetUp(self):
|
|
12
|
+
"""
|
|
13
|
+
asyncSetUp runs before each test method in IsolatedAsyncioTestCase.
|
|
14
|
+
We instantiate an AsyncExternalTaskClient and patch/mocks as needed.
|
|
15
|
+
"""
|
|
16
|
+
self.mock_client = AsyncMock(spec=AsyncExternalTaskClient)
|
|
17
|
+
self.mock_client.complete.return_value = True
|
|
18
|
+
self.mock_client.failure.return_value = True
|
|
19
|
+
self.mock_client.bpmn_failure.return_value = True
|
|
20
|
+
|
|
21
|
+
self.executor = AsyncExternalTaskExecutor(
|
|
22
|
+
worker_id="someWorker",
|
|
23
|
+
external_task_client=self.mock_client
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
async def test_execute_task_success(self):
|
|
27
|
+
async def success_action(task: ExternalTask):
|
|
28
|
+
return TaskResult.success(task, {"globalVar": 42}, {"localVar": "foo"})
|
|
29
|
+
|
|
30
|
+
task = ExternalTask({"id": "taskId", "topicName": "someTopic"})
|
|
31
|
+
|
|
32
|
+
result = await self.executor.execute_task(task, success_action)
|
|
33
|
+
|
|
34
|
+
# Assertions
|
|
35
|
+
self.assertTrue(result.is_success())
|
|
36
|
+
self.mock_client.complete.assert_awaited_once_with(
|
|
37
|
+
"taskId", {"globalVar": 42}, {"localVar": "foo"}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def test_execute_task_failure(self):
|
|
41
|
+
async def fail_action(task: ExternalTask):
|
|
42
|
+
return TaskResult.failure(
|
|
43
|
+
task,
|
|
44
|
+
error_message="Some error",
|
|
45
|
+
error_details="Details here",
|
|
46
|
+
retries=3,
|
|
47
|
+
retry_timeout=1000
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
task = ExternalTask({"id": "taskId", "topicName": "someTopic"})
|
|
51
|
+
result = await self.executor.execute_task(task, fail_action)
|
|
52
|
+
|
|
53
|
+
# Assertions
|
|
54
|
+
self.assertTrue(result.is_failure())
|
|
55
|
+
self.mock_client.failure.assert_awaited_once_with(
|
|
56
|
+
"taskId", "Some error", "Details here", 3, 1000
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def test_execute_task_bpmn_error(self):
|
|
60
|
+
async def bpmn_error_action(task: ExternalTask):
|
|
61
|
+
return TaskResult.bpmn_error(
|
|
62
|
+
task,
|
|
63
|
+
error_code="bpmn_err_code",
|
|
64
|
+
error_message="bpmn error message",
|
|
65
|
+
variables={"varA": True}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
task = ExternalTask({"id": "taskId", "topicName": "someTopic"})
|
|
69
|
+
result = await self.executor.execute_task(task, bpmn_error_action)
|
|
70
|
+
|
|
71
|
+
# Assertions
|
|
72
|
+
self.assertTrue(result.is_bpmn_error())
|
|
73
|
+
self.mock_client.bpmn_failure.assert_awaited_once_with(
|
|
74
|
+
"taskId", "bpmn_err_code", "bpmn error message", {"varA": True}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def test_execute_task_empty_result_raises_exception(self):
|
|
78
|
+
"""
|
|
79
|
+
If the action returns an "empty" TaskResult (not success/failure/BPMNError),
|
|
80
|
+
executor should raise an exception.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
async def empty_action(task: ExternalTask):
|
|
84
|
+
return TaskResult.empty_task_result(task)
|
|
85
|
+
|
|
86
|
+
task = ExternalTask({"id": "taskId", "topicName": "someTopic"})
|
|
87
|
+
|
|
88
|
+
with self.assertRaises(Exception) as ctx:
|
|
89
|
+
await self.executor.execute_task(task, empty_action)
|
|
90
|
+
|
|
91
|
+
self.assertIn("must be either complete/failure/BPMNError", str(ctx.exception))
|
|
92
|
+
|
|
93
|
+
async def test_handle_task_success_when_client_returns_false_raises_exception(self):
|
|
94
|
+
"""
|
|
95
|
+
If client.complete returns False, an Exception must be raised.
|
|
96
|
+
"""
|
|
97
|
+
self.mock_client.complete.return_value = False
|
|
98
|
+
|
|
99
|
+
async def success_action(task: ExternalTask):
|
|
100
|
+
return TaskResult.success(task, {"var": "val"})
|
|
101
|
+
|
|
102
|
+
task = ExternalTask({"id": "taskId", "topicName": "someTopic"})
|
|
103
|
+
|
|
104
|
+
with self.assertRaises(Exception) as ctx:
|
|
105
|
+
await self.executor.execute_task(task, success_action)
|
|
106
|
+
|
|
107
|
+
self.assertIn("Not able to mark complete for task_id=taskId", str(ctx.exception))
|
|
108
|
+
|
|
109
|
+
async def test_handle_task_failure_when_client_returns_false_raises_exception(self):
|
|
110
|
+
"""
|
|
111
|
+
If client.failure returns False, an Exception must be raised.
|
|
112
|
+
"""
|
|
113
|
+
self.mock_client.failure.return_value = False
|
|
114
|
+
|
|
115
|
+
async def fail_action(task: ExternalTask):
|
|
116
|
+
return TaskResult.failure(task, "errMsg", "errDetails", 3, 2000)
|
|
117
|
+
|
|
118
|
+
task = ExternalTask({"id": "taskId", "topicName": "someTopic"})
|
|
119
|
+
|
|
120
|
+
with self.assertRaises(Exception) as ctx:
|
|
121
|
+
await self.executor.execute_task(task, fail_action)
|
|
122
|
+
|
|
123
|
+
self.assertIn("Not able to mark failure for task_id=taskId", str(ctx.exception))
|
|
124
|
+
|
|
125
|
+
async def test_handle_task_bpmn_error_when_client_returns_false_raises_exception(self):
|
|
126
|
+
"""
|
|
127
|
+
If client.bpmn_failure returns False, an Exception must be raised.
|
|
128
|
+
"""
|
|
129
|
+
self.mock_client.bpmn_failure.return_value = False
|
|
130
|
+
|
|
131
|
+
async def bpmn_error_action(task: ExternalTask):
|
|
132
|
+
return TaskResult.bpmn_error(task, "ERR_CODE", "error message")
|
|
133
|
+
|
|
134
|
+
task = ExternalTask({"id": "taskId", "topicName": "someTopic"})
|
|
135
|
+
|
|
136
|
+
with self.assertRaises(Exception) as ctx:
|
|
137
|
+
await self.executor.execute_task(task, bpmn_error_action)
|
|
138
|
+
|
|
139
|
+
self.assertIn("Not able to mark BPMN Error for task_id=taskId", str(ctx.exception))
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import unittest
|
|
3
|
+
from unittest.mock import AsyncMock, patch
|
|
4
|
+
|
|
5
|
+
from operaton.client.async_external_task_client import AsyncExternalTaskClient
|
|
6
|
+
from operaton.external_task.async_external_task_worker import AsyncExternalTaskWorker
|
|
7
|
+
from operaton.external_task.external_task import ExternalTask, TaskResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncExternalTaskWorkerTest(unittest.IsolatedAsyncioTestCase):
|
|
11
|
+
|
|
12
|
+
async def asyncSetUp(self):
|
|
13
|
+
"""
|
|
14
|
+
Setup a worker with a mock AsyncExternalTaskClient
|
|
15
|
+
"""
|
|
16
|
+
self.mock_client = AsyncMock(spec=AsyncExternalTaskClient)
|
|
17
|
+
self.mock_client.fetch_and_lock.return_value = []
|
|
18
|
+
|
|
19
|
+
self.config = {"maxConcurrentTasks": 2, "sleepSeconds": 0} # faster tests
|
|
20
|
+
self.worker = AsyncExternalTaskWorker("testWorker", config=self.config)
|
|
21
|
+
# Replace the worker's .client with our mock
|
|
22
|
+
self.worker.client = self.mock_client
|
|
23
|
+
# Similarly, replace the executor's .external_task_client
|
|
24
|
+
self.worker.executor.external_task_client = self.mock_client
|
|
25
|
+
|
|
26
|
+
async def test_fetch_and_execute_no_tasks_returns_false(self):
|
|
27
|
+
"""
|
|
28
|
+
If fetch_and_lock returns [], then fetch_and_execute should return False.
|
|
29
|
+
"""
|
|
30
|
+
self.mock_client.fetch_and_lock.return_value = []
|
|
31
|
+
result = await self.worker.fetch_and_execute(
|
|
32
|
+
topic_name="myTopic",
|
|
33
|
+
action=AsyncMock(return_value=None) # doesn't matter, won't be called
|
|
34
|
+
)
|
|
35
|
+
self.assertFalse(result)
|
|
36
|
+
|
|
37
|
+
async def test_fetch_and_execute_tasks_creates_execute_task_coroutines(self):
|
|
38
|
+
"""
|
|
39
|
+
If fetch_and_lock returns multiple tasks, ensure each is passed into _execute_task
|
|
40
|
+
in the background.
|
|
41
|
+
"""
|
|
42
|
+
# 2 tasks with different variables
|
|
43
|
+
resp = [
|
|
44
|
+
{
|
|
45
|
+
"id": "task1",
|
|
46
|
+
"topicName": "myTopic",
|
|
47
|
+
"workerId": "aWorkerId",
|
|
48
|
+
"variables": {"foo": {"value": "bar"}}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "task2",
|
|
52
|
+
"topicName": "myTopic",
|
|
53
|
+
"workerId": "aWorkerId2",
|
|
54
|
+
"variables": {"abc": {"value": 123}}
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
self.mock_client.fetch_and_lock.return_value = resp
|
|
58
|
+
|
|
59
|
+
async def success_action(task: ExternalTask):
|
|
60
|
+
# Return a success result for each
|
|
61
|
+
return TaskResult.success(task, {"someGlobalVar": 99})
|
|
62
|
+
|
|
63
|
+
returned = await self.worker.fetch_and_execute("myTopic", success_action)
|
|
64
|
+
self.assertTrue(returned)
|
|
65
|
+
# confirm 2 tasks => 2 coroutines started
|
|
66
|
+
self.assertEqual(len(self.worker.running_tasks), 2)
|
|
67
|
+
|
|
68
|
+
# Let them all finish
|
|
69
|
+
await asyncio.gather(*self.worker.running_tasks, return_exceptions=True)
|
|
70
|
+
|
|
71
|
+
# Now they should be removed from running_tasks
|
|
72
|
+
self.assertEqual(len(self.worker.running_tasks), 0)
|
|
73
|
+
|
|
74
|
+
async def test_execute_task_failure_when_action_raises_exception(self):
|
|
75
|
+
"""
|
|
76
|
+
If an uncaught exception occurs in the user-provided action,
|
|
77
|
+
the worker’s _execute_task wraps the result as a failure and tries to call
|
|
78
|
+
external_task_client.failure(...)
|
|
79
|
+
"""
|
|
80
|
+
self.mock_client.fetch_and_lock.return_value = [
|
|
81
|
+
{"id": "task1", "topicName": "topicX", "workerId": "w1"}
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
async def fail_action(task: ExternalTask):
|
|
85
|
+
raise RuntimeError("Something went wrong")
|
|
86
|
+
|
|
87
|
+
# We'll run fetch_and_execute => it should spawn one background task
|
|
88
|
+
await self.worker.fetch_and_execute("topicX", fail_action)
|
|
89
|
+
|
|
90
|
+
# Wait for background tasks to complete
|
|
91
|
+
await asyncio.gather(*self.worker.running_tasks, return_exceptions=True)
|
|
92
|
+
|
|
93
|
+
# Confirm the worker attempted to call 'failure(...)'
|
|
94
|
+
self.mock_client.failure.assert_awaited_once()
|
|
95
|
+
task_id, error_message, error_details, retries, retry_timeout = self.mock_client.failure.call_args.args
|
|
96
|
+
self.assertEqual(task_id, "task1")
|
|
97
|
+
self.assertEqual("Task execution failed", error_message)
|
|
98
|
+
self.assertEqual("An unexpected error occurred while executing the task", error_details)
|
|
99
|
+
self.assertEqual(3, retries)
|
|
100
|
+
self.assertEqual(300000, retry_timeout)
|
|
101
|
+
|
|
102
|
+
@patch.object(AsyncExternalTaskWorker, "_fetch_and_execute_safe")
|
|
103
|
+
async def test_cancel_running_tasks_single_iteration(self, mock_fetch_and_execute):
|
|
104
|
+
# Make _fetch_and_execute_safe run exactly once, then return
|
|
105
|
+
async def one_iteration(*args, **kwargs):
|
|
106
|
+
await self.worker.semaphore.acquire()
|
|
107
|
+
await self.worker.fetch_and_execute(*args, **kwargs)
|
|
108
|
+
# no 'while True', so it ends
|
|
109
|
+
|
|
110
|
+
mock_fetch_and_execute.side_effect = one_iteration
|
|
111
|
+
|
|
112
|
+
async def fake_long_action(task):
|
|
113
|
+
await asyncio.sleep(9999999)
|
|
114
|
+
|
|
115
|
+
self.mock_client.fetch_and_lock.return_value = [{"id": "taskX", "topicName": "topicA"}]
|
|
116
|
+
|
|
117
|
+
sub_task = asyncio.create_task(
|
|
118
|
+
self.worker._fetch_and_execute_safe("topicA", fake_long_action)
|
|
119
|
+
)
|
|
120
|
+
self.worker.subscriptions.append(sub_task)
|
|
121
|
+
|
|
122
|
+
# Wait for that single iteration to run
|
|
123
|
+
await asyncio.sleep(0.2)
|
|
124
|
+
|
|
125
|
+
await self.worker.stop()
|
|
126
|
+
await asyncio.sleep(0) # let cancellation finish
|
|
127
|
+
|
|
128
|
+
for t in self.worker.running_tasks:
|
|
129
|
+
self.assertTrue(t.done())
|