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,420 @@
1
+ """
2
+ Protocol wrapper for the communication via RabbitMQ.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import ssl
8
+ from abc import ABCMeta
9
+ from collections.abc import Awaitable, Callable
10
+ from inspect import isawaitable
11
+
12
+ import aio_pika
13
+ import aio_pika.abc
14
+ import aio_pika.exceptions
15
+ import stamina
16
+ from aiormq import AMQPConnectionError, ChannelNotFoundEntity, ChannelPreconditionFailed
17
+
18
+ from ..core.exceptions import ProConBadMessage, ProConMessageRejected
19
+
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ AMPQ_DEFAULT_PORT = 5672
24
+ AMPQS_DEFAULT_PORT = 5671 # TLS secured AMPQ
25
+
26
+
27
+ class AbstractQueueHandler(metaclass=ABCMeta):
28
+ """Base class for all handlers reading/writing to a RMQ queue"""
29
+ _parent: 'RabbitMQClient'
30
+
31
+ # @abstractmethod
32
+ # async def close(self):
33
+ # ...
34
+
35
+ @property
36
+ def client_connected(self) -> asyncio.Event:
37
+ return self._parent.connection.connected
38
+
39
+
40
+ class QueueSubscriber(AbstractQueueHandler):
41
+ """Wrapper to handle a queue with subscription to a topic."""
42
+
43
+ _is_consuming: asyncio.Event
44
+ _is_started: asyncio.Event
45
+ _is_stopped: asyncio.Event
46
+ _consumer_task: asyncio.Task | None
47
+ _msg_handler_tasks: set[asyncio.Task]
48
+ _rmq_queue: aio_pika.abc.AbstractRobustQueue
49
+
50
+ consumer_tag: str
51
+ queue_name: str
52
+ routing_key: str
53
+
54
+ def __init__(
55
+ self,
56
+ queue_name: str,
57
+ routing_key: str,
58
+ msg_callback: Callable[[bytes], None] | Awaitable[bytes],
59
+ _parent: 'RabbitMQClient',
60
+ ):
61
+ """
62
+ Wrapper to handle a queue with subscription to a topic.
63
+
64
+ Args:
65
+ queue_name: Name of the queue to create.
66
+ msg_callback: Callable accepting the incoming message as parameter.
67
+ routing_key: Routing for the queue to bind to.
68
+ _parent: The client (used internally).
69
+ """
70
+ self.queue_name = queue_name
71
+ self.routing_key = routing_key
72
+ self._callback = msg_callback
73
+ self._parent = _parent
74
+ self.consumer_tag = "not-set"
75
+ self._consumer_task = None
76
+
77
+ self._event_lock = asyncio.Lock() # Locks access to the following events
78
+ self._is_started = asyncio.Event() # Indicates the queue is polling
79
+ self._is_stopped = asyncio.Event() # Indicates that the polling has stopped
80
+ self._is_stopped.set()
81
+
82
+ async def connect_to_queue(self):
83
+ self._rmq_queue = await self._parent.channel.declare_queue(
84
+ name=self.queue_name,
85
+ durable=True,
86
+ passive=True # do not create queue if it does not exist
87
+ )
88
+
89
+ async def _disconnect_from_queue(self):
90
+ # log.debug('Unbinding queue "%s"', self.queue_name)
91
+ # cancel consuming this since we can not shut down the whole connection
92
+ try:
93
+ # this is not working on reconnect, will reconnect anyway
94
+ await self._rmq_queue.cancel(consumer_tag=self.consumer_tag, timeout=10)
95
+ self.consumer_tag="canceled"
96
+ # WORKAROUND: cancel will not remove the queue form reconnect callbacks in RobustChannel,
97
+ # so a reconnect will still fail since it wants to connect to a non-existing (canceled) queue.
98
+ # Other option is to reset the channel on reconnect and rebuild from own managed state.
99
+ self._parent.channel._queues.pop(self.queue_name, None)
100
+ except Exception as e:
101
+ log.error(f"Failed to cancel consuming queue .{self.queue_name}: {e}")
102
+
103
+ async def delete(self, force: bool = False):
104
+ # log.debug('Deleting queue "%s"', self.queue_name)
105
+ await self._rmq_queue.delete(
106
+ if_unused=not force,
107
+ if_empty=not force
108
+ )
109
+
110
+ async def _consumer_loop(self):
111
+ """
112
+ Poll the RMQ queue and consume messages.
113
+ """
114
+ # The Lock and Events prevent start-/stopping while in the progress of being start-/stopped.
115
+ async with self._event_lock:
116
+ self._is_stopped.clear()
117
+ self._is_started.set()
118
+ log.debug(f'Start consuming on <queue:%s>', self.queue_name)
119
+
120
+ try:
121
+ async with self._rmq_queue.iterator() as queue_iterator:
122
+ # we need this tag to cancel the connection to the queue
123
+ # it is hidden in iterator which would clean up when it is stopped, but this never happens
124
+ self.consumer_tag = queue_iterator._consumer_tag
125
+ async for message in queue_iterator:
126
+ await self._message_handler(message)
127
+
128
+ finally:
129
+ async with self._event_lock:
130
+ self._is_started.clear()
131
+ self._is_stopped.set()
132
+ log.debug(f'Stop consuming on <queue:%s>', self.queue_name)
133
+
134
+ async def run_consumer_loop(self):
135
+ """Start the consumer task that polls for messages on the queue."""
136
+ if self._is_started.is_set():
137
+ log.warning('Consumer started, but is already running! <queue:%s>', self.queue_name)
138
+ return
139
+ await self._is_stopped.wait()
140
+ await self.client_connected.wait() # avoid consuming when disconnected
141
+
142
+ self._consumer_task = asyncio.create_task(
143
+ self._consumer_loop(),
144
+ name="job-offer-consumer-loop"
145
+ )
146
+ await self._consumer_task # <-- this will run until canceled
147
+
148
+ async def stop_consumer_loop(self):
149
+ """Stop the consumer task and listening for messages."""
150
+ if self._is_stopped.is_set():
151
+ # log.warning('Stop on non-running consumer! <queue:%s>', self.queue_name)
152
+ return
153
+ await self._is_started.wait()
154
+ # Cancel the task and wait for it to end
155
+ self._consumer_task.cancel()
156
+ try:
157
+ await self._consumer_task
158
+ self._consumer_task = None
159
+ except asyncio.CancelledError:
160
+ pass
161
+ finally:
162
+ await self._disconnect_from_queue() # so on reconnect we do not look for this queue again
163
+
164
+ async def _message_handler(self, message: aio_pika.abc.AbstractIncomingMessage):
165
+ """Handle incoming messages in a callback and react to the message
166
+ depending on (un)successful execution of the callback."""
167
+ # difference between "nack" and "reject" -> https://www.rabbitmq.com/docs/nack#overview
168
+ log.debug('→✉ Message received. <route:%s> <msg_id:%s>',
169
+ self.routing_key, message.message_id)
170
+ try:
171
+ res = self._callback(message.body)
172
+ if isawaitable(res): # allow for sync and async callbacks
173
+ await res
174
+
175
+ # Reject and drop message on deserialization/validation errors
176
+ except ProConBadMessage as ex:
177
+ await message.reject()
178
+ cause = f" -> {str(ex.__cause__)}" if ex.__cause__ else str(ex)
179
+ log.error('✖ Dropped invalid or broken message! %s <msg_id:%s>', cause, message.message_id)
180
+
181
+ # The worker does currently accept no jobs
182
+ except ProConMessageRejected as ex:
183
+ await message.reject(requeue=True)
184
+ cause = f" -> {str(ex.__cause__)}" if ex.__cause__ else str(ex)
185
+ log.error('↩ Unable to process message! Message requeued. %s <msg_id:%s>', cause, message.message_id)
186
+
187
+ # Reject and requeue after all other errors and propagate exception
188
+ except Exception as ex:
189
+ if self.client_connected.is_set(): # avoid sending error messages when disconnected
190
+ await message.reject(requeue=True)
191
+ log.error('↩ Exception during message handling! Message requeued. <msg_id:%s>', message.message_id)
192
+ raise ex
193
+
194
+ # Ack on successful handling of the message (this happens *before* processing the job)
195
+ else:
196
+ await message.ack()
197
+ log.debug('✔ Message processed successfully! <msg_id:%s>', message.message_id)
198
+
199
+
200
+ class QueuePublisher(AbstractQueueHandler):
201
+ """Wrapper to handle sending messages to a queue"""
202
+
203
+ def __init__(
204
+ self,
205
+ exchange: aio_pika.abc.AbstractExchange,
206
+ routing_key: str,
207
+ _parent: 'RabbitMQClient'
208
+ ):
209
+ """
210
+ Wrapper to handle sending messages to a queue.
211
+
212
+ Args:
213
+ exchange: The exchange messages will be sent to.
214
+ routing_key: The routing key messages will be sent to.
215
+ _parent: The client (used internally).
216
+ """
217
+ self._exchange = exchange
218
+ self._routing_key = routing_key
219
+ self._parent = _parent
220
+
221
+ async def close(self):
222
+ pass
223
+
224
+ async def send(self, msg: str, wait_for_ack: bool = False):
225
+ await self.client_connected.wait() # avoid sending when disconnected
226
+ message = aio_pika.Message(msg.encode())
227
+ log.debug('←✉ Sending message: <route:%s> <msg_id:%s> Content: %s',
228
+ self._routing_key, message.message_id, message.body)
229
+ await self._exchange.publish(
230
+ message,
231
+ routing_key=self._routing_key,
232
+ )
233
+
234
+
235
+ class RabbitMQClient:
236
+ """Wrapper providing a publisher/subscriber interface for a RabbitMQ connection."""
237
+
238
+ connection: aio_pika.abc.AbstractRobustConnection | None = None
239
+ channel: aio_pika.abc.AbstractRobustChannel
240
+ exchange: aio_pika.abc.AbstractExchange
241
+ _handlers: list[QueueSubscriber | QueuePublisher] = []
242
+ connected: asyncio.Event
243
+
244
+ def __init__(
245
+ self,
246
+ url: str | None = None,
247
+ host: str = '',
248
+ port: int = AMPQ_DEFAULT_PORT,
249
+ login: str = '',
250
+ password: str = '',
251
+ exchange: str = '',
252
+ vhost: str = '/'
253
+ ):
254
+ """
255
+
256
+ Args:
257
+ url: RFC3986 formatted broker address. When None the other keywords are
258
+ used for configuration.
259
+ host: Hostname of the broker
260
+ port: Broker port 5672 by default
261
+ login: Username string. ‘guest’ by default.
262
+ password: Password string. ‘guest’ by default.
263
+ exchange: The exchange name as string.
264
+ vhost: The server internal virtual host name to use.
265
+ """
266
+ self._connection_params = {
267
+ 'url': url,
268
+ 'host': host,
269
+ 'port': port,
270
+ 'login': login,
271
+ 'password': password,
272
+ 'exchange': exchange,
273
+ 'virtualhost': vhost,
274
+ 'timeout': 60,
275
+ }
276
+
277
+ if port == AMPQS_DEFAULT_PORT:
278
+ self._connection_params['ssl_context'] = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
279
+ # ssl_options = aio_pika.abc.SSLOptions()
280
+ self._connection_params['ssl'] = True
281
+
282
+ async def connect_and_run(self) -> None:
283
+ """Establish the connection to the server and declare an exchange."""
284
+ log.info('Connecting to RabbitMQ server ... ')
285
+ log.debug(f'RMQ connection parameters:\n'
286
+ f'url : {self._connection_params["url"]}\n'
287
+ f'host : {self._connection_params["host"]}\n'
288
+ f'port : {self._connection_params["port"]}\n'
289
+ f'login : {self._connection_params["login"]}\n'
290
+ f'password : *****\n'
291
+ f'exchange : {self._connection_params["exchange"]}\n'
292
+ f'virtualhost: {self._connection_params["virtualhost"]}\n'
293
+ f'timeout : {self._connection_params["timeout"]}\n')
294
+
295
+ await self._try_connecting()
296
+ # self.connection.close_callbacks.add(self.on_server_disconnected)
297
+ # self.connection.reconnect_callbacks.add(self.on_server_reconnected)
298
+
299
+ self.channel = await self.connection.channel()
300
+ await self.channel.set_qos(prefetch_count=1)
301
+
302
+ exchange_name: str = self._connection_params.pop('exchange')
303
+ self.exchange = await self.channel.get_exchange(
304
+ name=exchange_name,
305
+ ensure=False,
306
+ )
307
+
308
+ log.info('Connection established')
309
+
310
+ @stamina.retry(on=(AMQPConnectionError, ConnectionRefusedError), attempts=50, wait_initial=1.0)
311
+ async def _try_connecting(self):
312
+ self.connection = await aio_pika.connect_robust(
313
+ **self._connection_params
314
+ )
315
+ await self.connection.connected.wait() # a bit paranoid, but make sure we're connected
316
+
317
+ @property
318
+ def is_connected(self) -> bool:
319
+ """Return True if the RMQ client is connected."""
320
+ # Before connecting to the server `self.connection` is None
321
+ return self.connection and self.connection.connected.is_set()
322
+
323
+ # async def on_server_disconnected(self, con: aio_pika.abc.AbstractRobustConnection,
324
+ # exc: aiormq.exceptions.ConnectionClosed):
325
+ # # log.warning(f'Connection to RabbitMQ server lost! '
326
+ # # f'(code: {exc.errno}, reason: {exc.reason} text: "{exc.strerror}")')
327
+ # self.connected.clear()
328
+
329
+ # async def on_server_reconnected(self, *args):
330
+ # # log.info(f'Connection to RabbitMQ server reestablished. ({args})')
331
+ # self.connected.set()
332
+
333
+ async def _stop_all_handlers(self) -> None:
334
+ ...
335
+ # TODO
336
+
337
+ async def close(self):
338
+ """Close the RabbitMQ connection"""
339
+ log.info('Closing RabbitMQ connection ...')
340
+ # await self._stop_all_handlers() # ???
341
+ if self.is_connected:
342
+ await self.connection.close()
343
+
344
+ async def subscriber(
345
+ self,
346
+ queue_name: str,
347
+ callback: Callable,
348
+ routing_key: str = ''
349
+ ) -> QueueSubscriber | None:
350
+ """
351
+ Factory to create a QueueSubscriber bound to this exchange.
352
+
353
+ Args:
354
+ queue_name: Name of the queue to create.
355
+ callback: Callable accepting the incoming message as parameter.
356
+ routing_key: Routing for the queue to bind to.
357
+
358
+ Returns:
359
+ Instantiated QueueSubscriber object
360
+ """
361
+ log.debug('Creating subscription (queue: "%s", routing_key: "%s")',
362
+ queue_name, routing_key)
363
+
364
+ consumer = QueueSubscriber(
365
+ queue_name=queue_name,
366
+ routing_key=routing_key,
367
+ msg_callback=callback,
368
+ _parent=self
369
+ )
370
+ try:
371
+ await consumer.connect_to_queue()
372
+ self._handlers.append(consumer)
373
+ except (ChannelPreconditionFailed, ChannelNotFoundEntity) as exc:
374
+ log.error(f"Can not connect to queue '{consumer.queue_name}': {exc}")
375
+ return None
376
+ except Exception as exc:
377
+ log.error(f"Error connecting to queue '{consumer.queue_name}': {exc}")
378
+ raise exc
379
+
380
+ return consumer
381
+
382
+ async def publisher(self, routing_key: str) -> QueuePublisher:
383
+ """
384
+ Factory to create a QueueSubscriber bound to this exchange.
385
+
386
+ Args:
387
+ routing_key: Routing for the queue to bind to.
388
+
389
+ Returns:
390
+ Instantiated QueuePublisher object.
391
+ """
392
+ log.debug('Creating publisher (routing_key: "%s")', routing_key)
393
+
394
+ publisher = QueuePublisher(
395
+ exchange=self.exchange,
396
+ routing_key=routing_key,
397
+ _parent=self
398
+ )
399
+ self._handlers.append(publisher)
400
+ return publisher
401
+
402
+ async def does_queue_exist(self, queue_name: str):
403
+ connection = None
404
+ try:
405
+ connection = await aio_pika.connect_robust(**self._connection_params)
406
+ async with connection.channel() as channel:
407
+ await channel.declare_queue(queue_name, passive=True)
408
+ log.debug(f"Queue '{queue_name}' exists.")
409
+ return True
410
+
411
+ except (ChannelPreconditionFailed, ChannelNotFoundEntity) as e:
412
+ log.error(f"Queue '{queue_name}' does not exist, can not connect and will not create. {e}")
413
+ return False
414
+ except Exception as e:
415
+ log.error(f"Error checking if queue '{queue_name}' does exist. {e}")
416
+ raise e
417
+
418
+ finally:
419
+ if connection:
420
+ await connection.close()
@@ -0,0 +1,3 @@
1
+ from .job import ProConJob, RemoteExecutionContext
2
+ from .worker import ProConWorker
3
+ from .foreman import ProConForeman
@@ -0,0 +1,128 @@
1
+ import asyncio
2
+ import platform
3
+ import signal
4
+ from asyncio import AbstractEventLoop, Future, Event, CancelledError
5
+
6
+ from ..runtime.worker import ProConWorker, log
7
+
8
+
9
+ class ProConForeman:
10
+ """
11
+ Helper class to set up the asyncio context for a Worker, start it,
12
+ handle signals and exceptions.
13
+ """
14
+
15
+ worker: ProConWorker
16
+ _worker_task: asyncio.Task
17
+ loop: AbstractEventLoop
18
+ is_shutting_down: Event
19
+
20
+ # This code takes many hints and tips from:
21
+ # https://www.roguelynn.com/words/asyncio-we-did-it-wrong/
22
+
23
+ def __init__(self, worker: ProConWorker, *, debug: bool = True):
24
+ self.worker = worker
25
+ self.is_shutting_down = Event()
26
+
27
+ self.loop = asyncio.new_event_loop()
28
+ self.loop.set_debug(debug)
29
+ self.loop.set_exception_handler(self._handle_loop_exception)
30
+ self.install_signal_handlers()
31
+ self.start()
32
+
33
+ # noinspection PyUnresolvedReferences
34
+ def install_signal_handlers(self):
35
+ # Windows does not support signals, so skip it there
36
+ if platform.system() != 'Windows':
37
+ log.info("Installing signal handlers")
38
+ signals = (signal.SIGHUP, signal.SIGTERM, signal.SIGINT)
39
+ for s in signals:
40
+ self.loop.add_signal_handler(
41
+ # Bind the variable `s` to avoid late binding.
42
+ # This is working even though the linter might tell otherwise.
43
+ s, lambda s=s: self._shutdown_on_signal(s)
44
+ )
45
+
46
+ def start(self):
47
+ """Starts the worker and will block as long as the event loop runs."""
48
+ log.info("Starting worker runtime")
49
+ try:
50
+ self.loop.run_until_complete(
51
+ self.worker.run(),
52
+ )
53
+ log.debug("Worker task exited.")
54
+ except KeyboardInterrupt:
55
+ log.warning("Process received keyboard interrupt!")
56
+ except Exception as exc:
57
+ log.exception(f"Shutdown triggered by unhandled {exc.__class__.__name__} exception!")
58
+ finally:
59
+ if not self.is_shutting_down.is_set():
60
+ self.loop.run_until_complete(
61
+ self.shutdown(wait_on_worker=False)
62
+ )
63
+ self.loop.close()
64
+ log.info("Successfully shutdown worker runtime")
65
+
66
+ def _shutdown_on_signal(self, sig: signal.Signals | None = None):
67
+ """Handle system signals like SIGINT, SIGTERM"""
68
+ if sig:
69
+ log.warning(f"Received exit signal {sig.name}...")
70
+
71
+ if not self.is_shutting_down.is_set():
72
+ asyncio.create_task(
73
+ self.shutdown(wait_on_worker=True), name="shutdown_on_signal"
74
+ )
75
+
76
+ def _shutdown_on_exception(self, exc: Exception | None):
77
+ """Handle exceptions that are not explicitly handled in the framework and bubble up to the 'foreman'."""
78
+ if exc:
79
+ log.warning(f"Shutting triggered by unhandled {exc.__class__.__name__} exception!")
80
+
81
+ if not self.is_shutting_down.is_set():
82
+ asyncio.create_task(
83
+ self.shutdown(wait_on_worker=False), name="shutdown_on_exception"
84
+ )
85
+
86
+ async def shutdown(self, wait_on_worker: bool = False):
87
+ """Cleanup running tasks and shutdown the async event loop.
88
+
89
+ Args:
90
+ wait_on_worker: If true, wait for the Worker task to finish its calculations.
91
+ """
92
+ if self.is_shutting_down.is_set():
93
+ log.warning("Shutdown triggered while already shutting down!")
94
+ return
95
+ self.is_shutting_down.set()
96
+
97
+ log.info("Shutting down...")
98
+
99
+ # Signal the Worker to stop and wait for it to exit
100
+ await self.worker.stop()
101
+ if wait_on_worker:
102
+ log.debug("Waiting for the Worker task to finish ...")
103
+ await self._worker_task
104
+
105
+ # Cleanup all dangling task
106
+ tasks = [t for t in asyncio.all_tasks()
107
+ if t is not asyncio.current_task()]
108
+ if tasks:
109
+ log.warning(f"Cancelling {len(tasks)} outstanding tasks")
110
+ log.debug(f"Tasks to be cancelled: {[t.get_name() for t in tasks]}")
111
+ for task in tasks:
112
+ task.cancel()
113
+ await asyncio.gather(*tasks, return_exceptions=True)
114
+
115
+ log.info("Stopping event loop")
116
+ self.loop.stop()
117
+
118
+ def _handle_loop_exception(self, loop: AbstractEventLoop, context: dict):
119
+ """Global exception handler for event loop."""
120
+ # context["message"] will always be there; but context variables may not
121
+ task: asyncio.Task | None = context.get("task", None)
122
+ task_name = f"[Task-name: {task.get_name()}]" if task else ""
123
+ exception: Exception | None = context.get("exception", None)
124
+ log.exception(f"Caught exception: {context['message']} {task_name}", exc_info=exception)
125
+
126
+ # Most exceptions in the loop happen during shutdown. If that's not the case,
127
+ # be on the safe side and shut everything down.
128
+ self._shutdown_on_exception(exception)