django-lambda-tasks 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.
- django_lambda_tasks-0.1.0.dist-info/METADATA +462 -0
- django_lambda_tasks-0.1.0.dist-info/RECORD +15 -0
- django_lambda_tasks-0.1.0.dist-info/WHEEL +4 -0
- lambda_tasks/__init__.py +0 -0
- lambda_tasks/admin.py +42 -0
- lambda_tasks/apps.py +8 -0
- lambda_tasks/decorators.py +359 -0
- lambda_tasks/handler.py +50 -0
- lambda_tasks/logging.py +34 -0
- lambda_tasks/migrations/0001_initial.py +73 -0
- lambda_tasks/migrations/__init__.py +0 -0
- lambda_tasks/models.py +240 -0
- lambda_tasks/secret_loader.py +183 -0
- lambda_tasks/settings.py +76 -0
- lambda_tasks/timeouts.py +78 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decorator and wrapper for lambda tasks.
|
|
3
|
+
|
|
4
|
+
Provides LambdaTaskWrapper, which wraps a callable to expose:
|
|
5
|
+
- direct __call__(**kwargs) — synchronous invocation
|
|
6
|
+
- on_commit(**kwargs) — enqueue via django.db.transaction.on_commit
|
|
7
|
+
|
|
8
|
+
Also provides the lambda_task decorator factory.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import inspect
|
|
13
|
+
import types
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any, overload
|
|
17
|
+
|
|
18
|
+
import pydantic
|
|
19
|
+
|
|
20
|
+
from lambda_tasks.models import SQSLambdaTask, SQSLambdaTaskMessage
|
|
21
|
+
from lambda_tasks.settings import MAX_TIMEOUT, LambdaTasksSettings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_kwargs_model(func: Callable[..., Any]) -> type[pydantic.BaseModel]:
|
|
25
|
+
"""Build a Pydantic model matching the keyword-only parameters of *func*.
|
|
26
|
+
|
|
27
|
+
The model uses strict mode to prevent silent coercion (e.g. "1" → int),
|
|
28
|
+
with the exception that bool is accepted for int-annotated fields since
|
|
29
|
+
bool is a legitimate int subclass in Python.
|
|
30
|
+
"""
|
|
31
|
+
sig = inspect.signature(func)
|
|
32
|
+
hints = func.__annotations__.copy()
|
|
33
|
+
hints.pop("return", None)
|
|
34
|
+
|
|
35
|
+
fields: dict[str, Any] = {}
|
|
36
|
+
|
|
37
|
+
for name, param in sig.parameters.items():
|
|
38
|
+
annotation = hints.get(name, Any)
|
|
39
|
+
|
|
40
|
+
if param.default is inspect.Parameter.empty:
|
|
41
|
+
fields[name] = (annotation, ...)
|
|
42
|
+
else:
|
|
43
|
+
fields[name] = (annotation, param.default)
|
|
44
|
+
|
|
45
|
+
return pydantic.create_model(
|
|
46
|
+
f"_{func.__name__}_kwargs", # type: ignore[union-attr]
|
|
47
|
+
__config__=pydantic.ConfigDict(strict=True, extra="forbid"),
|
|
48
|
+
**fields,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LambdaTaskWrapper:
|
|
53
|
+
"""Wraps a function to be executed as a background task.
|
|
54
|
+
|
|
55
|
+
Preserves __name__, __doc__, and __wrapped__ via functools.wraps.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
# Declared explicitly so type checkers can see them (set by functools.update_wrapper)
|
|
59
|
+
__wrapped__: Callable[..., Any]
|
|
60
|
+
__name__: str
|
|
61
|
+
__doc__: str | None
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
func: Callable[..., Any],
|
|
66
|
+
*,
|
|
67
|
+
delay: int = 0,
|
|
68
|
+
soft_timeout: int | None = None,
|
|
69
|
+
hard_timeout: int | None = None,
|
|
70
|
+
queue: str = "default",
|
|
71
|
+
ignore_errors: tuple[type[BaseException], ...] = (),
|
|
72
|
+
retry_on: tuple[type[BaseException], ...] = (),
|
|
73
|
+
) -> None:
|
|
74
|
+
self._validate_func(func=func)
|
|
75
|
+
self._validate_timeouts(soft_timeout=soft_timeout, hard_timeout=hard_timeout)
|
|
76
|
+
self._validate_ignore_errors(ignore_errors=ignore_errors)
|
|
77
|
+
self._validate_retry_on(retry_on=retry_on)
|
|
78
|
+
self._validate_no_overlap(retry_on=retry_on, ignore_errors=ignore_errors)
|
|
79
|
+
|
|
80
|
+
functools.update_wrapper(self, func)
|
|
81
|
+
|
|
82
|
+
self._func: types.FunctionType = func # type: ignore[assignment]
|
|
83
|
+
self._delay = delay
|
|
84
|
+
self._soft_timeout = soft_timeout
|
|
85
|
+
self._hard_timeout = hard_timeout
|
|
86
|
+
self._queue = queue
|
|
87
|
+
self._ignore_errors = ignore_errors
|
|
88
|
+
self._retry_on = retry_on
|
|
89
|
+
self._kwargs_model: type[pydantic.BaseModel] = _build_kwargs_model(func)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def resolved_timeouts(self) -> tuple[int, int]:
|
|
93
|
+
"""Return (soft_timeout, hard_timeout) resolved against settings defaults.
|
|
94
|
+
|
|
95
|
+
Merges decorator-supplied values with settings defaults, then validates
|
|
96
|
+
the final pair. Result is cached after first access.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
return self._resolved_timeouts_cache
|
|
100
|
+
except AttributeError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
conf = LambdaTasksSettings()
|
|
104
|
+
soft = (
|
|
105
|
+
self._soft_timeout
|
|
106
|
+
if self._soft_timeout is not None
|
|
107
|
+
else conf.DEFAULT_SOFT_TIMEOUT
|
|
108
|
+
)
|
|
109
|
+
hard = (
|
|
110
|
+
self._hard_timeout
|
|
111
|
+
if self._hard_timeout is not None
|
|
112
|
+
else conf.DEFAULT_HARD_TIMEOUT
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Validate settings-sourced values against the cap (decorator values are checked at decoration time)
|
|
116
|
+
for name, value, source in (
|
|
117
|
+
("soft_timeout", soft, self._soft_timeout),
|
|
118
|
+
("hard_timeout", hard, self._hard_timeout),
|
|
119
|
+
):
|
|
120
|
+
if source is None and value > MAX_TIMEOUT:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"{name} ({value}) from settings exceeds the maximum allowed value of {MAX_TIMEOUT} seconds."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Cross-validate the resolved pair (decorator values may mix with settings defaults)
|
|
126
|
+
if soft >= hard:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Resolved soft_timeout ({soft}) must be strictly less than "
|
|
129
|
+
f"hard_timeout ({hard}) for task '{self.__name__}'."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
self._resolved_timeouts_cache: tuple[int, int] = (soft, hard)
|
|
133
|
+
return self._resolved_timeouts_cache
|
|
134
|
+
|
|
135
|
+
def __call__(self, **kwargs: Any) -> Any:
|
|
136
|
+
"""Directly invoke the wrapped function."""
|
|
137
|
+
self._kwargs_model.model_validate(kwargs)
|
|
138
|
+
return self._func(**kwargs)
|
|
139
|
+
|
|
140
|
+
def _build_task(self, *, kwargs: dict[str, Any]) -> SQSLambdaTask:
|
|
141
|
+
"""Pop overrides, validate kwargs, and build a SQSLambdaSQSLambdaTaskMessage.
|
|
142
|
+
|
|
143
|
+
Mutates *kwargs* in-place (pops ``_delay`` and ``_n_retries``).
|
|
144
|
+
Returns ``SQSLambdaTask``.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
pydantic.ValidationError: if the remaining kwargs fail type validation.
|
|
148
|
+
"""
|
|
149
|
+
delay = kwargs.pop("_delay", self._delay)
|
|
150
|
+
n_retries = kwargs.pop("_n_retries", 0)
|
|
151
|
+
|
|
152
|
+
self._kwargs_model.model_validate(kwargs)
|
|
153
|
+
|
|
154
|
+
message = SQSLambdaTaskMessage(
|
|
155
|
+
task_name=f"{self._func.__module__}.{self._func.__qualname__}",
|
|
156
|
+
invocation_id=str(uuid.uuid4()),
|
|
157
|
+
kwargs=dict(kwargs),
|
|
158
|
+
n_retries=n_retries,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return SQSLambdaTask(
|
|
162
|
+
message=message,
|
|
163
|
+
delay=delay,
|
|
164
|
+
queue=self._queue,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def serialize(self, **kwargs: Any) -> dict:
|
|
168
|
+
"""Serialize this task invocation to a JSON-compatible dict for deferred enqueuing.
|
|
169
|
+
|
|
170
|
+
Accepts task kwargs plus reserved override kwargs:
|
|
171
|
+
_delay: int
|
|
172
|
+
|
|
173
|
+
Returns a dict matching SQSLambdaTaskMessage schema with a stable invocation_id.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
pydantic.ValidationError: if kwargs fail the task's declared type annotations.
|
|
177
|
+
"""
|
|
178
|
+
task = self._build_task(kwargs=kwargs)
|
|
179
|
+
return task.model_dump()
|
|
180
|
+
|
|
181
|
+
def execute_on_commit(self, **kwargs: Any) -> None:
|
|
182
|
+
"""Enqueue the task to run after the current transaction commits.
|
|
183
|
+
|
|
184
|
+
Accepts task kwargs plus reserved override kwargs:
|
|
185
|
+
_delay: int
|
|
186
|
+
"""
|
|
187
|
+
task = self._build_task(kwargs=kwargs)
|
|
188
|
+
task.execute_on_commit()
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _validate_func(*, func: Callable[..., Any]) -> None:
|
|
192
|
+
"""Raise TypeError if *func* has positional, **kwargs, underscore-prefixed, or unannotated parameters."""
|
|
193
|
+
sig = inspect.signature(func)
|
|
194
|
+
hints = func.__annotations__.copy()
|
|
195
|
+
hints.pop("return", None)
|
|
196
|
+
name: str = getattr(func, "__name__", repr(func))
|
|
197
|
+
|
|
198
|
+
for param in sig.parameters.values():
|
|
199
|
+
if param.kind in (
|
|
200
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
201
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
202
|
+
):
|
|
203
|
+
raise TypeError(
|
|
204
|
+
f"lambda_task functions must use keyword-only arguments. "
|
|
205
|
+
f"'{name}' has positional parameter '{param.name}'."
|
|
206
|
+
)
|
|
207
|
+
if param.kind is inspect.Parameter.VAR_KEYWORD:
|
|
208
|
+
raise TypeError(
|
|
209
|
+
f"lambda_task functions must not use **kwargs. "
|
|
210
|
+
f"'{name}' has a **{param.name} parameter — declare all parameters explicitly."
|
|
211
|
+
)
|
|
212
|
+
if param.name.startswith("_"):
|
|
213
|
+
raise TypeError(
|
|
214
|
+
f"lambda_task function parameters must not start with '_' "
|
|
215
|
+
f"(reserved for on_commit overrides). "
|
|
216
|
+
f"'{name}' has reserved parameter '{param.name}'."
|
|
217
|
+
)
|
|
218
|
+
if param.kind is inspect.Parameter.KEYWORD_ONLY and param.name not in hints:
|
|
219
|
+
raise TypeError(
|
|
220
|
+
f"lambda_task function parameters must be type-annotated. "
|
|
221
|
+
f"'{name}' has unannotated parameter '{param.name}'."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def ignore_errors(self) -> tuple[type[BaseException], ...]:
|
|
226
|
+
"""Exception types that are treated as non-fatal during task execution."""
|
|
227
|
+
return self._ignore_errors
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def retry_on(self) -> tuple[type[BaseException], ...]:
|
|
231
|
+
"""Exception types that trigger an automatic retry."""
|
|
232
|
+
return self._retry_on
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _validate_ignore_errors(
|
|
236
|
+
*, ignore_errors: tuple[type[BaseException], ...]
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Raise TypeError if any element is not a subclass of BaseException."""
|
|
239
|
+
for item in ignore_errors:
|
|
240
|
+
if not (isinstance(item, type) and issubclass(item, BaseException)):
|
|
241
|
+
raise TypeError(
|
|
242
|
+
f"ignore_errors must contain only exception types (subclasses of "
|
|
243
|
+
f"BaseException); got {item!r}."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _validate_retry_on(*, retry_on: tuple[type[BaseException], ...]) -> None:
|
|
248
|
+
"""Raise TypeError if any element is not a subclass of BaseException."""
|
|
249
|
+
for item in retry_on:
|
|
250
|
+
if not (isinstance(item, type) and issubclass(item, BaseException)):
|
|
251
|
+
raise TypeError(
|
|
252
|
+
f"retry_on must contain only exception types (subclasses of "
|
|
253
|
+
f"BaseException); got {item!r}."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _validate_no_overlap(
|
|
258
|
+
*,
|
|
259
|
+
retry_on: tuple[type[BaseException], ...],
|
|
260
|
+
ignore_errors: tuple[type[BaseException], ...],
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Raise TypeError if any type in retry_on and ignore_errors overlap via subclass."""
|
|
263
|
+
for retry_type in retry_on:
|
|
264
|
+
for ignore_type in ignore_errors:
|
|
265
|
+
if issubclass(retry_type, ignore_type) or issubclass(
|
|
266
|
+
ignore_type, retry_type
|
|
267
|
+
):
|
|
268
|
+
raise TypeError(
|
|
269
|
+
f"retry_on and ignore_errors must not overlap: "
|
|
270
|
+
f"{retry_type.__name__!r} and {ignore_type.__name__!r} conflict "
|
|
271
|
+
f"(one is a subclass of the other)."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _validate_timeouts(
|
|
276
|
+
*, soft_timeout: int | None, hard_timeout: int | None
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Raise ValueError if any timeout exceeds 900s or soft_timeout >= hard_timeout."""
|
|
279
|
+
for name, value in (
|
|
280
|
+
("soft_timeout", soft_timeout),
|
|
281
|
+
("hard_timeout", hard_timeout),
|
|
282
|
+
):
|
|
283
|
+
if value is not None and value > MAX_TIMEOUT:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"{name} ({value}) exceeds the maximum allowed value of {MAX_TIMEOUT} seconds."
|
|
286
|
+
)
|
|
287
|
+
if soft_timeout is not None and hard_timeout is not None:
|
|
288
|
+
if soft_timeout >= hard_timeout:
|
|
289
|
+
raise ValueError(
|
|
290
|
+
f"soft_timeout ({soft_timeout}) must be strictly less than "
|
|
291
|
+
f"hard_timeout ({hard_timeout})."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@overload
|
|
296
|
+
def lambda_task(
|
|
297
|
+
func: Callable[..., Any],
|
|
298
|
+
*,
|
|
299
|
+
delay: int = ...,
|
|
300
|
+
soft_timeout: int | None = ...,
|
|
301
|
+
hard_timeout: int | None = ...,
|
|
302
|
+
queue: str = ...,
|
|
303
|
+
ignore_errors: tuple[type[BaseException], ...] = ...,
|
|
304
|
+
retry_on: tuple[type[BaseException], ...] = ...,
|
|
305
|
+
) -> LambdaTaskWrapper: ...
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@overload
|
|
309
|
+
def lambda_task(
|
|
310
|
+
func: None = None,
|
|
311
|
+
*,
|
|
312
|
+
delay: int = ...,
|
|
313
|
+
soft_timeout: int | None = ...,
|
|
314
|
+
hard_timeout: int | None = ...,
|
|
315
|
+
queue: str = ...,
|
|
316
|
+
ignore_errors: tuple[type[BaseException], ...] = ...,
|
|
317
|
+
retry_on: tuple[type[BaseException], ...] = ...,
|
|
318
|
+
) -> Callable[[Callable[..., Any]], LambdaTaskWrapper]: ...
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def lambda_task(
|
|
322
|
+
func: Callable[..., Any] | None = None,
|
|
323
|
+
*,
|
|
324
|
+
delay: int = 0,
|
|
325
|
+
soft_timeout: int | None = None,
|
|
326
|
+
hard_timeout: int | None = None,
|
|
327
|
+
queue: str = "default",
|
|
328
|
+
ignore_errors: tuple[type[BaseException], ...] = (),
|
|
329
|
+
retry_on: tuple[type[BaseException], ...] = (),
|
|
330
|
+
) -> LambdaTaskWrapper | Callable[[Callable[..., Any]], LambdaTaskWrapper]:
|
|
331
|
+
"""Decorator factory that registers a function as a background task.
|
|
332
|
+
|
|
333
|
+
Can be used with or without parentheses::
|
|
334
|
+
|
|
335
|
+
@lambda_task
|
|
336
|
+
def my_task(*, x: int): ...
|
|
337
|
+
|
|
338
|
+
@lambda_task(delay=5, soft_timeout=60, hard_timeout=120)
|
|
339
|
+
def my_task(*, x: int): ...
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def _decorate(f: Callable[..., Any]) -> LambdaTaskWrapper:
|
|
343
|
+
wrapper = LambdaTaskWrapper(
|
|
344
|
+
f,
|
|
345
|
+
delay=delay,
|
|
346
|
+
soft_timeout=soft_timeout,
|
|
347
|
+
hard_timeout=hard_timeout,
|
|
348
|
+
queue=queue,
|
|
349
|
+
ignore_errors=ignore_errors,
|
|
350
|
+
retry_on=retry_on,
|
|
351
|
+
)
|
|
352
|
+
return wrapper
|
|
353
|
+
|
|
354
|
+
if func is not None:
|
|
355
|
+
# Called as @lambda_task (no parentheses)
|
|
356
|
+
return _decorate(func)
|
|
357
|
+
|
|
358
|
+
# Called as @lambda_task(...) — return the decorator
|
|
359
|
+
return _decorate
|
lambda_tasks/handler.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AWS Lambda handler for lambda_tasks.
|
|
3
|
+
|
|
4
|
+
Processes a batch of SQS records using partial-batch failure reporting.
|
|
5
|
+
Each record is processed independently — a failure in one record does not
|
|
6
|
+
prevent processing of other records.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import django
|
|
13
|
+
from django.apps import apps as django_apps
|
|
14
|
+
|
|
15
|
+
from lambda_tasks.models import SQSLambdaTaskMessage
|
|
16
|
+
from lambda_tasks.secret_loader import resolve_secrets_into_env
|
|
17
|
+
|
|
18
|
+
# Cold-start Django setup — runs once per Lambda container.
|
|
19
|
+
# Secrets are resolved first so Django settings can reference the populated
|
|
20
|
+
# env vars. resolve_secrets_into_env() is idempotent and caches fetched
|
|
21
|
+
# secrets in-process, so subsequent invocations pay no extra cost.
|
|
22
|
+
if os.environ.get("DJANGO_SETTINGS_MODULE") and not django_apps.ready:
|
|
23
|
+
resolve_secrets_into_env()
|
|
24
|
+
django.setup()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def handler(*, event: dict, context: object) -> dict:
|
|
31
|
+
"""AWS Lambda entry point. Processes a batch of SQS records.
|
|
32
|
+
|
|
33
|
+
Returns a partial-batch failure report so AWS only re-drives failed records.
|
|
34
|
+
"""
|
|
35
|
+
batch_item_failures: list[dict] = []
|
|
36
|
+
|
|
37
|
+
for record in event["Records"]:
|
|
38
|
+
try:
|
|
39
|
+
SQSLambdaTaskMessage.model_validate_json(
|
|
40
|
+
record["body"]
|
|
41
|
+
).execute_immediately()
|
|
42
|
+
except Exception:
|
|
43
|
+
logger.error(
|
|
44
|
+
"Failed to process SQS record %s",
|
|
45
|
+
record.get("messageId"),
|
|
46
|
+
exc_info=True,
|
|
47
|
+
)
|
|
48
|
+
batch_item_failures.append({"itemIdentifier": record["messageId"]})
|
|
49
|
+
|
|
50
|
+
return {"batchItemFailures": batch_item_failures}
|
lambda_tasks/logging.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provides task_logger — a LoggerAdapter that automatically includes the
|
|
3
|
+
current invocation_id on every log record while a task is executing.
|
|
4
|
+
|
|
5
|
+
Usage in task functions::
|
|
6
|
+
|
|
7
|
+
from lambda_tasks.logging import task_logger
|
|
8
|
+
|
|
9
|
+
@lambda_task(...)
|
|
10
|
+
def my_task(*, user_id: int) -> None:
|
|
11
|
+
task_logger.info("processing user %s", user_id)
|
|
12
|
+
# → "processing user 42" tagged with the active invocation_id
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import MutableMapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _TaskLogger(logging.LoggerAdapter):
|
|
21
|
+
"""LoggerAdapter that prepends [invocation_id] to every message."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
super().__init__(logging.getLogger("lambda_tasks.task"), extra={})
|
|
25
|
+
self.invocation_id: str | None = None
|
|
26
|
+
|
|
27
|
+
def process(
|
|
28
|
+
self, msg: Any, kwargs: MutableMapping[str, Any]
|
|
29
|
+
) -> tuple[Any, MutableMapping[str, Any]]:
|
|
30
|
+
prefix = f"[{self.invocation_id}] " if self.invocation_id else ""
|
|
31
|
+
return f"{prefix}{msg}", kwargs
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
task_logger = _TaskLogger()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Generated by Django 6.0.3 on 2026-03-26 11:44
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.CreateModel(
|
|
14
|
+
name="TaskRecord",
|
|
15
|
+
fields=[
|
|
16
|
+
(
|
|
17
|
+
"id",
|
|
18
|
+
models.BigAutoField(
|
|
19
|
+
auto_created=True,
|
|
20
|
+
primary_key=True,
|
|
21
|
+
serialize=False,
|
|
22
|
+
verbose_name="ID",
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
("task_name", models.CharField(editable=False, max_length=255)),
|
|
26
|
+
("invocation_id", models.UUIDField(editable=False, unique=True)),
|
|
27
|
+
("kwargs", models.JSONField(editable=False)),
|
|
28
|
+
("n_retries", models.PositiveSmallIntegerField(editable=False)),
|
|
29
|
+
(
|
|
30
|
+
"status",
|
|
31
|
+
models.CharField(
|
|
32
|
+
choices=[
|
|
33
|
+
("RUNNING", "Running"),
|
|
34
|
+
("SUCCESS", "Success"),
|
|
35
|
+
("FAILED", "Failed"),
|
|
36
|
+
("RETRYING", "Retrying"),
|
|
37
|
+
],
|
|
38
|
+
editable=False,
|
|
39
|
+
max_length=10,
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
("start_time", models.DateTimeField(editable=False, null=True)),
|
|
43
|
+
("end_time", models.DateTimeField(editable=False, null=True)),
|
|
44
|
+
("result", models.JSONField(editable=False, null=True)),
|
|
45
|
+
("traceback", models.TextField(editable=False, null=True)),
|
|
46
|
+
],
|
|
47
|
+
options={
|
|
48
|
+
"ordering": ["-start_time"],
|
|
49
|
+
"indexes": [
|
|
50
|
+
models.Index(
|
|
51
|
+
fields=["task_name"], name="lambda_task_task_na_f00cb7_idx"
|
|
52
|
+
),
|
|
53
|
+
models.Index(
|
|
54
|
+
fields=["invocation_id"], name="lambda_task_invocat_3d5a22_idx"
|
|
55
|
+
),
|
|
56
|
+
models.Index(
|
|
57
|
+
fields=["status"], name="lambda_task_status_6901ee_idx"
|
|
58
|
+
),
|
|
59
|
+
models.Index(
|
|
60
|
+
fields=["-start_time"], name="lambda_task_start_t_9caffc_idx"
|
|
61
|
+
),
|
|
62
|
+
],
|
|
63
|
+
"constraints": [
|
|
64
|
+
models.CheckConstraint(
|
|
65
|
+
condition=models.Q(
|
|
66
|
+
("status__in", ["RUNNING", "SUCCESS", "FAILED", "RETRYING"])
|
|
67
|
+
),
|
|
68
|
+
name="taskrecord_status_valid",
|
|
69
|
+
)
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
),
|
|
73
|
+
]
|
|
File without changes
|