lenapi 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.
- lenapi/__init__.py +79 -0
- lenapi/application.py +636 -0
- lenapi/authorization.py +96 -0
- lenapi/background.py +395 -0
- lenapi/cli.py +731 -0
- lenapi/database.py +84 -0
- lenapi/dependencies.py +12 -0
- lenapi/logging.py +13 -0
- lenapi/middleware.py +144 -0
- lenapi/model.py +54 -0
- lenapi/models.py +1052 -0
- lenapi/mongodb.py +262 -0
- lenapi/openapi.py +181 -0
- lenapi/parameters.py +8 -0
- lenapi/payments.py +543 -0
- lenapi/request.py +177 -0
- lenapi/response.py +91 -0
- lenapi/security.py +665 -0
- lenapi/security_store.py +193 -0
- lenapi/service.py +350 -0
- lenapi-0.1.0.dist-info/METADATA +959 -0
- lenapi-0.1.0.dist-info/RECORD +25 -0
- lenapi-0.1.0.dist-info/WHEEL +5 -0
- lenapi-0.1.0.dist-info/entry_points.txt +2 -0
- lenapi-0.1.0.dist-info/top_level.txt +1 -0
lenapi/authorization.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Reusable role, permission, and object ownership dependencies."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from .dependencies import Depends
|
|
8
|
+
from .request import Request
|
|
9
|
+
from .response import HTTPException
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _claim(identity: Any, *names: str) -> Any:
|
|
13
|
+
if isinstance(identity, Mapping):
|
|
14
|
+
for name in names:
|
|
15
|
+
if name in identity:
|
|
16
|
+
return identity[name]
|
|
17
|
+
elif isinstance(identity, (str, int)) and {"sub", "user_id", "id"}.intersection(names):
|
|
18
|
+
return identity
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _as_set(value: Any) -> set[str]:
|
|
23
|
+
if value is None:
|
|
24
|
+
return set()
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
return set(value.split())
|
|
27
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
28
|
+
return {str(item) for item in value}
|
|
29
|
+
return {str(value)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def require_role(*roles: str, auth: Callable) -> Callable:
|
|
33
|
+
"""Build a dependency that requires at least one named role."""
|
|
34
|
+
required = {role for role in roles if role}
|
|
35
|
+
if not required:
|
|
36
|
+
raise ValueError("require_role needs at least one role")
|
|
37
|
+
|
|
38
|
+
async def check_role(identity=Depends(auth)):
|
|
39
|
+
actual = _as_set(_claim(identity, "roles", "role"))
|
|
40
|
+
if not required.intersection(actual):
|
|
41
|
+
raise HTTPException(403, "Insufficient role")
|
|
42
|
+
return identity
|
|
43
|
+
|
|
44
|
+
return check_role
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def require_permission(*permissions: str, auth: Callable, match_all: bool = False) -> Callable:
|
|
48
|
+
"""Build a dependency that checks permissions or OAuth-style scopes."""
|
|
49
|
+
required = {permission for permission in permissions if permission}
|
|
50
|
+
if not required:
|
|
51
|
+
raise ValueError("require_permission needs at least one permission")
|
|
52
|
+
|
|
53
|
+
async def check_permission(identity=Depends(auth)):
|
|
54
|
+
actual = _as_set(_claim(identity, "permissions", "scopes", "scope"))
|
|
55
|
+
allowed = required.issubset(actual) if match_all else bool(required.intersection(actual))
|
|
56
|
+
if not allowed:
|
|
57
|
+
raise HTTPException(403, "Insufficient permission")
|
|
58
|
+
return identity
|
|
59
|
+
|
|
60
|
+
return check_permission
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def require_owner(
|
|
64
|
+
load_object: Callable[[str], Any],
|
|
65
|
+
*,
|
|
66
|
+
auth: Callable,
|
|
67
|
+
path_param: str = "id",
|
|
68
|
+
owner_field: str = "user_id",
|
|
69
|
+
) -> Callable:
|
|
70
|
+
"""Build a dependency that loads a route object and permits only its owner.
|
|
71
|
+
|
|
72
|
+
``load_object`` receives the string path parameter and returns the model row
|
|
73
|
+
(or ``None``). The authenticated identity must contain ``sub``, ``user_id``,
|
|
74
|
+
or ``id``. The loaded object is returned to the endpoint.
|
|
75
|
+
"""
|
|
76
|
+
async def check_owner(request: Request, identity=Depends(auth)):
|
|
77
|
+
object_id = request.path_params.get(path_param)
|
|
78
|
+
if object_id is None:
|
|
79
|
+
raise HTTPException(400, f"Missing object path parameter '{path_param}'")
|
|
80
|
+
resource = load_object(str(object_id))
|
|
81
|
+
if inspect.isawaitable(resource):
|
|
82
|
+
resource = await resource
|
|
83
|
+
if resource is None:
|
|
84
|
+
raise HTTPException(404, "Not found")
|
|
85
|
+
|
|
86
|
+
if isinstance(resource, Mapping):
|
|
87
|
+
owner_id = resource.get(owner_field)
|
|
88
|
+
else:
|
|
89
|
+
owner_id = getattr(resource, owner_field, None)
|
|
90
|
+
subject = _claim(identity, "sub", "user_id", "id")
|
|
91
|
+
if owner_id is None or subject is None or str(owner_id) != str(subject):
|
|
92
|
+
# Hide whether another user's object exists.
|
|
93
|
+
raise HTTPException(404, "Not found")
|
|
94
|
+
return resource
|
|
95
|
+
|
|
96
|
+
return check_owner
|
lenapi/background.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Small in-process background task support for route handlers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import and_, or_, select, update
|
|
12
|
+
|
|
13
|
+
from .models import BACKGROUND_JOBS_TABLE
|
|
14
|
+
|
|
15
|
+
from .logging import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger("background")
|
|
18
|
+
|
|
19
|
+
_MEMORY_QUEUE_LOCK = threading.RLock()
|
|
20
|
+
_MEMORY_QUEUE_JOBS: dict[str, dict[str, dict]] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BackgroundTasks:
|
|
24
|
+
"""Collect functions to run after the response body has been sent."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._tasks: list[tuple[Callable, tuple, dict]] = []
|
|
28
|
+
|
|
29
|
+
def add_task(self, function: Callable, *args, **kwargs) -> None:
|
|
30
|
+
if not callable(function):
|
|
31
|
+
raise TypeError("background task must be callable")
|
|
32
|
+
self._tasks.append((function, args, kwargs))
|
|
33
|
+
|
|
34
|
+
async def run(self) -> None:
|
|
35
|
+
tasks, self._tasks = self._tasks, []
|
|
36
|
+
for function, args, kwargs in tasks:
|
|
37
|
+
try:
|
|
38
|
+
if inspect.iscoroutinefunction(function):
|
|
39
|
+
await function(*args, **kwargs)
|
|
40
|
+
else:
|
|
41
|
+
result = await asyncio.to_thread(function, *args, **kwargs)
|
|
42
|
+
if inspect.isawaitable(result):
|
|
43
|
+
await result
|
|
44
|
+
except Exception:
|
|
45
|
+
logger.exception("Background task %r failed", function)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BackgroundQueue:
|
|
49
|
+
"""A database-, Redis-, or in-memory-cache-backed queue with an async worker.
|
|
50
|
+
|
|
51
|
+
Import/register the same task functions in the API and worker process. Jobs
|
|
52
|
+
use JSON-compatible positional and keyword arguments, and delivery is at
|
|
53
|
+
least once, so handlers should be idempotent.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, database=None, *, backend: str = "database", redis_url: str | None = None,
|
|
57
|
+
queue_name: str = "default", max_attempts: int = 3, retry_delay: float = 5,
|
|
58
|
+
lease_seconds: float = 60) -> None:
|
|
59
|
+
backend = backend.lower()
|
|
60
|
+
if backend in {"cache", "system", "system_cache"}:
|
|
61
|
+
backend = "memory"
|
|
62
|
+
if backend not in {"database", "redis", "memory"}:
|
|
63
|
+
raise ValueError("BackgroundQueue backend must be 'database', 'redis', or 'memory'/'cache'")
|
|
64
|
+
if backend == "database" and (database is None or not hasattr(database, "engine")):
|
|
65
|
+
raise ValueError("Database BackgroundQueue requires a configured lenApi Database")
|
|
66
|
+
if backend == "redis" and not redis_url:
|
|
67
|
+
raise ValueError("Redis BackgroundQueue requires redis_url")
|
|
68
|
+
if isinstance(max_attempts, bool) or not isinstance(max_attempts, int) or max_attempts < 1:
|
|
69
|
+
raise ValueError("max_attempts must be a positive integer")
|
|
70
|
+
if retry_delay < 0 or lease_seconds <= 0:
|
|
71
|
+
raise ValueError("retry_delay must be non-negative and lease_seconds must be positive")
|
|
72
|
+
self.backend = backend
|
|
73
|
+
self.database = database
|
|
74
|
+
self.engine = database.engine if database is not None else None
|
|
75
|
+
self.max_attempts = max_attempts
|
|
76
|
+
self.retry_delay = float(retry_delay)
|
|
77
|
+
self.lease_seconds = float(lease_seconds)
|
|
78
|
+
self._tasks: dict[str, Callable] = {}
|
|
79
|
+
self.queue_name = queue_name
|
|
80
|
+
self._memory_jobs = _MEMORY_QUEUE_JOBS.setdefault(queue_name, {})
|
|
81
|
+
self._stream = f"lenapi:jobs:{queue_name}"
|
|
82
|
+
self._group = f"lenapi-workers:{queue_name}"
|
|
83
|
+
self._consumer = uuid.uuid4().hex
|
|
84
|
+
self._redis = None
|
|
85
|
+
if backend == "redis":
|
|
86
|
+
try:
|
|
87
|
+
from redis.asyncio import Redis
|
|
88
|
+
except ImportError as exc:
|
|
89
|
+
raise ImportError("Redis BackgroundQueue requires `pip install 'lenapi[redis]'`") from exc
|
|
90
|
+
self._redis = Redis.from_url(redis_url, decode_responses=True)
|
|
91
|
+
|
|
92
|
+
def task(self, name: str | None = None):
|
|
93
|
+
"""Register a worker handler with ``@queue.task('send_email')``."""
|
|
94
|
+
def register(function):
|
|
95
|
+
task_name = name or f"{function.__module__}.{function.__name__}"
|
|
96
|
+
if not task_name or len(task_name) > 200:
|
|
97
|
+
raise ValueError("task names must contain 1 to 200 characters")
|
|
98
|
+
if task_name in self._tasks and self._tasks[task_name] is not function:
|
|
99
|
+
raise ValueError(f"A background task named {task_name!r} is already registered")
|
|
100
|
+
self._tasks[task_name] = function
|
|
101
|
+
return function
|
|
102
|
+
return register
|
|
103
|
+
|
|
104
|
+
async def enqueue(self, task_name: str, *args, **kwargs) -> str:
|
|
105
|
+
"""Queue a named task and return its job ID."""
|
|
106
|
+
if not isinstance(task_name, str) or not task_name or len(task_name) > 200:
|
|
107
|
+
raise ValueError("task_name must contain 1 to 200 characters")
|
|
108
|
+
payload = {"args": args, "kwargs": kwargs}
|
|
109
|
+
encoded = json.dumps(payload, separators=(",", ":"))
|
|
110
|
+
job_id = uuid.uuid4().hex
|
|
111
|
+
now = time.time()
|
|
112
|
+
|
|
113
|
+
if self.backend == "memory":
|
|
114
|
+
with _MEMORY_QUEUE_LOCK:
|
|
115
|
+
self._memory_jobs[job_id] = {
|
|
116
|
+
"job_id": job_id,
|
|
117
|
+
"task_name": task_name,
|
|
118
|
+
"payload": json.loads(encoded),
|
|
119
|
+
"status": "queued",
|
|
120
|
+
"attempts": 0,
|
|
121
|
+
"available_at": now,
|
|
122
|
+
"locked_until": None,
|
|
123
|
+
"last_error": None,
|
|
124
|
+
"created_at": now,
|
|
125
|
+
"completed_at": None,
|
|
126
|
+
}
|
|
127
|
+
return job_id
|
|
128
|
+
|
|
129
|
+
if self.backend == "redis":
|
|
130
|
+
event_id = await self._redis.xadd(self._stream, {
|
|
131
|
+
"job_id": job_id,
|
|
132
|
+
"task_name": task_name,
|
|
133
|
+
"payload": encoded,
|
|
134
|
+
"attempts": "0",
|
|
135
|
+
"available_at": str(now),
|
|
136
|
+
})
|
|
137
|
+
await self._redis.hset(self._redis_state_key(job_id), mapping={
|
|
138
|
+
"status": "queued", "attempts": "0", "task_name": task_name,
|
|
139
|
+
"stream_id": event_id, "last_error": "",
|
|
140
|
+
})
|
|
141
|
+
return job_id
|
|
142
|
+
|
|
143
|
+
def insert_job():
|
|
144
|
+
with self.engine.begin() as connection:
|
|
145
|
+
connection.execute(BACKGROUND_JOBS_TABLE.insert().values(
|
|
146
|
+
job_id=job_id,
|
|
147
|
+
task_name=task_name,
|
|
148
|
+
payload=json.loads(encoded),
|
|
149
|
+
status="queued",
|
|
150
|
+
attempts=0,
|
|
151
|
+
available_at=now,
|
|
152
|
+
created_at=now,
|
|
153
|
+
))
|
|
154
|
+
|
|
155
|
+
await asyncio.to_thread(insert_job)
|
|
156
|
+
return job_id
|
|
157
|
+
|
|
158
|
+
def _claim(self):
|
|
159
|
+
now = time.time()
|
|
160
|
+
ready = and_(BACKGROUND_JOBS_TABLE.c.status == "queued",
|
|
161
|
+
BACKGROUND_JOBS_TABLE.c.available_at <= now)
|
|
162
|
+
expired = and_(BACKGROUND_JOBS_TABLE.c.status == "running",
|
|
163
|
+
BACKGROUND_JOBS_TABLE.c.locked_until <= now)
|
|
164
|
+
statement = (
|
|
165
|
+
select(BACKGROUND_JOBS_TABLE)
|
|
166
|
+
.where(or_(ready, expired))
|
|
167
|
+
.order_by(BACKGROUND_JOBS_TABLE.c.available_at, BACKGROUND_JOBS_TABLE.c.created_at)
|
|
168
|
+
.limit(1)
|
|
169
|
+
.with_for_update(skip_locked=True)
|
|
170
|
+
)
|
|
171
|
+
with self.engine.begin() as connection:
|
|
172
|
+
row = connection.execute(statement).mappings().first()
|
|
173
|
+
if row is None:
|
|
174
|
+
return None
|
|
175
|
+
attempts = row["attempts"] + 1
|
|
176
|
+
connection.execute(
|
|
177
|
+
update(BACKGROUND_JOBS_TABLE)
|
|
178
|
+
.where(BACKGROUND_JOBS_TABLE.c.job_id == row["job_id"])
|
|
179
|
+
.values(status="running", attempts=attempts, locked_until=now + self.lease_seconds)
|
|
180
|
+
)
|
|
181
|
+
return {**row, "attempts": attempts}
|
|
182
|
+
|
|
183
|
+
def _claim_memory(self):
|
|
184
|
+
now = time.time()
|
|
185
|
+
with _MEMORY_QUEUE_LOCK:
|
|
186
|
+
eligible = [
|
|
187
|
+
job for job in self._memory_jobs.values()
|
|
188
|
+
if (job["status"] == "queued" and job["available_at"] <= now)
|
|
189
|
+
or (job["status"] == "running" and (job["locked_until"] or 0) <= now)
|
|
190
|
+
]
|
|
191
|
+
if not eligible:
|
|
192
|
+
return None
|
|
193
|
+
job = min(eligible, key=lambda item: (item["available_at"], item["created_at"]))
|
|
194
|
+
job["status"] = "running"
|
|
195
|
+
job["attempts"] += 1
|
|
196
|
+
job["locked_until"] = now + self.lease_seconds
|
|
197
|
+
job["last_error"] = None
|
|
198
|
+
return dict(job)
|
|
199
|
+
|
|
200
|
+
def _finish_memory(self, job_id: str, attempts: int, *, error: str | None = None) -> None:
|
|
201
|
+
now = time.time()
|
|
202
|
+
with _MEMORY_QUEUE_LOCK:
|
|
203
|
+
job = self._memory_jobs.get(job_id)
|
|
204
|
+
if job is None:
|
|
205
|
+
return
|
|
206
|
+
job["locked_until"] = None
|
|
207
|
+
if error is None:
|
|
208
|
+
job.update(status="done", last_error=None, completed_at=now)
|
|
209
|
+
elif attempts >= self.max_attempts:
|
|
210
|
+
job.update(status="failed", last_error=error[:4000], completed_at=now)
|
|
211
|
+
else:
|
|
212
|
+
job.update(
|
|
213
|
+
status="queued",
|
|
214
|
+
available_at=now + self.retry_delay * attempts,
|
|
215
|
+
last_error=error[:4000],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _redis_state_key(self, job_id: str) -> str:
|
|
219
|
+
return f"lenapi:job-status:{self.queue_name}:{job_id}"
|
|
220
|
+
|
|
221
|
+
async def _ensure_redis_group(self):
|
|
222
|
+
try:
|
|
223
|
+
await self._redis.xgroup_create(self._stream, self._group, id="0", mkstream=True)
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
if "BUSYGROUP" not in str(exc):
|
|
226
|
+
raise
|
|
227
|
+
|
|
228
|
+
async def _claim_redis(self):
|
|
229
|
+
await self._ensure_redis_group()
|
|
230
|
+
messages = []
|
|
231
|
+
try:
|
|
232
|
+
claimed = await self._redis.xautoclaim(
|
|
233
|
+
self._stream, self._group, self._consumer,
|
|
234
|
+
min_idle_time=max(1, int(self.lease_seconds * 1000)),
|
|
235
|
+
start_id="0-0", count=1,
|
|
236
|
+
)
|
|
237
|
+
if len(claimed) > 1:
|
|
238
|
+
messages = claimed[1]
|
|
239
|
+
except (AttributeError, NotImplementedError):
|
|
240
|
+
# Older Redis clients/servers can still consume new jobs; pending
|
|
241
|
+
# job recovery requires Redis Streams XAUTOCLAIM support.
|
|
242
|
+
pass
|
|
243
|
+
if not messages:
|
|
244
|
+
response = await self._redis.xreadgroup(
|
|
245
|
+
self._group, self._consumer, {self._stream: ">"}, count=1
|
|
246
|
+
)
|
|
247
|
+
if response:
|
|
248
|
+
messages = response[0][1]
|
|
249
|
+
if not messages:
|
|
250
|
+
return None
|
|
251
|
+
stream_id, fields = messages[0]
|
|
252
|
+
if float(fields.get("available_at", "0")) > time.time():
|
|
253
|
+
await self._redis.xack(self._stream, self._group, stream_id)
|
|
254
|
+
await self._redis.xdel(self._stream, stream_id)
|
|
255
|
+
await self._redis.xadd(self._stream, fields)
|
|
256
|
+
return None
|
|
257
|
+
job = {
|
|
258
|
+
"stream_id": stream_id,
|
|
259
|
+
"job_id": fields["job_id"],
|
|
260
|
+
"task_name": fields["task_name"],
|
|
261
|
+
"payload": json.loads(fields["payload"]),
|
|
262
|
+
"attempts": int(fields.get("attempts", "0")) + 1,
|
|
263
|
+
}
|
|
264
|
+
await self._redis.hset(self._redis_state_key(job["job_id"]), mapping={
|
|
265
|
+
"status": "running", "attempts": str(job["attempts"]),
|
|
266
|
+
"last_error": "", "stream_id": stream_id,
|
|
267
|
+
})
|
|
268
|
+
return job
|
|
269
|
+
|
|
270
|
+
async def _finish_redis(self, job: dict, *, error: str | None = None):
|
|
271
|
+
stream_id = job["stream_id"]
|
|
272
|
+
job_id = job["job_id"]
|
|
273
|
+
state_key = self._redis_state_key(job_id)
|
|
274
|
+
if error is None:
|
|
275
|
+
await self._redis.xack(self._stream, self._group, stream_id)
|
|
276
|
+
await self._redis.xdel(self._stream, stream_id)
|
|
277
|
+
await self._redis.hset(state_key, mapping={
|
|
278
|
+
"status": "done", "attempts": str(job["attempts"]), "last_error": "",
|
|
279
|
+
})
|
|
280
|
+
return
|
|
281
|
+
if job["attempts"] >= self.max_attempts:
|
|
282
|
+
await self._redis.xack(self._stream, self._group, stream_id)
|
|
283
|
+
await self._redis.xdel(self._stream, stream_id)
|
|
284
|
+
await self._redis.hset(state_key, mapping={
|
|
285
|
+
"status": "failed", "attempts": str(job["attempts"]), "last_error": error[:4000],
|
|
286
|
+
})
|
|
287
|
+
return
|
|
288
|
+
await self._redis.xadd(self._stream, {
|
|
289
|
+
"job_id": job_id,
|
|
290
|
+
"task_name": job["task_name"],
|
|
291
|
+
"payload": json.dumps(job["payload"], separators=(",", ":")),
|
|
292
|
+
"attempts": str(job["attempts"]),
|
|
293
|
+
"available_at": str(time.time() + self.retry_delay * job["attempts"]),
|
|
294
|
+
})
|
|
295
|
+
await self._redis.xack(self._stream, self._group, stream_id)
|
|
296
|
+
await self._redis.xdel(self._stream, stream_id)
|
|
297
|
+
await self._redis.hset(state_key, mapping={
|
|
298
|
+
"status": "queued", "attempts": str(job["attempts"]), "last_error": error[:4000],
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
def _finish(self, job_id: str, attempts: int, *, error: str | None = None) -> None:
|
|
302
|
+
now = time.time()
|
|
303
|
+
if error is None:
|
|
304
|
+
values = {"status": "done", "locked_until": None, "last_error": None, "completed_at": now}
|
|
305
|
+
elif attempts >= self.max_attempts:
|
|
306
|
+
values = {"status": "failed", "locked_until": None, "last_error": error[:4000]}
|
|
307
|
+
else:
|
|
308
|
+
values = {
|
|
309
|
+
"status": "queued", "locked_until": None,
|
|
310
|
+
"available_at": now + self.retry_delay * attempts,
|
|
311
|
+
"last_error": error[:4000],
|
|
312
|
+
}
|
|
313
|
+
with self.engine.begin() as connection:
|
|
314
|
+
connection.execute(update(BACKGROUND_JOBS_TABLE)
|
|
315
|
+
.where(BACKGROUND_JOBS_TABLE.c.job_id == job_id)
|
|
316
|
+
.values(**values))
|
|
317
|
+
|
|
318
|
+
async def run_once(self) -> bool:
|
|
319
|
+
"""Claim and execute one available job; return False when the queue is empty."""
|
|
320
|
+
if self.backend == "redis":
|
|
321
|
+
job = await self._claim_redis()
|
|
322
|
+
elif self.backend == "memory":
|
|
323
|
+
job = self._claim_memory()
|
|
324
|
+
else:
|
|
325
|
+
job = await asyncio.to_thread(self._claim)
|
|
326
|
+
if job is None:
|
|
327
|
+
return False
|
|
328
|
+
try:
|
|
329
|
+
function = self._tasks[job["task_name"]]
|
|
330
|
+
payload = job["payload"]
|
|
331
|
+
if inspect.iscoroutinefunction(function):
|
|
332
|
+
await function(*payload["args"], **payload["kwargs"])
|
|
333
|
+
else:
|
|
334
|
+
result = await asyncio.to_thread(function, *payload["args"], **payload["kwargs"])
|
|
335
|
+
if inspect.isawaitable(result):
|
|
336
|
+
await result
|
|
337
|
+
except Exception as exc:
|
|
338
|
+
logger.exception("Background job %s (%s) failed", job["job_id"], job["task_name"])
|
|
339
|
+
if self.backend == "redis":
|
|
340
|
+
await self._finish_redis(job, error=repr(exc))
|
|
341
|
+
elif self.backend == "memory":
|
|
342
|
+
self._finish_memory(job["job_id"], job["attempts"], error=repr(exc))
|
|
343
|
+
else:
|
|
344
|
+
await asyncio.to_thread(self._finish, job["job_id"], job["attempts"], error=repr(exc))
|
|
345
|
+
else:
|
|
346
|
+
if self.backend == "redis":
|
|
347
|
+
await self._finish_redis(job)
|
|
348
|
+
elif self.backend == "memory":
|
|
349
|
+
self._finish_memory(job["job_id"], job["attempts"])
|
|
350
|
+
else:
|
|
351
|
+
await asyncio.to_thread(self._finish, job["job_id"], job["attempts"])
|
|
352
|
+
return True
|
|
353
|
+
|
|
354
|
+
async def run_worker(self, *, poll_interval: float = 1, stop_event: asyncio.Event | None = None) -> None:
|
|
355
|
+
"""Keep processing jobs until ``stop_event`` is set."""
|
|
356
|
+
if poll_interval <= 0:
|
|
357
|
+
raise ValueError("poll_interval must be positive")
|
|
358
|
+
while stop_event is None or not stop_event.is_set():
|
|
359
|
+
found_job = await self.run_once()
|
|
360
|
+
if not found_job:
|
|
361
|
+
try:
|
|
362
|
+
await asyncio.wait_for(
|
|
363
|
+
stop_event.wait() if stop_event is not None else asyncio.sleep(poll_interval),
|
|
364
|
+
timeout=poll_interval,
|
|
365
|
+
)
|
|
366
|
+
except asyncio.TimeoutError:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
async def status(self, job_id: str) -> dict | None:
|
|
370
|
+
"""Return the current state for a job."""
|
|
371
|
+
if self.backend == "redis":
|
|
372
|
+
state = await self._redis.hgetall(self._redis_state_key(job_id))
|
|
373
|
+
if not state:
|
|
374
|
+
return None
|
|
375
|
+
if "attempts" in state:
|
|
376
|
+
state["attempts"] = int(state["attempts"])
|
|
377
|
+
return state
|
|
378
|
+
|
|
379
|
+
if self.backend == "memory":
|
|
380
|
+
with _MEMORY_QUEUE_LOCK:
|
|
381
|
+
job = self._memory_jobs.get(job_id)
|
|
382
|
+
return None if job is None else dict(job)
|
|
383
|
+
|
|
384
|
+
def read_job():
|
|
385
|
+
with self.engine.connect() as connection:
|
|
386
|
+
row = connection.execute(
|
|
387
|
+
select(BACKGROUND_JOBS_TABLE).where(BACKGROUND_JOBS_TABLE.c.job_id == job_id)
|
|
388
|
+
).mappings().first()
|
|
389
|
+
return None if row is None else dict(row)
|
|
390
|
+
return await asyncio.to_thread(read_job)
|
|
391
|
+
|
|
392
|
+
async def close(self) -> None:
|
|
393
|
+
"""Close the optional Redis client connection pool."""
|
|
394
|
+
if self._redis is not None:
|
|
395
|
+
await self._redis.aclose()
|