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/__init__.py +7 -0
- workercommon/background.py +72 -0
- workercommon/commands.py +42 -0
- workercommon/config.py +81 -0
- workercommon/connectionpool.py +97 -0
- workercommon/database/__init__.py +16 -0
- workercommon/database/base.py +173 -0
- workercommon/database/pg.py +278 -0
- workercommon/database/py.typed +0 -0
- workercommon/locking.py +52 -0
- workercommon/py.typed +0 -0
- workercommon/rabbitmqueue.py +455 -0
- workercommon/test.py +177 -0
- workercommon/worker.py +347 -0
- workercommon-0.4.1.dist-info/METADATA +25 -0
- workercommon-0.4.1.dist-info/RECORD +19 -0
- workercommon-0.4.1.dist-info/WHEEL +5 -0
- workercommon-0.4.1.dist-info/licenses/LICENSE.TXT +9 -0
- workercommon-0.4.1.dist-info/top_level.txt +1 -0
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,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
|