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.
@@ -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}")