iddqueue 0.13.0rc1__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.
- iddqueue/__init__.py +15 -0
- iddqueue/broker.py +667 -0
- iddqueue/cancellation.py +51 -0
- iddqueue/cancellation.sql +6 -0
- iddqueue/cli.py +474 -0
- iddqueue/control.py +87 -0
- iddqueue/control.sql +6 -0
- iddqueue/coordination.sql +11 -0
- iddqueue/deduplication.sql +12 -0
- iddqueue/failures.py +18 -0
- iddqueue/history.py +70 -0
- iddqueue/history.sql +13 -0
- iddqueue/metrics.py +68 -0
- iddqueue/rate_limits.py +122 -0
- iddqueue/results.py +118 -0
- iddqueue/scheduler.py +75 -0
- iddqueue/scheduler.sql +9 -0
- iddqueue/schema.py +55 -0
- iddqueue/schema.sql +31 -0
- iddqueue/utils.py +206 -0
- iddqueue-0.13.0rc1.dist-info/METADATA +730 -0
- iddqueue-0.13.0rc1.dist-info/RECORD +25 -0
- iddqueue-0.13.0rc1.dist-info/WHEEL +4 -0
- iddqueue-0.13.0rc1.dist-info/entry_points.txt +3 -0
- iddqueue-0.13.0rc1.dist-info/licenses/LICENSE +19 -0
iddqueue/utils.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from contextlib import ExitStack, contextmanager
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
from urllib.parse import parse_qsl, urlparse
|
|
7
|
+
|
|
8
|
+
import tenacity
|
|
9
|
+
from dramatiq import Message, MessageProxy, get_encoder
|
|
10
|
+
from dramatiq.errors import BrokerConnectionError
|
|
11
|
+
from psycopg import InterfaceError, OperationalError, sql
|
|
12
|
+
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
|
13
|
+
from psycopg.errors import AdminShutdown
|
|
14
|
+
from psycopg_pool import ConnectionPool
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DISCONNECT_ERRORS = (
|
|
20
|
+
AdminShutdown,
|
|
21
|
+
InterfaceError,
|
|
22
|
+
OperationalError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
retry_pg = tenacity.retry(
|
|
27
|
+
retry=tenacity.retry_if_exception_type(
|
|
28
|
+
DISCONNECT_ERRORS + (BrokerConnectionError,)
|
|
29
|
+
),
|
|
30
|
+
reraise=True,
|
|
31
|
+
wait=tenacity.wait_random_exponential(multiplier=1, max=30),
|
|
32
|
+
stop=tenacity.stop_after_attempt(10),
|
|
33
|
+
before_sleep=tenacity.before_sleep_log(logger, logging.INFO),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_conn(conn):
|
|
38
|
+
try:
|
|
39
|
+
ConnectionPool.check_connection(conn)
|
|
40
|
+
except DISCONNECT_ERRORS as e:
|
|
41
|
+
if not conn.closed:
|
|
42
|
+
logger.debug("Closing connexion due to error: %s", e)
|
|
43
|
+
try:
|
|
44
|
+
conn.close()
|
|
45
|
+
except Exception as close_e:
|
|
46
|
+
logger.debug("Failed to close connexion: %s", close_e)
|
|
47
|
+
raise BrokerConnectionError(str(e)) from None
|
|
48
|
+
return conn
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@retry_pg
|
|
52
|
+
def getconn(pool):
|
|
53
|
+
# Get a reliable connection to Postgres.
|
|
54
|
+
if pool.closed:
|
|
55
|
+
pool.open()
|
|
56
|
+
conn = pool.getconn()
|
|
57
|
+
try:
|
|
58
|
+
check_conn(conn)
|
|
59
|
+
except BrokerConnectionError:
|
|
60
|
+
pool.putconn(conn)
|
|
61
|
+
raise # Let tenacity control retry.
|
|
62
|
+
return conn
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def make_pool(url, maxconn=16):
|
|
66
|
+
if isinstance(url, str):
|
|
67
|
+
if "://" in url:
|
|
68
|
+
parts = urlparse(url)
|
|
69
|
+
kwargs = dict(parse_qsl(parts.query))
|
|
70
|
+
conninfo = url.split("?", 1)[0]
|
|
71
|
+
pool_options = {
|
|
72
|
+
key: kwargs.pop(key) for key in ("minconn", "maxconn") if key in kwargs
|
|
73
|
+
}
|
|
74
|
+
kwargs = conninfo_to_dict(conninfo, **kwargs)
|
|
75
|
+
kwargs.update(pool_options)
|
|
76
|
+
else:
|
|
77
|
+
kwargs = conninfo_to_dict(url)
|
|
78
|
+
else:
|
|
79
|
+
kwargs = dict(url)
|
|
80
|
+
|
|
81
|
+
maxconn = int(kwargs.pop("maxconn", maxconn))
|
|
82
|
+
minconn = int(kwargs.pop("minconn", 0))
|
|
83
|
+
kwargs.setdefault("application_name", "iddqueue")
|
|
84
|
+
kwargs.setdefault("keepalives", "1")
|
|
85
|
+
kwargs.setdefault("keepalives_count", "2")
|
|
86
|
+
kwargs.setdefault("keepalives_idle", "5")
|
|
87
|
+
kwargs.setdefault("keepalives_interval", "2")
|
|
88
|
+
return ConnectionPool(
|
|
89
|
+
make_conninfo(**kwargs),
|
|
90
|
+
min_size=minconn,
|
|
91
|
+
max_size=maxconn,
|
|
92
|
+
kwargs={"autocommit": True},
|
|
93
|
+
open=False,
|
|
94
|
+
check=ConnectionPool.check_connection,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def raise_connection_error(fn):
|
|
99
|
+
# Raises Dramatiq connection error on Psycopg error
|
|
100
|
+
|
|
101
|
+
@functools.wraps(fn)
|
|
102
|
+
def wrapper(*a, **kw):
|
|
103
|
+
try:
|
|
104
|
+
return fn(*a, **kw)
|
|
105
|
+
except DISCONNECT_ERRORS as e:
|
|
106
|
+
raise BrokerConnectionError(str(e))
|
|
107
|
+
|
|
108
|
+
return wrapper
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def quote_ident(raw):
|
|
112
|
+
# Quote an SQL identifier, free from a connection object.
|
|
113
|
+
return '"%s"' % raw.replace('"', '""')
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def unlisten_all(conn):
|
|
117
|
+
if not conn.closed:
|
|
118
|
+
try:
|
|
119
|
+
with conn.cursor() as cur:
|
|
120
|
+
cur.execute("UNLISTEN *")
|
|
121
|
+
# Discard notifications buffered before UNLISTEN.
|
|
122
|
+
list(conn.notifies(timeout=0))
|
|
123
|
+
except DISCONNECT_ERRORS:
|
|
124
|
+
conn.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@contextmanager
|
|
128
|
+
def transaction(conn_or_pool, listen=None):
|
|
129
|
+
with ExitStack() as defer:
|
|
130
|
+
if hasattr(conn_or_pool, "getconn"):
|
|
131
|
+
conn = getconn(conn_or_pool)
|
|
132
|
+
defer.callback(conn_or_pool.putconn, conn)
|
|
133
|
+
else:
|
|
134
|
+
conn = conn_or_pool
|
|
135
|
+
|
|
136
|
+
if listen:
|
|
137
|
+
autocommit = conn.autocommit
|
|
138
|
+
conn.autocommit = True
|
|
139
|
+
try:
|
|
140
|
+
with conn.cursor() as curs:
|
|
141
|
+
curs.execute(sql.SQL("LISTEN {}").format(sql.Identifier(listen)))
|
|
142
|
+
try:
|
|
143
|
+
yield curs
|
|
144
|
+
finally:
|
|
145
|
+
unlisten_all(conn)
|
|
146
|
+
finally:
|
|
147
|
+
if not conn.closed:
|
|
148
|
+
conn.autocommit = autocommit
|
|
149
|
+
else:
|
|
150
|
+
with conn.transaction(), conn.cursor() as curs:
|
|
151
|
+
yield curs
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def wait_for_notifies(conn, timeout=1):
|
|
155
|
+
# Receive one batch without waiting for the entire timeout after a notify.
|
|
156
|
+
return list(conn.notifies(timeout=timeout, stop_after=1))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def storage_namespace(schema="dramatiq", prefix=""):
|
|
161
|
+
# PostgreSQL identifiers cannot contain NUL, so components cannot overlap.
|
|
162
|
+
return schema + "\0" + prefix + "\0"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def notification_channel(key, event, *, schema="dramatiq", prefix=""):
|
|
166
|
+
legacy = f"dramatiq.{key}.{event}"
|
|
167
|
+
if schema == "dramatiq" and not prefix and len(legacy.encode()) <= 63:
|
|
168
|
+
return legacy
|
|
169
|
+
digest = sha256((storage_namespace(schema, prefix) + str(key) + "\0" + event).encode()).hexdigest()
|
|
170
|
+
return "dpg." + digest[:48]
|
|
171
|
+
|
|
172
|
+
class QueryManager:
|
|
173
|
+
def __init__(self, queries, schema="dramatiq", prefix=""):
|
|
174
|
+
self.queries = queries
|
|
175
|
+
self.schema = schema
|
|
176
|
+
self.prefix = prefix
|
|
177
|
+
self.build_queries(schema, prefix)
|
|
178
|
+
|
|
179
|
+
def channel(self, key, event):
|
|
180
|
+
return notification_channel(key, event, schema=self.schema, prefix=self.prefix)
|
|
181
|
+
|
|
182
|
+
def build_queries(self, schema=None, prefix=None):
|
|
183
|
+
schema = self.schema if schema is None else schema
|
|
184
|
+
prefix = self.prefix if prefix is None else prefix
|
|
185
|
+
self.schema, self.prefix = schema, prefix
|
|
186
|
+
|
|
187
|
+
for name, query in self.queries.items():
|
|
188
|
+
setattr(
|
|
189
|
+
self,
|
|
190
|
+
name,
|
|
191
|
+
query.format(
|
|
192
|
+
schema=quote_ident(schema),
|
|
193
|
+
tablename=quote_ident(prefix + "queue"),
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def tidy4json(data):
|
|
199
|
+
if isinstance(data, (Message, MessageProxy)):
|
|
200
|
+
# Translate python data into decoded json.
|
|
201
|
+
# Encode message using Dramatiq encoder. But immediatly decode it as
|
|
202
|
+
# standard json to send native json to PostgreSQL.
|
|
203
|
+
# e.g. date formating problem
|
|
204
|
+
return json.loads(data.encode())
|
|
205
|
+
else:
|
|
206
|
+
return json.loads(get_encoder().encode(data))
|