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,237 @@
1
+ import base64
2
+ from http import HTTPStatus
3
+ from unittest import TestCase
4
+ from unittest.mock import patch
5
+
6
+ import responses
7
+
8
+ from operaton.client.engine_client import EngineClient, ENGINE_LOCAL_BASE_URL
9
+
10
+
11
+ class EngineClientAuthTest(TestCase):
12
+ tenant_id = "6172cdf0-7b32-4460-9da0-ded5107aa977"
13
+ process_key = "PARALLEL_STEPS_EXAMPLE"
14
+
15
+ def setUp(self):
16
+ token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
17
+ '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
18
+ self.client = EngineClient(
19
+ config={"auth_bearer": {"access_token": token}})
20
+
21
+ @responses.activate
22
+ def test_auth_basic_start_process_success(self):
23
+ resp_payload = {
24
+ "links": [
25
+ {
26
+ "method": "GET",
27
+ "href": "http://localhost:8080/engine-rest/process-instance/cb678be8-9b93-11ea-bad9-0242ac110002",
28
+ "rel": "self"
29
+ }
30
+ ],
31
+ "id": "cb678be8-9b93-11ea-bad9-0242ac110002",
32
+ "definitionId": "PARALLEL_STEPS_EXAMPLE:1:9b72da83-9b91-11ea-bad9-0242ac110002",
33
+ "businessKey": "123456",
34
+ "caseInstanceId": None,
35
+ "ended": False,
36
+ "suspended": False,
37
+ "tenantId": None
38
+ }
39
+ responses.add(responses.POST, self.client.get_start_process_instance_url(self.process_key, self.tenant_id),
40
+ json=resp_payload, status=HTTPStatus.OK)
41
+ actual_resp_payload = self.client.start_process(self.process_key, {}, self.tenant_id, "123456")
42
+ self.assertDictEqual(resp_payload, actual_resp_payload)
43
+
44
+ @responses.activate
45
+ def test_auth_basic_start_process_not_found_raises_exception(self):
46
+ resp_payload = {
47
+ "type": "RestException",
48
+ "message": "No matching process definition with key: PROCESS_KEY_NOT_EXISTS and tenant-id: tenant_123"
49
+ }
50
+ responses.add(responses.POST,
51
+ self.client.get_start_process_instance_url("PROCESS_KEY_NOT_EXISTS", self.tenant_id),
52
+ status=HTTPStatus.NOT_FOUND, json=resp_payload)
53
+ with self.assertRaises(Exception) as exception_ctx:
54
+ self.client.start_process("PROCESS_KEY_NOT_EXISTS", {}, self.tenant_id)
55
+
56
+ self.assertEqual("received 404 : RestException : "
57
+ "No matching process definition with key: PROCESS_KEY_NOT_EXISTS and tenant-id: tenant_123",
58
+ str(exception_ctx.exception))
59
+
60
+ @responses.activate
61
+ def test_auth_basic_start_process_bad_request_raises_exception(self):
62
+ client = EngineClient()
63
+ expected_message = "Cannot instantiate process definition " \
64
+ "PARALLEL_STEPS_EXAMPLE:1:9b72da83-9b91-11ea-bad9-0242ac110002: " \
65
+ "Cannot convert value '1aa2345' of type 'Integer' to java type java.lang.Integer"
66
+ resp_payload = {
67
+ "type": "InvalidRequestException",
68
+ "message": expected_message
69
+ }
70
+ responses.add(responses.POST, client.get_start_process_instance_url(self.process_key, self.tenant_id),
71
+ status=HTTPStatus.BAD_REQUEST, json=resp_payload)
72
+ with self.assertRaises(Exception) as exception_ctx:
73
+ client.start_process(self.process_key, {"int_var": "1aa2345"}, self.tenant_id)
74
+
75
+ self.assertEqual(f"received 400 : InvalidRequestException : {expected_message}", str(exception_ctx.exception))
76
+
77
+ @responses.activate
78
+ def test_auth_basic_start_process_server_error_raises_exception(self):
79
+ responses.add(responses.POST, self.client.get_start_process_instance_url(self.process_key, self.tenant_id),
80
+ status=HTTPStatus.INTERNAL_SERVER_ERROR)
81
+ with self.assertRaises(Exception) as exception_ctx:
82
+ self.client.start_process(self.process_key, {"int_var": "1aa2345"}, self.tenant_id)
83
+
84
+ self.assertEqual(HTTPStatus.INTERNAL_SERVER_ERROR, exception_ctx.exception.response.status_code)
85
+ self.assertIn("Server Error: Internal Server Error", str(exception_ctx.exception))
86
+
87
+ @responses.activate
88
+ def test_auth_basic_get_process_instance_success(self):
89
+ resp_payload = [
90
+ {
91
+ "links": [],
92
+ "id": "c2c68785-9f42-11ea-a841-0242ac1c0004",
93
+ "definitionId": "PARALLEL_STEPS_EXAMPLE:1:88613042-9f42-11ea-a841-0242ac1c0004",
94
+ "businessKey": None,
95
+ "caseInstanceId": None,
96
+ "ended": False,
97
+ "suspended": False,
98
+ "tenantId": self.tenant_id
99
+ }
100
+ ]
101
+ get_process_instance_url = f"{ENGINE_LOCAL_BASE_URL}/process-instance" \
102
+ f"?processDefinitionKey={self.process_key}" \
103
+ f"&tenantIdIn={self.tenant_id}" \
104
+ f"&variables=intVar_eq_1,strVar_eq_hello"
105
+ responses.add(responses.GET, get_process_instance_url, status=HTTPStatus.OK, json=resp_payload)
106
+ actual_resp_payload = self.client.get_process_instance(process_key=self.process_key,
107
+ variables=["intVar_eq_1", "strVar_eq_hello"],
108
+ tenant_ids=[self.tenant_id])
109
+ self.assertListEqual(resp_payload, actual_resp_payload)
110
+
111
+ @responses.activate
112
+ def test_auth_basic_get_process_instance_bad_request_raises_exception(self):
113
+ expected_message = "Invalid variable comparator specified: XXX"
114
+ resp_payload = {
115
+ "type": "InvalidRequestException",
116
+ "message": expected_message
117
+ }
118
+ get_process_instance_url = f"{ENGINE_LOCAL_BASE_URL}/process-instance" \
119
+ f"?processDefinitionKey={self.process_key}" \
120
+ f"&tenantIdIn={self.tenant_id}" \
121
+ f"&variables=intVar_XXX_1,strVar_eq_hello"
122
+ responses.add(responses.GET, get_process_instance_url, status=HTTPStatus.BAD_REQUEST, json=resp_payload)
123
+ with self.assertRaises(Exception) as exception_ctx:
124
+ self.client.get_process_instance(process_key=self.process_key,
125
+ variables=["intVar_XXX_1", "strVar_eq_hello"],
126
+ tenant_ids=[self.tenant_id])
127
+
128
+ self.assertEqual(f"received 400 : InvalidRequestException : {expected_message}", str(exception_ctx.exception))
129
+
130
+ @responses.activate
131
+ def test_auth_basic_get_process_instance_server_error_raises_exception(self):
132
+ get_process_instance_url = f"{ENGINE_LOCAL_BASE_URL}/process-instance" \
133
+ f"?processDefinitionKey={self.process_key}" \
134
+ f"&tenantIdIn={self.tenant_id}" \
135
+ f"&variables=intVar_XXX_1,strVar_eq_hello"
136
+ responses.add(responses.GET, get_process_instance_url, status=HTTPStatus.INTERNAL_SERVER_ERROR)
137
+ with self.assertRaises(Exception) as exception_ctx:
138
+ self.client.get_process_instance(process_key=self.process_key,
139
+ variables=["intVar_XXX_1", "strVar_eq_hello"],
140
+ tenant_ids=[self.tenant_id])
141
+
142
+ self.assertEqual(HTTPStatus.INTERNAL_SERVER_ERROR, exception_ctx.exception.response.status_code)
143
+ self.assertIn("Server Error: Internal Server Error", str(exception_ctx.exception))
144
+
145
+ @patch('requests.post')
146
+ def test_auth_basic_correlate_message_with_only_message_name(self, mock_post):
147
+ expected_request_payload = {
148
+ "messageName": "CANCEL_MESSAGE",
149
+ "withoutTenantId": True,
150
+ "resultEnabled": True
151
+ }
152
+ token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
153
+ '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
154
+ self.client.correlate_message("CANCEL_MESSAGE")
155
+ mock_post.assert_called_with(ENGINE_LOCAL_BASE_URL + "/message",
156
+ json=expected_request_payload,
157
+ headers={'Content-Type': 'application/json',
158
+ 'Authorization': f'Bearer {token}'})
159
+
160
+ @patch('requests.post')
161
+ def test_auth_basic_correlate_message_with_business_key(self, mock_post):
162
+ expected_request_payload = {
163
+ "messageName": "CANCEL_MESSAGE",
164
+ "withoutTenantId": True,
165
+ "businessKey": "123456",
166
+ "resultEnabled": True
167
+ }
168
+ token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
169
+ '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
170
+ self.client.correlate_message("CANCEL_MESSAGE", business_key="123456")
171
+ mock_post.assert_called_with(ENGINE_LOCAL_BASE_URL + "/message",
172
+ json=expected_request_payload,
173
+ headers={'Content-Type': 'application/json',
174
+ 'Authorization': f'Bearer {token}'})
175
+
176
+ @patch('requests.post')
177
+ def test_auth_basic_correlate_message_with_tenant_id(self, mock_post):
178
+ expected_request_payload = {
179
+ "messageName": "CANCEL_MESSAGE",
180
+ "withoutTenantId": False,
181
+ "tenantId": "123456",
182
+ "resultEnabled": True
183
+ }
184
+ token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
185
+ '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
186
+ self.client.correlate_message("CANCEL_MESSAGE", tenant_id="123456")
187
+ mock_post.assert_called_with(ENGINE_LOCAL_BASE_URL + "/message",
188
+ json=expected_request_payload,
189
+ headers={'Content-Type': 'application/json',
190
+ 'Authorization': f'Bearer {token}'})
191
+
192
+ @responses.activate
193
+ def test_auth_basic_correlate_message_invalid_message_name_raises_exception(self):
194
+ expected_message = "org.operaton.bpm.engine.MismatchingMessageCorrelationException: " \
195
+ "Cannot correlate message 'XXX': No process definition or execution matches the parameters"
196
+ resp_payload = {
197
+ "type": "RestException",
198
+ "message": expected_message
199
+ }
200
+ correlate_msg_url = f"{ENGINE_LOCAL_BASE_URL}/message"
201
+ responses.add(responses.POST, correlate_msg_url, status=HTTPStatus.BAD_REQUEST, json=resp_payload)
202
+ with self.assertRaises(Exception) as exception_ctx:
203
+ self.client.correlate_message(message_name="XXX")
204
+
205
+ self.assertEqual(f"received 400 : RestException : {expected_message}", str(exception_ctx.exception))
206
+
207
+ @responses.activate
208
+ def test_auth_basic_get_process_instance_variable_without_meta(self):
209
+ process_instance_id = "c2c68785-9f42-11ea-a841-0242ac1c0004"
210
+ variable_name = "var1"
211
+ process_instance_var_url = \
212
+ f"{ENGINE_LOCAL_BASE_URL}/process-instance/{process_instance_id}/variables/{variable_name}"
213
+ resp_frame_payload = {"value": None, "valueInfo": {}, "type": ""}
214
+ resp_data_payload = base64.decodebytes(b"operaton")
215
+ process_instance_var_data_url = f"{process_instance_var_url}/data"
216
+
217
+ responses.add(responses.GET, process_instance_var_url, status=HTTPStatus.OK, json=resp_frame_payload)
218
+ responses.add(responses.GET, process_instance_var_data_url, status=HTTPStatus.OK, body=resp_data_payload)
219
+
220
+ resp = self.client.get_process_instance_variable(process_instance_id, variable_name)
221
+ self.assertEqual("operaton\n", resp)
222
+
223
+ @responses.activate
224
+ def test_auth_basic_get_process_instance_variable_with_meta(self):
225
+ process_instance_id = "c2c68785-9f42-11ea-a841-0242ac1c0004"
226
+ variable_name = "var1"
227
+ process_instance_var_url = \
228
+ f"{ENGINE_LOCAL_BASE_URL}/process-instance/{process_instance_id}/variables/{variable_name}"
229
+ resp_frame_payload = {"value": None, "valueInfo": {}, "type": ""}
230
+ resp_data_payload = base64.decodebytes(b"operaton")
231
+ process_instance_var_data_url = f"{process_instance_var_url}/data"
232
+
233
+ responses.add(responses.GET, process_instance_var_url, status=HTTPStatus.OK, json=resp_frame_payload)
234
+ responses.add(responses.GET, process_instance_var_data_url, status=HTTPStatus.OK, body=resp_data_payload)
235
+
236
+ resp = self.client.get_process_instance_variable(process_instance_id, variable_name, True)
237
+ self.assertEqual({"value": "operaton\n", "valueInfo": {}, "type": ""}, resp)
@@ -0,0 +1,17 @@
1
+ from unittest import TestCase
2
+
3
+ from operaton.client.engine_client import ENGINE_LOCAL_BASE_URL
4
+ from operaton.client.external_task_client import ExternalTaskClient
5
+
6
+
7
+ class ExternalTaskClientTest(TestCase):
8
+
9
+ def test_creation_with_no_debug_config(self):
10
+ client = ExternalTaskClient(1, ENGINE_LOCAL_BASE_URL, {})
11
+ self.assertFalse(client.is_debug)
12
+ self.assertFalse(client.config.get("isDebug"))
13
+
14
+ def test_creation_with_debug_config(self):
15
+ client = ExternalTaskClient(1, ENGINE_LOCAL_BASE_URL, {"isDebug": True})
16
+ self.assertTrue(client.is_debug)
17
+ self.assertTrue(client.config.get("isDebug"))
@@ -0,0 +1,19 @@
1
+ from unittest import TestCase
2
+
3
+ from operaton.client.engine_client import ENGINE_LOCAL_BASE_URL
4
+ from operaton.client.external_task_client import ExternalTaskClient
5
+
6
+
7
+ class ExternalTaskClientTest(TestCase):
8
+
9
+ def test_auth_basic_creation_with_no_debug_config(self):
10
+ client = ExternalTaskClient(
11
+ 1, ENGINE_LOCAL_BASE_URL, {"auth_basic": {"username": "demo", "password": "demo"}})
12
+ self.assertFalse(client.is_debug)
13
+ self.assertFalse(client.config.get("isDebug"))
14
+
15
+ def test_auth_basic_creation_with_debug_config(self):
16
+ client = ExternalTaskClient(
17
+ 1, ENGINE_LOCAL_BASE_URL,{"auth_basic": {"username": "demo", "password": "demo"}, "isDebug": True})
18
+ self.assertTrue(client.is_debug)
19
+ self.assertTrue(client.config.get("isDebug"))
@@ -0,0 +1,24 @@
1
+ from unittest import TestCase
2
+
3
+ from operaton.client.engine_client import ENGINE_LOCAL_BASE_URL
4
+ from operaton.client.external_task_client import ExternalTaskClient
5
+
6
+
7
+ class ExternalTaskClientTest(TestCase):
8
+
9
+ def test_auth_bearer_creation_with_no_debug_config(self):
10
+ token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
11
+ '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
12
+ client = ExternalTaskClient(
13
+ 1, ENGINE_LOCAL_BASE_URL, {"auth_bearer": {"access_token": token}})
14
+ self.assertFalse(client.is_debug)
15
+ self.assertFalse(client.config.get("isDebug"))
16
+
17
+ def test_auth_bearer_creation_with_debug_config(self):
18
+ token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0'
19
+ '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M')
20
+ client = ExternalTaskClient(
21
+ 1, ENGINE_LOCAL_BASE_URL,
22
+ {"auth_bearer": {"access_token": token}, "isDebug": True})
23
+ self.assertTrue(client.is_debug)
24
+ self.assertTrue(client.config.get("isDebug"))
File without changes
@@ -0,0 +1,91 @@
1
+ import logging
2
+
3
+ from operaton.client.async_external_task_client import AsyncExternalTaskClient
4
+ from operaton.utils.log_utils import log_with_context
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class AsyncExternalTaskExecutor:
10
+
11
+ def __init__(self, worker_id: str, external_task_client: AsyncExternalTaskClient):
12
+ self.worker_id = worker_id
13
+ self.external_task_client = external_task_client
14
+
15
+ async def execute_task(self, task, action):
16
+ topic = task.get_topic_name()
17
+ task_id = task.get_task_id()
18
+ self._log_with_context(f"Executing external task for Topic: {topic}", task_id=task_id)
19
+ task_result = await action(task)
20
+ # in case task result is not set inside action function, set it in task here
21
+ task.set_task_result(task_result)
22
+ await self._handle_task_result(task_result)
23
+ return task_result
24
+
25
+ async def _handle_task_result(self, task_result):
26
+ task = task_result.get_task()
27
+ topic = task.get_topic_name()
28
+ task_id = task.get_task_id()
29
+ if task_result.is_success():
30
+ await self._handle_task_success(task_id, task_result, topic)
31
+ elif task_result.is_bpmn_error():
32
+ await self._handle_task_bpmn_error(task_id, task_result, topic)
33
+ elif task_result.is_failure():
34
+ await self._handle_task_failure(task_id, task_result, topic)
35
+ else:
36
+ err_msg = f"task result for task_id={task_id} must be either complete/failure/BPMNError"
37
+ self._log_with_context(err_msg, task_id=task_id, log_level='warning')
38
+ raise Exception(err_msg)
39
+
40
+ def _strip_long_variables(self, variables):
41
+ """remove value of complex variables for the dict"""
42
+ if not variables:
43
+ return variables
44
+ cleaned = {}
45
+ for k, v in variables.items():
46
+ if isinstance(v, dict) and v.get("type", "") in ("File", "Bytes"):
47
+ cleaned[k] = {**v, "value": "..."}
48
+ else:
49
+ cleaned[k] = v
50
+ return cleaned
51
+
52
+ async def _handle_task_success(self, task_id, task_result, topic):
53
+ self._log_with_context(f"Marking task complete for Topic: {topic}", task_id)
54
+ if await self.external_task_client.complete(task_id, task_result.global_variables, task_result.local_variables):
55
+ self._log_with_context(f"Marked task completed - Topic: {topic} "
56
+ f"global_variables: {self._strip_long_variables(task_result.global_variables)} "
57
+ f"local_variables: {self._strip_long_variables(task_result.local_variables)}",
58
+ task_id, log_level='debug')
59
+ else:
60
+ self._log_with_context(f"Not able to mark task completed - Topic: {topic} "
61
+ f"global_variables: {self._strip_long_variables(task_result.global_variables)} "
62
+ f"local_variables: {self._strip_long_variables(task_result.local_variables)}",
63
+ task_id, log_level='error')
64
+ raise Exception(f"Not able to mark complete for task_id={task_id} "
65
+ f"for topic={topic}, worker_id={self.worker_id}")
66
+
67
+ async def _handle_task_failure(self, task_id, task_result, topic):
68
+ self._log_with_context(f"Marking task failed - Topic: {topic} task_result: {task_result}", task_id)
69
+ if await self.external_task_client.failure(task_id, task_result.error_message, task_result.error_details,
70
+ task_result.retries, task_result.retry_timeout):
71
+ self._log_with_context(f"Marked task failed - Topic: {topic} task_result: {task_result}", task_id)
72
+ else:
73
+ self._log_with_context(f"Not able to mark task failure - Topic: {topic}", task_id=task_id)
74
+ raise Exception(f"Not able to mark failure for task_id={task_id} "
75
+ f"for topic={topic}, worker_id={self.worker_id}")
76
+
77
+ async def _handle_task_bpmn_error(self, task_id, task_result, topic):
78
+ bpmn_error_handled = await self.external_task_client.bpmn_failure(task_id, task_result.bpmn_error_code,
79
+ task_result.error_message,
80
+ task_result.global_variables)
81
+ if bpmn_error_handled:
82
+ self._log_with_context(f"BPMN Error Handled: {bpmn_error_handled} "
83
+ f"Topic: {topic} task_result: {task_result}")
84
+ else:
85
+ self._log_with_context(f"Not able to mark BPMN error - Topic: {topic}", task_id=task_id)
86
+ raise Exception(f"Not able to mark BPMN Error for task_id={task_id} "
87
+ f"for topic={topic}, worker_id={self.worker_id}")
88
+
89
+ def _log_with_context(self, msg, task_id=None, log_level='info', **kwargs):
90
+ context = {"WORKER_ID": self.worker_id, "TASK_ID": task_id}
91
+ log_with_context(msg, context=context, log_level=log_level, **kwargs)
@@ -0,0 +1,181 @@
1
+ import asyncio
2
+ from typing import Any, Callable, Dict, List, Optional
3
+
4
+ from operaton.client.async_external_task_client import AsyncExternalTaskClient
5
+ from operaton.client.external_task_client import ENGINE_LOCAL_BASE_URL
6
+ from operaton.external_task.async_external_task_executor import AsyncExternalTaskExecutor
7
+ from operaton.external_task.external_task import ExternalTask
8
+ from operaton.utils.auth_basic import obfuscate_password
9
+ from operaton.utils.log_utils import log_with_context
10
+ from operaton.utils.utils import get_exception_detail
11
+
12
+
13
+ class AsyncExternalTaskWorker:
14
+ DEFAULT_SLEEP_SECONDS = 1 # Sleep duration when no tasks are fetched
15
+
16
+ def __init__(
17
+ self,
18
+ worker_id: str,
19
+ base_url: str = ENGINE_LOCAL_BASE_URL,
20
+ config: Optional[Dict[str, Any]] = None,
21
+ ):
22
+ self.config = config or {}
23
+ self.worker_id = worker_id
24
+ self.client = AsyncExternalTaskClient(self.worker_id, base_url, self.config)
25
+ self.executor = AsyncExternalTaskExecutor(self.worker_id, self.client)
26
+ self.subscriptions: List[asyncio.Task] = []
27
+ max_concurrent_tasks = self.config.get('maxConcurrentTasks', 10)
28
+ self.semaphore = asyncio.Semaphore(max_concurrent_tasks)
29
+ self.running_tasks = set()
30
+ self._log_with_context(
31
+ f"Created new External Task Worker with config: {obfuscate_password(self.config)}"
32
+ )
33
+
34
+ async def subscribe(
35
+ self,
36
+ topic_handlers: Dict[str, Callable[[ExternalTask], Any]],
37
+ process_variables: Optional[Dict[str, Any]] = None,
38
+ variables: Optional[List[str]] = None,
39
+ ):
40
+ self.subscriptions = [
41
+ asyncio.create_task(
42
+ self._fetch_and_execute_safe(topic, action, process_variables, variables)
43
+ )
44
+ for topic, action in topic_handlers.items()
45
+ ]
46
+ await asyncio.gather(*self.subscriptions)
47
+
48
+ async def _fetch_and_execute_safe(
49
+ self,
50
+ topic_name: str,
51
+ action: Callable[[ExternalTask], Any],
52
+ process_variables: Optional[Dict[str, Any]] = None,
53
+ variables: Optional[List[str]] = None,
54
+ ):
55
+ sleep_seconds = self._get_sleep_seconds()
56
+ while True:
57
+ try:
58
+ await self.semaphore.acquire()
59
+ tasks_processed = await self.fetch_and_execute(topic_name, action, process_variables, variables)
60
+ if not tasks_processed:
61
+ # Release semaphore if no tasks were fetched
62
+ self.semaphore.release()
63
+ await asyncio.sleep(sleep_seconds)
64
+ else:
65
+ await asyncio.sleep(0) # Yield control to the event loop
66
+ except asyncio.CancelledError:
67
+ self._log_with_context(f"Task for topic {topic_name} was cancelled.")
68
+ break
69
+ except Exception as e:
70
+ self._log_with_context(
71
+ f"Error fetching and executing tasks: {get_exception_detail(e)} "
72
+ f"for topic={topic_name} with Process variables: {process_variables}. "
73
+ f"Retrying after {sleep_seconds} seconds",
74
+ exc_info=True,
75
+ log_level="error"
76
+ )
77
+ self.semaphore.release()
78
+ await asyncio.sleep(sleep_seconds)
79
+
80
+ async def fetch_and_execute(
81
+ self,
82
+ topic_name: str,
83
+ action: Callable[[ExternalTask], Any],
84
+ process_variables: Optional[Dict[str, Any]] = None,
85
+ variables: Optional[List[str]] = None,
86
+ ):
87
+ self._log_with_context(
88
+ f"Fetching and executing external tasks for Topic: {topic_name} "
89
+ f"with Process variables: {process_variables}",
90
+ log_level="debug"
91
+ )
92
+ resp_json = await self.client.fetch_and_lock([topic_name], process_variables, variables)
93
+ tasks = self._parse_response(resp_json, topic_name, process_variables)
94
+ if not tasks:
95
+ return False
96
+
97
+ for task in tasks:
98
+ # Start processing the task in the background
99
+ running_task = asyncio.create_task(self._execute_task(task, action))
100
+ self.running_tasks.add(running_task)
101
+ # Release semaphore when task is done
102
+ running_task.add_done_callback(lambda t: self.semaphore.release())
103
+ # Remove from running_tasks when done
104
+ running_task.add_done_callback(self.running_tasks.discard)
105
+ return True
106
+
107
+ def _parse_response(
108
+ self,
109
+ resp_json: List[Dict[str, Any]],
110
+ topic_name: str,
111
+ process_variables: Optional[Dict[str, Any]],
112
+ ) -> List[ExternalTask]:
113
+ tasks = [ExternalTask(context) for context in resp_json or []]
114
+ tasks_count = len(tasks)
115
+ self._log_with_context(
116
+ f"{tasks_count} external task(s) found for "
117
+ f"Topic: {topic_name}, Process variables: {process_variables}",
118
+ log_level="debug"
119
+ )
120
+ return tasks
121
+
122
+ async def _execute_task(self, task: ExternalTask, action: Callable[[ExternalTask], Any]):
123
+ try:
124
+ await self.executor.execute_task(task, action)
125
+ except asyncio.CancelledError:
126
+ task_result = task.failure(
127
+ error_message='Task execution cancelled',
128
+ error_details='Task was cancelled by the user or system',
129
+ max_retries=self.config.get('retries', AsyncExternalTaskClient.default_config['retries']),
130
+ retry_timeout=self.config.get('retryTimeout', AsyncExternalTaskClient.default_config['retryTimeout'])
131
+ )
132
+ await self.executor._handle_task_result(task_result)
133
+ self._log_with_context(
134
+ f"Task execution cancelled for task_id: {task.get_task_id()}",
135
+ topic=task.get_topic_name(),
136
+ task_id=task.get_task_id(),
137
+ log_level="info"
138
+ )
139
+ return task_result
140
+ except Exception as e:
141
+ task_result = task.failure(
142
+ error_message='Task execution failed',
143
+ error_details='An unexpected error occurred while executing the task',
144
+ max_retries=self.config.get('retries', AsyncExternalTaskClient.default_config['retries']),
145
+ retry_timeout=self.config.get('retryTimeout', AsyncExternalTaskClient.default_config['retryTimeout'])
146
+ )
147
+ await self.executor._handle_task_result(task_result)
148
+ self._log_with_context(
149
+ f"Error when executing task: {get_exception_detail(e)}. "
150
+ f"Task execution cancelled for task_id: {task.get_task_id()}.",
151
+ topic=task.get_topic_name(),
152
+ task_id=task.get_task_id(),
153
+ log_level="error",
154
+ exc_info=True
155
+ )
156
+ return task_result
157
+
158
+ def _log_with_context(
159
+ self,
160
+ msg: str,
161
+ topic: Optional[str] = None,
162
+ task_id: Optional[str] = None,
163
+ log_level: str = "info",
164
+ **kwargs: Any,
165
+ ):
166
+ context = {"WORKER_ID": str(self.worker_id), "TOPIC": topic, "TASK_ID": task_id}
167
+ log_with_context(msg, context=context, log_level=log_level, **kwargs)
168
+
169
+ def _get_sleep_seconds(self) -> int:
170
+ return self.config.get("sleepSeconds", self.DEFAULT_SLEEP_SECONDS)
171
+
172
+ async def stop(self):
173
+ # First, cancel running tasks
174
+ for task in self.running_tasks:
175
+ task.cancel()
176
+ await asyncio.gather(*self.running_tasks, return_exceptions=True)
177
+
178
+ # Then, cancel the fetch loops (subscriptions)
179
+ for task in self.subscriptions:
180
+ task.cancel()
181
+ await asyncio.gather(*self.subscriptions, return_exceptions=True)