swiftpy-framework 0.1.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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, ClassVar, Self
|
|
10
|
+
|
|
11
|
+
import redis.asyncio as aioredis
|
|
12
|
+
from redis.exceptions import NoScriptError, RedisError
|
|
13
|
+
|
|
14
|
+
from swiftpy.core.config import Config, ConfigurationError
|
|
15
|
+
from swiftpy.core.container import get_container
|
|
16
|
+
from swiftpy.queue.job import Job, set_dispatch_handler
|
|
17
|
+
from swiftpy.support.keys import validate_signing_key
|
|
18
|
+
from swiftpy.support.redis_config import resolve_redis_params
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("swiftpy.queue")
|
|
21
|
+
|
|
22
|
+
_POP_AND_LEASE_LUA = """
|
|
23
|
+
local function sanitize_utf8(s)
|
|
24
|
+
local n = #s
|
|
25
|
+
local has_non_ascii = false
|
|
26
|
+
for k = 1, n do
|
|
27
|
+
if string.byte(s, k) >= 128 then
|
|
28
|
+
has_non_ascii = true
|
|
29
|
+
break
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
if not has_non_ascii then
|
|
33
|
+
return s
|
|
34
|
+
end
|
|
35
|
+
local i = 1
|
|
36
|
+
local out = nil
|
|
37
|
+
local last = 1
|
|
38
|
+
local repl = string.char(239, 191, 189)
|
|
39
|
+
while i <= n do
|
|
40
|
+
local b1 = string.byte(s, i)
|
|
41
|
+
local seq_len = 0
|
|
42
|
+
if b1 < 128 then
|
|
43
|
+
seq_len = 1
|
|
44
|
+
elseif b1 >= 194 and b1 <= 223 and i + 1 <= n then
|
|
45
|
+
local b2 = string.byte(s, i + 1)
|
|
46
|
+
if b2 >= 128 and b2 <= 191 then
|
|
47
|
+
seq_len = 2
|
|
48
|
+
end
|
|
49
|
+
elseif b1 >= 224 and b1 <= 239 and i + 2 <= n then
|
|
50
|
+
local b2, b3 = string.byte(s, i + 1, i + 2)
|
|
51
|
+
if b2 >= 128 and b2 <= 191 and b3 >= 128 and b3 <= 191 then
|
|
52
|
+
if not (b1 == 224 and b2 < 160) and not (b1 == 237 and b2 > 159) then
|
|
53
|
+
seq_len = 3
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
elseif b1 >= 240 and b1 <= 244 and i + 3 <= n then
|
|
57
|
+
local b2, b3, b4 = string.byte(s, i + 1, i + 3)
|
|
58
|
+
if b2 >= 128 and b2 <= 191 and b3 >= 128 and b3 <= 191 and b4 >= 128 and b4 <= 191 then
|
|
59
|
+
if not (b1 == 240 and b2 < 144) and not (b1 == 244 and b2 > 143) then
|
|
60
|
+
seq_len = 4
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
if seq_len > 0 then
|
|
65
|
+
i = i + seq_len
|
|
66
|
+
else
|
|
67
|
+
if not out then
|
|
68
|
+
out = {}
|
|
69
|
+
end
|
|
70
|
+
if i > last then
|
|
71
|
+
out[#out + 1] = string.sub(s, last, i - 1)
|
|
72
|
+
end
|
|
73
|
+
out[#out + 1] = repl
|
|
74
|
+
i = i + 1
|
|
75
|
+
last = i
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
if not out then
|
|
79
|
+
return s
|
|
80
|
+
end
|
|
81
|
+
if last <= n then
|
|
82
|
+
out[#out + 1] = string.sub(s, last)
|
|
83
|
+
end
|
|
84
|
+
return table.concat(out)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
local job = redis.call('lpop', KEYS[1])
|
|
88
|
+
if job then
|
|
89
|
+
job = sanitize_utf8(job)
|
|
90
|
+
redis.call('rpush', KEYS[2], job)
|
|
91
|
+
local member = ARGV[1] .. string.char(31) .. job
|
|
92
|
+
redis.call('zadd', KEYS[3], ARGV[2], member)
|
|
93
|
+
return job
|
|
94
|
+
end
|
|
95
|
+
return nil
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
_ACK_JOB_LUA = """
|
|
99
|
+
local removed = redis.call('lrem', KEYS[1], 1, ARGV[1])
|
|
100
|
+
local zremoved = redis.call('zrem', KEYS[2], ARGV[2])
|
|
101
|
+
if removed > 0 or zremoved > 0 then
|
|
102
|
+
return 1
|
|
103
|
+
end
|
|
104
|
+
return 0
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
_RELEASE_JOB_LUA = """
|
|
108
|
+
local removed = redis.call('lrem', KEYS[1], 1, ARGV[1])
|
|
109
|
+
local zremoved = redis.call('zrem', KEYS[2], ARGV[2])
|
|
110
|
+
if removed == 0 and zremoved == 0 then
|
|
111
|
+
return 0
|
|
112
|
+
end
|
|
113
|
+
local delay = tonumber(ARGV[4]) or 0
|
|
114
|
+
if delay > 0 then
|
|
115
|
+
local score = tonumber(ARGV[5]) + delay
|
|
116
|
+
redis.call('zadd', KEYS[3], score, ARGV[3])
|
|
117
|
+
else
|
|
118
|
+
redis.call('lpush', KEYS[3], ARGV[3])
|
|
119
|
+
end
|
|
120
|
+
return 1
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
_REAP_LEASES_LUA = """
|
|
124
|
+
local function update_top_attempts(raw_json, new_attempts)
|
|
125
|
+
local n = #raw_json
|
|
126
|
+
local i = 1
|
|
127
|
+
local depth = 0
|
|
128
|
+
local in_str = false
|
|
129
|
+
local escaped = false
|
|
130
|
+
while i <= n do
|
|
131
|
+
local b = string.byte(raw_json, i)
|
|
132
|
+
if in_str then
|
|
133
|
+
if escaped then
|
|
134
|
+
escaped = false
|
|
135
|
+
elseif b == 92 then
|
|
136
|
+
escaped = true
|
|
137
|
+
elseif b == 34 then
|
|
138
|
+
in_str = false
|
|
139
|
+
end
|
|
140
|
+
i = i + 1
|
|
141
|
+
else
|
|
142
|
+
if b == 34 then
|
|
143
|
+
local j = i + 1
|
|
144
|
+
local esc = false
|
|
145
|
+
while j <= n do
|
|
146
|
+
local c = string.byte(raw_json, j)
|
|
147
|
+
if esc then
|
|
148
|
+
esc = false
|
|
149
|
+
elseif c == 92 then
|
|
150
|
+
esc = true
|
|
151
|
+
elseif c == 34 then
|
|
152
|
+
break
|
|
153
|
+
end
|
|
154
|
+
j = j + 1
|
|
155
|
+
end
|
|
156
|
+
local key_name = string.sub(raw_json, i + 1, j - 1)
|
|
157
|
+
local k = j + 1
|
|
158
|
+
while k <= n do
|
|
159
|
+
local ws = string.byte(raw_json, k)
|
|
160
|
+
if ws == 32 or ws == 9 or ws == 10 or ws == 13 then
|
|
161
|
+
k = k + 1
|
|
162
|
+
else
|
|
163
|
+
break
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
if depth == 1 and key_name == 'attempts' and string.byte(raw_json, k) == 58 then
|
|
167
|
+
local v_start = k + 1
|
|
168
|
+
while v_start <= n do
|
|
169
|
+
local ws = string.byte(raw_json, v_start)
|
|
170
|
+
if ws == 32 or ws == 9 or ws == 10 or ws == 13 then
|
|
171
|
+
v_start = v_start + 1
|
|
172
|
+
else
|
|
173
|
+
break
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
local v_end = v_start
|
|
177
|
+
while v_end <= n do
|
|
178
|
+
local vc = string.byte(raw_json, v_end)
|
|
179
|
+
if vc == 44 or vc == 125 or vc == 32 or vc == 9 or vc == 10 or vc == 13 then
|
|
180
|
+
break
|
|
181
|
+
end
|
|
182
|
+
v_end = v_end + 1
|
|
183
|
+
end
|
|
184
|
+
return string.sub(raw_json, 1, v_start - 1) .. tostring(new_attempts) .. string.sub(raw_json, v_end)
|
|
185
|
+
end
|
|
186
|
+
i = j + 1
|
|
187
|
+
else
|
|
188
|
+
if b == 123 or b == 91 then
|
|
189
|
+
depth = depth + 1
|
|
190
|
+
elseif b == 125 or b == 93 then
|
|
191
|
+
depth = depth - 1
|
|
192
|
+
end
|
|
193
|
+
i = i + 1
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
local rev = string.reverse(raw_json)
|
|
198
|
+
local pos = string.find(rev, '}')
|
|
199
|
+
if pos then
|
|
200
|
+
local close_idx = n - pos + 1
|
|
201
|
+
local inner = string.sub(raw_json, 1, close_idx - 1)
|
|
202
|
+
if string.match(inner, '^{%s*$') then
|
|
203
|
+
return inner .. '"attempts":' .. tostring(new_attempts) .. string.sub(raw_json, close_idx)
|
|
204
|
+
end
|
|
205
|
+
return inner .. ',"attempts":' .. tostring(new_attempts) .. string.sub(raw_json, close_idx)
|
|
206
|
+
end
|
|
207
|
+
return raw_json
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
local expired = redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]) or 50)
|
|
211
|
+
local max_failed = tonumber(ARGV[4]) or 0
|
|
212
|
+
local count = 0
|
|
213
|
+
for i, member in ipairs(expired) do
|
|
214
|
+
redis.call('zrem', KEYS[1], member)
|
|
215
|
+
local sep = string.find(member, string.char(31))
|
|
216
|
+
if sep then
|
|
217
|
+
local worker_id = string.sub(member, 1, sep - 1)
|
|
218
|
+
local raw_payload = string.sub(member, sep + 1)
|
|
219
|
+
local proc_key = ARGV[3] .. worker_id
|
|
220
|
+
redis.call('lrem', proc_key, 1, raw_payload)
|
|
221
|
+
|
|
222
|
+
local ok, data = pcall(cjson.decode, raw_payload)
|
|
223
|
+
if ok and type(data) == 'table' then
|
|
224
|
+
local attempts = (tonumber(data['attempts']) or 0) + 1
|
|
225
|
+
local tries = tonumber(data['tries']) or 3
|
|
226
|
+
local updated_raw = update_top_attempts(raw_payload, attempts)
|
|
227
|
+
if attempts >= tries then
|
|
228
|
+
local record_json = '{"job_id":' .. cjson.encode(tostring(data['job_id'] or 'unknown'))
|
|
229
|
+
.. ',"job_class":' .. cjson.encode(tostring(data['job_class'] or 'unknown'))
|
|
230
|
+
.. ',"queue":' .. cjson.encode(tostring(data['queue'] or 'default'))
|
|
231
|
+
.. ',"payload":' .. updated_raw
|
|
232
|
+
.. ',"exception":{"type":"LeaseExpiredError","message":'
|
|
233
|
+
.. cjson.encode('Job lease expired and max retry attempts (' .. tries .. ') reached')
|
|
234
|
+
.. ',"traceback":""},"failed_at":' .. ARGV[1]
|
|
235
|
+
.. ',"attempts":' .. attempts
|
|
236
|
+
.. ',"tries":' .. tries .. '}'
|
|
237
|
+
redis.call('rpush', KEYS[3], record_json)
|
|
238
|
+
if max_failed > 0 then
|
|
239
|
+
redis.call('ltrim', KEYS[3], -max_failed, -1)
|
|
240
|
+
end
|
|
241
|
+
else
|
|
242
|
+
redis.call('lpush', KEYS[2], updated_raw)
|
|
243
|
+
end
|
|
244
|
+
else
|
|
245
|
+
redis.call('lpush', KEYS[2], raw_payload)
|
|
246
|
+
end
|
|
247
|
+
count = count + 1
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
return count
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _default_queue_prefix() -> str:
|
|
255
|
+
try:
|
|
256
|
+
cfg = Config.get_instance()
|
|
257
|
+
val = cfg.get("queue.connections.redis.prefix") or cfg.get("queue.prefix")
|
|
258
|
+
if val and isinstance(val, str):
|
|
259
|
+
return val
|
|
260
|
+
except ConfigurationError:
|
|
261
|
+
logger.debug("Falling back to default queue prefix", exc_info=True)
|
|
262
|
+
return "swiftpy_queue:"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class QueueDispatcher:
|
|
266
|
+
"""
|
|
267
|
+
Queue dispatcher responsible for pushing jobs onto Redis-backed queues,
|
|
268
|
+
managing delayed job scheduling, and handling dead letter queues.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
_instance: ClassVar[QueueDispatcher | None] = None
|
|
272
|
+
|
|
273
|
+
def __init__(
|
|
274
|
+
self,
|
|
275
|
+
redis_client: aioredis.Redis | None = None,
|
|
276
|
+
connection_pool: aioredis.ConnectionPool | None = None,
|
|
277
|
+
redis_url: str | None = None,
|
|
278
|
+
host: str | None = None,
|
|
279
|
+
port: int | None = None,
|
|
280
|
+
password: str | None = None,
|
|
281
|
+
database: int | None = None,
|
|
282
|
+
prefix: str | None = None,
|
|
283
|
+
max_connections: int | None = None,
|
|
284
|
+
timeout: float = 30.0,
|
|
285
|
+
failed_max_size: int = 1000,
|
|
286
|
+
) -> None:
|
|
287
|
+
self._client: aioredis.Redis | None = redis_client
|
|
288
|
+
self._pool: aioredis.ConnectionPool | None = connection_pool
|
|
289
|
+
self._redis_url = redis_url
|
|
290
|
+
self.host = host
|
|
291
|
+
self.port = port
|
|
292
|
+
self.password = password
|
|
293
|
+
self._explicit_database = database
|
|
294
|
+
self.database = database if database is not None else 0
|
|
295
|
+
self.prefix = prefix if prefix is not None else _default_queue_prefix()
|
|
296
|
+
self.max_connections = max_connections or 50
|
|
297
|
+
self.timeout = timeout
|
|
298
|
+
self.failed_max_size = max(0, int(failed_max_size))
|
|
299
|
+
self._owns_client = redis_client is None
|
|
300
|
+
self._owns_pool = connection_pool is None and redis_client is None
|
|
301
|
+
self._client_lock = asyncio.Lock()
|
|
302
|
+
self._script_shas: dict[str, str] = {}
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
def get_instance(cls) -> QueueDispatcher:
|
|
306
|
+
"""Retrieve the active application's QueueDispatcher from the container, or fallback instance."""
|
|
307
|
+
try:
|
|
308
|
+
container = get_container()
|
|
309
|
+
if container.has(cls):
|
|
310
|
+
return container.resolve(cls)
|
|
311
|
+
inst = cls()
|
|
312
|
+
container.instance(cls, inst)
|
|
313
|
+
return inst
|
|
314
|
+
except RuntimeError:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
if cls._instance is None:
|
|
318
|
+
cls._instance = cls()
|
|
319
|
+
return cls._instance
|
|
320
|
+
|
|
321
|
+
@classmethod
|
|
322
|
+
def set_instance(cls, dispatcher: QueueDispatcher | None) -> None:
|
|
323
|
+
"""Override or reset the QueueDispatcher instance in the active container and fallback."""
|
|
324
|
+
cls._instance = dispatcher
|
|
325
|
+
try:
|
|
326
|
+
container = get_container()
|
|
327
|
+
if container.has(cls):
|
|
328
|
+
if dispatcher is not None:
|
|
329
|
+
container.instance(cls, dispatcher)
|
|
330
|
+
else:
|
|
331
|
+
container.singleton(cls, lambda _: cls())
|
|
332
|
+
except RuntimeError:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
async def get_client(self) -> aioredis.Redis:
|
|
336
|
+
"""Lazily initialize and return the async Redis client."""
|
|
337
|
+
if self._client is not None:
|
|
338
|
+
return self._client
|
|
339
|
+
async with self._client_lock:
|
|
340
|
+
if self._client is not None:
|
|
341
|
+
return self._client
|
|
342
|
+
if self._redis_url:
|
|
343
|
+
self._client = aioredis.from_url(
|
|
344
|
+
self._redis_url,
|
|
345
|
+
decode_responses=True,
|
|
346
|
+
encoding_errors="replace",
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
if self._pool is None:
|
|
350
|
+
params = resolve_redis_params(
|
|
351
|
+
section="queue",
|
|
352
|
+
host=self.host,
|
|
353
|
+
port=self.port,
|
|
354
|
+
password=self.password,
|
|
355
|
+
database=self._explicit_database,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
self._pool = aioredis.BlockingConnectionPool(
|
|
359
|
+
host=params["host"],
|
|
360
|
+
port=params["port"],
|
|
361
|
+
db=params["database"],
|
|
362
|
+
password=params["password"] or None,
|
|
363
|
+
decode_responses=True,
|
|
364
|
+
encoding_errors="replace",
|
|
365
|
+
max_connections=self.max_connections,
|
|
366
|
+
timeout=self.timeout,
|
|
367
|
+
)
|
|
368
|
+
self._client = aioredis.Redis(
|
|
369
|
+
connection_pool=self._pool,
|
|
370
|
+
decode_responses=True,
|
|
371
|
+
encoding_errors="replace",
|
|
372
|
+
)
|
|
373
|
+
self._owns_client = True
|
|
374
|
+
return self._client
|
|
375
|
+
|
|
376
|
+
def _queue_key(self, queue: str) -> str:
|
|
377
|
+
return f"{self.prefix}{queue}"
|
|
378
|
+
|
|
379
|
+
def _delayed_key(self, queue: str) -> str:
|
|
380
|
+
return f"{self.prefix}{queue}:delayed"
|
|
381
|
+
|
|
382
|
+
def _failed_key(self, queue: str) -> str:
|
|
383
|
+
return f"{self.prefix}{queue}:failed"
|
|
384
|
+
|
|
385
|
+
def _leases_key(self, queue: str) -> str:
|
|
386
|
+
return f"{self.prefix}{queue}:leases"
|
|
387
|
+
|
|
388
|
+
def _processing_key(self, queue: str, worker_id: str) -> str:
|
|
389
|
+
return f"{self.prefix}{queue}:processing:{worker_id}"
|
|
390
|
+
|
|
391
|
+
def _processing_prefix(self, queue: str) -> str:
|
|
392
|
+
return f"{self.prefix}{queue}:processing:"
|
|
393
|
+
|
|
394
|
+
async def _eval_lua(
|
|
395
|
+
self,
|
|
396
|
+
client: aioredis.Redis,
|
|
397
|
+
script: str,
|
|
398
|
+
numkeys: int,
|
|
399
|
+
*keys_and_args: str | float,
|
|
400
|
+
) -> Any:
|
|
401
|
+
"""Execute a Lua script with cached EVALSHA and transparent NoScriptError reload."""
|
|
402
|
+
sha = self._script_shas.get(script)
|
|
403
|
+
if sha is None and hasattr(client, "script_load"):
|
|
404
|
+
try:
|
|
405
|
+
sha = await client.script_load(script)
|
|
406
|
+
self._script_shas[script] = sha
|
|
407
|
+
except (RedisError, ConnectionError, OSError):
|
|
408
|
+
sha = None
|
|
409
|
+
|
|
410
|
+
if sha is not None and hasattr(client, "evalsha"):
|
|
411
|
+
try:
|
|
412
|
+
return await client.evalsha(sha, numkeys, *keys_and_args)
|
|
413
|
+
except NoScriptError:
|
|
414
|
+
sha = await client.script_load(script)
|
|
415
|
+
self._script_shas[script] = sha
|
|
416
|
+
return await client.evalsha(sha, numkeys, *keys_and_args)
|
|
417
|
+
|
|
418
|
+
return await client.eval(script, numkeys, *keys_and_args)
|
|
419
|
+
|
|
420
|
+
async def pop_and_lease(
|
|
421
|
+
self,
|
|
422
|
+
queue: str,
|
|
423
|
+
worker_id: str,
|
|
424
|
+
lease_duration: float = 60.0,
|
|
425
|
+
) -> str | None:
|
|
426
|
+
"""
|
|
427
|
+
Atomically move a job from the ready queue to this worker's processing list
|
|
428
|
+
and register a lease in the leases sorted set.
|
|
429
|
+
"""
|
|
430
|
+
client = await self.get_client()
|
|
431
|
+
deadline = time.time() + float(lease_duration)
|
|
432
|
+
res = await self._eval_lua(
|
|
433
|
+
client,
|
|
434
|
+
_POP_AND_LEASE_LUA,
|
|
435
|
+
3,
|
|
436
|
+
self._queue_key(queue),
|
|
437
|
+
self._processing_key(queue, worker_id),
|
|
438
|
+
self._leases_key(queue),
|
|
439
|
+
worker_id,
|
|
440
|
+
str(deadline),
|
|
441
|
+
)
|
|
442
|
+
if isinstance(res, (bytes, bytearray)):
|
|
443
|
+
return res.decode("utf-8")
|
|
444
|
+
return str(res) if res is not None else None
|
|
445
|
+
|
|
446
|
+
async def ack(
|
|
447
|
+
self,
|
|
448
|
+
queue: str,
|
|
449
|
+
worker_id: str,
|
|
450
|
+
raw_payload: str,
|
|
451
|
+
) -> bool:
|
|
452
|
+
"""
|
|
453
|
+
Atomically acknowledge a completed job: removes it from worker's processing list
|
|
454
|
+
and deletes its lease from the leases set.
|
|
455
|
+
"""
|
|
456
|
+
client = await self.get_client()
|
|
457
|
+
member = f"{worker_id}\x1f{raw_payload}"
|
|
458
|
+
res = await self._eval_lua(
|
|
459
|
+
client,
|
|
460
|
+
_ACK_JOB_LUA,
|
|
461
|
+
2,
|
|
462
|
+
self._processing_key(queue, worker_id),
|
|
463
|
+
self._leases_key(queue),
|
|
464
|
+
raw_payload,
|
|
465
|
+
member,
|
|
466
|
+
)
|
|
467
|
+
return bool(res)
|
|
468
|
+
|
|
469
|
+
async def release(
|
|
470
|
+
self,
|
|
471
|
+
queue: str,
|
|
472
|
+
worker_id: str,
|
|
473
|
+
raw_payload: str,
|
|
474
|
+
delay: float = 0.0,
|
|
475
|
+
new_payload: str | None = None,
|
|
476
|
+
) -> bool:
|
|
477
|
+
"""
|
|
478
|
+
Atomically release an in-flight job: removes it from processing list and leases set,
|
|
479
|
+
and re-queues it onto the ready queue (at the head) or the delayed set if delay > 0.
|
|
480
|
+
"""
|
|
481
|
+
client = await self.get_client()
|
|
482
|
+
payload_to_store = new_payload if new_payload is not None else raw_payload
|
|
483
|
+
target_key = (
|
|
484
|
+
self._delayed_key(queue) if delay > 0 else self._queue_key(queue)
|
|
485
|
+
)
|
|
486
|
+
member = f"{worker_id}\x1f{raw_payload}"
|
|
487
|
+
res = await self._eval_lua(
|
|
488
|
+
client,
|
|
489
|
+
_RELEASE_JOB_LUA,
|
|
490
|
+
3,
|
|
491
|
+
self._processing_key(queue, worker_id),
|
|
492
|
+
self._leases_key(queue),
|
|
493
|
+
target_key,
|
|
494
|
+
raw_payload,
|
|
495
|
+
member,
|
|
496
|
+
payload_to_store,
|
|
497
|
+
str(delay),
|
|
498
|
+
str(time.time()),
|
|
499
|
+
)
|
|
500
|
+
return bool(res)
|
|
501
|
+
|
|
502
|
+
async def reap_expired_leases(
|
|
503
|
+
self,
|
|
504
|
+
queue: str,
|
|
505
|
+
batch_size: int = 50,
|
|
506
|
+
) -> int:
|
|
507
|
+
"""
|
|
508
|
+
Scan for expired leases, remove them from crashed worker processing lists,
|
|
509
|
+
increment their attempt count, and either move to DLQ (if tries reached)
|
|
510
|
+
or re-queue at the head of the ready queue.
|
|
511
|
+
"""
|
|
512
|
+
client = await self.get_client()
|
|
513
|
+
res = await self._eval_lua(
|
|
514
|
+
client,
|
|
515
|
+
_REAP_LEASES_LUA,
|
|
516
|
+
3,
|
|
517
|
+
self._leases_key(queue),
|
|
518
|
+
self._queue_key(queue),
|
|
519
|
+
self._failed_key(queue),
|
|
520
|
+
str(time.time()),
|
|
521
|
+
str(batch_size),
|
|
522
|
+
self._processing_prefix(queue),
|
|
523
|
+
str(self.failed_max_size),
|
|
524
|
+
)
|
|
525
|
+
return int(res) if res is not None else 0
|
|
526
|
+
|
|
527
|
+
async def processing_size(
|
|
528
|
+
self,
|
|
529
|
+
queue: str = "default",
|
|
530
|
+
worker_id: str | None = None,
|
|
531
|
+
) -> int:
|
|
532
|
+
"""Return the number of in-flight jobs being processed."""
|
|
533
|
+
client = await self.get_client()
|
|
534
|
+
if worker_id is not None:
|
|
535
|
+
return int(
|
|
536
|
+
await client.llen(self._processing_key(queue, worker_id))
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
total = 0
|
|
540
|
+
async for key in client.scan_iter(
|
|
541
|
+
match=f"{self._processing_prefix(queue)}*"
|
|
542
|
+
):
|
|
543
|
+
total += int(await client.llen(key))
|
|
544
|
+
return total
|
|
545
|
+
|
|
546
|
+
async def leases_size(self, queue: str = "default") -> int:
|
|
547
|
+
"""Return the number of active leases recorded for the given queue."""
|
|
548
|
+
client = await self.get_client()
|
|
549
|
+
return int(await client.zcard(self._leases_key(queue)))
|
|
550
|
+
|
|
551
|
+
async def push(
|
|
552
|
+
self,
|
|
553
|
+
job: Job,
|
|
554
|
+
queue: str | None = None,
|
|
555
|
+
delay: float = 0,
|
|
556
|
+
) -> str:
|
|
557
|
+
"""
|
|
558
|
+
Serialize a Job instance and push it onto the Redis queue.
|
|
559
|
+
If delay > 0, schedules it in the delayed sorted set.
|
|
560
|
+
"""
|
|
561
|
+
client = await self.get_client()
|
|
562
|
+
target_queue = queue or job.queue or "default"
|
|
563
|
+
job.queue = target_queue
|
|
564
|
+
job.pushed_at = time.time()
|
|
565
|
+
|
|
566
|
+
payload_dict = job.serialize()
|
|
567
|
+
payload_str = json.dumps(payload_dict, ensure_ascii=False)
|
|
568
|
+
|
|
569
|
+
if delay > 0:
|
|
570
|
+
score = time.time() + float(delay)
|
|
571
|
+
await client.zadd(self._delayed_key(target_queue), {payload_str: score})
|
|
572
|
+
logger.debug(
|
|
573
|
+
"Scheduled job %s on %s with delay %ss",
|
|
574
|
+
job.job_id,
|
|
575
|
+
target_queue,
|
|
576
|
+
delay,
|
|
577
|
+
)
|
|
578
|
+
else:
|
|
579
|
+
await client.rpush(self._queue_key(target_queue), payload_str)
|
|
580
|
+
logger.debug("Pushed job %s to %s", job.job_id, target_queue)
|
|
581
|
+
|
|
582
|
+
return job.job_id
|
|
583
|
+
|
|
584
|
+
async def push_raw(
|
|
585
|
+
self,
|
|
586
|
+
queue: str,
|
|
587
|
+
raw_payload: str,
|
|
588
|
+
delay: float = 0,
|
|
589
|
+
) -> None:
|
|
590
|
+
"""
|
|
591
|
+
Push a raw JSON string envelope onto the target queue or delayed set.
|
|
592
|
+
"""
|
|
593
|
+
client = await self.get_client()
|
|
594
|
+
if delay > 0:
|
|
595
|
+
score = time.time() + float(delay)
|
|
596
|
+
await client.zadd(self._delayed_key(queue), {raw_payload: score})
|
|
597
|
+
else:
|
|
598
|
+
await client.rpush(self._queue_key(queue), raw_payload)
|
|
599
|
+
|
|
600
|
+
async def push_failed(
|
|
601
|
+
self,
|
|
602
|
+
job: Job | None,
|
|
603
|
+
exception: BaseException,
|
|
604
|
+
queue: str = "default",
|
|
605
|
+
payload: dict[str, Any] | None = None,
|
|
606
|
+
) -> None:
|
|
607
|
+
"""
|
|
608
|
+
Push a failed job record to the Dead Letter Queue (DLQ).
|
|
609
|
+
"""
|
|
610
|
+
client = await self.get_client()
|
|
611
|
+
target_queue = queue or (job.queue if job else "default")
|
|
612
|
+
|
|
613
|
+
job_id = (
|
|
614
|
+
job.job_id
|
|
615
|
+
if job
|
|
616
|
+
else (payload.get("job_id") if payload else uuid.uuid4().hex)
|
|
617
|
+
)
|
|
618
|
+
job_class = (
|
|
619
|
+
f"{job.__class__.__module__}:{job.__class__.__qualname__}"
|
|
620
|
+
if job
|
|
621
|
+
else (payload.get("job_class") if payload else "unknown")
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
record: dict[str, Any] = {
|
|
625
|
+
"job_id": job_id,
|
|
626
|
+
"job_class": job_class,
|
|
627
|
+
"queue": target_queue,
|
|
628
|
+
"payload": payload
|
|
629
|
+
if payload is not None
|
|
630
|
+
else (job.serialize() if job else {}),
|
|
631
|
+
"exception": {
|
|
632
|
+
"type": type(exception).__name__,
|
|
633
|
+
"message": str(exception),
|
|
634
|
+
"traceback": traceback.format_exc(),
|
|
635
|
+
},
|
|
636
|
+
"failed_at": time.time(),
|
|
637
|
+
"attempts": job.attempts
|
|
638
|
+
if job
|
|
639
|
+
else (payload.get("attempts", 0) if payload else 0),
|
|
640
|
+
"tries": job.tries if job else (payload.get("tries", 0) if payload else 0),
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
record_str = json.dumps(record, ensure_ascii=False)
|
|
644
|
+
failed_key = self._failed_key(target_queue)
|
|
645
|
+
await client.rpush(failed_key, record_str)
|
|
646
|
+
if self.failed_max_size > 0:
|
|
647
|
+
await client.ltrim(failed_key, -self.failed_max_size, -1)
|
|
648
|
+
logger.error(
|
|
649
|
+
"Moved job %s [%s] to Dead Letter Queue (%s)",
|
|
650
|
+
job_id,
|
|
651
|
+
job_class,
|
|
652
|
+
failed_key,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
async def size(self, queue: str = "default") -> int:
|
|
656
|
+
"""Return the number of pending jobs in the active queue."""
|
|
657
|
+
client = await self.get_client()
|
|
658
|
+
return int(await client.llen(self._queue_key(queue)))
|
|
659
|
+
|
|
660
|
+
async def delayed_size(self, queue: str = "default") -> int:
|
|
661
|
+
"""Return the number of scheduled jobs in the delayed set."""
|
|
662
|
+
client = await self.get_client()
|
|
663
|
+
return int(await client.zcard(self._delayed_key(queue)))
|
|
664
|
+
|
|
665
|
+
async def failed_size(self, queue: str = "default") -> int:
|
|
666
|
+
"""Return the number of failed jobs in the Dead Letter Queue."""
|
|
667
|
+
client = await self.get_client()
|
|
668
|
+
return int(await client.llen(self._failed_key(queue)))
|
|
669
|
+
|
|
670
|
+
async def get_failed_jobs(
|
|
671
|
+
self,
|
|
672
|
+
queue: str = "default",
|
|
673
|
+
start: int = 0,
|
|
674
|
+
stop: int = -1,
|
|
675
|
+
) -> list[dict[str, Any]]:
|
|
676
|
+
"""Retrieve recorded jobs from the Dead Letter Queue."""
|
|
677
|
+
client = await self.get_client()
|
|
678
|
+
items = await client.lrange(self._failed_key(queue), start, stop)
|
|
679
|
+
results: list[dict[str, Any]] = []
|
|
680
|
+
for raw in items:
|
|
681
|
+
try:
|
|
682
|
+
results.append(json.loads(raw))
|
|
683
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
684
|
+
results.append({"raw": raw})
|
|
685
|
+
return results
|
|
686
|
+
|
|
687
|
+
async def retry_failed(
|
|
688
|
+
self,
|
|
689
|
+
queue: str = "default",
|
|
690
|
+
job_id: str | None = None,
|
|
691
|
+
) -> int:
|
|
692
|
+
"""
|
|
693
|
+
Requeue failed jobs from the Dead Letter Queue back into the active ready queue.
|
|
694
|
+
If job_id is None or 'all', retries all failed jobs.
|
|
695
|
+
"""
|
|
696
|
+
client = await self.get_client()
|
|
697
|
+
failed_key = self._failed_key(queue)
|
|
698
|
+
items = await client.lrange(failed_key, 0, -1)
|
|
699
|
+
retried_count = 0
|
|
700
|
+
|
|
701
|
+
for raw in items:
|
|
702
|
+
raw_str = (
|
|
703
|
+
raw.decode("utf-8", errors="replace")
|
|
704
|
+
if isinstance(raw, (bytes, bytearray))
|
|
705
|
+
else str(raw)
|
|
706
|
+
)
|
|
707
|
+
try:
|
|
708
|
+
record = json.loads(raw_str)
|
|
709
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
710
|
+
logger.debug("Skipping unparseable failed job record", exc_info=True)
|
|
711
|
+
continue
|
|
712
|
+
|
|
713
|
+
rec_id = record.get("job_id")
|
|
714
|
+
if job_id in (None, "all") or rec_id == job_id:
|
|
715
|
+
payload = record.get("payload")
|
|
716
|
+
parsed_payload: dict[str, Any] | None = None
|
|
717
|
+
if isinstance(payload, dict):
|
|
718
|
+
parsed_payload = dict(payload)
|
|
719
|
+
elif isinstance(payload, str):
|
|
720
|
+
try:
|
|
721
|
+
maybe_dict = json.loads(payload)
|
|
722
|
+
if isinstance(maybe_dict, dict):
|
|
723
|
+
parsed_payload = maybe_dict
|
|
724
|
+
except (json.JSONDecodeError, TypeError):
|
|
725
|
+
parsed_payload = None
|
|
726
|
+
|
|
727
|
+
if parsed_payload is not None:
|
|
728
|
+
parsed_payload["attempts"] = 0
|
|
729
|
+
if "job_class" in parsed_payload:
|
|
730
|
+
key = Job.get_app_key()
|
|
731
|
+
if key is not None and str(key).strip():
|
|
732
|
+
validate_signing_key(str(key), purpose="APP_KEY")
|
|
733
|
+
parsed_payload["signature"] = Job._compute_signature(
|
|
734
|
+
parsed_payload, str(key)
|
|
735
|
+
)
|
|
736
|
+
elif not Job.get_allow_unsigned():
|
|
737
|
+
raise ConfigurationError(
|
|
738
|
+
"APP_KEY (>=32 bytes) is required to re-sign retried queue jobs."
|
|
739
|
+
)
|
|
740
|
+
requeue_str = json.dumps(parsed_payload, ensure_ascii=False)
|
|
741
|
+
elif isinstance(payload, str):
|
|
742
|
+
requeue_str = payload
|
|
743
|
+
else:
|
|
744
|
+
requeue_str = raw_str
|
|
745
|
+
|
|
746
|
+
await client.rpush(self._queue_key(queue), requeue_str)
|
|
747
|
+
await client.lrem(failed_key, 1, raw_str)
|
|
748
|
+
retried_count += 1
|
|
749
|
+
if job_id not in (None, "all"):
|
|
750
|
+
break
|
|
751
|
+
|
|
752
|
+
return retried_count
|
|
753
|
+
|
|
754
|
+
async def flush_failed(self, queue: str = "default") -> int:
|
|
755
|
+
"""Clear all failed records from the Dead Letter Queue for the given queue name."""
|
|
756
|
+
client = await self.get_client()
|
|
757
|
+
failed_key = self._failed_key(queue)
|
|
758
|
+
count = int(await client.llen(failed_key))
|
|
759
|
+
await client.delete(failed_key)
|
|
760
|
+
return count
|
|
761
|
+
|
|
762
|
+
async def clear(self, queue: str = "default") -> None:
|
|
763
|
+
"""Clear active, delayed, failed, lease, and processing queues for the given queue name."""
|
|
764
|
+
client = await self.get_client()
|
|
765
|
+
keys_to_delete = [
|
|
766
|
+
self._queue_key(queue),
|
|
767
|
+
self._delayed_key(queue),
|
|
768
|
+
self._failed_key(queue),
|
|
769
|
+
self._leases_key(queue),
|
|
770
|
+
]
|
|
771
|
+
async for key in client.scan_iter(
|
|
772
|
+
match=f"{self._processing_prefix(queue)}*"
|
|
773
|
+
):
|
|
774
|
+
keys_to_delete.append(key)
|
|
775
|
+
|
|
776
|
+
if keys_to_delete:
|
|
777
|
+
await client.delete(*keys_to_delete)
|
|
778
|
+
|
|
779
|
+
async def close(self) -> None:
|
|
780
|
+
"""Close the Redis client and connection pool if owned."""
|
|
781
|
+
if self._owns_client and self._client is not None:
|
|
782
|
+
await self._client.aclose()
|
|
783
|
+
self._client = None
|
|
784
|
+
if self._owns_pool and self._pool is not None:
|
|
785
|
+
await self._pool.aclose()
|
|
786
|
+
self._pool = None
|
|
787
|
+
|
|
788
|
+
async def aclose(self) -> None:
|
|
789
|
+
"""Alias for close()."""
|
|
790
|
+
await self.close()
|
|
791
|
+
|
|
792
|
+
async def __aenter__(self) -> Self:
|
|
793
|
+
return self
|
|
794
|
+
|
|
795
|
+
async def __aexit__(self, *args: object) -> None:
|
|
796
|
+
await self.close()
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
async def push(
|
|
800
|
+
job: Job,
|
|
801
|
+
queue: str | None = None,
|
|
802
|
+
delay: float = 0,
|
|
803
|
+
) -> str:
|
|
804
|
+
"""
|
|
805
|
+
Push a Job onto the default QueueDispatcher.
|
|
806
|
+
"""
|
|
807
|
+
return await QueueDispatcher.get_instance().push(job, queue=queue, delay=delay)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
# Wire the Job.dispatch() hook to delegate to QueueDispatcher.get_instance().push
|
|
811
|
+
set_dispatch_handler(
|
|
812
|
+
lambda j, q, d: QueueDispatcher.get_instance().push(j, queue=q, delay=d)
|
|
813
|
+
)
|