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,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,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)
|