django-lambda-tasks 0.6.7__tar.gz → 0.8.0__tar.gz
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.6.7 → django_lambda_tasks-0.8.0}/.github/workflows/ci.yml +15 -1
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/steering/product.md +1 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/steering/structure.md +1 -1
- django_lambda_tasks-0.6.7/README.md → django_lambda_tasks-0.8.0/PKG-INFO +17 -0
- django_lambda_tasks-0.6.7/PKG-INFO → django_lambda_tasks-0.8.0/README.md +5 -12
- django_lambda_tasks-0.8.0/docker-compose.yml +11 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/README.md +12 -5
- django_lambda_tasks-0.8.0/example/docker-compose.yml +14 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/example_project/settings.py +7 -2
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/checks.py +21 -1
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/handler.py +2 -0
- django_lambda_tasks-0.8.0/lambda_tasks/migrations/0003_taskrecord_taskrecord_kwargs_trgm.py +29 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/models.py +8 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/pyproject.toml +2 -1
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/settings.py +7 -2
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_batch_worker.py +9 -0
- django_lambda_tasks-0.8.0/tests/test_checks.py +21 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_handler.py +76 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_models.py +8 -1
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.github/workflows/release.yml +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.gitignore +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/async-local-execution/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/async-local-execution/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/async-local-execution/requirements.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/async-local-execution/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/eager-mode-example-app/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/import-string-task-resolution/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/retry-delay/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/retry-delay/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/retry-delay/requirements.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/retry-delay/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/rse-background-tasks/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/singleton-task/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/singleton-task/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/singleton-task/requirements.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/singleton-task/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/ssm-environment-loader/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/task-retry/.config.kiro +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/task-retry/design.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/task-retry/requirements.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/task-retry/tasks.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/steering/tech.md +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.pre-commit-config.yaml +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.vscode/settings.json +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/example_app/__init__.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/example_app/apps.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/example_app/tasks.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/example_app/urls.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/example_app/views.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/example_project/__init__.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/example_project/urls.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/example_project/wsgi.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/example/manage.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/__init__.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/admin.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/apps.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/decorators.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/environment_loader.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/local_executor.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/logging.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/migrations/0001_initial.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/migrations/__init__.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/secret_loader.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/settings.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/tasks.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/timeouts.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/conftest.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_admin.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_batch.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_decorator.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_decorators.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_deferred_enqueue.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_environment_loader.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_kwargs_only.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_local_executor.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_logging.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_memory_limit.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_noop_execution.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_secret_loader.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_serializer.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_settings.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_tasks.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_timeout_validation.py +0 -0
- {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/tests/test_timeouts.py +0 -0
|
@@ -30,12 +30,26 @@ jobs:
|
|
|
30
30
|
strategy:
|
|
31
31
|
matrix:
|
|
32
32
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
33
|
+
services:
|
|
34
|
+
postgres:
|
|
35
|
+
image: postgres:17
|
|
36
|
+
env:
|
|
37
|
+
POSTGRES_DB: lambda_tasks_test
|
|
38
|
+
POSTGRES_USER: postgres
|
|
39
|
+
POSTGRES_PASSWORD: postgres
|
|
40
|
+
ports:
|
|
41
|
+
- 5432:5432
|
|
42
|
+
options: >-
|
|
43
|
+
--health-cmd "pg_isready -U postgres"
|
|
44
|
+
--health-interval 10s
|
|
45
|
+
--health-timeout 5s
|
|
46
|
+
--health-retries 5
|
|
33
47
|
steps:
|
|
34
48
|
- uses: actions/checkout@v4
|
|
35
49
|
- name: Install uv and set the python version
|
|
36
50
|
uses: astral-sh/setup-uv@v6
|
|
37
51
|
with:
|
|
38
52
|
python-version: ${{ matrix.python-version }}
|
|
39
|
-
- name: Run
|
|
53
|
+
- name: Run tests
|
|
40
54
|
run: |
|
|
41
55
|
uv run pytest --cov-branch --cov-report term-missing --cov=tests/ --cov=lambda_tasks/
|
|
@@ -143,6 +143,7 @@ class SQSLambdaTaskMessage(BaseModel):
|
|
|
143
143
|
|
|
144
144
|
`handler(event, context)` in `handler.py`:
|
|
145
145
|
- Processes each SQS record independently
|
|
146
|
+
- Calls `django.db.close_old_connections()` before each record to close database connections that have become unusable between warm invocations (idle timeouts, RDS failovers, network interruptions)
|
|
146
147
|
- Passes `record["messageId"]` as `message_id` to `execute_immediately()` — this becomes the `TaskRecord` primary key, enabling deduplication on redelivery
|
|
147
148
|
- Returns `{"batchItemFailures": [...]}` for partial-batch failure reporting
|
|
148
149
|
- Only pre-execution failures (malformed message, import error, misconfiguration) are reported as `batchItemFailures` — task logic failures are caught and recorded as `FAILED` TaskRecords without raising
|
|
@@ -43,7 +43,7 @@ django-lambda-tasks/
|
|
|
43
43
|
- `decorators.py` — defines `LambdaTaskWrapper` and `@lambda_task` decorator factory; enforces kwargs-only at decoration time; validates timeouts (> 0, soft < hard) at decoration time; upper-bound timeout validation deferred to execution time based on queue type
|
|
44
44
|
- `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (Pydantic, SQS schema + execution logic), `SQSLambdaTask` (Pydantic, holds message + routing; `_execute()` inspects queue config to publish to SQS, submit to Batch via `submit_batch_job`, execute eagerly, or submit to the local process pool; `execute_on_commit()` registers `_execute` with `transaction.on_commit`)
|
|
45
45
|
- `local_executor.py` — `ProcessPoolExecutor`-based async local execution; `get_pool()` lazily creates a module-level pool; `submit_task()` fire-and-forget submission; `_execute_in_worker()` deserializes and runs the task in a child process; `_pool_initializer()` calls `django.setup()` per worker (and sets `SIGINT` to `SIG_IGN` so workers ignore Ctrl+C); `_install_shutdown_handlers()` installs main-thread `SIGINT`/`SIGTERM` handlers that release the pool before chaining to the previous handler
|
|
46
|
-
- `handler.py` — Lambda entry point (`handler(event, context)`); cold-start init (memory limit → temporary log handler → `resolve_environment()` → `resolve_secrets_into_env()` → handler removed → conditional `django.setup()`) runs inside the handler on first invocation, guarded by `_cold_start_done` sentinel; processes SQS records independently; returns `batchItemFailures`. Also provides `main()` as the AWS Batch container entry point (`python -m lambda_tasks.handler`) — reads `LAMBDA_TASKS_MESSAGE` env var, constructs a synthetic SQS event, and delegates to `handler()`
|
|
46
|
+
- `handler.py` — Lambda entry point (`handler(event, context)`); cold-start init (memory limit → temporary log handler → `resolve_environment()` → `resolve_secrets_into_env()` → handler removed → conditional `django.setup()`) runs inside the handler on first invocation, guarded by `_cold_start_done` sentinel; calls `db.close_old_connections()` before each SQS record to handle stale connections on warm starts; processes SQS records independently; returns `batchItemFailures`. Also provides `main()` as the AWS Batch container entry point (`python -m lambda_tasks.handler`) — reads `LAMBDA_TASKS_MESSAGE` env var, constructs a synthetic SQS event, and delegates to `handler()`
|
|
47
47
|
- `environment_loader.py` — loads env vars from a Secrets Manager secret at cold start; validates flat JSON format; idempotent via `_loaded` sentinel
|
|
48
48
|
- `secret_loader.py` — resolves `LAMBDA_TASKS_SECRET_*` env vars from Secrets Manager before Django starts; validates format, detects conflicts, batches API calls; idempotent via `_loaded` sentinel
|
|
49
49
|
- `logging.py` — `task_logger` singleton; `message_id` set/cleared around each task execution
|
|
@@ -1,9 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-lambda-tasks
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Run async tasks in a lambda function
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: awslambdaric
|
|
7
|
+
Requires-Dist: boto3
|
|
8
|
+
Requires-Dist: django
|
|
9
|
+
Requires-Dist: pydantic
|
|
10
|
+
Requires-Dist: redis
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
1
13
|
# Django Lambda Tasks
|
|
2
14
|
|
|
3
15
|
A Django library for offloading work to AWS Lambda or AWS Batch outside of the request-response cycle. Tasks are defined with a decorator, enqueued to SQS on transaction commit, and executed by a Lambda handler or Batch container. Task results, status, and metadata are persisted in the Django database.
|
|
4
16
|
|
|
5
17
|
> **Platform note:** Unix-based systems only (Linux, macOS). Timeout enforcement relies on `SIGALRM`.
|
|
6
18
|
|
|
19
|
+
> **Database note:** PostgreSQL is required. The `TaskRecord` model uses PostgreSQL-specific features.
|
|
20
|
+
|
|
7
21
|
---
|
|
8
22
|
|
|
9
23
|
## Installation
|
|
@@ -78,6 +92,7 @@ A runnable Django project is included in the [`example/`](example/) directory. I
|
|
|
78
92
|
|
|
79
93
|
```bash
|
|
80
94
|
cd example
|
|
95
|
+
docker compose up -d
|
|
81
96
|
uv run python manage.py migrate
|
|
82
97
|
uv run python manage.py createsuperuser
|
|
83
98
|
uv run python manage.py runserver
|
|
@@ -535,6 +550,8 @@ lambda_tasks.handler.handler
|
|
|
535
550
|
|
|
536
551
|
The handler performs cold-start initialisation on the first invocation (not at module import time) to avoid Lambda init-duration timeouts. The sequence is: temporary log handler attached → `resolve_environment()` → `resolve_secrets_into_env()` → log handler removed → conditional `django.setup()`. A module-level sentinel ensures this runs only once; subsequent warm invocations skip it entirely.
|
|
537
552
|
|
|
553
|
+
Before processing each SQS record, the handler calls `django.db.close_old_connections()` to close any database connections that have become unusable between invocations. Lambda containers are reused across invocations (warm starts), but database connections may be closed server-side due to idle timeouts, RDS failovers, or network interruptions. Without this, the first database query in a task would fail with `OperationalError: the connection is closed`.
|
|
554
|
+
|
|
538
555
|
Ensure the Lambda execution environment has `DJANGO_SETTINGS_MODULE` set and that all task modules are importable (i.e. your application code is on the Python path).
|
|
539
556
|
|
|
540
557
|
| Environment Variable | Required | Description |
|
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: django-lambda-tasks
|
|
3
|
-
Version: 0.6.7
|
|
4
|
-
Summary: Run async tasks in a lambda function
|
|
5
|
-
Requires-Python: >=3.10
|
|
6
|
-
Requires-Dist: awslambdaric
|
|
7
|
-
Requires-Dist: boto3
|
|
8
|
-
Requires-Dist: django
|
|
9
|
-
Requires-Dist: pydantic
|
|
10
|
-
Requires-Dist: redis
|
|
11
|
-
Description-Content-Type: text/markdown
|
|
12
|
-
|
|
13
1
|
# Django Lambda Tasks
|
|
14
2
|
|
|
15
3
|
A Django library for offloading work to AWS Lambda or AWS Batch outside of the request-response cycle. Tasks are defined with a decorator, enqueued to SQS on transaction commit, and executed by a Lambda handler or Batch container. Task results, status, and metadata are persisted in the Django database.
|
|
16
4
|
|
|
17
5
|
> **Platform note:** Unix-based systems only (Linux, macOS). Timeout enforcement relies on `SIGALRM`.
|
|
18
6
|
|
|
7
|
+
> **Database note:** PostgreSQL is required. The `TaskRecord` model uses PostgreSQL-specific features.
|
|
8
|
+
|
|
19
9
|
---
|
|
20
10
|
|
|
21
11
|
## Installation
|
|
@@ -90,6 +80,7 @@ A runnable Django project is included in the [`example/`](example/) directory. I
|
|
|
90
80
|
|
|
91
81
|
```bash
|
|
92
82
|
cd example
|
|
83
|
+
docker compose up -d
|
|
93
84
|
uv run python manage.py migrate
|
|
94
85
|
uv run python manage.py createsuperuser
|
|
95
86
|
uv run python manage.py runserver
|
|
@@ -547,6 +538,8 @@ lambda_tasks.handler.handler
|
|
|
547
538
|
|
|
548
539
|
The handler performs cold-start initialisation on the first invocation (not at module import time) to avoid Lambda init-duration timeouts. The sequence is: temporary log handler attached → `resolve_environment()` → `resolve_secrets_into_env()` → log handler removed → conditional `django.setup()`. A module-level sentinel ensures this runs only once; subsequent warm invocations skip it entirely.
|
|
549
540
|
|
|
541
|
+
Before processing each SQS record, the handler calls `django.db.close_old_connections()` to close any database connections that have become unusable between invocations. Lambda containers are reused across invocations (warm starts), but database connections may be closed server-side due to idle timeouts, RDS failovers, or network interruptions. Without this, the first database query in a task would fail with `OperationalError: the connection is closed`.
|
|
542
|
+
|
|
550
543
|
Ensure the Lambda execution environment has `DJANGO_SETTINGS_MODULE` set and that all task modules are importable (i.e. your application code is on the Python path).
|
|
551
544
|
|
|
552
545
|
| Environment Variable | Required | Description |
|
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
# Example App
|
|
2
2
|
|
|
3
|
-
A minimal Django app demonstrating `django-lambda-tasks` with `
|
|
3
|
+
A minimal Django app demonstrating `django-lambda-tasks` with `LAMBDA_TASKS_LOCAL_WORKERS = 2`. Tasks execute in a background process pool — no AWS infrastructure required.
|
|
4
4
|
|
|
5
5
|
## Prerequisites
|
|
6
6
|
|
|
7
7
|
- `uv` installed
|
|
8
|
+
- Docker installed (for PostgreSQL)
|
|
8
9
|
- All commands run from the `example/` directory
|
|
9
10
|
|
|
10
11
|
## Setup
|
|
11
12
|
|
|
12
|
-
**1.
|
|
13
|
+
**1. Start PostgreSQL**
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
docker compose up -d
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**2. Apply migrations**
|
|
13
20
|
|
|
14
21
|
```bash
|
|
15
22
|
uv run python manage.py migrate
|
|
16
23
|
```
|
|
17
24
|
|
|
18
|
-
**
|
|
25
|
+
**3. Create a superuser** (to access the admin interface)
|
|
19
26
|
|
|
20
27
|
```bash
|
|
21
28
|
uv run python manage.py createsuperuser
|
|
22
29
|
```
|
|
23
30
|
|
|
24
|
-
**
|
|
31
|
+
**4. Start the development server**
|
|
25
32
|
|
|
26
33
|
```bash
|
|
27
34
|
uv run python manage.py runserver
|
|
@@ -33,7 +40,7 @@ uv run python manage.py runserver
|
|
|
33
40
|
|
|
34
41
|
Visit: `http://127.0.0.1:8000/trigger/?name=Alice`
|
|
35
42
|
|
|
36
|
-
The task runs
|
|
43
|
+
The task runs in a background worker process and the response confirms it was triggered.
|
|
37
44
|
|
|
38
45
|
**View task records in the admin**
|
|
39
46
|
|
|
@@ -17,6 +17,7 @@ INSTALLED_APPS = [
|
|
|
17
17
|
"django.contrib.sessions",
|
|
18
18
|
"django.contrib.messages",
|
|
19
19
|
"django.contrib.staticfiles",
|
|
20
|
+
"django.contrib.postgres",
|
|
20
21
|
"lambda_tasks",
|
|
21
22
|
"example_app",
|
|
22
23
|
]
|
|
@@ -53,8 +54,12 @@ WSGI_APPLICATION = "example_project.wsgi.application"
|
|
|
53
54
|
|
|
54
55
|
DATABASES = {
|
|
55
56
|
"default": {
|
|
56
|
-
"ENGINE": "django.db.backends.
|
|
57
|
-
"NAME":
|
|
57
|
+
"ENGINE": "django.db.backends.postgresql",
|
|
58
|
+
"NAME": "example",
|
|
59
|
+
"USER": "postgres",
|
|
60
|
+
"PASSWORD": "postgres",
|
|
61
|
+
"HOST": "localhost",
|
|
62
|
+
"PORT": "5432",
|
|
58
63
|
}
|
|
59
64
|
}
|
|
60
65
|
|
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
"""Django deployment checks for lambda_tasks."""
|
|
2
2
|
|
|
3
3
|
from django.conf import settings as django_settings
|
|
4
|
-
from django.core.checks import Warning, register
|
|
4
|
+
from django.core.checks import Error, Warning, register
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register("lambda_tasks")
|
|
8
|
+
def check_contrib_postgres_installed(
|
|
9
|
+
*, app_configs: object, **kwargs: object
|
|
10
|
+
) -> list[Error]:
|
|
11
|
+
"""Error if django.contrib.postgres is not in INSTALLED_APPS."""
|
|
12
|
+
errors: list[Error] = []
|
|
13
|
+
|
|
14
|
+
if "django.contrib.postgres" not in django_settings.INSTALLED_APPS:
|
|
15
|
+
errors.append(
|
|
16
|
+
Error(
|
|
17
|
+
"django.contrib.postgres is not in INSTALLED_APPS.",
|
|
18
|
+
hint="Add 'django.contrib.postgres' to INSTALLED_APPS. "
|
|
19
|
+
"It is required for PostgreSQL-specific indexes used by lambda_tasks.",
|
|
20
|
+
id="lambda_tasks.E001",
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return errors
|
|
5
25
|
|
|
6
26
|
|
|
7
27
|
@register("lambda_tasks", deploy=True)
|
|
@@ -21,6 +21,7 @@ import urllib.request
|
|
|
21
21
|
import uuid
|
|
22
22
|
|
|
23
23
|
import django
|
|
24
|
+
from django import db
|
|
24
25
|
from django.apps import apps
|
|
25
26
|
|
|
26
27
|
from lambda_tasks.environment_loader import resolve_environment
|
|
@@ -132,6 +133,7 @@ def handler(event: dict, context: object) -> dict:
|
|
|
132
133
|
|
|
133
134
|
for record in event["Records"]:
|
|
134
135
|
try:
|
|
136
|
+
db.close_old_connections()
|
|
135
137
|
SQSLambdaTaskMessage.model_validate_json(
|
|
136
138
|
record["body"]
|
|
137
139
|
).execute_immediately(message_id=record["messageId"])
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Generated by Django 6.0.3 on 2026-08-17 12:45
|
|
2
|
+
|
|
3
|
+
import django.contrib.postgres.indexes
|
|
4
|
+
import django.db.models.functions.comparison
|
|
5
|
+
from django.contrib.postgres.operations import TrigramExtension
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
("lambda_tasks", "0002_alter_taskrecord_status"),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
operations = [
|
|
16
|
+
TrigramExtension(),
|
|
17
|
+
migrations.AddIndex(
|
|
18
|
+
model_name="taskrecord",
|
|
19
|
+
index=django.contrib.postgres.indexes.GinIndex(
|
|
20
|
+
django.contrib.postgres.indexes.OpClass(
|
|
21
|
+
django.db.models.functions.comparison.Cast(
|
|
22
|
+
"kwargs", output_field=models.TextField()
|
|
23
|
+
),
|
|
24
|
+
name="gin_trgm_ops",
|
|
25
|
+
),
|
|
26
|
+
name="taskrecord_kwargs_trgm",
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
]
|
|
@@ -8,10 +8,12 @@ import traceback
|
|
|
8
8
|
import uuid
|
|
9
9
|
|
|
10
10
|
import boto3
|
|
11
|
+
from django.contrib.postgres.indexes import GinIndex, OpClass
|
|
11
12
|
from django.core.cache import caches
|
|
12
13
|
from django.core.exceptions import ImproperlyConfigured
|
|
13
14
|
from django.db import models, transaction
|
|
14
15
|
from django.db.models import Q
|
|
16
|
+
from django.db.models.functions import Cast
|
|
15
17
|
from django.utils.module_loading import import_string
|
|
16
18
|
from django.utils.timezone import now
|
|
17
19
|
from pydantic import BaseModel, ConfigDict, Field
|
|
@@ -74,6 +76,12 @@ class TaskRecord(models.Model):
|
|
|
74
76
|
models.Index(fields=["task_name"]),
|
|
75
77
|
models.Index(fields=["status"]),
|
|
76
78
|
models.Index(fields=["-start_time"]),
|
|
79
|
+
GinIndex(
|
|
80
|
+
OpClass(
|
|
81
|
+
Cast("kwargs", output_field=models.TextField()), name="gin_trgm_ops"
|
|
82
|
+
),
|
|
83
|
+
name="taskrecord_kwargs_trgm",
|
|
84
|
+
),
|
|
77
85
|
]
|
|
78
86
|
constraints = [
|
|
79
87
|
models.CheckConstraint(
|
|
@@ -7,7 +7,7 @@ packages = ["lambda_tasks"]
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "django-lambda-tasks"
|
|
10
|
-
version = "0.
|
|
10
|
+
version = "0.8.0"
|
|
11
11
|
description = "Run async tasks in a lambda function"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.10"
|
|
@@ -30,6 +30,7 @@ dev = [
|
|
|
30
30
|
"pytest-xdist",
|
|
31
31
|
"pytest-cov",
|
|
32
32
|
"ty",
|
|
33
|
+
"psycopg",
|
|
33
34
|
]
|
|
34
35
|
|
|
35
36
|
[tool.ty.rules]
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
DATABASES = {
|
|
2
2
|
"default": {
|
|
3
|
-
"ENGINE": "django.db.backends.
|
|
4
|
-
"NAME": "
|
|
3
|
+
"ENGINE": "django.db.backends.postgresql",
|
|
4
|
+
"NAME": "lambda_tasks_test",
|
|
5
|
+
"USER": "postgres",
|
|
6
|
+
"PASSWORD": "postgres",
|
|
7
|
+
"HOST": "localhost",
|
|
8
|
+
"PORT": "5432",
|
|
5
9
|
}
|
|
6
10
|
}
|
|
7
11
|
|
|
@@ -9,6 +13,7 @@ INSTALLED_APPS = [
|
|
|
9
13
|
"django.contrib.admin",
|
|
10
14
|
"django.contrib.contenttypes",
|
|
11
15
|
"django.contrib.auth",
|
|
16
|
+
"django.contrib.postgres",
|
|
12
17
|
"lambda_tasks",
|
|
13
18
|
]
|
|
14
19
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for lambda_tasks.batch_worker entry point."""
|
|
2
2
|
|
|
3
3
|
import uuid
|
|
4
|
+
from unittest.mock import patch
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
|
|
@@ -8,6 +9,14 @@ from lambda_tasks.decorators import lambda_task
|
|
|
8
9
|
from lambda_tasks.handler import main
|
|
9
10
|
from lambda_tasks.models import SQSLambdaTaskMessage, TaskRecord
|
|
10
11
|
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def _patch_close_old_connections():
|
|
15
|
+
"""Prevent close_old_connections from closing the test DB connection."""
|
|
16
|
+
with patch("lambda_tasks.handler.db.close_old_connections"):
|
|
17
|
+
yield
|
|
18
|
+
|
|
19
|
+
|
|
11
20
|
QUEUES = {
|
|
12
21
|
"default": {"queue_url": "https://sqs.us-east-1.amazonaws.com/000000000000/default"}
|
|
13
22
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Tests for the django.contrib.postgres system check."""
|
|
2
|
+
|
|
3
|
+
from lambda_tasks.checks import check_contrib_postgres_installed
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_check_passes_when_contrib_postgres_installed(settings):
|
|
7
|
+
settings.INSTALLED_APPS = [
|
|
8
|
+
"django.contrib.postgres",
|
|
9
|
+
"lambda_tasks",
|
|
10
|
+
]
|
|
11
|
+
errors = check_contrib_postgres_installed(app_configs=None)
|
|
12
|
+
assert errors == []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_check_errors_when_contrib_postgres_missing(settings):
|
|
16
|
+
settings.INSTALLED_APPS = [
|
|
17
|
+
"lambda_tasks",
|
|
18
|
+
]
|
|
19
|
+
errors = check_contrib_postgres_installed(app_configs=None)
|
|
20
|
+
assert len(errors) == 1
|
|
21
|
+
assert errors[0].id == "lambda_tasks.E001"
|
|
@@ -26,6 +26,14 @@ from hypothesis import strategies as st
|
|
|
26
26
|
|
|
27
27
|
from lambda_tasks.handler import handler
|
|
28
28
|
|
|
29
|
+
|
|
30
|
+
@pytest.fixture(autouse=True)
|
|
31
|
+
def _patch_close_old_connections():
|
|
32
|
+
"""Prevent close_old_connections from triggering the DB access guard in unit tests."""
|
|
33
|
+
with patch("lambda_tasks.handler.db.close_old_connections"):
|
|
34
|
+
yield
|
|
35
|
+
|
|
36
|
+
|
|
29
37
|
# ---------------------------------------------------------------------------
|
|
30
38
|
# Signature test
|
|
31
39
|
# ---------------------------------------------------------------------------
|
|
@@ -457,3 +465,71 @@ class TestColdStartLogging:
|
|
|
457
465
|
|
|
458
466
|
assert lambda_tasks_logger.handlers == []
|
|
459
467
|
assert lambda_tasks_logger.level == logging.NOTSET
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
# Stale connection handling
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class TestHandlerClosesOldConnections:
|
|
476
|
+
def test_close_old_connections_called_before_each_record(self):
|
|
477
|
+
"""close_old_connections() is called before processing each SQS record."""
|
|
478
|
+
call_order = []
|
|
479
|
+
|
|
480
|
+
def _track_close():
|
|
481
|
+
call_order.append("close")
|
|
482
|
+
|
|
483
|
+
def _make_message(body: str):
|
|
484
|
+
msg = MagicMock()
|
|
485
|
+
|
|
486
|
+
def _execute(**kwargs):
|
|
487
|
+
call_order.append("execute")
|
|
488
|
+
|
|
489
|
+
msg.execute_immediately.side_effect = _execute
|
|
490
|
+
return msg
|
|
491
|
+
|
|
492
|
+
records = [
|
|
493
|
+
_make_record("msg-1", _valid_body()),
|
|
494
|
+
_make_record("msg-2", _valid_body()),
|
|
495
|
+
]
|
|
496
|
+
|
|
497
|
+
with (
|
|
498
|
+
patch(
|
|
499
|
+
"lambda_tasks.handler.db.close_old_connections",
|
|
500
|
+
side_effect=_track_close,
|
|
501
|
+
),
|
|
502
|
+
_patch_model_validate(_make_message),
|
|
503
|
+
):
|
|
504
|
+
handler(event={"Records": records}, context=None)
|
|
505
|
+
|
|
506
|
+
assert call_order == ["close", "execute", "close", "execute"]
|
|
507
|
+
|
|
508
|
+
def test_close_old_connections_called_even_when_record_fails(self):
|
|
509
|
+
"""close_old_connections() is called before each record, including ones that fail."""
|
|
510
|
+
close_count = 0
|
|
511
|
+
|
|
512
|
+
def _track_close():
|
|
513
|
+
nonlocal close_count
|
|
514
|
+
close_count += 1
|
|
515
|
+
|
|
516
|
+
def _make_message(body: str):
|
|
517
|
+
msg = MagicMock()
|
|
518
|
+
msg.execute_immediately.side_effect = RuntimeError("boom")
|
|
519
|
+
return msg
|
|
520
|
+
|
|
521
|
+
records = [
|
|
522
|
+
_make_record("msg-1", _valid_body()),
|
|
523
|
+
_make_record("msg-2", _valid_body()),
|
|
524
|
+
]
|
|
525
|
+
|
|
526
|
+
with (
|
|
527
|
+
patch(
|
|
528
|
+
"lambda_tasks.handler.db.close_old_connections",
|
|
529
|
+
side_effect=_track_close,
|
|
530
|
+
),
|
|
531
|
+
_patch_model_validate(_make_message),
|
|
532
|
+
):
|
|
533
|
+
handler(event={"Records": records}, context=None)
|
|
534
|
+
|
|
535
|
+
assert close_count == 2
|
|
@@ -1096,7 +1096,14 @@ def test_property_4_retry_increments_n_retries(n_retries, exc_type_name):
|
|
|
1096
1096
|
@pytest.mark.django_db(transaction=True)
|
|
1097
1097
|
@given(
|
|
1098
1098
|
x=st.integers(),
|
|
1099
|
-
label=st.text(
|
|
1099
|
+
label=st.text(
|
|
1100
|
+
min_size=1,
|
|
1101
|
+
max_size=20,
|
|
1102
|
+
alphabet=st.characters(
|
|
1103
|
+
blacklist_characters="\x00",
|
|
1104
|
+
blacklist_categories=("Cs",),
|
|
1105
|
+
),
|
|
1106
|
+
),
|
|
1100
1107
|
)
|
|
1101
1108
|
@h_settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])
|
|
1102
1109
|
def test_property_5_matching_exc_enqueues_retry_same_kwargs(x, label):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/async-local-execution/design.md
RENAMED
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/async-local-execution/tasks.md
RENAMED
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/deferred-task-enqueue/design.md
RENAMED
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/deferred-task-enqueue/tasks.md
RENAMED
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/eager-mode-example-app/design.md
RENAMED
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/eager-mode-example-app/tasks.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/retry-delay/.config.kiro
RENAMED
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/retry-delay/requirements.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/rse-background-tasks/design.md
RENAMED
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/rse-background-tasks/tasks.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/singleton-task/.config.kiro
RENAMED
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/singleton-task/design.md
RENAMED
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/singleton-task/requirements.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/ssm-environment-loader/design.md
RENAMED
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/ssm-environment-loader/tasks.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/.kiro/specs/task-retry/requirements.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_lambda_tasks-0.6.7 → django_lambda_tasks-0.8.0}/lambda_tasks/migrations/0001_initial.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|