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.
Files changed (75) hide show
  1. py_app_runner/__init__.py +11 -0
  2. py_app_runner/audit/__init__.py +29 -0
  3. py_app_runner/audit/_service.py +91 -0
  4. py_app_runner/audit/_service_args.py +44 -0
  5. py_app_runner/audit/audit.py +319 -0
  6. py_app_runner/audit/commands.py +151 -0
  7. py_app_runner/audit/diff.py +202 -0
  8. py_app_runner/audit/errors.py +8 -0
  9. py_app_runner/audit/event.py +130 -0
  10. py_app_runner/audit/store.py +134 -0
  11. py_app_runner/bridge/__init__.py +0 -0
  12. py_app_runner/bridge/_service.py +265 -0
  13. py_app_runner/bridge/_service_args.py +24 -0
  14. py_app_runner/bridge/api.py +138 -0
  15. py_app_runner/bridge/encoders/__init__.py +5 -0
  16. py_app_runner/bridge/encoders/base.py +24 -0
  17. py_app_runner/bridge/encoders/json_encoder.py +26 -0
  18. py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
  19. py_app_runner/bridge/web_app.py +31 -0
  20. py_app_runner/bridge/websocket.py +313 -0
  21. py_app_runner/colors.py +73 -0
  22. py_app_runner/config.py +132 -0
  23. py_app_runner/crypto/__init__.py +14 -0
  24. py_app_runner/crypto/_service.py +75 -0
  25. py_app_runner/crypto/_service_args.py +54 -0
  26. py_app_runner/crypto/commands.py +164 -0
  27. py_app_runner/crypto/envelope.py +144 -0
  28. py_app_runner/crypto/errors.py +8 -0
  29. py_app_runner/crypto/fields.py +300 -0
  30. py_app_runner/crypto/passwords.py +66 -0
  31. py_app_runner/db_pools.py +20 -0
  32. py_app_runner/http_exception.py +31 -0
  33. py_app_runner/logger_handlers.py +167 -0
  34. py_app_runner/migrations/__init__.py +5 -0
  35. py_app_runner/migrations/_service.py +296 -0
  36. py_app_runner/migrations/_service_args.py +91 -0
  37. py_app_runner/migrations/commands.py +386 -0
  38. py_app_runner/migrations/discovery.py +108 -0
  39. py_app_runner/migrations/states.py +63 -0
  40. py_app_runner/migrations/tracker.py +141 -0
  41. py_app_runner/py.typed +0 -0
  42. py_app_runner/pybridge.py +64 -0
  43. py_app_runner/queue/__init__.py +25 -0
  44. py_app_runner/queue/_service.py +231 -0
  45. py_app_runner/queue/_service_args.py +67 -0
  46. py_app_runner/queue/commands.py +180 -0
  47. py_app_runner/queue/driver_pg.py +464 -0
  48. py_app_runner/queue/driver_redis.py +613 -0
  49. py_app_runner/queue/handler.py +90 -0
  50. py_app_runner/queue/interface.py +63 -0
  51. py_app_runner/queue/job.py +46 -0
  52. py_app_runner/queue/worker.py +221 -0
  53. py_app_runner/registry.py +54 -0
  54. py_app_runner/request_handler/__init__.py +0 -0
  55. py_app_runner/request_handler/auth_service.py +123 -0
  56. py_app_runner/request_handler/decorators.py +304 -0
  57. py_app_runner/request_handler/handlers.py +604 -0
  58. py_app_runner/request_handler/pagination.py +24 -0
  59. py_app_runner/return_model.py +78 -0
  60. py_app_runner/runner.py +182 -0
  61. py_app_runner/throttle/__init__.py +5 -0
  62. py_app_runner/throttle/throttle.py +217 -0
  63. py_app_runner/tick_service.py +308 -0
  64. py_app_runner/timer.py +289 -0
  65. py_app_runner/utils.py +346 -0
  66. py_app_runner/wbcm/__init__.py +0 -0
  67. py_app_runner/wbcm/device_connections.py +89 -0
  68. py_app_runner/wbcm/factory.py +113 -0
  69. py_app_runner/wbcm/wb_connection_manager.py +333 -0
  70. py_app_runner/wbcm/ws_interface.py +56 -0
  71. py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
  72. py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
  73. py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
  74. py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
  75. 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)