py-app-runner 0.5.49.dev0__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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""The Redis driver, on streams.
|
|
2
|
+
|
|
3
|
+
Streams rather than lists because a list hands a job out and forgets it, so the worker that
|
|
4
|
+
popped one and then died took it with it. A consumer group keeps every delivered entry until
|
|
5
|
+
somebody acknowledges it, and XAUTOCLAIM hands one back once it has gone idle longer than the
|
|
6
|
+
visibility timeout - which is the same "a claim is a deadline, not a flag" the database
|
|
7
|
+
driver gets from `reserved_until`.
|
|
8
|
+
|
|
9
|
+
What it cannot do is the thing the database driver exists for: a push here is a write to a
|
|
10
|
+
second system, so it cannot join the transaction that caused it, and no durability setting
|
|
11
|
+
on the redis side changes that. Reach for this when the volume genuinely warrants it, or
|
|
12
|
+
when losing a job would be survivable.
|
|
13
|
+
|
|
14
|
+
Key layout:
|
|
15
|
+
|
|
16
|
+
{prefix}j:{id} hash the job; everything mutable lives here
|
|
17
|
+
{prefix}q:{queue}:s:{n} stream ids that are ready, one stream per priority level
|
|
18
|
+
{prefix}q:{queue}:levels zset which priorities that queue has streams for
|
|
19
|
+
{prefix}q:{queue}:delayed zset ids scored by the second they become due
|
|
20
|
+
{prefix}u:{key} string the job id holding a unique key
|
|
21
|
+
{prefix}failed zset ids scored by when they failed
|
|
22
|
+
{prefix}queues set queue names, so status can enumerate them
|
|
23
|
+
{prefix}seq string the id counter
|
|
24
|
+
|
|
25
|
+
A stream entry carries the job id and nothing else, because a stream entry cannot be edited.
|
|
26
|
+
The stream indexes what is ready; the hash is the job.
|
|
27
|
+
|
|
28
|
+
Needs Redis 6.2 or newer for XAUTOCLAIM. **Not cluster aware**: a job's keys span more than
|
|
29
|
+
one slot by design, and every script below addresses keys it builds itself rather than
|
|
30
|
+
declaring them, so a cluster would refuse them.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import datetime
|
|
34
|
+
import json
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from py_app_runner.queue.driver_pg import encode_payload
|
|
38
|
+
from py_app_runner.queue.job import Job, QueueError
|
|
39
|
+
|
|
40
|
+
# One consumer name for the whole fleet. Naming consumers per host and pid would leak a dead
|
|
41
|
+
# consumer entry on every deploy, and idle time is tracked per entry rather than per
|
|
42
|
+
# consumer, so sharing the name costs nothing. Who actually holds a job is in `reserved_by`.
|
|
43
|
+
CONSUMER = "shared"
|
|
44
|
+
|
|
45
|
+
# How many due jobs a single reserve call promotes out of the delayed set. Bounded so that a
|
|
46
|
+
# backlog of a million scheduled jobs coming due at once does not turn one reserve into a
|
|
47
|
+
# long script that blocks the server.
|
|
48
|
+
PROMOTE_LIMIT = 50
|
|
49
|
+
|
|
50
|
+
MAX_ERROR_BYTES = 60000
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fit_error(error: str) -> str:
|
|
54
|
+
raw = error.encode("utf-8")
|
|
55
|
+
if len(raw) <= MAX_ERROR_BYTES:
|
|
56
|
+
return error
|
|
57
|
+
|
|
58
|
+
return raw[:MAX_ERROR_BYTES].decode("utf-8", errors="ignore") + "\n... truncated"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stamp(moment: datetime.datetime) -> str:
|
|
62
|
+
return moment.strftime("%Y-%m-%d %H:%M:%S")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def now_utc() -> datetime.datetime:
|
|
66
|
+
return datetime.datetime.now(datetime.UTC)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ARGV: prefix, queue, name, payload, priority, max_attempts, unique, available_at,
|
|
70
|
+
# created_at, now
|
|
71
|
+
_PUSH = """
|
|
72
|
+
local p, queue, uk = ARGV[1], ARGV[2], ARGV[7]
|
|
73
|
+
|
|
74
|
+
if uk ~= '' then
|
|
75
|
+
local held = redis.call('GET', p .. 'u:' .. uk)
|
|
76
|
+
if held then
|
|
77
|
+
if redis.call('EXISTS', p .. 'j:' .. held) == 1 then
|
|
78
|
+
return held
|
|
79
|
+
end
|
|
80
|
+
redis.call('DEL', p .. 'u:' .. uk)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
local id = redis.call('INCR', p .. 'seq')
|
|
85
|
+
local job = p .. 'j:' .. id
|
|
86
|
+
local level = ARGV[5]
|
|
87
|
+
|
|
88
|
+
redis.call('HSET', job,
|
|
89
|
+
'queue', queue, 'name', ARGV[3], 'payload', ARGV[4],
|
|
90
|
+
'attempts', '0', 'max_attempts', ARGV[6], 'priority', level,
|
|
91
|
+
'unique_key', uk, 'available_at', ARGV[8], 'reserved_until', '0',
|
|
92
|
+
'reserved_by', '', 'last_error', '', 'created_at', ARGV[9], 'entry', '')
|
|
93
|
+
|
|
94
|
+
if uk ~= '' then
|
|
95
|
+
redis.call('SET', p .. 'u:' .. uk, id)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
redis.call('SADD', p .. 'queues', queue)
|
|
99
|
+
|
|
100
|
+
if tonumber(ARGV[8]) > tonumber(ARGV[10]) then
|
|
101
|
+
redis.call('ZADD', p .. 'q:' .. queue .. ':delayed', tonumber(ARGV[8]), id)
|
|
102
|
+
else
|
|
103
|
+
local entry = redis.call('XADD', p .. 'q:' .. queue .. ':s:' .. level, '*', 'job', id)
|
|
104
|
+
redis.call('HSET', job, 'entry', entry)
|
|
105
|
+
redis.call('ZADD', p .. 'q:' .. queue .. ':levels', tonumber(level), level)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
return tostring(id)
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# ARGV: prefix, queue, now, timeout, worker, group, consumer, promote_limit
|
|
112
|
+
#
|
|
113
|
+
# Promote what is due, then walk the priority levels highest first. XAUTOCLAIM runs before
|
|
114
|
+
# XREADGROUP on each level so a dead worker's job is retried before new work is started,
|
|
115
|
+
# rather than being left at the back of the queue behind everything pushed since.
|
|
116
|
+
_RESERVE = """
|
|
117
|
+
local p, queue = ARGV[1], ARGV[2]
|
|
118
|
+
local now, timeout = tonumber(ARGV[3]), tonumber(ARGV[4])
|
|
119
|
+
local worker, group, consumer = ARGV[5], ARGV[6], ARGV[7]
|
|
120
|
+
|
|
121
|
+
local levels = p .. 'q:' .. queue .. ':levels'
|
|
122
|
+
local delayed = p .. 'q:' .. queue .. ':delayed'
|
|
123
|
+
|
|
124
|
+
local due = redis.call('ZRANGEBYSCORE', delayed, '-inf', now, 'LIMIT', 0, tonumber(ARGV[8]))
|
|
125
|
+
for i = 1, #due do
|
|
126
|
+
local id = due[i]
|
|
127
|
+
-- Only the caller that actually removed it may add the stream entry, or two workers
|
|
128
|
+
-- promoting at once would each add one for the same job.
|
|
129
|
+
if redis.call('ZREM', delayed, id) == 1 then
|
|
130
|
+
local job = p .. 'j:' .. id
|
|
131
|
+
local level = redis.call('HGET', job, 'priority')
|
|
132
|
+
if level then
|
|
133
|
+
local entry = redis.call('XADD', p .. 'q:' .. queue .. ':s:' .. level, '*', 'job', id)
|
|
134
|
+
redis.call('HSET', job, 'entry', entry)
|
|
135
|
+
redis.call('ZADD', levels, tonumber(level), level)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
local ranked = redis.call('ZREVRANGE', levels, 0, -1)
|
|
141
|
+
for i = 1, #ranked do
|
|
142
|
+
local stream = p .. 'q:' .. queue .. ':s:' .. ranked[i]
|
|
143
|
+
-- Created at 0 rather than $, because jobs are pushed long before any worker starts and
|
|
144
|
+
-- $ would skip every one of them. pcall because it errors if the group already exists.
|
|
145
|
+
redis.pcall('XGROUP', 'CREATE', stream, group, '0', 'MKSTREAM')
|
|
146
|
+
|
|
147
|
+
local taken = nil
|
|
148
|
+
local auto = redis.call('XAUTOCLAIM', stream, group, consumer, timeout * 1000, '0-0', 'COUNT', 1)
|
|
149
|
+
if auto and auto[2] and auto[2][1] and auto[2][1][2] then
|
|
150
|
+
taken = auto[2][1]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if not taken then
|
|
154
|
+
local read = redis.call('XREADGROUP', 'GROUP', group, consumer,
|
|
155
|
+
'COUNT', 1, 'STREAMS', stream, '>')
|
|
156
|
+
if read and read[1] and read[1][2] and read[1][2][1] then
|
|
157
|
+
taken = read[1][2][1]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if taken then
|
|
162
|
+
local entry, id = taken[1], taken[2][2]
|
|
163
|
+
local job = p .. 'j:' .. id
|
|
164
|
+
local until_ = tonumber(redis.call('HGET', job, 'reserved_until') or '0') or 0
|
|
165
|
+
if redis.call('EXISTS', job) == 0 then
|
|
166
|
+
-- The hash is gone: a tombstone entry for a job already completed. Drop it.
|
|
167
|
+
redis.call('XACK', stream, group, entry)
|
|
168
|
+
redis.call('XDEL', stream, entry)
|
|
169
|
+
elseif until_ > now then
|
|
170
|
+
-- Somebody still legitimately holds this one and XAUTOCLAIM handed it over
|
|
171
|
+
-- early. Park it back in the delayed set until its claim runs out. Parking is
|
|
172
|
+
-- not an attempt, so `attempts` is deliberately untouched here.
|
|
173
|
+
redis.call('ZADD', p .. 'q:' .. queue .. ':delayed', until_, id)
|
|
174
|
+
redis.call('HSET', job, 'entry', '')
|
|
175
|
+
redis.call('XACK', stream, group, entry)
|
|
176
|
+
redis.call('XDEL', stream, entry)
|
|
177
|
+
else
|
|
178
|
+
redis.call('HINCRBY', job, 'attempts', 1)
|
|
179
|
+
redis.call('HSET', job, 'reserved_by', worker,
|
|
180
|
+
'reserved_until', now + timeout, 'entry', entry)
|
|
181
|
+
return {id, entry, redis.call('HGETALL', job)}
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
return nil
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
# ARGV: prefix, queue, id, entry, group
|
|
190
|
+
_COMPLETE = """
|
|
191
|
+
local p, queue, id, entry, group = ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]
|
|
192
|
+
local job = p .. 'j:' .. id
|
|
193
|
+
local level = redis.call('HGET', job, 'priority') or '0'
|
|
194
|
+
local stream = p .. 'q:' .. queue .. ':s:' .. level
|
|
195
|
+
|
|
196
|
+
redis.call('XACK', stream, group, entry)
|
|
197
|
+
redis.call('XDEL', stream, entry)
|
|
198
|
+
|
|
199
|
+
local uk = redis.call('HGET', job, 'unique_key')
|
|
200
|
+
if uk and uk ~= '' then
|
|
201
|
+
redis.call('DEL', p .. 'u:' .. uk)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
redis.call('DEL', job)
|
|
205
|
+
return 1
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
# ARGV: prefix, queue, id, entry, group, available_at, error
|
|
209
|
+
_RELEASE = """
|
|
210
|
+
local p, queue, id, entry, group = ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]
|
|
211
|
+
local job = p .. 'j:' .. id
|
|
212
|
+
local level = redis.call('HGET', job, 'priority') or '0'
|
|
213
|
+
local stream = p .. 'q:' .. queue .. ':s:' .. level
|
|
214
|
+
|
|
215
|
+
-- The delayed ZADD happens before the ACK and DEL on purpose: a crash between them leaves
|
|
216
|
+
-- the job in both places, which a later reserve resolves, rather than in neither.
|
|
217
|
+
redis.call('ZADD', p .. 'q:' .. queue .. ':delayed', tonumber(ARGV[6]), id)
|
|
218
|
+
redis.call('HSET', job, 'available_at', ARGV[6], 'reserved_until', '0',
|
|
219
|
+
'reserved_by', '', 'last_error', ARGV[7], 'entry', '')
|
|
220
|
+
|
|
221
|
+
redis.call('XACK', stream, group, entry)
|
|
222
|
+
redis.call('XDEL', stream, entry)
|
|
223
|
+
return 1
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
# ARGV: prefix, queue, id, entry, group, error, now, failed_at
|
|
227
|
+
_FAIL = """
|
|
228
|
+
local p, queue, id, entry, group = ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]
|
|
229
|
+
local job = p .. 'j:' .. id
|
|
230
|
+
local level = redis.call('HGET', job, 'priority') or '0'
|
|
231
|
+
local stream = p .. 'q:' .. queue .. ':s:' .. level
|
|
232
|
+
|
|
233
|
+
redis.call('XACK', stream, group, entry)
|
|
234
|
+
redis.call('XDEL', stream, entry)
|
|
235
|
+
|
|
236
|
+
local uk = redis.call('HGET', job, 'unique_key')
|
|
237
|
+
if uk and uk ~= '' then
|
|
238
|
+
redis.call('DEL', p .. 'u:' .. uk)
|
|
239
|
+
redis.call('HSET', job, 'unique_key', '')
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
-- No copy to a second structure: the hash stays where it is and joins the failed set, so
|
|
243
|
+
-- `queue retry` puts back the job that failed rather than a reconstruction of it, and the
|
|
244
|
+
-- id never changes.
|
|
245
|
+
redis.call('HSET', job, 'error', ARGV[6], 'failed_at', ARGV[8],
|
|
246
|
+
'reserved_until', '0', 'reserved_by', '', 'entry', '')
|
|
247
|
+
redis.call('ZADD', p .. 'failed', tonumber(ARGV[7]), id)
|
|
248
|
+
return 1
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
# ARGV: prefix, id, max_attempts, now
|
|
252
|
+
_REQUEUE = """
|
|
253
|
+
local p, id = ARGV[1], ARGV[2]
|
|
254
|
+
local job = p .. 'j:' .. id
|
|
255
|
+
|
|
256
|
+
-- A job that was not in the failed set is not a failed job. Returning here rather than
|
|
257
|
+
-- carrying on is what stops `retry --id N` naming a live pending job from resetting it and
|
|
258
|
+
-- adding a second stream entry for it.
|
|
259
|
+
if redis.call('ZREM', p .. 'failed', id) == 0 then
|
|
260
|
+
return 0
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if redis.call('EXISTS', job) == 0 then
|
|
264
|
+
return 0
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
local queue = redis.call('HGET', job, 'queue')
|
|
268
|
+
if not queue or queue == '' then queue = 'default' end
|
|
269
|
+
|
|
270
|
+
redis.call('HSET', job, 'attempts', '0', 'max_attempts', ARGV[3], 'priority', '0',
|
|
271
|
+
'unique_key', '', 'available_at', ARGV[4], 'reserved_until', '0',
|
|
272
|
+
'reserved_by', '', 'last_error', '', 'error', '', 'failed_at', '')
|
|
273
|
+
|
|
274
|
+
local entry = redis.call('XADD', p .. 'q:' .. queue .. ':s:0', '*', 'job', id)
|
|
275
|
+
redis.call('HSET', job, 'entry', entry)
|
|
276
|
+
redis.call('ZADD', p .. 'q:' .. queue .. ':levels', 0, '0')
|
|
277
|
+
redis.call('SADD', p .. 'queues', queue)
|
|
278
|
+
return 1
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
# ARGV: prefix, id
|
|
282
|
+
_FORGET = """
|
|
283
|
+
local p, id = ARGV[1], ARGV[2]
|
|
284
|
+
if redis.call('ZREM', p .. 'failed', id) == 0 then
|
|
285
|
+
return 0
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
redis.call('DEL', p .. 'j:' .. id)
|
|
289
|
+
return 1
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class RedisQueue:
|
|
294
|
+
def __init__(self, redis_con: Any, prefix: str = "queue:", group: str = "workers") -> None:
|
|
295
|
+
self.redis_con = redis_con
|
|
296
|
+
self.prefix = prefix or "queue:"
|
|
297
|
+
self.group = group or "workers"
|
|
298
|
+
|
|
299
|
+
# register_script gives EVALSHA with an automatic fall back to EVAL when the server
|
|
300
|
+
# has forgotten the script - after a restart, or a SCRIPT FLUSH - which is otherwise
|
|
301
|
+
# a NOSCRIPT error the caller has to know to retry.
|
|
302
|
+
self._push = redis_con.register_script(_PUSH)
|
|
303
|
+
self._reserve = redis_con.register_script(_RESERVE)
|
|
304
|
+
self._complete = redis_con.register_script(_COMPLETE)
|
|
305
|
+
self._release = redis_con.register_script(_RELEASE)
|
|
306
|
+
self._fail = redis_con.register_script(_FAIL)
|
|
307
|
+
self._requeue = redis_con.register_script(_REQUEUE)
|
|
308
|
+
self._forget = redis_con.register_script(_FORGET)
|
|
309
|
+
|
|
310
|
+
##############
|
|
311
|
+
### Naming ###
|
|
312
|
+
##############
|
|
313
|
+
|
|
314
|
+
def _job_key(self, job_id: int | str) -> str:
|
|
315
|
+
return f"{self.prefix}j:{job_id}"
|
|
316
|
+
|
|
317
|
+
def _stream(self, queue: str, level: int | str) -> str:
|
|
318
|
+
return f"{self.prefix}q:{queue}:s:{level}"
|
|
319
|
+
|
|
320
|
+
def _levels(self, queue: str) -> str:
|
|
321
|
+
return f"{self.prefix}q:{queue}:levels"
|
|
322
|
+
|
|
323
|
+
def _delayed(self, queue: str) -> str:
|
|
324
|
+
return f"{self.prefix}q:{queue}:delayed"
|
|
325
|
+
|
|
326
|
+
############
|
|
327
|
+
### Push ###
|
|
328
|
+
############
|
|
329
|
+
|
|
330
|
+
async def push(
|
|
331
|
+
self,
|
|
332
|
+
name: str,
|
|
333
|
+
payload: dict[str, Any] | None = None,
|
|
334
|
+
delay: int = 0,
|
|
335
|
+
queue: str = "default",
|
|
336
|
+
priority: int = 0,
|
|
337
|
+
unique: str | None = None,
|
|
338
|
+
max_attempts: int = 3,
|
|
339
|
+
) -> int:
|
|
340
|
+
moment = now_utc()
|
|
341
|
+
now = int(moment.timestamp())
|
|
342
|
+
|
|
343
|
+
result = await self._push(
|
|
344
|
+
keys=[],
|
|
345
|
+
args=[
|
|
346
|
+
self.prefix,
|
|
347
|
+
queue or "default",
|
|
348
|
+
name,
|
|
349
|
+
encode_payload(payload or {}),
|
|
350
|
+
priority,
|
|
351
|
+
max(1, max_attempts),
|
|
352
|
+
unique or "",
|
|
353
|
+
now + max(0, delay),
|
|
354
|
+
stamp(moment),
|
|
355
|
+
now,
|
|
356
|
+
],
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return int(result)
|
|
360
|
+
|
|
361
|
+
###############
|
|
362
|
+
### Reserve ###
|
|
363
|
+
###############
|
|
364
|
+
|
|
365
|
+
async def reserve(self, queues: list[str], timeout: int, worker: str) -> Job | None:
|
|
366
|
+
for queue in queues:
|
|
367
|
+
job = await self._reserve_from(queue, timeout, worker)
|
|
368
|
+
if job is not None:
|
|
369
|
+
return job
|
|
370
|
+
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
async def _reserve_from(self, queue: str, timeout: int, worker: str) -> Job | None:
|
|
374
|
+
now = int(now_utc().timestamp())
|
|
375
|
+
|
|
376
|
+
result = await self._reserve(
|
|
377
|
+
keys=[],
|
|
378
|
+
args=[
|
|
379
|
+
self.prefix,
|
|
380
|
+
queue,
|
|
381
|
+
now,
|
|
382
|
+
max(1, timeout),
|
|
383
|
+
worker[:64],
|
|
384
|
+
self.group,
|
|
385
|
+
CONSUMER,
|
|
386
|
+
PROMOTE_LIMIT,
|
|
387
|
+
],
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if not result:
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
job_id, entry, flat = result
|
|
394
|
+
fields = self._unflatten(flat)
|
|
395
|
+
|
|
396
|
+
job = self._to_job(int(job_id), fields, str(entry))
|
|
397
|
+
if job is None:
|
|
398
|
+
# Undecodable payload. It will not decode on the next attempt either, so it goes
|
|
399
|
+
# straight to failed rather than spending its whole budget rediscovering that.
|
|
400
|
+
await self._fail_raw(
|
|
401
|
+
job_id=int(job_id),
|
|
402
|
+
queue=fields.get("queue") or queue,
|
|
403
|
+
entry=str(entry),
|
|
404
|
+
error="Payload is not valid JSON, so no handler could be given it.",
|
|
405
|
+
)
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
return job
|
|
409
|
+
|
|
410
|
+
def _unflatten(self, flat: list[str]) -> dict[str, str]:
|
|
411
|
+
"""HGETALL comes back from Lua as a flat array, not a map."""
|
|
412
|
+
|
|
413
|
+
return {flat[i]: flat[i + 1] for i in range(0, len(flat) - 1, 2)}
|
|
414
|
+
|
|
415
|
+
def _to_job(self, job_id: int, fields: dict[str, str], entry: str) -> Job | None:
|
|
416
|
+
try:
|
|
417
|
+
payload = json.loads(fields.get("payload") or "")
|
|
418
|
+
except (TypeError, ValueError):
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
if not isinstance(payload, dict):
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
return Job(
|
|
425
|
+
id=job_id,
|
|
426
|
+
queue=fields.get("queue") or "default",
|
|
427
|
+
name=fields.get("name") or "",
|
|
428
|
+
payload=payload,
|
|
429
|
+
payload_json=fields.get("payload") or "",
|
|
430
|
+
# Floored at 1: a job being handed to a handler has been attempted at least once
|
|
431
|
+
# by definition, and a hash edited by hand should not make that read as zero.
|
|
432
|
+
attempts=max(1, int(fields.get("attempts") or 1)),
|
|
433
|
+
max_attempts=max(1, int(fields.get("max_attempts") or 1)),
|
|
434
|
+
handle=entry,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
##################
|
|
438
|
+
### Completion ###
|
|
439
|
+
##################
|
|
440
|
+
|
|
441
|
+
async def delete(self, job: Job) -> None:
|
|
442
|
+
await self._complete(keys=[], args=[self.prefix, job.queue, job.id, job.handle, self.group])
|
|
443
|
+
|
|
444
|
+
async def release(self, job: Job, delay: int = 0, error: str = "") -> None:
|
|
445
|
+
now = int(now_utc().timestamp())
|
|
446
|
+
|
|
447
|
+
await self._release(
|
|
448
|
+
keys=[],
|
|
449
|
+
args=[
|
|
450
|
+
self.prefix,
|
|
451
|
+
job.queue,
|
|
452
|
+
job.id,
|
|
453
|
+
job.handle,
|
|
454
|
+
self.group,
|
|
455
|
+
now + max(0, delay),
|
|
456
|
+
fit_error(error),
|
|
457
|
+
],
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
async def fail(self, job: Job, error: str) -> None:
|
|
461
|
+
await self._fail_raw(job.id, job.queue, job.handle, error)
|
|
462
|
+
|
|
463
|
+
async def _fail_raw(self, job_id: int, queue: str, entry: str, error: str) -> None:
|
|
464
|
+
moment = now_utc()
|
|
465
|
+
|
|
466
|
+
await self._fail(
|
|
467
|
+
keys=[],
|
|
468
|
+
args=[
|
|
469
|
+
self.prefix,
|
|
470
|
+
queue,
|
|
471
|
+
job_id,
|
|
472
|
+
entry,
|
|
473
|
+
self.group,
|
|
474
|
+
fit_error(error),
|
|
475
|
+
int(moment.timestamp()),
|
|
476
|
+
stamp(moment),
|
|
477
|
+
],
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
###############
|
|
481
|
+
### Reports ###
|
|
482
|
+
###############
|
|
483
|
+
|
|
484
|
+
async def _queue_names(self) -> list[str]:
|
|
485
|
+
names = await self.redis_con.smembers(f"{self.prefix}queues")
|
|
486
|
+
return sorted(str(name) for name in names)
|
|
487
|
+
|
|
488
|
+
async def _counts(self, queue: str, now: int) -> dict[str, Any]:
|
|
489
|
+
levels = await self.redis_con.zrevrange(self._levels(queue), 0, -1)
|
|
490
|
+
|
|
491
|
+
ready = 0
|
|
492
|
+
held = 0
|
|
493
|
+
for level in levels:
|
|
494
|
+
stream = self._stream(queue, level)
|
|
495
|
+
entries = int(await self.redis_con.xlen(stream))
|
|
496
|
+
ready += entries
|
|
497
|
+
|
|
498
|
+
if entries == 0:
|
|
499
|
+
# No entries means no pending list to read, and asking anyway would be a
|
|
500
|
+
# round trip per empty priority level.
|
|
501
|
+
continue
|
|
502
|
+
|
|
503
|
+
# An absent group counts as zero rather than being created here: reporting on a
|
|
504
|
+
# queue must not be what brings its consumer group into existence, which
|
|
505
|
+
# XGROUP CREATE would.
|
|
506
|
+
for info in await self.redis_con.xinfo_groups(stream):
|
|
507
|
+
if info.get("name") == self.group:
|
|
508
|
+
held += int(info.get("pending") or 0)
|
|
509
|
+
|
|
510
|
+
due = int(await self.redis_con.zcount(self._delayed(queue), "-inf", now))
|
|
511
|
+
scheduled = int(await self.redis_con.zcard(self._delayed(queue)))
|
|
512
|
+
|
|
513
|
+
pending = max(0, ready - held) + due
|
|
514
|
+
delayed = max(0, scheduled - due)
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
"queue": queue,
|
|
518
|
+
"pending": pending,
|
|
519
|
+
"delayed": delayed,
|
|
520
|
+
"reserved": held,
|
|
521
|
+
"total": pending + delayed + held,
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async def pending(self, queue: str | None = None) -> int:
|
|
525
|
+
now = int(now_utc().timestamp())
|
|
526
|
+
queues = [queue] if queue is not None else await self._queue_names()
|
|
527
|
+
|
|
528
|
+
total = 0
|
|
529
|
+
for name in queues:
|
|
530
|
+
total += (await self._counts(name, now))["pending"]
|
|
531
|
+
|
|
532
|
+
return total
|
|
533
|
+
|
|
534
|
+
async def stats(self) -> list[dict[str, Any]]:
|
|
535
|
+
now = int(now_utc().timestamp())
|
|
536
|
+
|
|
537
|
+
rows = []
|
|
538
|
+
for name in await self._queue_names():
|
|
539
|
+
counts = await self._counts(name, now)
|
|
540
|
+
if counts["total"] > 0:
|
|
541
|
+
rows.append(counts)
|
|
542
|
+
|
|
543
|
+
return rows
|
|
544
|
+
|
|
545
|
+
async def failed_count(self) -> int:
|
|
546
|
+
return int(await self.redis_con.zcard(f"{self.prefix}failed"))
|
|
547
|
+
|
|
548
|
+
async def failed_rows(self, limit: int) -> list[dict[str, Any]]:
|
|
549
|
+
ids = await self.redis_con.zrevrange(f"{self.prefix}failed", 0, max(1, limit) - 1)
|
|
550
|
+
|
|
551
|
+
rows = []
|
|
552
|
+
for job_id in ids:
|
|
553
|
+
fields = await self.redis_con.hgetall(self._job_key(job_id))
|
|
554
|
+
if not fields:
|
|
555
|
+
# The hash went while we were reading. Skipped rather than reported as a
|
|
556
|
+
# row of blanks.
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
rows.append(
|
|
560
|
+
{
|
|
561
|
+
"id": int(job_id),
|
|
562
|
+
"failed_at": fields.get("failed_at") or "",
|
|
563
|
+
"queue": fields.get("queue") or "",
|
|
564
|
+
"name": fields.get("name") or "",
|
|
565
|
+
"attempts": int(fields.get("attempts") or 0),
|
|
566
|
+
"error": fields.get("error") or "",
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
return rows
|
|
571
|
+
|
|
572
|
+
async def retry_failed(self, job_id: int | None, max_attempts: int) -> int:
|
|
573
|
+
now = int(now_utc().timestamp())
|
|
574
|
+
ids = [job_id] if job_id is not None else await self.redis_con.zrange(f"{self.prefix}failed", 0, -1)
|
|
575
|
+
|
|
576
|
+
requeued = 0
|
|
577
|
+
for one in ids:
|
|
578
|
+
moved = await self._requeue(keys=[], args=[self.prefix, one, max(1, max_attempts), now])
|
|
579
|
+
requeued += int(moved)
|
|
580
|
+
|
|
581
|
+
return requeued
|
|
582
|
+
|
|
583
|
+
async def forget_failed(self, job_id: int | None, before: str | None) -> int:
|
|
584
|
+
if job_id is not None:
|
|
585
|
+
ids: list[Any] = [job_id]
|
|
586
|
+
elif before is not None:
|
|
587
|
+
ids = await self.redis_con.zrangebyscore(f"{self.prefix}failed", "-inf", f"({self._epoch(before)}")
|
|
588
|
+
else:
|
|
589
|
+
ids = await self.redis_con.zrange(f"{self.prefix}failed", 0, -1)
|
|
590
|
+
|
|
591
|
+
removed = 0
|
|
592
|
+
for one in ids:
|
|
593
|
+
removed += int(await self._forget(keys=[], args=[self.prefix, one]))
|
|
594
|
+
|
|
595
|
+
return removed
|
|
596
|
+
|
|
597
|
+
def _epoch(self, before: str) -> int:
|
|
598
|
+
"""Read a --before date as UTC.
|
|
599
|
+
|
|
600
|
+
Reading it as local time would silently move the cut-off by the offset, and the only
|
|
601
|
+
symptom is rows that should have gone still being there - or worse, rows that should
|
|
602
|
+
have stayed being gone.
|
|
603
|
+
"""
|
|
604
|
+
|
|
605
|
+
for pattern in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
|
606
|
+
try:
|
|
607
|
+
parsed = datetime.datetime.strptime(before, pattern)
|
|
608
|
+
except ValueError:
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
return int(parsed.replace(tzinfo=datetime.UTC).timestamp())
|
|
612
|
+
|
|
613
|
+
raise QueueError(f"{before!r} is not a date this queue can read.")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Turning the `name` on a row into something callable.
|
|
2
|
+
|
|
3
|
+
What is stored is what was pushed - the alias, not the resolved target - so rows outlive
|
|
4
|
+
the deploy that wrote them. That is the whole point of allowing an alias: a class can be
|
|
5
|
+
renamed or moved without orphaning every job already in the table.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import inspect
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from typing import Any, Protocol, cast, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from py_app_runner.queue.job import Job, QueueError
|
|
14
|
+
|
|
15
|
+
# A resolved handler is either an object with an async `handle`, or a plain async callable.
|
|
16
|
+
HandlerCallable = Callable[[dict[str, Any], Job], Awaitable[None]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class Handler(Protocol):
|
|
21
|
+
async def handle(self, payload: dict[str, Any], job: Job) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _import_target(path: str) -> Any:
|
|
25
|
+
"""Import "package.module:attr", or "package.module.attr"."""
|
|
26
|
+
|
|
27
|
+
if ":" in path:
|
|
28
|
+
module_name, _, attribute = path.partition(":")
|
|
29
|
+
else:
|
|
30
|
+
module_name, _, attribute = path.rpartition(".")
|
|
31
|
+
|
|
32
|
+
if not module_name or not attribute:
|
|
33
|
+
raise QueueError(f'No handler {path!r}: expected "module:attribute" or "module.attribute".')
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
module = importlib.import_module(module_name)
|
|
37
|
+
except ImportError as e:
|
|
38
|
+
raise QueueError(f"No handler {path!r}: {e}") from e
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
return getattr(module, attribute)
|
|
42
|
+
except AttributeError as e:
|
|
43
|
+
raise QueueError(f"No handler {path!r}: {module_name} has no {attribute!r}.") from e
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve(name: str, handlers: dict[str, Any] | None = None) -> HandlerCallable:
|
|
47
|
+
"""Resolve a job name to something awaitable.
|
|
48
|
+
|
|
49
|
+
Order: a configured alias wins, then the name is treated as an import path. The alias
|
|
50
|
+
short-circuits entirely, which is what lets a renamed class keep working without
|
|
51
|
+
touching the rows that name it.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
configured = (handlers or {}).get(name)
|
|
55
|
+
target = configured if configured is not None else name
|
|
56
|
+
|
|
57
|
+
if isinstance(target, str):
|
|
58
|
+
target = _import_target(target)
|
|
59
|
+
|
|
60
|
+
# The cast is the honest shape of this function: what comes back from an import is
|
|
61
|
+
# `object` as far as the type checker is concerned, and only the callable check below
|
|
62
|
+
# establishes anything more. Checking that a handler is *awaitable* is not possible
|
|
63
|
+
# here without calling it, so that failure surfaces at run time - where the worker
|
|
64
|
+
# already routes it through the same release/fail path as any other job failure.
|
|
65
|
+
if inspect.isclass(target):
|
|
66
|
+
instance = target()
|
|
67
|
+
handle = getattr(instance, "handle", None)
|
|
68
|
+
if handle is None or not callable(handle):
|
|
69
|
+
raise QueueError(f"Handler {name!r} resolved to {target!r}, which has no handle() method.")
|
|
70
|
+
|
|
71
|
+
return cast(HandlerCallable, handle)
|
|
72
|
+
|
|
73
|
+
if callable(target):
|
|
74
|
+
return cast(HandlerCallable, target)
|
|
75
|
+
|
|
76
|
+
raise QueueError(f"Handler {name!r} resolved to {target!r}, which is not callable.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def assert_resolvable(name: str, handlers: dict[str, Any] | None = None) -> None:
|
|
80
|
+
"""Check at push time, not at run time.
|
|
81
|
+
|
|
82
|
+
A name that resolves to nothing fails on every attempt and then sits in the failed
|
|
83
|
+
table, having consumed its whole retry budget to discover something that was knowable
|
|
84
|
+
at the call site.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
if not name:
|
|
88
|
+
raise QueueError("A job needs a handler name.")
|
|
89
|
+
|
|
90
|
+
resolve(name, handlers)
|