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.
@@ -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
+