workercommon 0.4.1__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.
workercommon/worker.py ADDED
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+
4
+ if sys.version_info < (3, 8):
5
+ raise RuntimeError("At least Python 3.8 is required")
6
+
7
+ from pathlib import Path
8
+ from .database import Connection, Cursor
9
+ from . import rabbitmqueue
10
+ import json, re, pika.connection, pika.channel, pika.spec, logging
11
+ from collections.abc import Mapping
12
+ from time import sleep
13
+ from threading import Thread, Lock
14
+ from typing import Iterable, Optional, Any, Callable, List
15
+ from multiprocessing import Process, Manager, Event
16
+ from multiprocessing.managers import SyncManager
17
+ from concurrent.futures import ThreadPoolExecutor
18
+
19
+
20
+ class BaseWorker(object):
21
+ def start(self) -> None:
22
+ raise NotImplementedError
23
+
24
+ def stop(self) -> None:
25
+ raise NotImplementedError
26
+
27
+ def join(self, timeout: Optional[float] = None) -> None:
28
+ raise NotImplementedError
29
+
30
+
31
+ class ThreadWorker(BaseWorker, Thread):
32
+ def __init__(self):
33
+ BaseWorker.__init__(self)
34
+ Thread.__init__(self)
35
+
36
+ def start(self) -> None:
37
+ Thread.start(self)
38
+
39
+ def join(self, timeout: Optional[float] = None) -> None:
40
+ Thread.join(self, timeout)
41
+
42
+
43
+ class ReaderWorker(ThreadWorker):
44
+ def __init__(
45
+ self,
46
+ database_source: Callable[[], Connection],
47
+ readq_parameters: rabbitmqueue.Parameters,
48
+ any_exchange: bool = False,
49
+ **readq_arguments: Any,
50
+ ):
51
+ super().__init__()
52
+ self.lock = Lock()
53
+ self.readq_parameters = readq_parameters
54
+ self.readq_arguments = readq_arguments
55
+ self.readq: Optional[rabbitmqueue.ReceiveSendQueue] = None
56
+
57
+ self.database_source: Optional[Callable[[], Connection]] = database_source
58
+ self.database: Optional[Connection] = None
59
+ self.restart = True
60
+ self.outbox: List[rabbitmqueue.ToSend] = []
61
+ self.any_exchange = any_exchange
62
+
63
+ self.callback_pool_lock = Lock()
64
+ self.callback_pool: Optional[ThreadPoolExecutor] = None
65
+
66
+ def get_readq(self) -> rabbitmqueue.ReceiveSendQueue:
67
+ if self.readq is None:
68
+ raise RuntimeError("Read rabbitmqueue is not available")
69
+ return self.readq
70
+
71
+ def on_start(self) -> None:
72
+ pass
73
+
74
+ def on_stop(self) -> None:
75
+ pass
76
+
77
+ def run(self) -> None:
78
+ try:
79
+ with self.callback_pool_lock:
80
+ self.callback_pool = ThreadPoolExecutor(
81
+ max_workers=1, thread_name_prefix="reader-callback"
82
+ )
83
+
84
+ self.on_start()
85
+
86
+ while self.restart:
87
+ with self.lock:
88
+ self.restart = False
89
+ self.readq = rabbitmqueue.ReceiveSendQueue(
90
+ self.readq_parameters,
91
+ self.callback,
92
+ self.maybe_restart,
93
+ **self.readq_arguments,
94
+ )
95
+ if self.outbox:
96
+ with self.lock:
97
+ if self.readq is not None:
98
+ self.readq.add_to_outbox(self.outbox)
99
+ self.outbox.clear()
100
+
101
+ try:
102
+ self.get_readq().run()
103
+ except BaseException:
104
+ logging.exception("Lost the queue connection")
105
+
106
+ with self.lock:
107
+ if self.readq is not None:
108
+ self.outbox.extend(self.readq.get_outbox())
109
+
110
+ with self.lock:
111
+ restart = self.restart
112
+
113
+ if restart:
114
+ logging.info("Trying to start a new one")
115
+ with self.lock:
116
+ if self.readq is not None:
117
+ try:
118
+ self.readq.close()
119
+ except BaseException:
120
+ logging.exception("Failed to close existing readq")
121
+ finally:
122
+ self.readq = None
123
+
124
+ elif self.outbox:
125
+ logging.info(f"Trying to send outbox: {self.outbox}")
126
+ rabbitmqueue.SendQueue.quick_send(self.readq_parameters, self.outbox)
127
+
128
+ finally:
129
+ try:
130
+ self.on_stop()
131
+ finally:
132
+ self.close()
133
+
134
+ def maybe_restart(self, restart: bool, remaining: List[rabbitmqueue.ToSend]) -> None:
135
+ with self.lock:
136
+ self.restart = restart
137
+ self.outbox.extend(remaining)
138
+
139
+ def close(self) -> None:
140
+ with self.callback_pool_lock:
141
+ if self.callback_pool is not None:
142
+ self.callback_pool.shutdown()
143
+ self.callback_pool = None
144
+
145
+ if self.database_source is not None:
146
+ self.database_source = None
147
+
148
+ if self.database is not None:
149
+ self.database.close()
150
+ self.database = None
151
+
152
+ def get_cursor(self) -> Cursor:
153
+ if self.database is None:
154
+ if self.database_source is None:
155
+ raise RuntimeError("Database source is unavailable")
156
+ self.database = self.database_source()
157
+
158
+ return self.database.cursor()
159
+
160
+ def get_callback_pool(self) -> ThreadPoolExecutor:
161
+ with self.callback_pool_lock:
162
+ if self.callback_pool is None:
163
+ raise RuntimeError("callback_pool is not available")
164
+
165
+ return self.callback_pool
166
+
167
+ def allow_properties(self, properties: pika.spec.BasicProperties) -> bool:
168
+ return True
169
+
170
+ def ack(self, channel: pika.channel.Channel, delivery_tag: int) -> None:
171
+ logging.debug(f"ACKing {delivery_tag} in {channel}")
172
+ channel.basic_ack(delivery_tag)
173
+
174
+ def nack(self, channel: pika.channel.Channel, delivery_tag: int) -> None:
175
+ logging.debug(f"NACKing {delivery_tag} in {channel}")
176
+ channel.basic_nack(delivery_tag)
177
+
178
+ def callback_job(
179
+ self,
180
+ ch: pika.channel.Channel,
181
+ method: pika.spec.Basic.Deliver,
182
+ properties: pika.spec.BasicProperties,
183
+ body: str,
184
+ ) -> None:
185
+ "Run within a background thread"
186
+ try:
187
+ if not self.allow_properties(properties):
188
+ logging.debug(f"Not handling due to properties: {properties}")
189
+ return
190
+
191
+ root = json.loads(body)
192
+ filtered = self.filter_message(root)
193
+
194
+ if filtered is not None:
195
+ with self.get_cursor() as cursor:
196
+ self.handle_message(cursor, filtered)
197
+
198
+ logging.debug(f"Successfully handled {method.delivery_tag}")
199
+ self.get_readq().add_callback_threadsafe(
200
+ lambda: self.ack(ch, method.delivery_tag)
201
+ )
202
+
203
+ except BaseException as e:
204
+ logging.exception(f"Failed to handle message: {e}")
205
+ try:
206
+ self.get_readq().add_callback_threadsafe(
207
+ lambda: self.nack(ch, method.delivery_tag)
208
+ )
209
+ except BaseException:
210
+ logging.exception(f"Failed to NACK {method.delivery_tag} in {ch}")
211
+
212
+ def callback(
213
+ self,
214
+ ch: pika.channel.Channel,
215
+ method: pika.spec.Basic.Deliver,
216
+ properties: pika.spec.BasicProperties,
217
+ body: str,
218
+ ) -> None:
219
+ if self.readq_parameters.exchange:
220
+ if (
221
+ not self.any_exchange
222
+ and method.exchange != self.readq_parameters.exchange
223
+ ):
224
+ return
225
+
226
+ try:
227
+ self.get_callback_pool().submit(
228
+ self.callback_job, ch, method, properties, body
229
+ )
230
+
231
+ except BaseException as e:
232
+ logging.exception(f"Failed to submit message to callback pool: {e}")
233
+ try:
234
+ self.nack(ch, method.delivery_tag)
235
+ except BaseException:
236
+ logging.exception(f"Failed to NACK {method.delivery_tag} in {ch}")
237
+
238
+ def filter_message(self, message: Any) -> Optional[Any]:
239
+ # Return value will be passed on to handle_message(), but only if isn't None.
240
+ raise NotImplementedError
241
+
242
+ def handle_message(self, cursor: Cursor, message: Any) -> None:
243
+ raise NotImplementedError
244
+
245
+ def stop(self) -> None:
246
+ with self.lock:
247
+ if self.readq is not None:
248
+ self.readq.close()
249
+ self.outbox.extend(self.readq.get_outbox())
250
+ self.readq = None
251
+
252
+
253
+ class ReaderWorkerWithRetryLimit(ReaderWorker):
254
+ DEATH_HEADER = "x-death"
255
+ COUNT = "count"
256
+ REASON = "reason"
257
+ EXPIRED = "expired"
258
+
259
+ def __init__(
260
+ self,
261
+ database_source: Callable[[], Connection],
262
+ readq_parameters: rabbitmqueue.Parameters,
263
+ retry_limit: int,
264
+ any_exchange: bool = False,
265
+ **readq_arguments: Any,
266
+ ):
267
+ if retry_limit < 1:
268
+ raise ValueError(f"Invalid retry limit: {retry_limit}")
269
+
270
+ super().__init__(
271
+ database_source,
272
+ readq_parameters,
273
+ any_exchange,
274
+ **readq_arguments,
275
+ )
276
+ self.retry_limit = retry_limit
277
+
278
+ def nack(self, channel: pika.channel.Channel, delivery_tag: int) -> None:
279
+ logging.debug(f"NACKing {delivery_tag} in {channel}")
280
+ channel.basic_nack(delivery_tag, requeue=False)
281
+
282
+ @classmethod
283
+ def _get_expiration_count(cls, death: Iterable[Mapping[str, Any]]) -> Optional[int]:
284
+ for entry in death:
285
+ if (
286
+ entry.get(cls.REASON) == cls.EXPIRED and
287
+ (count := entry.get(cls.COUNT)) is not None
288
+ ):
289
+ return count
290
+ return None
291
+
292
+ def allow_properties(self, properties: pika.spec.BasicProperties) -> bool:
293
+ if not properties.headers:
294
+ return True
295
+
296
+ if (death := properties.headers.get(self.DEATH_HEADER)) and isinstance(death, list):
297
+ if (
298
+ (count := self._get_expiration_count(death)) is not None and
299
+ count > self.retry_limit
300
+ ):
301
+ logging.warning(f"Giving up after {count} attempts")
302
+ return False
303
+
304
+ return True
305
+
306
+
307
+ class BaseWorkerManager(Process):
308
+ def stop(self) -> None:
309
+ raise NotImplementedError
310
+
311
+
312
+ class WorkerManager(BaseWorkerManager):
313
+ def __init__(
314
+ self,
315
+ manager: SyncManager,
316
+ worker_source: Callable[[BaseWorkerManager], BaseWorker],
317
+ daemon: bool = True,
318
+ ):
319
+ super().__init__(daemon=daemon)
320
+ self.manager = manager
321
+ self.kill_event = manager.Event()
322
+ self.worker_source = worker_source
323
+ self.worker: Optional[BaseWorker] = None
324
+
325
+ def run(self) -> None:
326
+ self.worker = self.worker_source(self)
327
+ if self.worker is None:
328
+ raise RuntimeError("Failed to create worker")
329
+
330
+ try:
331
+ self.worker.start()
332
+ while True:
333
+ try:
334
+ if self.kill_event.wait():
335
+ break
336
+ except KeyboardInterrupt:
337
+ continue
338
+
339
+ finally:
340
+ try:
341
+ self.worker.stop()
342
+ self.worker.join()
343
+ finally:
344
+ self.worker = None
345
+
346
+ def stop(self) -> None:
347
+ self.kill_event.set()
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: workercommon
3
+ Version: 0.4.1
4
+ Summary: Support code for various projects
5
+ Author-email: "Neil E. Hodges" <47hasbegun@gmail.com>
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Repository, https://gitgud.io/takeshitakenji/workercommon
8
+ Project-URL: Issues, https://gitgud.io/takeshitakenji/workercommon/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE.TXT
16
+ Requires-Dist: psycopg2
17
+ Requires-Dist: pika
18
+ Dynamic: license-file
19
+
20
+ # Requirements
21
+
22
+ 1. Python 3.8
23
+ 2. A Unix-like OS for `fcntl` support
24
+ 3. [psycopg2](https://www.psycopg.org/)
25
+ 4. [Pika](https://pika.readthedocs.io/en/stable/)
@@ -0,0 +1,19 @@
1
+ workercommon/__init__.py,sha256=fACYRNZj4tmBo_b4y3bXOxuh9Z8RAGOdyYfsB3du5SA,214
2
+ workercommon/background.py,sha256=GnxekfqTdCwJxEYWs4CRjw8wX8wil6NMSsOBdprC9Tk,2315
3
+ workercommon/commands.py,sha256=nh_IFldPdoJARZfuu25Ek386FjvJ29o8jFrBCk-cAxw,1221
4
+ workercommon/config.py,sha256=IRoXNz-XxoRY4uTAM8WgXBtGu4uCwuGQ-nn4W8q_QDw,2635
5
+ workercommon/connectionpool.py,sha256=Me9ZG7_01WsJS7y4NI8hn9hDtp7NjUCiXN8IGsDHoug,2944
6
+ workercommon/locking.py,sha256=XzscyiQfC0H81u3PHvYI-MbtQgGeeGO4W0QHfTmOlZk,1539
7
+ workercommon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ workercommon/rabbitmqueue.py,sha256=QG0QhwjGkDfXLJgAka0q5I0211EvVBE6fLjjr4RKVFM,14916
9
+ workercommon/test.py,sha256=q9xVCke9V_aw7lvcFrYpM8M--pSgVAW-WVvXmuhnrEs,6014
10
+ workercommon/worker.py,sha256=ZW3_A_Rrp-yIuO930QbFDpW6z21Z1EH24UH-dHGDQcw,11226
11
+ workercommon/database/__init__.py,sha256=gIRWSp1G7uViNiH0M6k4qJ0U8zgyXZAB4-0Ez5EBS1Y,305
12
+ workercommon/database/base.py,sha256=q4Pj1IsBY9VVgTMgq7C8MPUtMhIIH9pqVNsky3GmlGQ,5237
13
+ workercommon/database/pg.py,sha256=YxAVg_ed6a8MUSauRUfTi-k3HxeDWvF1vU7zvk1xQGY,8802
14
+ workercommon/database/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ workercommon-0.4.1.dist-info/licenses/LICENSE.TXT,sha256=OAxGb1o7fGlNe_idAAo-h19FOhyzCyMknW9jzKBFhec,1267
16
+ workercommon-0.4.1.dist-info/METADATA,sha256=MxFVJ0vRtx4Bdt-b0P8rMluRpPvg5JYq7s4TrElkGrY,857
17
+ workercommon-0.4.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
18
+ workercommon-0.4.1.dist-info/top_level.txt,sha256=8Gvm4g09owW_DX-sx4TJmGsfuGj39DEr3Ovpy-jnhr0,13
19
+ workercommon-0.4.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,9 @@
1
+ Copyright 2020 Neil E. Hodges
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ workercommon