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.
Files changed (66) hide show
  1. examples/__init__.py +0 -0
  2. examples/bpmn_error_example.py +75 -0
  3. examples/correlate_message.py +11 -0
  4. examples/event_subprocess_example.py +50 -0
  5. examples/examples_auth_basic/__init__.py +0 -0
  6. examples/examples_auth_basic/fetch_and_execute.py +31 -0
  7. examples/examples_auth_basic/get_process_instance.py +12 -0
  8. examples/examples_auth_basic/start_process.py +15 -0
  9. examples/examples_auth_basic/task_handler_example.py +44 -0
  10. examples/fetch_and_execute.py +30 -0
  11. examples/get_process_instance.py +12 -0
  12. examples/retry_task_example.py +58 -0
  13. examples/start_process.py +14 -0
  14. examples/task_handler_example.py +44 -0
  15. examples/tasks_example.py +36 -0
  16. operaton/__init__.py +0 -0
  17. operaton/client/__init__.py +0 -0
  18. operaton/client/async_external_task_client.py +171 -0
  19. operaton/client/engine_client.py +180 -0
  20. operaton/client/external_task_client.py +166 -0
  21. operaton/client/tests/__init__.py +0 -0
  22. operaton/client/tests/test_async_external_task_client.py +128 -0
  23. operaton/client/tests/test_async_external_task_client_auth.py +42 -0
  24. operaton/client/tests/test_async_external_task_client_bearer.py +43 -0
  25. operaton/client/tests/test_engine_client.py +228 -0
  26. operaton/client/tests/test_engine_client_auth.py +231 -0
  27. operaton/client/tests/test_engine_client_bearer.py +237 -0
  28. operaton/client/tests/test_external_task_client.py +17 -0
  29. operaton/client/tests/test_external_task_client_auth.py +19 -0
  30. operaton/client/tests/test_external_task_client_bearer.py +24 -0
  31. operaton/external_task/__init__.py +0 -0
  32. operaton/external_task/async_external_task_executor.py +91 -0
  33. operaton/external_task/async_external_task_worker.py +181 -0
  34. operaton/external_task/external_task.py +173 -0
  35. operaton/external_task/external_task_executor.py +88 -0
  36. operaton/external_task/external_task_worker.py +92 -0
  37. operaton/external_task/tests/__init__.py +0 -0
  38. operaton/external_task/tests/test_async_external_task_executor.py +139 -0
  39. operaton/external_task/tests/test_async_external_task_worker.py +129 -0
  40. operaton/external_task/tests/test_external_task.py +106 -0
  41. operaton/external_task/tests/test_external_task_executor.py +200 -0
  42. operaton/external_task/tests/test_external_task_worker.py +147 -0
  43. operaton/process_definition/__init__.py +0 -0
  44. operaton/process_definition/process_definition_client.py +123 -0
  45. operaton/process_definition/tests/__init__.py +0 -0
  46. operaton/process_definition/tests/test_process_definition_client.py +181 -0
  47. operaton/utils/__init__.py +0 -0
  48. operaton/utils/auth_basic.py +28 -0
  49. operaton/utils/auth_bearer.py +28 -0
  50. operaton/utils/log_utils.py +31 -0
  51. operaton/utils/response_utils.py +35 -0
  52. operaton/utils/tests/test_auth_basic.py +30 -0
  53. operaton/utils/tests/test_auth_bearer.py +27 -0
  54. operaton/utils/tests/test_response_utils.py +43 -0
  55. operaton/utils/tests/test_utils.py +21 -0
  56. operaton/utils/utils.py +14 -0
  57. operaton/variables/__init__.py +0 -0
  58. operaton/variables/properties.py +27 -0
  59. operaton/variables/tests/test_properties.py +20 -0
  60. operaton/variables/tests/test_variables.py +60 -0
  61. operaton/variables/variables.py +45 -0
  62. operaton_external_task_client_python3-1.0.0.dist-info/METADATA +258 -0
  63. operaton_external_task_client_python3-1.0.0.dist-info/RECORD +66 -0
  64. operaton_external_task_client_python3-1.0.0.dist-info/WHEEL +5 -0
  65. operaton_external_task_client_python3-1.0.0.dist-info/licenses/LICENSE +201 -0
  66. 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())