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
swiftpy/queue/worker.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import signal
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from redis.exceptions import NoScriptError, RedisError
|
|
14
|
+
|
|
15
|
+
from swiftpy.core.config import ConfigurationError
|
|
16
|
+
from swiftpy.core.container import get_container
|
|
17
|
+
from swiftpy.database.pool import DatabasePool
|
|
18
|
+
from swiftpy.queue.dispatcher import QueueDispatcher
|
|
19
|
+
from swiftpy.queue.job import Job, JobSerializationError
|
|
20
|
+
from swiftpy.support.keys import validate_signing_key
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("swiftpy.queue")
|
|
23
|
+
|
|
24
|
+
_QUEUE_NAME_REGEX = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
|
25
|
+
|
|
26
|
+
# Atomic Lua script to migrate due delayed jobs into the active queue list
|
|
27
|
+
_PROMOTE_DELAYED_LUA = """
|
|
28
|
+
local due = redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 50)
|
|
29
|
+
for i, job in ipairs(due) do
|
|
30
|
+
redis.call('zrem', KEYS[1], job)
|
|
31
|
+
redis.call('rpush', KEYS[2], job)
|
|
32
|
+
end
|
|
33
|
+
return #due
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class QueueWorker:
|
|
38
|
+
"""
|
|
39
|
+
Queue worker process responsible for consuming jobs from Redis queues,
|
|
40
|
+
migrating scheduled delayed jobs, managing backoff retries, and recording
|
|
41
|
+
failed jobs into the Dead Letter Queue.
|
|
42
|
+
|
|
43
|
+
Queue delivery operates with at-least-once semantics; job handlers must be idempotent.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
queues: str | list[str] = "default",
|
|
49
|
+
dispatcher: QueueDispatcher | None = None,
|
|
50
|
+
sleep: float = 1.0,
|
|
51
|
+
max_jobs: int | None = None,
|
|
52
|
+
stop_when_empty: bool = False,
|
|
53
|
+
timeout: float | None = None,
|
|
54
|
+
worker_id: str | None = None,
|
|
55
|
+
concurrency: int = 1,
|
|
56
|
+
promote_interval: float = 0.1,
|
|
57
|
+
allow_unsigned: bool | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
if isinstance(queues, str):
|
|
60
|
+
raw_queues = [q.strip() for q in queues.split(",") if q.strip()]
|
|
61
|
+
else:
|
|
62
|
+
raw_queues = list(queues) if queues else []
|
|
63
|
+
|
|
64
|
+
if not raw_queues:
|
|
65
|
+
raise ValueError("Invalid queue name: queue name cannot be empty")
|
|
66
|
+
|
|
67
|
+
for q in raw_queues:
|
|
68
|
+
if not _QUEUE_NAME_REGEX.fullmatch(q):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Invalid queue name '{q}': must match ^[A-Za-z0-9_.-]{{1,64}}$"
|
|
71
|
+
)
|
|
72
|
+
self.queues = raw_queues
|
|
73
|
+
|
|
74
|
+
self._owns_dispatcher = dispatcher is None
|
|
75
|
+
self.dispatcher: QueueDispatcher = (
|
|
76
|
+
dispatcher if dispatcher is not None else QueueDispatcher()
|
|
77
|
+
)
|
|
78
|
+
self.sleep = max(0.01, float(sleep))
|
|
79
|
+
self.max_jobs = max_jobs
|
|
80
|
+
self.stop_when_empty = stop_when_empty
|
|
81
|
+
self.timeout = timeout
|
|
82
|
+
self.worker_id = worker_id or f"worker-{uuid.uuid4().hex[:8]}"
|
|
83
|
+
self.concurrency = max(1, int(concurrency))
|
|
84
|
+
self.promote_interval = max(0.01, float(promote_interval))
|
|
85
|
+
self.allow_unsigned = allow_unsigned
|
|
86
|
+
self._semaphore = asyncio.Semaphore(self.concurrency)
|
|
87
|
+
self._active_tasks: set[asyncio.Task[Any]] = set()
|
|
88
|
+
self._original_handlers: dict[signal.Signals, Any] = {}
|
|
89
|
+
|
|
90
|
+
self._should_stop: bool = False
|
|
91
|
+
self._is_working: bool = False
|
|
92
|
+
self.jobs_processed: int = 0
|
|
93
|
+
self.jobs_failed: int = 0
|
|
94
|
+
self._promote_script_sha: str | None = None
|
|
95
|
+
self._last_promoted_at: float = 0.0
|
|
96
|
+
|
|
97
|
+
def stop(self) -> None:
|
|
98
|
+
"""Signal the worker process to gracefully stop."""
|
|
99
|
+
logger.info("Worker %s received stop signal", self.worker_id)
|
|
100
|
+
self._should_stop = True
|
|
101
|
+
|
|
102
|
+
def _setup_signal_handlers(self) -> None:
|
|
103
|
+
"""Register SIGINT and SIGTERM handlers for graceful shutdown."""
|
|
104
|
+
try:
|
|
105
|
+
loop = asyncio.get_running_loop()
|
|
106
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
107
|
+
try:
|
|
108
|
+
self._original_handlers[sig] = signal.getsignal(sig)
|
|
109
|
+
except (ValueError, AttributeError):
|
|
110
|
+
pass
|
|
111
|
+
try:
|
|
112
|
+
loop.add_signal_handler(sig, self.stop)
|
|
113
|
+
except (NotImplementedError, RuntimeError):
|
|
114
|
+
signal.signal(sig, lambda s, f: self.stop())
|
|
115
|
+
except (RuntimeError, ValueError):
|
|
116
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
117
|
+
try:
|
|
118
|
+
self._original_handlers[sig] = signal.getsignal(sig)
|
|
119
|
+
except (ValueError, AttributeError):
|
|
120
|
+
pass
|
|
121
|
+
signal.signal(sig, lambda s, f: self.stop())
|
|
122
|
+
|
|
123
|
+
def _restore_signal_handlers(self) -> None:
|
|
124
|
+
"""Restore original signal handlers on worker exit."""
|
|
125
|
+
for sig, handler in self._original_handlers.items():
|
|
126
|
+
try:
|
|
127
|
+
loop = asyncio.get_running_loop()
|
|
128
|
+
loop.remove_signal_handler(sig)
|
|
129
|
+
except (NotImplementedError, RuntimeError, ValueError):
|
|
130
|
+
pass
|
|
131
|
+
try:
|
|
132
|
+
if handler is not None:
|
|
133
|
+
signal.signal(sig, handler)
|
|
134
|
+
except (ValueError, AttributeError):
|
|
135
|
+
pass
|
|
136
|
+
self._original_handlers.clear()
|
|
137
|
+
|
|
138
|
+
async def _promote_delayed_jobs(self, queue: str) -> int:
|
|
139
|
+
"""
|
|
140
|
+
Atomically move scheduled jobs with score <= now from delayed ZSET to active queue list.
|
|
141
|
+
Uses SHA-cached EVALSHA for efficiency.
|
|
142
|
+
"""
|
|
143
|
+
if not hasattr(self.dispatcher, "get_client"):
|
|
144
|
+
return 0
|
|
145
|
+
client = await self.dispatcher.get_client()
|
|
146
|
+
delayed_key = self.dispatcher._delayed_key(queue)
|
|
147
|
+
queue_key = self.dispatcher._queue_key(queue)
|
|
148
|
+
now_str = str(time.time())
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
if self._promote_script_sha is None:
|
|
152
|
+
try:
|
|
153
|
+
self._promote_script_sha = await client.script_load(_PROMOTE_DELAYED_LUA)
|
|
154
|
+
except (RedisError, ConnectionError, OSError):
|
|
155
|
+
logger.debug("Could not pre-load promote script SHA", exc_info=True)
|
|
156
|
+
|
|
157
|
+
if self._promote_script_sha is not None:
|
|
158
|
+
try:
|
|
159
|
+
res = await client.evalsha(
|
|
160
|
+
self._promote_script_sha,
|
|
161
|
+
2,
|
|
162
|
+
delayed_key,
|
|
163
|
+
queue_key,
|
|
164
|
+
now_str,
|
|
165
|
+
)
|
|
166
|
+
except NoScriptError:
|
|
167
|
+
self._promote_script_sha = await client.script_load(_PROMOTE_DELAYED_LUA)
|
|
168
|
+
res = await client.evalsha(
|
|
169
|
+
self._promote_script_sha,
|
|
170
|
+
2,
|
|
171
|
+
delayed_key,
|
|
172
|
+
queue_key,
|
|
173
|
+
now_str,
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
res = await client.eval(
|
|
177
|
+
_PROMOTE_DELAYED_LUA,
|
|
178
|
+
2,
|
|
179
|
+
delayed_key,
|
|
180
|
+
queue_key,
|
|
181
|
+
now_str,
|
|
182
|
+
)
|
|
183
|
+
return int(res) if res is not None else 0
|
|
184
|
+
except (TimeoutError, RedisError, ConnectionError, OSError) as exc:
|
|
185
|
+
logger.warning(
|
|
186
|
+
"Error promoting delayed jobs for queue '%s': %s", queue, exc
|
|
187
|
+
)
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
async def _pop_next_job(self) -> tuple[str, str] | None:
|
|
191
|
+
"""
|
|
192
|
+
Check all monitored queues in order, throttled delayed-job promotion & lease reaping,
|
|
193
|
+
and atomically pop & lease the first available job payload.
|
|
194
|
+
"""
|
|
195
|
+
now = time.monotonic()
|
|
196
|
+
if (
|
|
197
|
+
self._last_promoted_at <= 0.0
|
|
198
|
+
or (now - self._last_promoted_at) >= self.promote_interval
|
|
199
|
+
):
|
|
200
|
+
self._last_promoted_at = now
|
|
201
|
+
for queue in self.queues:
|
|
202
|
+
await self._promote_delayed_jobs(queue)
|
|
203
|
+
await self.dispatcher.reap_expired_leases(queue)
|
|
204
|
+
|
|
205
|
+
for queue in self.queues:
|
|
206
|
+
lease_duration = (self.timeout + 30.0) if self.timeout else 60.0
|
|
207
|
+
raw_payload = await self.dispatcher.pop_and_lease(
|
|
208
|
+
queue,
|
|
209
|
+
self.worker_id,
|
|
210
|
+
lease_duration=lease_duration,
|
|
211
|
+
)
|
|
212
|
+
if raw_payload is not None:
|
|
213
|
+
return queue, raw_payload
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
async def _all_queues_empty(self) -> bool:
|
|
217
|
+
"""
|
|
218
|
+
Check whether all monitored active queues, delayed queues, and in-flight processing lists have zero jobs.
|
|
219
|
+
"""
|
|
220
|
+
for queue in self.queues:
|
|
221
|
+
active_size = await self.dispatcher.size(queue)
|
|
222
|
+
if active_size > 0:
|
|
223
|
+
return False
|
|
224
|
+
delayed_size = await self.dispatcher.delayed_size(queue)
|
|
225
|
+
if delayed_size > 0:
|
|
226
|
+
return False
|
|
227
|
+
proc_size = await self.dispatcher.processing_size(queue)
|
|
228
|
+
if proc_size > 0:
|
|
229
|
+
return False
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
async def process_job(self, queue: str, raw_payload: str) -> bool:
|
|
233
|
+
"""
|
|
234
|
+
Deserialize and process a single queued job payload.
|
|
235
|
+
Returns True if handled, False on failure.
|
|
236
|
+
"""
|
|
237
|
+
# 1. Parse JSON envelope
|
|
238
|
+
try:
|
|
239
|
+
data = json.loads(raw_payload)
|
|
240
|
+
if not isinstance(data, dict):
|
|
241
|
+
raise JobSerializationError(
|
|
242
|
+
f"Invalid job envelope: expected dict, got {type(data).__name__}"
|
|
243
|
+
)
|
|
244
|
+
except (
|
|
245
|
+
json.JSONDecodeError,
|
|
246
|
+
UnicodeDecodeError,
|
|
247
|
+
TypeError,
|
|
248
|
+
ValueError,
|
|
249
|
+
JobSerializationError,
|
|
250
|
+
) as exc:
|
|
251
|
+
logger.error(
|
|
252
|
+
"Worker %s encountered unparseable JSON payload: %s",
|
|
253
|
+
self.worker_id,
|
|
254
|
+
exc,
|
|
255
|
+
)
|
|
256
|
+
await self.dispatcher.push_failed(
|
|
257
|
+
job=None,
|
|
258
|
+
exception=exc,
|
|
259
|
+
queue=queue,
|
|
260
|
+
payload={"raw": raw_payload},
|
|
261
|
+
)
|
|
262
|
+
await self.dispatcher.ack(queue, self.worker_id, raw_payload)
|
|
263
|
+
self.jobs_failed += 1
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
# 2. Reconstruct Job instance
|
|
267
|
+
try:
|
|
268
|
+
job = Job.deserialize(data, allow_unsigned=self.allow_unsigned)
|
|
269
|
+
except (
|
|
270
|
+
JobSerializationError,
|
|
271
|
+
TypeError,
|
|
272
|
+
ValueError,
|
|
273
|
+
KeyError,
|
|
274
|
+
AttributeError,
|
|
275
|
+
) as exc:
|
|
276
|
+
logger.error(
|
|
277
|
+
"Worker %s failed to deserialize job envelope: %s",
|
|
278
|
+
self.worker_id,
|
|
279
|
+
exc,
|
|
280
|
+
)
|
|
281
|
+
await self.dispatcher.push_failed(
|
|
282
|
+
job=None,
|
|
283
|
+
exception=exc,
|
|
284
|
+
queue=queue,
|
|
285
|
+
payload=data,
|
|
286
|
+
)
|
|
287
|
+
await self.dispatcher.ack(queue, self.worker_id, raw_payload)
|
|
288
|
+
self.jobs_failed += 1
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
# 3. Increment attempt count
|
|
292
|
+
job.attempts += 1
|
|
293
|
+
effective_timeout = job.timeout or self.timeout
|
|
294
|
+
|
|
295
|
+
# 4. Execute Job.handle()
|
|
296
|
+
logger.debug(
|
|
297
|
+
"Worker %s executing job %s [%s] attempt %d/%d on queue '%s'",
|
|
298
|
+
self.worker_id,
|
|
299
|
+
job.job_id,
|
|
300
|
+
job.__class__.__name__,
|
|
301
|
+
job.attempts,
|
|
302
|
+
job.tries,
|
|
303
|
+
queue,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
if effective_timeout is not None and effective_timeout > 0:
|
|
308
|
+
await asyncio.wait_for(job.handle(), timeout=effective_timeout)
|
|
309
|
+
else:
|
|
310
|
+
await job.handle()
|
|
311
|
+
except asyncio.CancelledError:
|
|
312
|
+
# Worker task was cancelled mid-execution: preserve job and attempts by re-queuing at head
|
|
313
|
+
logger.warning(
|
|
314
|
+
"Worker %s cancelled while processing job %s; re-queueing",
|
|
315
|
+
self.worker_id,
|
|
316
|
+
job.job_id,
|
|
317
|
+
)
|
|
318
|
+
requeue_payload = json.dumps(
|
|
319
|
+
job.serialize(allow_unsigned=self.allow_unsigned),
|
|
320
|
+
ensure_ascii=False,
|
|
321
|
+
)
|
|
322
|
+
await self.dispatcher.release(
|
|
323
|
+
queue,
|
|
324
|
+
self.worker_id,
|
|
325
|
+
raw_payload,
|
|
326
|
+
delay=0,
|
|
327
|
+
new_payload=requeue_payload,
|
|
328
|
+
)
|
|
329
|
+
raise
|
|
330
|
+
except Exception as exc: # noqa: BLE001 - user job handler can raise any standard Exception
|
|
331
|
+
await self._handle_job_failure(job, queue, raw_payload, data, exc)
|
|
332
|
+
return False
|
|
333
|
+
else:
|
|
334
|
+
logger.info(
|
|
335
|
+
"Worker %s completed job %s [%s]",
|
|
336
|
+
self.worker_id,
|
|
337
|
+
job.job_id,
|
|
338
|
+
job.__class__.__name__,
|
|
339
|
+
)
|
|
340
|
+
await self.dispatcher.ack(queue, self.worker_id, raw_payload)
|
|
341
|
+
self.jobs_processed += 1
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
async def _handle_job_failure(
|
|
345
|
+
self,
|
|
346
|
+
job: Job,
|
|
347
|
+
queue: str,
|
|
348
|
+
raw_payload: str,
|
|
349
|
+
data: dict[str, Any],
|
|
350
|
+
exc: BaseException,
|
|
351
|
+
) -> None:
|
|
352
|
+
"""
|
|
353
|
+
Handle job failure: retry with backoff if attempts < tries, or move to DLQ.
|
|
354
|
+
"""
|
|
355
|
+
logger.warning(
|
|
356
|
+
"Job %s [%s] failed on attempt %d/%d: %s",
|
|
357
|
+
job.job_id,
|
|
358
|
+
job.__class__.__name__,
|
|
359
|
+
job.attempts,
|
|
360
|
+
job.tries,
|
|
361
|
+
exc,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if job.attempts < job.tries:
|
|
365
|
+
# Retry with backoff
|
|
366
|
+
delay = job.get_backoff_delay()
|
|
367
|
+
logger.info(
|
|
368
|
+
"Retrying job %s [%s] in %.2fs (attempt %d/%d)",
|
|
369
|
+
job.job_id,
|
|
370
|
+
job.__class__.__name__,
|
|
371
|
+
delay,
|
|
372
|
+
job.attempts + 1,
|
|
373
|
+
job.tries,
|
|
374
|
+
)
|
|
375
|
+
requeue_payload = json.dumps(
|
|
376
|
+
job.serialize(allow_unsigned=self.allow_unsigned),
|
|
377
|
+
ensure_ascii=False,
|
|
378
|
+
)
|
|
379
|
+
await self.dispatcher.release(
|
|
380
|
+
queue,
|
|
381
|
+
self.worker_id,
|
|
382
|
+
raw_payload,
|
|
383
|
+
delay=delay,
|
|
384
|
+
new_payload=requeue_payload,
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
# Retries exhausted -> Dead Letter Queue
|
|
388
|
+
logger.error(
|
|
389
|
+
"Job %s [%s] exhausted all %d attempts. Moving to Dead Letter Queue.",
|
|
390
|
+
job.job_id,
|
|
391
|
+
job.__class__.__name__,
|
|
392
|
+
job.tries,
|
|
393
|
+
)
|
|
394
|
+
try:
|
|
395
|
+
res = job.failed(exc)
|
|
396
|
+
if inspect.isawaitable(res):
|
|
397
|
+
await res
|
|
398
|
+
except Exception:
|
|
399
|
+
logger.exception(
|
|
400
|
+
"Error executing failed() hook on job %s",
|
|
401
|
+
job.job_id,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
await self.dispatcher.push_failed(
|
|
405
|
+
job=job,
|
|
406
|
+
exception=exc,
|
|
407
|
+
queue=queue,
|
|
408
|
+
payload=data,
|
|
409
|
+
)
|
|
410
|
+
await self.dispatcher.ack(queue, self.worker_id, raw_payload)
|
|
411
|
+
self.jobs_failed += 1
|
|
412
|
+
|
|
413
|
+
async def _run_job_with_semaphore(self, queue: str, raw_payload: str) -> None:
|
|
414
|
+
"""Run job inside concurrency semaphore."""
|
|
415
|
+
try:
|
|
416
|
+
await self.process_job(queue, raw_payload)
|
|
417
|
+
finally:
|
|
418
|
+
self._semaphore.release()
|
|
419
|
+
|
|
420
|
+
async def work(self) -> None:
|
|
421
|
+
"""
|
|
422
|
+
Run the worker loop pulling and executing jobs.
|
|
423
|
+
"""
|
|
424
|
+
effective_key = Job.get_app_key()
|
|
425
|
+
unsigned_allowed = (
|
|
426
|
+
self.allow_unsigned
|
|
427
|
+
if self.allow_unsigned is not None
|
|
428
|
+
else Job.get_allow_unsigned()
|
|
429
|
+
)
|
|
430
|
+
if effective_key is None or not str(effective_key).strip():
|
|
431
|
+
if not unsigned_allowed:
|
|
432
|
+
raise ConfigurationError(
|
|
433
|
+
"APP_KEY (>=32 bytes) is required to run QueueWorker. "
|
|
434
|
+
"Configure APP_KEY or pass allow_unsigned=True for local tests."
|
|
435
|
+
)
|
|
436
|
+
else:
|
|
437
|
+
validate_signing_key(str(effective_key), purpose="APP_KEY")
|
|
438
|
+
|
|
439
|
+
self._setup_signal_handlers()
|
|
440
|
+
self._is_working = True
|
|
441
|
+
|
|
442
|
+
# Initialize DatabasePool if available in the application container
|
|
443
|
+
try:
|
|
444
|
+
container = get_container()
|
|
445
|
+
if container.has(DatabasePool):
|
|
446
|
+
pool = container.resolve(DatabasePool)
|
|
447
|
+
if not getattr(pool, "is_initialized", False):
|
|
448
|
+
await pool.init()
|
|
449
|
+
except RuntimeError:
|
|
450
|
+
pass
|
|
451
|
+
|
|
452
|
+
logger.info(
|
|
453
|
+
"Worker %s started monitoring queues: %s (concurrency=%d)",
|
|
454
|
+
self.worker_id,
|
|
455
|
+
", ".join(self.queues),
|
|
456
|
+
self.concurrency,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
while not self._should_stop:
|
|
461
|
+
# Check maximum job limit including in-flight tasks
|
|
462
|
+
if self.max_jobs is not None:
|
|
463
|
+
completed = self.jobs_processed + self.jobs_failed
|
|
464
|
+
if completed >= self.max_jobs:
|
|
465
|
+
logger.info(
|
|
466
|
+
"Worker %s reached max_jobs limit (%d)",
|
|
467
|
+
self.worker_id,
|
|
468
|
+
self.max_jobs,
|
|
469
|
+
)
|
|
470
|
+
break
|
|
471
|
+
if completed + len(self._active_tasks) >= self.max_jobs:
|
|
472
|
+
await asyncio.wait(
|
|
473
|
+
self._active_tasks,
|
|
474
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
475
|
+
)
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
acquired = False
|
|
479
|
+
if self.concurrency > 1:
|
|
480
|
+
await self._semaphore.acquire()
|
|
481
|
+
acquired = True
|
|
482
|
+
try:
|
|
483
|
+
job_data = await self._pop_next_job()
|
|
484
|
+
except BaseException:
|
|
485
|
+
if acquired:
|
|
486
|
+
self._semaphore.release()
|
|
487
|
+
raise
|
|
488
|
+
|
|
489
|
+
if job_data is not None:
|
|
490
|
+
queue, raw_payload = job_data
|
|
491
|
+
if self.concurrency > 1:
|
|
492
|
+
try:
|
|
493
|
+
task = asyncio.create_task(
|
|
494
|
+
self._run_job_with_semaphore(queue, raw_payload)
|
|
495
|
+
)
|
|
496
|
+
except BaseException:
|
|
497
|
+
if acquired:
|
|
498
|
+
self._semaphore.release()
|
|
499
|
+
raise
|
|
500
|
+
self._active_tasks.add(task)
|
|
501
|
+
|
|
502
|
+
def _on_done(t: asyncio.Task[Any]) -> None:
|
|
503
|
+
self._active_tasks.discard(t)
|
|
504
|
+
if not t.cancelled() and t.exception() is not None:
|
|
505
|
+
logger.exception(
|
|
506
|
+
"Queue worker task failed",
|
|
507
|
+
exc_info=t.exception(),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
task.add_done_callback(_on_done)
|
|
511
|
+
else:
|
|
512
|
+
try:
|
|
513
|
+
await self.process_job(queue, raw_payload)
|
|
514
|
+
except Exception:
|
|
515
|
+
logger.exception(
|
|
516
|
+
"Worker %s encountered unexpected error processing job on queue '%s'",
|
|
517
|
+
self.worker_id,
|
|
518
|
+
queue,
|
|
519
|
+
)
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
if acquired:
|
|
523
|
+
self._semaphore.release()
|
|
524
|
+
|
|
525
|
+
# No active job available
|
|
526
|
+
if self.stop_when_empty and await self._all_queues_empty():
|
|
527
|
+
if self._active_tasks:
|
|
528
|
+
await asyncio.wait(
|
|
529
|
+
self._active_tasks,
|
|
530
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
531
|
+
)
|
|
532
|
+
continue
|
|
533
|
+
logger.info(
|
|
534
|
+
"Worker %s stopping because queues are empty",
|
|
535
|
+
self.worker_id,
|
|
536
|
+
)
|
|
537
|
+
break
|
|
538
|
+
|
|
539
|
+
await asyncio.sleep(self.sleep)
|
|
540
|
+
finally:
|
|
541
|
+
self._is_working = False
|
|
542
|
+
if self._active_tasks:
|
|
543
|
+
await asyncio.gather(*self._active_tasks, return_exceptions=True)
|
|
544
|
+
self._restore_signal_handlers()
|
|
545
|
+
if self._owns_dispatcher:
|
|
546
|
+
await self.dispatcher.close()
|
|
547
|
+
logger.info(
|
|
548
|
+
"Worker %s stopped. (Processed: %d, Failed: %d)",
|
|
549
|
+
self.worker_id,
|
|
550
|
+
self.jobs_processed,
|
|
551
|
+
self.jobs_failed,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
async def run(self) -> None:
|
|
555
|
+
"""Alias for work()."""
|
|
556
|
+
await self.work()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SwiftPY Support Utilities.
|
|
3
|
+
|
|
4
|
+
Cross-cutting helper functions and configuration resolvers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from swiftpy.support.redis_config import build_redis_url, resolve_redis_params
|
|
10
|
+
from swiftpy.support.strings import pluralize, snake_case
|
|
11
|
+
|
|
12
|
+
__all__ = ["build_redis_url", "pluralize", "resolve_redis_params", "snake_case"]
|
swiftpy/support/keys.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application cryptographic key decoding and entropy validation helpers.
|
|
3
|
+
|
|
4
|
+
Provides unified parsing of ``base64:`` prefixed keys and raw secrets across
|
|
5
|
+
subsystems (Queue, Events, Filesystem, Application startup) so every consumer
|
|
6
|
+
derives identical key material and enforces minimum entropy.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
13
|
+
|
|
14
|
+
from swiftpy.core.config import Config, ConfigurationError, env
|
|
15
|
+
|
|
16
|
+
MIN_SIGNING_KEY_BYTES = 32
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def decode_signing_key(raw_key: str) -> bytes:
|
|
20
|
+
"""Decode a raw or ``base64:`` prefixed signing key into bytes."""
|
|
21
|
+
if not isinstance(raw_key, str) or not raw_key.strip():
|
|
22
|
+
raise ConfigurationError("Signing key must be a non-empty string.")
|
|
23
|
+
|
|
24
|
+
if raw_key.startswith("base64:"):
|
|
25
|
+
encoded = raw_key[len("base64:") :].strip()
|
|
26
|
+
if not encoded:
|
|
27
|
+
raise ConfigurationError("Malformed base64 signing key: empty payload.")
|
|
28
|
+
try:
|
|
29
|
+
return base64.b64decode(encoded.encode("ascii"), validate=True)
|
|
30
|
+
except (ValueError, binascii.Error, UnicodeEncodeError) as exc:
|
|
31
|
+
raise ConfigurationError(
|
|
32
|
+
f"Malformed base64 application signing key: {exc}"
|
|
33
|
+
) from exc
|
|
34
|
+
|
|
35
|
+
return raw_key.encode("utf-8")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_signing_key(
|
|
39
|
+
raw_key: str | None,
|
|
40
|
+
*,
|
|
41
|
+
min_bytes: int = MIN_SIGNING_KEY_BYTES,
|
|
42
|
+
purpose: str = "APP_KEY",
|
|
43
|
+
) -> bytes:
|
|
44
|
+
"""Decode and validate that a signing key meets minimum byte entropy."""
|
|
45
|
+
if raw_key is None or not isinstance(raw_key, str) or not raw_key.strip():
|
|
46
|
+
raise ConfigurationError(
|
|
47
|
+
f"{purpose} is required and must be configured (at least {min_bytes} bytes)."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
decoded = decode_signing_key(raw_key)
|
|
51
|
+
if len(decoded) < min_bytes:
|
|
52
|
+
raise ConfigurationError(
|
|
53
|
+
f"{purpose} must decode to at least {min_bytes} bytes (got {len(decoded)} bytes)."
|
|
54
|
+
)
|
|
55
|
+
return decoded
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_configured_app_key(config: Config | None = None) -> str | None:
|
|
59
|
+
"""Resolve ``app.key`` / ``key`` from Config (when declared) or ``env('APP_KEY')``."""
|
|
60
|
+
cfg = config
|
|
61
|
+
if cfg is None:
|
|
62
|
+
try:
|
|
63
|
+
cfg = Config.get_instance()
|
|
64
|
+
except (ConfigurationError, RuntimeError):
|
|
65
|
+
cfg = None
|
|
66
|
+
|
|
67
|
+
if cfg is not None and (cfg.has("app.key") or cfg.has("key")):
|
|
68
|
+
raw = cfg.get("app.key") if cfg.has("app.key") else cfg.get("key")
|
|
69
|
+
if isinstance(raw, str) and raw.strip():
|
|
70
|
+
return raw
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
env_key = env("APP_KEY")
|
|
74
|
+
if env_key is not None and str(env_key).strip():
|
|
75
|
+
return str(env_key)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_configured_previous_keys(config: Config | None = None) -> list[str]:
|
|
80
|
+
"""Resolve retired keys for signature rotation from Config or environment."""
|
|
81
|
+
cfg = config
|
|
82
|
+
if cfg is None:
|
|
83
|
+
try:
|
|
84
|
+
cfg = Config.get_instance()
|
|
85
|
+
except (ConfigurationError, RuntimeError):
|
|
86
|
+
cfg = None
|
|
87
|
+
|
|
88
|
+
if cfg is not None and cfg.has("app.previous_keys"):
|
|
89
|
+
val = cfg.get("app.previous_keys")
|
|
90
|
+
if isinstance(val, (list, tuple)):
|
|
91
|
+
return [str(k) for k in val if str(k).strip()]
|
|
92
|
+
if isinstance(val, str) and val.strip():
|
|
93
|
+
return [k.strip() for k in val.split(",") if k.strip()]
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
env_prev = env("APP_PREVIOUS_KEYS")
|
|
97
|
+
if env_prev:
|
|
98
|
+
return [k.strip() for k in str(env_prev).split(",") if k.strip()]
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = [
|
|
103
|
+
"MIN_SIGNING_KEY_BYTES",
|
|
104
|
+
"decode_signing_key",
|
|
105
|
+
"resolve_configured_app_key",
|
|
106
|
+
"resolve_configured_previous_keys",
|
|
107
|
+
"validate_signing_key",
|
|
108
|
+
]
|