pinexq-procon 2.1.0.dev3__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.
- pinexq/procon/__init__.py +0 -0
- pinexq/procon/core/__init__.py +0 -0
- pinexq/procon/core/cli.py +442 -0
- pinexq/procon/core/exceptions.py +64 -0
- pinexq/procon/core/helpers.py +61 -0
- pinexq/procon/core/logconfig.py +48 -0
- pinexq/procon/core/naming.py +36 -0
- pinexq/procon/core/types.py +15 -0
- pinexq/procon/dataslots/__init__.py +19 -0
- pinexq/procon/dataslots/abstractionlayer.py +215 -0
- pinexq/procon/dataslots/annotation.py +389 -0
- pinexq/procon/dataslots/dataslots.py +369 -0
- pinexq/procon/dataslots/datatypes.py +50 -0
- pinexq/procon/dataslots/default_reader_writer.py +26 -0
- pinexq/procon/dataslots/filebackend.py +126 -0
- pinexq/procon/dataslots/metadata.py +137 -0
- pinexq/procon/jobmanagement/__init__.py +9 -0
- pinexq/procon/jobmanagement/api_helpers.py +287 -0
- pinexq/procon/remote/__init__.py +0 -0
- pinexq/procon/remote/messages.py +250 -0
- pinexq/procon/remote/rabbitmq.py +420 -0
- pinexq/procon/runtime/__init__.py +3 -0
- pinexq/procon/runtime/foreman.py +128 -0
- pinexq/procon/runtime/job.py +384 -0
- pinexq/procon/runtime/settings.py +12 -0
- pinexq/procon/runtime/tool.py +16 -0
- pinexq/procon/runtime/worker.py +437 -0
- pinexq/procon/step/__init__.py +3 -0
- pinexq/procon/step/introspection.py +234 -0
- pinexq/procon/step/schema.py +99 -0
- pinexq/procon/step/step.py +119 -0
- pinexq/procon/step/versioning.py +84 -0
- pinexq_procon-2.1.0.dev3.dist-info/METADATA +83 -0
- pinexq_procon-2.1.0.dev3.dist-info/RECORD +35 -0
- pinexq_procon-2.1.0.dev3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from asyncio import Task
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from inspect import isawaitable
|
|
9
|
+
from typing import Any, Awaitable, Callable, TypeVar, Union
|
|
10
|
+
|
|
11
|
+
import pydantic
|
|
12
|
+
|
|
13
|
+
from ..core.exceptions import ProConException
|
|
14
|
+
from ..dataslots import DataSlotDescription, Metadata, SlotDescription
|
|
15
|
+
from ..dataslots.metadata import CallbackMetadataHandler
|
|
16
|
+
from ..remote.messages import (
|
|
17
|
+
DataslotInput,
|
|
18
|
+
DataslotOutput,
|
|
19
|
+
ErrorContent,
|
|
20
|
+
JobCommandMessageBody,
|
|
21
|
+
JobOfferContent,
|
|
22
|
+
JobResultContent,
|
|
23
|
+
JobResultMessageBody,
|
|
24
|
+
JobResultMetadataContent,
|
|
25
|
+
JobResultMetadataMessageBody,
|
|
26
|
+
JobStatus,
|
|
27
|
+
JobStatusContent,
|
|
28
|
+
JobStatusMessageBody,
|
|
29
|
+
MessageHeader,
|
|
30
|
+
MetadataUpdate,
|
|
31
|
+
SlotInfo,
|
|
32
|
+
)
|
|
33
|
+
from ..remote.rabbitmq import QueuePublisher, QueueSubscriber, RabbitMQClient
|
|
34
|
+
from ..runtime.settings import (
|
|
35
|
+
JOB_COMMAND_TOPIC,
|
|
36
|
+
JOB_RESULT_TOPIC,
|
|
37
|
+
JOB_STATUS_TOPIC,
|
|
38
|
+
)
|
|
39
|
+
from ..runtime.tool import handle_task_result
|
|
40
|
+
from ..step import Step
|
|
41
|
+
from ..step.step import ExecutionContext
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
T = TypeVar('T') # Any type.
|
|
47
|
+
CallableOrAwaitable = Union[Callable[[T], None], Callable[[T], Awaitable[None]]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class FunctionVersion:
|
|
52
|
+
name: str
|
|
53
|
+
version: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _create_dataslot_description(
|
|
57
|
+
dataslots: list[DataslotInput] | list[DataslotOutput]
|
|
58
|
+
) -> dict[str, DataSlotDescription]:
|
|
59
|
+
"""Convert the dataslot format from messages to the internal format"""
|
|
60
|
+
dataslot_description = {}
|
|
61
|
+
for ds in dataslots:
|
|
62
|
+
# Input and output dataslots have different attribute names for their list of slots
|
|
63
|
+
msg_slots = ds.sources if isinstance(ds, DataslotInput) else ds.destinations
|
|
64
|
+
slots = [
|
|
65
|
+
SlotDescription(
|
|
66
|
+
uri=str(slot.uri),
|
|
67
|
+
headers=(
|
|
68
|
+
{h.Key: h.Value for h in slot.prebuildheaders}
|
|
69
|
+
if slot.prebuildheaders is not None
|
|
70
|
+
else {}
|
|
71
|
+
),
|
|
72
|
+
mediatype=slot.mediatype,
|
|
73
|
+
)
|
|
74
|
+
for slot in msg_slots
|
|
75
|
+
]
|
|
76
|
+
dataslot_description[ds.name] = DataSlotDescription(
|
|
77
|
+
name=ds.name, slots=slots
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return dataslot_description
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class RemoteExecutionContext(ExecutionContext):
|
|
85
|
+
"""Wraps all information to call a function in a Step container.
|
|
86
|
+
|
|
87
|
+
Extends the basic contex with information from the JobManagement.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
current_job_offer: JobOfferContent | None = field(default=None)
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class JobInfo:
|
|
94
|
+
"""Wraps all information to start a single job."""
|
|
95
|
+
context_id: str
|
|
96
|
+
function_name: str
|
|
97
|
+
version: str
|
|
98
|
+
job_id: str | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def format_topic(format_str: str, job_info: JobInfo) -> str:
|
|
102
|
+
"""Creates the RabbitMQ topic/queue names from a format string definition."""
|
|
103
|
+
return format_str.format(
|
|
104
|
+
JobId=job_info.job_id,
|
|
105
|
+
ContextId=job_info.context_id,
|
|
106
|
+
FunctionName=job_info.function_name,
|
|
107
|
+
Version=job_info.version
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class JobCommunicationRMQ:
|
|
112
|
+
"""Job-specific communication using RabbitMQ"""
|
|
113
|
+
|
|
114
|
+
_job_command_sub: QueueSubscriber
|
|
115
|
+
_job_status_pub: QueuePublisher
|
|
116
|
+
_job_result_pub: QueuePublisher
|
|
117
|
+
_job_progress_pub: QueuePublisher
|
|
118
|
+
_rmq_client: RabbitMQClient
|
|
119
|
+
_job_cmd_cb: CallableOrAwaitable[JobCommandMessageBody] | None
|
|
120
|
+
_consumer_task: asyncio.Task | None
|
|
121
|
+
|
|
122
|
+
job_id: uuid.uuid4
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
rmq_client: RabbitMQClient,
|
|
127
|
+
job_info: JobInfo,
|
|
128
|
+
job_cmd_cb: CallableOrAwaitable[JobCommandMessageBody] | None = None
|
|
129
|
+
):
|
|
130
|
+
self._rmq_client = rmq_client
|
|
131
|
+
self.job_info = job_info
|
|
132
|
+
self._job_cmd_cb = job_cmd_cb
|
|
133
|
+
self.is_initialized = False
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def sender_id(self) -> str:
|
|
137
|
+
return f"job:{self.job_info.job_id}"
|
|
138
|
+
|
|
139
|
+
async def start(self):
|
|
140
|
+
await self.connect_to_job_queues()
|
|
141
|
+
self.is_initialized = True
|
|
142
|
+
|
|
143
|
+
async def stop(self):
|
|
144
|
+
await self._job_command_sub.stop_consumer_loop()
|
|
145
|
+
self.is_initialized = False
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def is_ready_to_send(self) -> bool:
|
|
149
|
+
return self._rmq_client.is_connected and self.is_initialized
|
|
150
|
+
|
|
151
|
+
async def connect_to_job_queues(self):
|
|
152
|
+
cmd_topic = format_topic(JOB_COMMAND_TOPIC, self.job_info)
|
|
153
|
+
log.debug("Subscribing to Job.Command queue: '%s'", cmd_topic)
|
|
154
|
+
|
|
155
|
+
# Connect to the _Job.Command_ queue
|
|
156
|
+
if await self._rmq_client.does_queue_exist(cmd_topic):
|
|
157
|
+
self._job_command_sub = await self._rmq_client.subscriber(
|
|
158
|
+
queue_name=cmd_topic,
|
|
159
|
+
routing_key=cmd_topic,
|
|
160
|
+
callback=self.on_job_cmd_message
|
|
161
|
+
)
|
|
162
|
+
self._consumer_task = asyncio.create_task(
|
|
163
|
+
self._job_command_sub.run_consumer_loop(), name="job-cmd-consumer"
|
|
164
|
+
)
|
|
165
|
+
self._consumer_task.add_done_callback(
|
|
166
|
+
functools.partial(handle_task_result, description="Job.Command consumer task")
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
log.error(f"Job.Command queue: {cmd_topic} does not exist, Job will be processed but no commands accepted.")
|
|
170
|
+
|
|
171
|
+
# Prepare the _Job.Status_ topic
|
|
172
|
+
status_topic = format_topic(JOB_STATUS_TOPIC, self.job_info)
|
|
173
|
+
log.debug("Connecting to Job.Status queue: '%s'", status_topic)
|
|
174
|
+
self._job_status_pub = await self._rmq_client.publisher(
|
|
175
|
+
routing_key=status_topic,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Prepare the _Job.Result_ topic
|
|
179
|
+
result_topic = format_topic(JOB_RESULT_TOPIC, self.job_info)
|
|
180
|
+
log.debug("Connecting to Job.Result queue: '%s'", result_topic)
|
|
181
|
+
self._job_result_pub = await self._rmq_client.publisher(
|
|
182
|
+
routing_key=result_topic,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def on_job_cmd_message(self, message: str | bytes):
|
|
186
|
+
try:
|
|
187
|
+
msg = JobCommandMessageBody.model_validate_json(message)
|
|
188
|
+
except pydantic.ValidationError as ex:
|
|
189
|
+
msg = "Deserialization of 'job.command' message failed!"
|
|
190
|
+
await self.report_job_error(msg, cause=ex)
|
|
191
|
+
log.exception("⚠ %s", msg, exc_info=ex)
|
|
192
|
+
else:
|
|
193
|
+
log.debug("'job.command' message deserialized: %s", str(msg))
|
|
194
|
+
if self._job_cmd_cb is not None:
|
|
195
|
+
result = self._job_cmd_cb(msg)
|
|
196
|
+
if isawaitable(result):
|
|
197
|
+
await result
|
|
198
|
+
else:
|
|
199
|
+
log.warning("Job command message received, but no handler is set!")
|
|
200
|
+
|
|
201
|
+
async def send_job_status(self, msg: JobStatusMessageBody):
|
|
202
|
+
json_msg = msg.model_dump_json(by_alias=True)
|
|
203
|
+
await self._job_status_pub.send(json_msg)
|
|
204
|
+
|
|
205
|
+
async def send_job_result(self, msg: JobResultMessageBody | JobResultMetadataMessageBody):
|
|
206
|
+
if not self.is_ready_to_send:
|
|
207
|
+
log.warning('Job communication not initialized. Skip sending "%s" message.', str(msg.type_))
|
|
208
|
+
return
|
|
209
|
+
json_msg = msg.model_dump_json(by_alias=True)
|
|
210
|
+
await self._job_result_pub.send(json_msg)
|
|
211
|
+
|
|
212
|
+
async def report_job_status(self, status: JobStatus):
|
|
213
|
+
"""Publishes the current state of the STM to 'Worker.Status.*'"""
|
|
214
|
+
content = JobStatusContent(job_id=self.job_info.job_id, status=status)
|
|
215
|
+
msg = JobStatusMessageBody(
|
|
216
|
+
header=MessageHeader(sender_id=self.sender_id, ),
|
|
217
|
+
content=content,
|
|
218
|
+
)
|
|
219
|
+
await self.send_job_status(msg)
|
|
220
|
+
|
|
221
|
+
async def report_job_error(self, title: str, cause: Exception | str):
|
|
222
|
+
"""Publishes an error message from an exception to 'Worker.Status.*'"""
|
|
223
|
+
if isinstance(cause, ProConException) and cause.user_message:
|
|
224
|
+
content = ErrorContent(
|
|
225
|
+
title=title, detail=cause.user_message, instance=f"job:{self.job_info.job_id}"
|
|
226
|
+
)
|
|
227
|
+
elif isinstance(cause, Exception):
|
|
228
|
+
content = ErrorContent.from_exception(
|
|
229
|
+
title=title, exception=cause, instance=f"job:{self.job_info.job_id}"
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
content = ErrorContent(
|
|
233
|
+
title=title, detail=cause, instance=f"job:{self.job_info.job_id}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
msg = JobStatusMessageBody(
|
|
237
|
+
header=MessageHeader(sender_id=self.sender_id, ),
|
|
238
|
+
content=content,
|
|
239
|
+
)
|
|
240
|
+
await self.send_job_status(msg)
|
|
241
|
+
|
|
242
|
+
async def report_job_result(self, result: Any):
|
|
243
|
+
msg = JobResultMessageBody(
|
|
244
|
+
header=MessageHeader(sender_id=self.sender_id, ),
|
|
245
|
+
content=JobResultContent(job_id=self.job_info.job_id, result=result),
|
|
246
|
+
)
|
|
247
|
+
await self.send_job_result(msg)
|
|
248
|
+
|
|
249
|
+
async def report_job_result_metadata(self, metadata_content: JobResultMetadataContent):
|
|
250
|
+
msg = JobResultMetadataMessageBody(
|
|
251
|
+
header=MessageHeader(sender_id=self.sender_id, ),
|
|
252
|
+
content=metadata_content,
|
|
253
|
+
)
|
|
254
|
+
await self.send_job_result(msg)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class ProConJob:
|
|
258
|
+
"""Business logic to run a single Job"""
|
|
259
|
+
|
|
260
|
+
_job_com: JobCommunicationRMQ
|
|
261
|
+
_background_tasks: set[Task]
|
|
262
|
+
|
|
263
|
+
step: Step
|
|
264
|
+
job_offer: JobOfferContent
|
|
265
|
+
job_info: JobInfo
|
|
266
|
+
status: JobStatus
|
|
267
|
+
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
step: Step,
|
|
271
|
+
function: FunctionVersion,
|
|
272
|
+
context_id: str,
|
|
273
|
+
job_offer: JobOfferContent,
|
|
274
|
+
_rmq_client: RabbitMQClient,
|
|
275
|
+
):
|
|
276
|
+
self.step = step
|
|
277
|
+
self.job_offer = job_offer
|
|
278
|
+
self.job_info = JobInfo(
|
|
279
|
+
job_id=str(job_offer.job_id),
|
|
280
|
+
context_id=context_id,
|
|
281
|
+
function_name=function.name,
|
|
282
|
+
version=function.version,
|
|
283
|
+
)
|
|
284
|
+
self.status = JobStatus.starting
|
|
285
|
+
|
|
286
|
+
self._background_tasks = set()
|
|
287
|
+
|
|
288
|
+
self._event_loop = asyncio.get_event_loop()
|
|
289
|
+
|
|
290
|
+
self._job_com = JobCommunicationRMQ(
|
|
291
|
+
rmq_client=_rmq_client,
|
|
292
|
+
job_info=self.job_info,
|
|
293
|
+
job_cmd_cb=self._on_job_cmd
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async def start(self):
|
|
297
|
+
await self._job_com.start()
|
|
298
|
+
await self._job_com.report_job_status(self.status)
|
|
299
|
+
|
|
300
|
+
async def disconnect_from_job_queues(self):
|
|
301
|
+
await self._job_com.stop()
|
|
302
|
+
|
|
303
|
+
async def process(self):
|
|
304
|
+
log.info("Start processing Job <id: %s>", self.job_info.job_id)
|
|
305
|
+
self.status = JobStatus.running
|
|
306
|
+
await self._job_com.report_job_status(self.status)
|
|
307
|
+
|
|
308
|
+
if self.job_info.function_name != self.job_offer.algorithm:
|
|
309
|
+
msg = (
|
|
310
|
+
f"The algorithm in the job offer '{self.job_offer.algorithm}' does "
|
|
311
|
+
f"not match the function name of this container ('{self.job_info.function_name}')!"
|
|
312
|
+
)
|
|
313
|
+
log.error(msg)
|
|
314
|
+
await self._job_com.report_job_error(title="Algorithm mismatch!", cause=msg)
|
|
315
|
+
else:
|
|
316
|
+
await self.call_step_function()
|
|
317
|
+
|
|
318
|
+
self.status = JobStatus.finished
|
|
319
|
+
await self._job_com.report_job_status(self.status)
|
|
320
|
+
await self.disconnect_from_job_queues()
|
|
321
|
+
|
|
322
|
+
log.info("Stop processing Job <id: %s>", self.job_info.job_id)
|
|
323
|
+
|
|
324
|
+
async def call_step_function(self):
|
|
325
|
+
"""Entrypoint for a step function's execution when running as a worker"""
|
|
326
|
+
context = RemoteExecutionContext(
|
|
327
|
+
function_name=self.job_info.function_name,
|
|
328
|
+
parameters=self.job_offer.parameters or {},
|
|
329
|
+
input_dataslots=_create_dataslot_description(self.job_offer.input_dataslots),
|
|
330
|
+
output_dataslots=_create_dataslot_description(self.job_offer.output_dataslots),
|
|
331
|
+
current_job_offer=self.job_offer,
|
|
332
|
+
metadata_handler=self._get_slot_metadata_handler(),
|
|
333
|
+
)
|
|
334
|
+
try:
|
|
335
|
+
# noinspection PyProtectedMember
|
|
336
|
+
result = await asyncio.to_thread(
|
|
337
|
+
self.step._call,
|
|
338
|
+
context=context
|
|
339
|
+
)
|
|
340
|
+
except Exception as ex:
|
|
341
|
+
msg = f"Exception while processing job <id:{self.job_info.job_id}>"
|
|
342
|
+
log.exception(msg, exc_info=ex)
|
|
343
|
+
await self._job_com.report_job_error(title=msg, cause=ex)
|
|
344
|
+
else:
|
|
345
|
+
log.info("Job finished successfully.")
|
|
346
|
+
await self._job_com.report_job_result(result)
|
|
347
|
+
|
|
348
|
+
def _get_slot_metadata_handler(self):
|
|
349
|
+
# Create the handler that will be called when accessing a Slot's metadata
|
|
350
|
+
return CallbackMetadataHandler(
|
|
351
|
+
getter=self._get_slot_metadata,
|
|
352
|
+
setter=self._set_slot_metadata
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
@cached_property
|
|
356
|
+
def _all_dataslots(self) -> dict[str, list[SlotInfo]]:
|
|
357
|
+
# Reduce in-/output dataslots into one dict, that is created and cached on first access
|
|
358
|
+
input_dataslots = {s.name: s.sources for s in self.job_offer.input_dataslots}
|
|
359
|
+
output_dataslots = {s.name: s.destinations for s in self.job_offer.output_dataslots}
|
|
360
|
+
return input_dataslots | output_dataslots
|
|
361
|
+
|
|
362
|
+
def _get_slot_metadata(self, slot: SlotDescription) -> Metadata:
|
|
363
|
+
# Get the metadata from the in-/output slot description in the job.offer
|
|
364
|
+
dataslots = self._all_dataslots
|
|
365
|
+
return dataslots[slot.dataslot_name][slot.index].metadata
|
|
366
|
+
|
|
367
|
+
def _set_slot_metadata(self, slot: SlotDescription, metadata: Metadata) -> None:
|
|
368
|
+
# Send a job.result.metadata message
|
|
369
|
+
meta_content = JobResultMetadataContent(
|
|
370
|
+
updates=[
|
|
371
|
+
MetadataUpdate(dataslot_name=slot.dataslot_name, slot_index=slot.index, metadata=metadata)
|
|
372
|
+
]
|
|
373
|
+
)
|
|
374
|
+
# Fire and forget the task, but keep a reference to prevent being garbage collected
|
|
375
|
+
task = self._event_loop.create_task(
|
|
376
|
+
self._job_com.report_job_result_metadata(meta_content), name="set_metadata"
|
|
377
|
+
)
|
|
378
|
+
self._background_tasks.add(task)
|
|
379
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
380
|
+
task.add_done_callback(handle_task_result)
|
|
381
|
+
|
|
382
|
+
async def _on_job_cmd(self, message: JobCommandMessageBody):
|
|
383
|
+
log.debug("Job command received: %s", message)
|
|
384
|
+
raise NotImplementedError()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# RabbitMQ
|
|
2
|
+
EXCHANGE_NAME = ""
|
|
3
|
+
|
|
4
|
+
# Topic definitions
|
|
5
|
+
JOB_OFFERS_TOPIC = "Job.{ContextId}.{FunctionName}.{Version}.Offers"
|
|
6
|
+
JOB_COMMAND_TOPIC = "Job.{ContextId}.{FunctionName}.{Version}.{JobId}.Command"
|
|
7
|
+
JOB_STATUS_TOPIC = "Job.{ContextId}.{FunctionName}.{Version}.{JobId}.Reporting.Status"
|
|
8
|
+
JOB_RESULT_TOPIC = "Job.{ContextId}.{FunctionName}.{Version}.{JobId}.Reporting.Result"
|
|
9
|
+
|
|
10
|
+
# Queue parameters
|
|
11
|
+
WORKER_COMMAND_PARAMS = {}
|
|
12
|
+
JOB_COMMAND_PARAMS = {}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
log = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
def handle_task_result(task: asyncio.Task, description: str = "", msg_on_success: bool = False):
|
|
7
|
+
description = description or task.get_name() # use the raw task name if no human-readable text was given
|
|
8
|
+
try:
|
|
9
|
+
# Calling .result() on a finished task raises the exception if one occurred
|
|
10
|
+
task.result()
|
|
11
|
+
if msg_on_success:
|
|
12
|
+
log.debug(f"{description} finished successfully")
|
|
13
|
+
except asyncio.CancelledError:
|
|
14
|
+
log.debug(f"{description} was cancelled")
|
|
15
|
+
except Exception as e:
|
|
16
|
+
log.exception(f"{description} raised an exception: {e}")
|