morp 7.0.0__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.
- morp/__init__.py +23 -0
- morp/compat.py +4 -0
- morp/config.py +173 -0
- morp/exception.py +35 -0
- morp/interface/__init__.py +81 -0
- morp/interface/base.py +441 -0
- morp/interface/dropfile.py +160 -0
- morp/interface/postgres.py +368 -0
- morp/interface/sqs.py +396 -0
- morp/message.py +354 -0
- morp-7.0.0.dist-info/METADATA +212 -0
- morp-7.0.0.dist-info/RECORD +15 -0
- morp-7.0.0.dist-info/WHEEL +5 -0
- morp-7.0.0.dist-info/licenses/LICENSE.txt +21 -0
- morp-7.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager, AbstractAsyncContextManager
|
|
2
|
+
import time
|
|
3
|
+
import select
|
|
4
|
+
|
|
5
|
+
import psycopg
|
|
6
|
+
from psycopg_pool import ConnectionPool
|
|
7
|
+
from datatypes import Datetime, Enum
|
|
8
|
+
|
|
9
|
+
from ..compat import *
|
|
10
|
+
from .base import Interface
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Postgres(Interface):
|
|
14
|
+
"""PostgreSQL interface using "FOR UPDATE SKIP LOCKED" to treat postgres
|
|
15
|
+
tables like a queue
|
|
16
|
+
|
|
17
|
+
https://github.com/Jaymon/morp/issues/18
|
|
18
|
+
"""
|
|
19
|
+
_connection = None
|
|
20
|
+
"""Will hold the postgres connection
|
|
21
|
+
|
|
22
|
+
https://www.psycopg.org/psycopg3/docs/api/connections.html
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
class Status(Enum):
|
|
26
|
+
"""The values for the status field in each queue table
|
|
27
|
+
"""
|
|
28
|
+
NEW = 1
|
|
29
|
+
"""Rows will be first inserted into the table using this value"""
|
|
30
|
+
|
|
31
|
+
PROCESSING = 2
|
|
32
|
+
"""If a row has been pulled out for processing it will have this value,
|
|
33
|
+
if it doesn't have this value then it can be pulled out of the queue
|
|
34
|
+
for processing and its status field will be updated to this value"""
|
|
35
|
+
|
|
36
|
+
RELEASED = 3
|
|
37
|
+
"""If a message was manually released it will contain this value and
|
|
38
|
+
count will be incremented"""
|
|
39
|
+
|
|
40
|
+
@asynccontextmanager
|
|
41
|
+
async def cursor(self, name, connection, **kwargs):
|
|
42
|
+
"""Return a connection cursor
|
|
43
|
+
|
|
44
|
+
https://www.psycopg.org/psycopg3/docs/api/cursors.html
|
|
45
|
+
|
|
46
|
+
:param name: str, the queue name
|
|
47
|
+
:param connection: psycopg.Connection, the postgres connection
|
|
48
|
+
:param **kwargs:
|
|
49
|
+
- transaction: bool, if True start a tx
|
|
50
|
+
returns: psycopg.Cursor
|
|
51
|
+
"""
|
|
52
|
+
cursor = None
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
cursor = connection.cursor()
|
|
56
|
+
|
|
57
|
+
if kwargs.get("transaction", False):
|
|
58
|
+
async with connection.transaction():
|
|
59
|
+
yield cursor
|
|
60
|
+
|
|
61
|
+
else:
|
|
62
|
+
yield cursor
|
|
63
|
+
|
|
64
|
+
finally:
|
|
65
|
+
if cursor is not None:
|
|
66
|
+
await cursor.close()
|
|
67
|
+
|
|
68
|
+
async def _connect(self, connection_config):
|
|
69
|
+
"""Connect to the db
|
|
70
|
+
|
|
71
|
+
https://www.psycopg.org/psycopg3/docs/api/connections.html
|
|
72
|
+
"""
|
|
73
|
+
self._connection = await psycopg.AsyncConnection.connect(
|
|
74
|
+
dbname=connection_config.path.lstrip("/"),
|
|
75
|
+
user=connection_config.username,
|
|
76
|
+
password=connection_config.password,
|
|
77
|
+
host=connection_config.hosts[0][0],
|
|
78
|
+
port=connection_config.hosts[0][1],
|
|
79
|
+
row_factory=psycopg.rows.dict_row,
|
|
80
|
+
# https://www.psycopg.org/psycopg3/docs/basic/transactions.html#autocommit-transactions
|
|
81
|
+
autocommit=True,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def _get_connection(self):
|
|
85
|
+
return self._connection
|
|
86
|
+
|
|
87
|
+
async def _close(self):
|
|
88
|
+
await self._connection.close()
|
|
89
|
+
self._connection = None
|
|
90
|
+
|
|
91
|
+
def _render_sql(self, rows, *names):
|
|
92
|
+
"""Given a list of rows and names turn that into valid sql
|
|
93
|
+
|
|
94
|
+
Internal method, used to make wrapping names and joining rows of a query
|
|
95
|
+
a bit easier
|
|
96
|
+
|
|
97
|
+
:param rows: list[str]|str, if a list then all the rows will be joined
|
|
98
|
+
with a newline
|
|
99
|
+
:param *names: str, one or more values to be wrapped in quotations and
|
|
100
|
+
formatted into the string at {} locations
|
|
101
|
+
:returns: str, the SQL
|
|
102
|
+
"""
|
|
103
|
+
if not isinstance(rows, str):
|
|
104
|
+
rows = "\n".join(rows)
|
|
105
|
+
|
|
106
|
+
return rows.format(*map(lambda n: f"\"{n}\"", names))
|
|
107
|
+
|
|
108
|
+
def _render_pubsub_name(self, name):
|
|
109
|
+
"""The LISTEN/NOTIFY name that ._send and ._recv uses"""
|
|
110
|
+
return f"{name}_notify"
|
|
111
|
+
|
|
112
|
+
def _render_index_name(self, name):
|
|
113
|
+
"""The name of the table index"""
|
|
114
|
+
return f"{name}_index"
|
|
115
|
+
|
|
116
|
+
async def _create_table(self, name, connection):
|
|
117
|
+
"""Internal method that will create the queue table named `name` if it
|
|
118
|
+
doesn't already exist
|
|
119
|
+
|
|
120
|
+
:param name: str, the queue name
|
|
121
|
+
:param connection: psycopg.Connection
|
|
122
|
+
"""
|
|
123
|
+
sqls = [
|
|
124
|
+
self._render_sql(
|
|
125
|
+
[
|
|
126
|
+
"CREATE TABLE IF NOT EXISTS {} (",
|
|
127
|
+
# https://www.postgresql.org/docs/current/functions-uuid.html
|
|
128
|
+
# after postgres 13 gen_random_uuid() is builtin
|
|
129
|
+
" _id UUID DEFAULT gen_random_uuid(),",
|
|
130
|
+
" body BYTEA,",
|
|
131
|
+
" status INTEGER,",
|
|
132
|
+
" count INTEGER DEFAULT 1,",
|
|
133
|
+
" valid TIMESTAMPTZ,",
|
|
134
|
+
" _created TIMESTAMPTZ,",
|
|
135
|
+
" _updated TIMESTAMPTZ",
|
|
136
|
+
")",
|
|
137
|
+
],
|
|
138
|
+
name
|
|
139
|
+
),
|
|
140
|
+
self._render_sql(
|
|
141
|
+
[
|
|
142
|
+
"CREATE INDEX IF NOT EXISTS {} ON {} (",
|
|
143
|
+
" valid,",
|
|
144
|
+
" status,",
|
|
145
|
+
" _created",
|
|
146
|
+
")",
|
|
147
|
+
],
|
|
148
|
+
self._render_index_name(name),
|
|
149
|
+
name
|
|
150
|
+
)
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
async with connection.transaction():
|
|
154
|
+
async with self.cursor(name, connection) as cursor:
|
|
155
|
+
for sql in sqls:
|
|
156
|
+
await cursor.execute(sql)
|
|
157
|
+
|
|
158
|
+
async def _send(self, name, connection, body, **kwargs):
|
|
159
|
+
now = valid = Datetime()
|
|
160
|
+
if delay_seconds := kwargs.get('delay_seconds', 0):
|
|
161
|
+
valid += delay_seconds
|
|
162
|
+
|
|
163
|
+
sql = self._render_sql(
|
|
164
|
+
[
|
|
165
|
+
"INSERT INTO {}",
|
|
166
|
+
" (body, status, valid, _created, _updated)",
|
|
167
|
+
"VALUES",
|
|
168
|
+
" (%s, %s, %s, %s, %s)",
|
|
169
|
+
"RETURNING _id",
|
|
170
|
+
],
|
|
171
|
+
name
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
sql_vars = [
|
|
175
|
+
body,
|
|
176
|
+
self.Status.NEW.value,
|
|
177
|
+
valid,
|
|
178
|
+
now,
|
|
179
|
+
now
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
async with self.cursor(name, connection) as cursor:
|
|
184
|
+
await cursor.execute(sql, sql_vars)
|
|
185
|
+
d = await cursor.fetchone()
|
|
186
|
+
|
|
187
|
+
# https://www.postgresql.org/docs/current/sql-notify.html
|
|
188
|
+
await cursor.execute(self._render_sql(
|
|
189
|
+
"NOTIFY {}",
|
|
190
|
+
self._render_pubsub_name(name)
|
|
191
|
+
))
|
|
192
|
+
|
|
193
|
+
return d["_id"], sql_vars
|
|
194
|
+
|
|
195
|
+
except psycopg.errors.UndefinedTable as e:
|
|
196
|
+
await self._create_table(name, connection)
|
|
197
|
+
return await self._send(name, connection, body, **kwargs)
|
|
198
|
+
|
|
199
|
+
async def _count(self, name, connection, **kwargs):
|
|
200
|
+
sql = self._render_sql("SELECT count(*) FROM {}", name)
|
|
201
|
+
|
|
202
|
+
async with self.cursor(name, connection) as cursor:
|
|
203
|
+
await cursor.execute(sql)
|
|
204
|
+
d = await cursor.fetchone()
|
|
205
|
+
return d["count"]
|
|
206
|
+
|
|
207
|
+
def _recv_to_fields(self, _id, body, raw):
|
|
208
|
+
fields = super()._recv_to_fields(_id, body, raw)
|
|
209
|
+
fields["_count"] = int(raw["count"])
|
|
210
|
+
return fields
|
|
211
|
+
|
|
212
|
+
async def _get_raw(self, name, connection):
|
|
213
|
+
"""Try and grab a row from the db queue
|
|
214
|
+
|
|
215
|
+
Internal method. This is broken out from ._recv because ._recv will
|
|
216
|
+
first try and get a row and if that fails it will subscribe to the
|
|
217
|
+
postgres pubsub until timeout expires. If it gets a hit on pubsub it
|
|
218
|
+
will call this method again looking for a matching row
|
|
219
|
+
|
|
220
|
+
:param name: str, the queue name
|
|
221
|
+
:param connection: Connection
|
|
222
|
+
:returns: dict|None, the found row
|
|
223
|
+
"""
|
|
224
|
+
valid = Datetime()
|
|
225
|
+
# https://www.postgresql.org/docs/current/sql-select.html
|
|
226
|
+
sql = self._render_sql(
|
|
227
|
+
[
|
|
228
|
+
"UPDATE {}",
|
|
229
|
+
"SET status = %s",
|
|
230
|
+
"WHERE _id = (",
|
|
231
|
+
" SELECT _id",
|
|
232
|
+
" FROM {}",
|
|
233
|
+
" WHERE valid <= %s",
|
|
234
|
+
" AND status != %s",
|
|
235
|
+
" ORDER BY _created ASC",
|
|
236
|
+
" FOR UPDATE SKIP LOCKED",
|
|
237
|
+
" LIMIT 1",
|
|
238
|
+
")",
|
|
239
|
+
"RETURNING",
|
|
240
|
+
" _id,",
|
|
241
|
+
" body,",
|
|
242
|
+
" status,",
|
|
243
|
+
" count,",
|
|
244
|
+
" valid,",
|
|
245
|
+
" _created,",
|
|
246
|
+
" _updated",
|
|
247
|
+
],
|
|
248
|
+
name,
|
|
249
|
+
name,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
sql_vars = [
|
|
253
|
+
self.Status.PROCESSING.value,
|
|
254
|
+
valid,
|
|
255
|
+
self.Status.PROCESSING.value,
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
# https://www.psycopg.org/psycopg3/docs/basic/transactions.html
|
|
260
|
+
async with connection.transaction():
|
|
261
|
+
async with self.cursor(name, connection) as cursor:
|
|
262
|
+
await cursor.execute(sql, sql_vars)
|
|
263
|
+
raw = await cursor.fetchone()
|
|
264
|
+
|
|
265
|
+
except psycopg.errors.UndefinedTable:
|
|
266
|
+
raw = None
|
|
267
|
+
|
|
268
|
+
return raw
|
|
269
|
+
|
|
270
|
+
async def _recv(self, name, connection, **kwargs):
|
|
271
|
+
_id = body = raw = None
|
|
272
|
+
timeout = kwargs.get('timeout', 0.0)
|
|
273
|
+
|
|
274
|
+
raw = await self._get_raw(name, connection)
|
|
275
|
+
if not raw:
|
|
276
|
+
async with self.cursor(name, connection) as cursor:
|
|
277
|
+
# https://www.postgresql.org/docs/current/sql-listen.html
|
|
278
|
+
await cursor.execute(self._render_sql(
|
|
279
|
+
"LISTEN {}",
|
|
280
|
+
self._render_pubsub_name(name)
|
|
281
|
+
))
|
|
282
|
+
|
|
283
|
+
# this answer https://stackoverflow.com/a/41649275 pointed me in
|
|
284
|
+
# the right direction on how to "consume" a message. I could've
|
|
285
|
+
# made this more complicated by wrapping it in a while loop and
|
|
286
|
+
# subtracting the elapsed time from timeout until it gets to zero
|
|
287
|
+
# since receiving the message is no guarrantee it will be able to
|
|
288
|
+
# consume the message, but it wouldn't have added much except make
|
|
289
|
+
# it technically more correct since other recv methods already
|
|
290
|
+
# check for None return values and re-call if no actual message was
|
|
291
|
+
# received.
|
|
292
|
+
#
|
|
293
|
+
# https://www.psycopg.org/docs/advanced.html#asynchronous-notifications
|
|
294
|
+
s = select.select([connection], [], [], timeout)
|
|
295
|
+
if s[0]:
|
|
296
|
+
raw = await self._get_raw(name, connection)
|
|
297
|
+
|
|
298
|
+
# this only works on psycopg 3.2+ which is still in development as
|
|
299
|
+
# of 2024-02-01
|
|
300
|
+
# https://www.psycopg.org/psycopg3/docs/api/objects.html#psycopg.Notify
|
|
301
|
+
# for notify in connection.notifies(timeout=timeout, stop_after=1):
|
|
302
|
+
# pout.v(notify)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if raw:
|
|
306
|
+
_id = raw["_id"]
|
|
307
|
+
body = raw["body"]
|
|
308
|
+
|
|
309
|
+
return _id, body, raw
|
|
310
|
+
|
|
311
|
+
async def _ack(self, name, connection, fields, **kwargs):
|
|
312
|
+
sql = self._render_sql("DELETE FROM {} WHERE _id = %s", name)
|
|
313
|
+
async with self.cursor(name, connection) as cursor:
|
|
314
|
+
await cursor.execute(sql, [fields["_id"]])
|
|
315
|
+
|
|
316
|
+
async def _release(self, name, connection, fields, **kwargs):
|
|
317
|
+
_updated = Datetime()
|
|
318
|
+
if delay_seconds := kwargs.get('delay_seconds', 0):
|
|
319
|
+
sql = self._render_sql(
|
|
320
|
+
[
|
|
321
|
+
"UPDATE {} SET",
|
|
322
|
+
" status = %s,",
|
|
323
|
+
" count = count + 1,",
|
|
324
|
+
" valid = %s,",
|
|
325
|
+
" _updated = %s",
|
|
326
|
+
"WHERE _id = %s",
|
|
327
|
+
],
|
|
328
|
+
name
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
sql_vars = [
|
|
332
|
+
self.Status.RELEASED.value,
|
|
333
|
+
_updated + delay_seconds,
|
|
334
|
+
_updated,
|
|
335
|
+
fields["_id"]
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
else:
|
|
339
|
+
sql = self._render_sql(
|
|
340
|
+
[
|
|
341
|
+
"UPDATE {} SET",
|
|
342
|
+
" status = %s,",
|
|
343
|
+
" count = count + 1,",
|
|
344
|
+
" _updated = %s",
|
|
345
|
+
"WHERE _id = %s",
|
|
346
|
+
],
|
|
347
|
+
name
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
sql_vars = [
|
|
351
|
+
self.Status.RELEASED.value,
|
|
352
|
+
_updated,
|
|
353
|
+
fields["_id"]
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
async with self.cursor(name, connection) as cursor:
|
|
357
|
+
await cursor.execute(sql, sql_vars)
|
|
358
|
+
|
|
359
|
+
async def _clear(self, name, connection, **kwargs):
|
|
360
|
+
sql = self._render_sql("DELETE FROM TABLE {} CASCADE", name)
|
|
361
|
+
async with self.cursor(name, connection) as cursor:
|
|
362
|
+
await cursor.execute(sql)
|
|
363
|
+
|
|
364
|
+
async def _delete(self, name, connection, **kwargs):
|
|
365
|
+
sql = self._render_sql("DROP TABLE IF EXISTS {} CASCADE", name)
|
|
366
|
+
async with self.cursor(name, connection) as cursor:
|
|
367
|
+
await cursor.execute(sql)
|
|
368
|
+
|