django-lambda-tasks 0.3.0__tar.gz → 0.4.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.4.0/.kiro/specs/async-local-execution/.config.kiro +1 -0
- django_lambda_tasks-0.4.0/.kiro/specs/async-local-execution/design.md +281 -0
- django_lambda_tasks-0.4.0/.kiro/specs/async-local-execution/requirements.md +97 -0
- django_lambda_tasks-0.4.0/.kiro/specs/async-local-execution/tasks.md +103 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/steering/product.md +33 -1
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/steering/structure.md +3 -1
- django_lambda_tasks-0.3.0/README.md → django_lambda_tasks-0.4.0/PKG-INFO +39 -0
- django_lambda_tasks-0.3.0/PKG-INFO → django_lambda_tasks-0.4.0/README.md +27 -12
- django_lambda_tasks-0.4.0/example/example_app/tasks.py +18 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_project/settings.py +16 -2
- django_lambda_tasks-0.4.0/lambda_tasks/local_executor.py +45 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/models.py +3 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/settings.py +14 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/pyproject.toml +1 -1
- django_lambda_tasks-0.4.0/tests/test_local_executor.py +646 -0
- django_lambda_tasks-0.3.0/example/example_app/tasks.py +0 -6
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.github/workflows/ci.yml +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.github/workflows/release.yml +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.gitignore +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/eager-mode-example-app/design.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/import-string-task-resolution/design.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/retry-delay/.config.kiro +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/retry-delay/design.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/retry-delay/requirements.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/retry-delay/tasks.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks/design.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/singleton-task/.config.kiro +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/singleton-task/design.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/singleton-task/requirements.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/singleton-task/tasks.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ssm-environment-loader/design.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/task-retry/.config.kiro +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/task-retry/design.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/task-retry/requirements.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/task-retry/tasks.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/steering/tech.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.pre-commit-config.yaml +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.vscode/settings.json +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/README.md +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_app/__init__.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_app/apps.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_app/urls.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_app/views.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_project/__init__.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_project/urls.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_project/wsgi.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/manage.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/__init__.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/admin.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/apps.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/decorators.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/environment_loader.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/handler.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/logging.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/migrations/0001_initial.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/migrations/__init__.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/secret_loader.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/tasks.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/timeouts.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/conftest.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/settings.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_admin.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_decorator.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_decorators.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_deferred_enqueue.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_environment_loader.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_handler.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_kwargs_only.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_logging.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_models.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_secret_loader.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_serializer.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_settings.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_tasks.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_timeout_validation.py +0 -0
- {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_timeouts.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"specId": "9b6bcf96-d290-4107-8540-6dfeb1b6b6cb", "workflowType": "requirements-first", "specType": "feature"}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# Design Document: Async Local Execution
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This feature adds a third execution mode to django-lambda-tasks: **async local mode**. When `LAMBDA_TASKS_LOCAL_WORKERS` is set to a positive integer, tasks are submitted to a `concurrent.futures.ProcessPoolExecutor` instead of SQS. Tasks execute in background worker processes with full timeout enforcement via `SIGALRM`, providing true parallelism without AWS infrastructure.
|
|
6
|
+
|
|
7
|
+
The execution mode hierarchy becomes:
|
|
8
|
+
1. **Eager mode** (`LAMBDA_TASKS_EAGER=True`) — synchronous, in-process, no timeouts
|
|
9
|
+
2. **Async local mode** (`LOCAL_WORKERS > 0`) — async, separate processes, timeouts enforced
|
|
10
|
+
3. **SQS mode** (default) — async, Lambda workers, timeouts enforced
|
|
11
|
+
|
|
12
|
+
This is a development-only feature that bridges the gap between eager mode (no parallelism, no timeouts) and full SQS/Lambda deployment.
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
```mermaid
|
|
17
|
+
graph TD
|
|
18
|
+
A[View calls execute_on_commit] --> B[transaction.on_commit fires]
|
|
19
|
+
B --> C{SQSLambdaTask._execute}
|
|
20
|
+
C -->|EAGER=True| D[execute_immediately in-process]
|
|
21
|
+
C -->|LOCAL_WORKERS > 0| E[Submit to ProcessPoolExecutor]
|
|
22
|
+
C -->|else| F[Send to SQS]
|
|
23
|
+
E --> G[Worker Process]
|
|
24
|
+
G --> H[model_validate_json]
|
|
25
|
+
H --> I[execute_immediately with SIGALRM]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Key Design Decisions
|
|
29
|
+
|
|
30
|
+
1. **ProcessPoolExecutor over multiprocessing.Pool**: `concurrent.futures` provides a cleaner API, automatic worker replacement on crash, and a simpler lifecycle model.
|
|
31
|
+
|
|
32
|
+
2. **JSON string serialization for IPC**: Rather than relying on pickle for the task message (which would work but couples to internal object layout), we serialize via `model_dump_json()` and deserialize via `model_validate_json()`. This mirrors the SQS path and guarantees picklability since strings are always picklable.
|
|
33
|
+
|
|
34
|
+
3. **Module-level pool storage**: The pool is stored in a module-level variable so it persists across Django requests for the lifetime of the server process. This avoids repeated process spawning.
|
|
35
|
+
|
|
36
|
+
4. **Fire-and-forget submission**: The dispatcher discards the `Future` returned by `submit()`. No callbacks, no `result()` calls. Worker failures are isolated and logged within the worker process via the existing `execute_immediately()` error handling.
|
|
37
|
+
|
|
38
|
+
5. **Pool initializer calls `django.setup()`**: Each worker process needs Django configured. The initializer runs once per worker and sets up Django using the `DJANGO_SETTINGS_MODULE` inherited from the parent process environment.
|
|
39
|
+
|
|
40
|
+
6. **Timeouts ARE enforced**: Unlike eager mode, async local workers are separate processes where `SIGALRM` works safely. The `TimeoutContext` condition changes from `if not conf.EAGER` to `if not conf.EAGER or conf.LOCAL_WORKERS > 0` — actually, since workers are separate processes that don't have `EAGER=True`, the existing `TimeoutContext` logic works unchanged in worker processes.
|
|
41
|
+
|
|
42
|
+
## Components and Interfaces
|
|
43
|
+
|
|
44
|
+
### New Module: `lambda_tasks/local_executor.py`
|
|
45
|
+
|
|
46
|
+
This module owns the process pool lifecycle and the worker entry point.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
"""Process pool executor for async local task execution."""
|
|
50
|
+
|
|
51
|
+
import uuid
|
|
52
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
53
|
+
|
|
54
|
+
from lambda_tasks.settings import LambdaTasksSettings
|
|
55
|
+
|
|
56
|
+
# Module-level pool — lazily created, reused for server lifetime
|
|
57
|
+
_pool: ProcessPoolExecutor | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pool_initializer() -> None:
|
|
61
|
+
"""Run once per worker process. Sets up Django."""
|
|
62
|
+
import django
|
|
63
|
+
django.setup()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_pool() -> ProcessPoolExecutor:
|
|
67
|
+
"""Return the shared ProcessPoolExecutor, creating it on first call."""
|
|
68
|
+
global _pool
|
|
69
|
+
if _pool is None:
|
|
70
|
+
conf = LambdaTasksSettings()
|
|
71
|
+
_pool = ProcessPoolExecutor(
|
|
72
|
+
max_workers=conf.LOCAL_WORKERS,
|
|
73
|
+
initializer=_pool_initializer,
|
|
74
|
+
)
|
|
75
|
+
return _pool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _execute_in_worker(*, message_json: str, message_id: str) -> None:
|
|
79
|
+
"""Worker entry point. Deserializes and executes the task.
|
|
80
|
+
|
|
81
|
+
Runs in a child process. Django is already set up via the pool initializer.
|
|
82
|
+
"""
|
|
83
|
+
from lambda_tasks.models import SQSLambdaTaskMessage
|
|
84
|
+
|
|
85
|
+
message = SQSLambdaTaskMessage.model_validate_json(message_json)
|
|
86
|
+
message.execute_immediately(message_id=message_id)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def submit_task(*, message_json: str) -> None:
|
|
90
|
+
"""Submit a task to the process pool. Fire-and-forget."""
|
|
91
|
+
pool = get_pool()
|
|
92
|
+
message_id = str(uuid.uuid4())
|
|
93
|
+
pool.submit(_execute_in_worker, message_json=message_json, message_id=message_id)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Modified: `lambda_tasks/settings.py`
|
|
97
|
+
|
|
98
|
+
Add the `LOCAL_WORKERS` property with validation:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
@property
|
|
102
|
+
def LOCAL_WORKERS(self) -> int:
|
|
103
|
+
value = int(getattr(django_settings, "LAMBDA_TASKS_LOCAL_WORKERS", 0))
|
|
104
|
+
if value < 0:
|
|
105
|
+
raise ImproperlyConfigured(
|
|
106
|
+
"LAMBDA_TASKS_LOCAL_WORKERS must be a non-negative integer."
|
|
107
|
+
)
|
|
108
|
+
if value > 0 and self.EAGER:
|
|
109
|
+
raise ImproperlyConfigured(
|
|
110
|
+
"LAMBDA_TASKS_LOCAL_WORKERS and LAMBDA_TASKS_EAGER are mutually exclusive. "
|
|
111
|
+
"Set one or the other, not both."
|
|
112
|
+
)
|
|
113
|
+
return value
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Modified: `lambda_tasks/models.py` — `SQSLambdaTask._execute()`
|
|
117
|
+
|
|
118
|
+
The dispatch logic gains a third branch:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
def _execute(self) -> None:
|
|
122
|
+
conf = LambdaTasksSettings()
|
|
123
|
+
|
|
124
|
+
if conf.EAGER:
|
|
125
|
+
self.message.execute_immediately(message_id=str(uuid.uuid4()))
|
|
126
|
+
elif conf.LOCAL_WORKERS > 0:
|
|
127
|
+
from lambda_tasks.local_executor import submit_task
|
|
128
|
+
submit_task(message_json=self.message.model_dump_json())
|
|
129
|
+
else:
|
|
130
|
+
# existing SQS path
|
|
131
|
+
...
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Modified: `lambda_tasks/timeouts.py`
|
|
135
|
+
|
|
136
|
+
No changes needed. The `TimeoutContext` checks `conf.EAGER` to decide whether to arm `SIGALRM`. In worker processes, `EAGER` is `False` (the setting is read from Django settings which are inherited), so timeouts are enforced automatically. The worker processes are separate OS processes where `SIGALRM` is safe to use.
|
|
137
|
+
|
|
138
|
+
## Data Models
|
|
139
|
+
|
|
140
|
+
### Settings Model (conceptual)
|
|
141
|
+
|
|
142
|
+
| Setting | Type | Default | Validation |
|
|
143
|
+
|---|---|---|---|
|
|
144
|
+
| `LAMBDA_TASKS_LOCAL_WORKERS` | `int` | `0` | Must be ≥ 0; mutually exclusive with `EAGER=True` |
|
|
145
|
+
|
|
146
|
+
### IPC Data Flow
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
Dispatcher (main process) Worker Process
|
|
150
|
+
───────────────────────────── ──────────────────────────────
|
|
151
|
+
SQSLambdaTaskMessage
|
|
152
|
+
→ model_dump_json()
|
|
153
|
+
→ JSON string (picklable)
|
|
154
|
+
→ submit() via ProcessPool
|
|
155
|
+
→ _execute_in_worker(message_json=..., message_id=...)
|
|
156
|
+
→ SQSLambdaTaskMessage.model_validate_json(message_json)
|
|
157
|
+
→ message.execute_immediately(message_id=message_id)
|
|
158
|
+
→ TaskRecord created, task runs with SIGALRM
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Module-Level State
|
|
162
|
+
|
|
163
|
+
| Variable | Module | Type | Lifecycle |
|
|
164
|
+
|---|---|---|---|
|
|
165
|
+
| `_pool` | `local_executor.py` | `ProcessPoolExecutor \| None` | Created on first `submit_task()` call, lives until process exit |
|
|
166
|
+
|
|
167
|
+
## Correctness Properties
|
|
168
|
+
|
|
169
|
+
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
|
170
|
+
|
|
171
|
+
### Property 1: Positive LOCAL_WORKERS is preserved
|
|
172
|
+
|
|
173
|
+
*For any* positive integer `n`, when `LAMBDA_TASKS_LOCAL_WORKERS` is set to `n` and `LAMBDA_TASKS_EAGER` is `False`, the `LambdaTasksSettings().LOCAL_WORKERS` property SHALL return exactly `n`.
|
|
174
|
+
|
|
175
|
+
**Validates: Requirements 1.1**
|
|
176
|
+
|
|
177
|
+
### Property 2: Negative LOCAL_WORKERS is rejected
|
|
178
|
+
|
|
179
|
+
*For any* negative integer `n`, when `LAMBDA_TASKS_LOCAL_WORKERS` is set to `n`, accessing `LambdaTasksSettings().LOCAL_WORKERS` SHALL raise `ImproperlyConfigured`.
|
|
180
|
+
|
|
181
|
+
**Validates: Requirements 1.3**
|
|
182
|
+
|
|
183
|
+
### Property 3: Mutual exclusion of EAGER and LOCAL_WORKERS
|
|
184
|
+
|
|
185
|
+
*For any* positive integer `n`, when both `LAMBDA_TASKS_EAGER` is `True` and `LAMBDA_TASKS_LOCAL_WORKERS` is set to `n`, accessing `LambdaTasksSettings().LOCAL_WORKERS` SHALL raise `ImproperlyConfigured`.
|
|
186
|
+
|
|
187
|
+
**Validates: Requirements 1.4**
|
|
188
|
+
|
|
189
|
+
### Property 4: Pool created with correct worker count
|
|
190
|
+
|
|
191
|
+
*For any* positive integer `n` (within reasonable bounds, e.g. 1–32), when `LOCAL_WORKERS` is `n`, the `ProcessPoolExecutor` created by `get_pool()` SHALL have `_max_workers` equal to `n`.
|
|
192
|
+
|
|
193
|
+
**Validates: Requirements 2.1**
|
|
194
|
+
|
|
195
|
+
### Property 5: Async local dispatch routes to pool
|
|
196
|
+
|
|
197
|
+
*For any* valid `SQSLambdaTaskMessage` and any positive `LOCAL_WORKERS` value, when `EAGER` is `False`, calling `SQSLambdaTask._execute()` SHALL call `ProcessPoolExecutor.submit()` and SHALL NOT call `boto3.client('sqs').send_message()`.
|
|
198
|
+
|
|
199
|
+
**Validates: Requirements 3.1, 7.2**
|
|
200
|
+
|
|
201
|
+
### Property 6: Task message serialization round-trip
|
|
202
|
+
|
|
203
|
+
*For any* valid `SQSLambdaTaskMessage` (with arbitrary `task_name`, `kwargs` containing JSON-serializable values, and non-negative `n_retries`), serializing via `model_dump_json()` and deserializing via `model_validate_json()` SHALL produce an equivalent message object.
|
|
204
|
+
|
|
205
|
+
**Validates: Requirements 6.1, 6.2, 3.2, 3.3**
|
|
206
|
+
|
|
207
|
+
## Error Handling
|
|
208
|
+
|
|
209
|
+
### Configuration Errors (startup time)
|
|
210
|
+
|
|
211
|
+
| Error Condition | Raised Exception | When |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `LOCAL_WORKERS < 0` | `ImproperlyConfigured` | On first access to `LambdaTasksSettings().LOCAL_WORKERS` |
|
|
214
|
+
| `EAGER=True` and `LOCAL_WORKERS > 0` | `ImproperlyConfigured` | On first access to `LambdaTasksSettings().LOCAL_WORKERS` |
|
|
215
|
+
|
|
216
|
+
### Runtime Errors (task execution)
|
|
217
|
+
|
|
218
|
+
| Error Condition | Behavior | Impact on Main Process |
|
|
219
|
+
|---|---|---|
|
|
220
|
+
| Task raises exception | `execute_immediately()` catches it, writes `FAILED` TaskRecord | None — worker is isolated |
|
|
221
|
+
| Worker process crashes | `ProcessPoolExecutor` replaces worker automatically | None — pool self-heals |
|
|
222
|
+
| Soft timeout exceeded | `SoftTimeLimitExceeded` raised in worker | None — signal is per-process |
|
|
223
|
+
| Hard timeout exceeded | `HardTimeLimitExceeded` raised in worker | None — signal is per-process |
|
|
224
|
+
| `model_validate_json()` fails | Exception in worker, no TaskRecord written | None — worker is isolated |
|
|
225
|
+
|
|
226
|
+
### Fire-and-Forget Implications
|
|
227
|
+
|
|
228
|
+
Since the dispatcher discards the `Future`:
|
|
229
|
+
- There is no mechanism to propagate worker exceptions back to the caller
|
|
230
|
+
- Failed tasks are only observable via `TaskRecord` entries in the database
|
|
231
|
+
- This matches the SQS behavior where the caller never sees task outcomes synchronously
|
|
232
|
+
|
|
233
|
+
## Testing Strategy
|
|
234
|
+
|
|
235
|
+
### Property-Based Tests (Hypothesis)
|
|
236
|
+
|
|
237
|
+
Property-based testing is appropriate for this feature because:
|
|
238
|
+
- Settings validation has clear input/output behavior across a range of integers
|
|
239
|
+
- Serialization round-trip is a classic PBT pattern
|
|
240
|
+
- Dispatch routing is a pure decision based on configuration values
|
|
241
|
+
|
|
242
|
+
**Library**: `hypothesis` (already in dev dependencies)
|
|
243
|
+
**Minimum iterations**: 100 per property test
|
|
244
|
+
**Tag format**: `Feature: async-local-execution, Property {number}: {property_text}`
|
|
245
|
+
|
|
246
|
+
Each correctness property maps to a single `@given`-decorated test function.
|
|
247
|
+
|
|
248
|
+
### Unit Tests (Example-Based)
|
|
249
|
+
|
|
250
|
+
| Test | Validates |
|
|
251
|
+
|---|---|
|
|
252
|
+
| `test_local_workers_default_is_zero` | Req 1.2 |
|
|
253
|
+
| `test_pool_reused_across_calls` | Req 2.2 |
|
|
254
|
+
| `test_pool_stored_at_module_level` | Req 2.4 |
|
|
255
|
+
| `test_dispatcher_does_not_wait_on_future` | Req 3.4, 5.3 |
|
|
256
|
+
| `test_eager_mode_with_zero_local_workers` | Req 7.1 |
|
|
257
|
+
| `test_sqs_mode_when_local_workers_zero` | Req 7.3 |
|
|
258
|
+
|
|
259
|
+
### Integration Tests
|
|
260
|
+
|
|
261
|
+
| Test | Validates |
|
|
262
|
+
|---|---|
|
|
263
|
+
| `test_pool_initializer_calls_django_setup` | Req 2.3 |
|
|
264
|
+
| `test_on_commit_submits_after_transaction` | Req 4.1 |
|
|
265
|
+
| `test_rollback_prevents_pool_submission` | Req 4.2 |
|
|
266
|
+
| `test_pool_survives_worker_exception` | Req 5.1 |
|
|
267
|
+
| `test_pool_replaces_crashed_worker` | Req 5.2 |
|
|
268
|
+
| `test_timeout_enforced_in_worker` | Req 8.1, 8.4 |
|
|
269
|
+
| `test_soft_timeout_isolated_to_worker` | Req 8.2 |
|
|
270
|
+
| `test_hard_timeout_isolated_to_worker` | Req 8.3 |
|
|
271
|
+
|
|
272
|
+
### Test File Location
|
|
273
|
+
|
|
274
|
+
All tests go in `tests/test_local_executor.py` following the project convention of one test file per source module.
|
|
275
|
+
|
|
276
|
+
### Mocking Strategy
|
|
277
|
+
|
|
278
|
+
- **`ProcessPoolExecutor`**: Mocked in dispatch routing tests to verify `submit()` is called with correct args without spawning real processes
|
|
279
|
+
- **`django.setup()`**: Mocked in initializer tests
|
|
280
|
+
- **`boto3.client`**: Mocked to verify SQS is NOT called in async local mode
|
|
281
|
+
- **Real pool**: Used in integration tests that verify crash isolation and timeout behavior (these tests spawn actual worker processes)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Requirements Document
|
|
2
|
+
|
|
3
|
+
## Introduction
|
|
4
|
+
|
|
5
|
+
This feature adds an async local execution mode to django-lambda-tasks using `concurrent.futures.ProcessPoolExecutor`. When enabled, tasks are submitted to a process pool and execute in the background without blocking the calling request. This is a development-only feature (like `LAMBDA_TASKS_EAGER`) that provides true parallelism for CPU-bound tasks without requiring AWS infrastructure or a separate service.
|
|
6
|
+
|
|
7
|
+
## Glossary
|
|
8
|
+
|
|
9
|
+
- **Process_Pool**: A `concurrent.futures.ProcessPoolExecutor` instance that manages a fixed number of worker processes for executing tasks in the background.
|
|
10
|
+
- **Worker_Process**: A child process in the Process_Pool that calls `django.setup()` once via the pool initializer and then executes submitted tasks.
|
|
11
|
+
- **Dispatcher**: The component within `SQSLambdaTask._execute()` that decides whether to send a task to SQS, execute eagerly, or submit to the Process_Pool.
|
|
12
|
+
- **Pool_Initializer**: A function passed to `ProcessPoolExecutor(initializer=...)` that runs once per Worker_Process to configure the Django environment.
|
|
13
|
+
- **Async_Local_Mode**: The execution mode activated by `LAMBDA_TASKS_LOCAL_WORKERS` being set to a positive integer, causing tasks to be submitted to the Process_Pool instead of SQS.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
### Requirement 1: Pool Configuration Setting
|
|
18
|
+
|
|
19
|
+
**User Story:** As a developer, I want to configure the local process pool size via a Django setting, so that I can control resource usage during development.
|
|
20
|
+
|
|
21
|
+
#### Acceptance Criteria
|
|
22
|
+
|
|
23
|
+
1. WHEN `LAMBDA_TASKS_LOCAL_WORKERS` is set to a positive integer, THE LambdaTasksSettings SHALL expose that value as the `LOCAL_WORKERS` property.
|
|
24
|
+
2. WHEN `LAMBDA_TASKS_LOCAL_WORKERS` is not set, THE LambdaTasksSettings SHALL return `0` as the `LOCAL_WORKERS` property.
|
|
25
|
+
3. IF `LAMBDA_TASKS_LOCAL_WORKERS` is set to a value less than zero, THEN THE LambdaTasksSettings SHALL raise `ImproperlyConfigured`.
|
|
26
|
+
4. IF both `LAMBDA_TASKS_EAGER` and `LAMBDA_TASKS_LOCAL_WORKERS` (greater than zero) are set, THEN THE LambdaTasksSettings SHALL raise `ImproperlyConfigured` indicating the two modes are mutually exclusive.
|
|
27
|
+
|
|
28
|
+
### Requirement 2: Process Pool Lifecycle
|
|
29
|
+
|
|
30
|
+
**User Story:** As a developer, I want the process pool to be created lazily and reused across requests, so that worker startup cost is paid only once.
|
|
31
|
+
|
|
32
|
+
#### Acceptance Criteria
|
|
33
|
+
|
|
34
|
+
1. WHEN the first task is submitted in Async_Local_Mode, THE Dispatcher SHALL create a Process_Pool with `max_workers` equal to the `LOCAL_WORKERS` setting.
|
|
35
|
+
2. WHILE the Process_Pool has been created, THE Dispatcher SHALL reuse the same Process_Pool instance for all subsequent task submissions.
|
|
36
|
+
3. THE Pool_Initializer SHALL call `django.setup()` once per Worker_Process using the `DJANGO_SETTINGS_MODULE` environment variable from the parent process.
|
|
37
|
+
4. THE Process_Pool SHALL be stored at module level so that it persists for the lifetime of the Django server process.
|
|
38
|
+
|
|
39
|
+
### Requirement 3: Task Submission
|
|
40
|
+
|
|
41
|
+
**User Story:** As a developer, I want tasks to be submitted to the process pool after transaction commit, so that the request returns immediately and the task runs in the background.
|
|
42
|
+
|
|
43
|
+
#### Acceptance Criteria
|
|
44
|
+
|
|
45
|
+
1. WHEN `LOCAL_WORKERS` is greater than zero and `EAGER` is `False`, THE Dispatcher SHALL submit the task to the Process_Pool via `ProcessPoolExecutor.submit()` instead of sending to SQS.
|
|
46
|
+
2. WHEN a task is submitted to the Process_Pool, THE Dispatcher SHALL pass the serialized `SQSLambdaTaskMessage` data and a new UUID4 `message_id` to the Worker_Process.
|
|
47
|
+
3. THE Worker_Process SHALL call `SQSLambdaTaskMessage.execute_immediately()` with the provided `message_id` to execute the task and write the TaskRecord.
|
|
48
|
+
4. WHEN a task is submitted to the Process_Pool, THE Dispatcher SHALL return immediately without waiting for the task to complete.
|
|
49
|
+
|
|
50
|
+
### Requirement 4: Transaction Commit Integration
|
|
51
|
+
|
|
52
|
+
**User Story:** As a developer, I want async local tasks to respect `transaction.on_commit` just like SQS tasks, so that tasks only run after the triggering transaction succeeds.
|
|
53
|
+
|
|
54
|
+
#### Acceptance Criteria
|
|
55
|
+
|
|
56
|
+
1. WHEN `execute_on_commit()` is called in Async_Local_Mode, THE Dispatcher SHALL register the pool submission with `transaction.on_commit` so the task is only submitted after the current transaction commits.
|
|
57
|
+
2. IF the transaction is rolled back, THEN THE Dispatcher SHALL not submit the task to the Process_Pool.
|
|
58
|
+
|
|
59
|
+
### Requirement 5: Worker Process Error Isolation
|
|
60
|
+
|
|
61
|
+
**User Story:** As a developer, I want worker process failures to be isolated from the Django server process, so that a crashing task does not bring down the dev server.
|
|
62
|
+
|
|
63
|
+
#### Acceptance Criteria
|
|
64
|
+
|
|
65
|
+
1. IF a task raises an unhandled exception in the Worker_Process, THEN THE Process_Pool SHALL continue operating and accept new task submissions.
|
|
66
|
+
2. IF a Worker_Process crashes, THEN THE Process_Pool SHALL replace the crashed Worker_Process with a new one that runs the Pool_Initializer.
|
|
67
|
+
3. WHEN a task is submitted to the Process_Pool, THE Dispatcher SHALL not attach any callbacks or wait on the returned Future object.
|
|
68
|
+
|
|
69
|
+
### Requirement 6: Picklability of Task Arguments
|
|
70
|
+
|
|
71
|
+
**User Story:** As a developer, I want clear feedback when task arguments cannot be sent to the worker process, so that I can fix serialization issues quickly.
|
|
72
|
+
|
|
73
|
+
#### Acceptance Criteria
|
|
74
|
+
|
|
75
|
+
1. THE Dispatcher SHALL submit the task message as a JSON string (the output of `model_dump_json()`) to the Worker_Process, ensuring picklability via standard string serialization.
|
|
76
|
+
2. WHEN the Worker_Process receives the JSON string, THE Worker_Process SHALL reconstruct the `SQSLambdaTaskMessage` via `model_validate_json()` before calling `execute_immediately()`.
|
|
77
|
+
|
|
78
|
+
### Requirement 7: Mode Precedence
|
|
79
|
+
|
|
80
|
+
**User Story:** As a developer, I want a clear precedence between execution modes, so that configuration is predictable.
|
|
81
|
+
|
|
82
|
+
#### Acceptance Criteria
|
|
83
|
+
|
|
84
|
+
1. WHEN `LAMBDA_TASKS_EAGER` is `True`, THE Dispatcher SHALL execute tasks synchronously regardless of the `LOCAL_WORKERS` setting (enforced by the mutual exclusion constraint in Requirement 1).
|
|
85
|
+
2. WHEN `LAMBDA_TASKS_EAGER` is `False` and `LOCAL_WORKERS` is greater than zero, THE Dispatcher SHALL submit tasks to the Process_Pool.
|
|
86
|
+
3. WHEN `LAMBDA_TASKS_EAGER` is `False` and `LOCAL_WORKERS` is zero, THE Dispatcher SHALL send tasks to SQS.
|
|
87
|
+
|
|
88
|
+
### Requirement 8: Timeout Behaviour in Async Local Mode
|
|
89
|
+
|
|
90
|
+
**User Story:** As a developer, I want timeouts to be enforced in worker processes, so that runaway tasks are terminated just like they would be in Lambda.
|
|
91
|
+
|
|
92
|
+
#### Acceptance Criteria
|
|
93
|
+
|
|
94
|
+
1. WHILE a task executes in a Worker_Process in Async_Local_Mode, THE TimeoutContext SHALL set up `SIGALRM`-based timeouts (same behaviour as Lambda execution).
|
|
95
|
+
2. IF a soft timeout fires in a Worker_Process, THEN THE TimeoutContext SHALL raise `SoftTimeoutError` in that Worker_Process without affecting the Django server process.
|
|
96
|
+
3. IF a hard timeout fires in a Worker_Process, THEN THE TimeoutContext SHALL raise `HardTimeoutError` in that Worker_Process without affecting the Django server process.
|
|
97
|
+
4. THE TimeoutContext SHALL NOT treat Async_Local_Mode the same as eager mode — timeouts are fully enforced because each Worker_Process is isolated from the dev server.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Implementation Plan: Async Local Execution
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Adds a third execution mode to django-lambda-tasks using `concurrent.futures.ProcessPoolExecutor`. Tasks are submitted to a process pool when `LAMBDA_TASKS_LOCAL_WORKERS` is set to a positive integer, providing true async parallelism with timeout enforcement for local development. Implementation follows TDD: write failing tests first, then implement.
|
|
6
|
+
|
|
7
|
+
## Tasks
|
|
8
|
+
|
|
9
|
+
- [x] 1. Add `LOCAL_WORKERS` setting with validation
|
|
10
|
+
- [x] 1.1 Write tests for `LOCAL_WORKERS` property in `tests/test_local_executor.py`
|
|
11
|
+
- Test that `LOCAL_WORKERS` defaults to `0` when setting is absent
|
|
12
|
+
- Test that a positive integer is returned correctly
|
|
13
|
+
- Test that a negative integer raises `ImproperlyConfigured`
|
|
14
|
+
- Test that setting both `EAGER=True` and `LOCAL_WORKERS > 0` raises `ImproperlyConfigured`
|
|
15
|
+
- _Requirements: 1.1, 1.2, 1.3, 1.4_
|
|
16
|
+
|
|
17
|
+
- [x] 1.2 Write property tests for `LOCAL_WORKERS` validation
|
|
18
|
+
- **Property 1: Positive LOCAL_WORKERS is preserved**
|
|
19
|
+
- **Validates: Requirements 1.1**
|
|
20
|
+
- **Property 2: Negative LOCAL_WORKERS is rejected**
|
|
21
|
+
- **Validates: Requirements 1.3**
|
|
22
|
+
- **Property 3: Mutual exclusion of EAGER and LOCAL_WORKERS**
|
|
23
|
+
- **Validates: Requirements 1.4**
|
|
24
|
+
|
|
25
|
+
- [x] 1.3 Implement `LOCAL_WORKERS` property in `lambda_tasks/settings.py`
|
|
26
|
+
- Add `LOCAL_WORKERS` property to `LambdaTasksSettings`
|
|
27
|
+
- Read from `LAMBDA_TASKS_LOCAL_WORKERS` Django setting with default `0`
|
|
28
|
+
- Raise `ImproperlyConfigured` if value is negative
|
|
29
|
+
- Raise `ImproperlyConfigured` if both `EAGER` and `LOCAL_WORKERS > 0`
|
|
30
|
+
- _Requirements: 1.1, 1.2, 1.3, 1.4_
|
|
31
|
+
|
|
32
|
+
- [x] 2. Create `lambda_tasks/local_executor.py` module
|
|
33
|
+
- [x] 2.1 Write tests for `get_pool()` in `tests/test_local_executor.py`
|
|
34
|
+
- Test that `get_pool()` returns a `ProcessPoolExecutor` with `_max_workers` equal to `LOCAL_WORKERS`
|
|
35
|
+
- Test that `get_pool()` returns the same instance on repeated calls (pool reuse)
|
|
36
|
+
- Test that the pool is stored at module level (`local_executor._pool`)
|
|
37
|
+
- _Requirements: 2.1, 2.2, 2.4_
|
|
38
|
+
|
|
39
|
+
- [x] 2.2 Write property test for pool worker count
|
|
40
|
+
- **Property 4: Pool created with correct worker count**
|
|
41
|
+
- **Validates: Requirements 2.1**
|
|
42
|
+
|
|
43
|
+
- [x] 2.3 Implement `get_pool()` and `_pool_initializer()` in `lambda_tasks/local_executor.py`
|
|
44
|
+
- Create module-level `_pool: ProcessPoolExecutor | None = None`
|
|
45
|
+
- Implement `_pool_initializer()` that calls `django.setup()`
|
|
46
|
+
- Implement `get_pool()` that lazily creates the pool with `max_workers=conf.LOCAL_WORKERS`
|
|
47
|
+
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
|
48
|
+
|
|
49
|
+
- [x] 2.4 Write tests for `submit_task()` in `tests/test_local_executor.py`
|
|
50
|
+
- Test that `submit_task()` calls `pool.submit()` with `_execute_in_worker`, the JSON string, and a UUID message_id
|
|
51
|
+
- Test that `submit_task()` does not wait on the returned Future
|
|
52
|
+
- _Requirements: 3.1, 3.2, 3.4, 5.3_
|
|
53
|
+
|
|
54
|
+
- [x] 2.5 Implement `submit_task()` and `_execute_in_worker()` in `lambda_tasks/local_executor.py`
|
|
55
|
+
- Implement `_execute_in_worker(*, message_json: str, message_id: str)` that deserializes and executes
|
|
56
|
+
- Implement `submit_task(*, message_json: str)` that generates a UUID and submits to the pool
|
|
57
|
+
- _Requirements: 3.1, 3.2, 3.3, 3.4, 6.1, 6.2_
|
|
58
|
+
|
|
59
|
+
- [x] 2.6 Write property test for message serialization round-trip
|
|
60
|
+
- **Property 6: Task message serialization round-trip**
|
|
61
|
+
- **Validates: Requirements 6.1, 6.2, 3.2, 3.3**
|
|
62
|
+
|
|
63
|
+
- [x] 3. Checkpoint
|
|
64
|
+
- Ensure all tests pass, ask the user if questions arise.
|
|
65
|
+
|
|
66
|
+
- [x] 4. Add async local dispatch branch to `SQSLambdaTask._execute()`
|
|
67
|
+
- [x] 4.1 Write tests for the dispatch routing in `tests/test_local_executor.py`
|
|
68
|
+
- Test that when `LOCAL_WORKERS > 0` and `EAGER=False`, `_execute()` calls `submit_task()` with the JSON-serialized message
|
|
69
|
+
- Test that when `LOCAL_WORKERS > 0` and `EAGER=False`, `_execute()` does NOT call `boto3.client('sqs').send_message()`
|
|
70
|
+
- Test that when `LOCAL_WORKERS=0` and `EAGER=False`, `_execute()` sends to SQS (existing behaviour preserved)
|
|
71
|
+
- Test that when `EAGER=True`, `_execute()` calls `execute_immediately()` (existing behaviour preserved)
|
|
72
|
+
- _Requirements: 3.1, 7.1, 7.2, 7.3_
|
|
73
|
+
|
|
74
|
+
- [x] 4.2 Write property test for async local dispatch routing
|
|
75
|
+
- **Property 5: Async local dispatch routes to pool**
|
|
76
|
+
- **Validates: Requirements 3.1, 7.2**
|
|
77
|
+
|
|
78
|
+
- [x] 4.3 Implement the third dispatch branch in `lambda_tasks/models.py`
|
|
79
|
+
- Add `elif conf.LOCAL_WORKERS > 0` branch in `SQSLambdaTask._execute()`
|
|
80
|
+
- Import `submit_task` from `lambda_tasks.local_executor`
|
|
81
|
+
- Call `submit_task(message_json=self.message.model_dump_json())`
|
|
82
|
+
- _Requirements: 3.1, 7.2_
|
|
83
|
+
|
|
84
|
+
- [x] 5. Write integration tests for transaction commit and error isolation
|
|
85
|
+
- [x] 5.1 Write integration tests in `tests/test_local_executor.py`
|
|
86
|
+
- Test that `execute_on_commit()` in async local mode submits after transaction commit
|
|
87
|
+
- Test that a rolled-back transaction does not submit to the pool
|
|
88
|
+
- Test that a worker exception does not crash the pool (pool continues accepting tasks)
|
|
89
|
+
- Test that `_pool_initializer` calls `django.setup()`
|
|
90
|
+
- _Requirements: 2.3, 4.1, 4.2, 5.1, 5.2_
|
|
91
|
+
|
|
92
|
+
- [x] 6. Final checkpoint
|
|
93
|
+
- Ensure all tests pass, ask the user if questions arise.
|
|
94
|
+
|
|
95
|
+
## Notes
|
|
96
|
+
|
|
97
|
+
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
|
98
|
+
- Each task references specific requirements for traceability
|
|
99
|
+
- Checkpoints ensure incremental validation
|
|
100
|
+
- Property tests validate universal correctness properties from the design document
|
|
101
|
+
- Unit tests validate specific examples and edge cases
|
|
102
|
+
- TDD order: tests are written before implementation in each group
|
|
103
|
+
- No changes to `lambda_tasks/timeouts.py` — workers inherit `EAGER=False` so `SIGALRM` works automatically
|
|
@@ -14,7 +14,8 @@ View → @lambda_task.execute_on_commit() → SQS → Lambda handler → SQSLamb
|
|
|
14
14
|
|
|
15
15
|
Key modules:
|
|
16
16
|
- `decorators.py` — `@lambda_task` decorator and `LambdaTaskWrapper`
|
|
17
|
-
- `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (SQS schema + execution), `SQSLambdaTask` (routing + SQS publish)
|
|
17
|
+
- `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (SQS schema + execution), `SQSLambdaTask` (routing + SQS publish or local pool submit)
|
|
18
|
+
- `local_executor.py` — `ProcessPoolExecutor`-based async local execution for development
|
|
18
19
|
- `handler.py` — AWS Lambda entry point; cold-start init runs on first invocation (not at import time) with partial-batch failure reporting
|
|
19
20
|
- `logging.py` — `task_logger` for invocation-scoped log output
|
|
20
21
|
- `settings.py` — lazy `LambdaTasksSettings` reading from Django settings
|
|
@@ -195,6 +196,7 @@ Statuses: `RUNNING`, `SUCCESS`, `FAILED`, `RETRYING`
|
|
|
195
196
|
| `LAMBDA_TASKS_DEFAULT_SOFT_TIMEOUT` | `270` | Soft timeout in seconds |
|
|
196
197
|
| `LAMBDA_TASKS_DEFAULT_HARD_TIMEOUT` | `300` | Hard timeout in seconds |
|
|
197
198
|
| `LAMBDA_TASKS_EAGER` | `False` | Run tasks synchronously in-process (no SQS) |
|
|
199
|
+
| `LAMBDA_TASKS_LOCAL_WORKERS` | `0` | Number of worker processes for async local execution (development only; mutually exclusive with `EAGER`) |
|
|
198
200
|
| `LAMBDA_TASKS_MAX_RETRIES` | `2880` | Maximum retry attempts before `MaxRetriesExceededError` is raised (60 × 24 × 2) |
|
|
199
201
|
| `LAMBDA_TASKS_SINGLETON_CACHE` | `"default"` | Django cache backend used for singleton task locks |
|
|
200
202
|
|
|
@@ -208,6 +210,36 @@ In eager mode a random UUID4 is generated as the `message_id` passed to `execute
|
|
|
208
210
|
|
|
209
211
|
**Timeouts are not enforced in eager mode.** `TimeoutContext` is still entered but becomes a no-op — it checks `LAMBDA_TASKS_EAGER` internally and skips `SIGALRM` setup. `SIGALRM`-based timeouts require a Lambda worker process, not a Django dev server thread. Timeout values are still validated at decoration time.
|
|
210
212
|
|
|
213
|
+
## Async Local Mode
|
|
214
|
+
|
|
215
|
+
Set `LAMBDA_TASKS_LOCAL_WORKERS` to a positive integer to run tasks in a background `ProcessPoolExecutor`. Tasks are submitted after transaction commit (same as SQS mode) but execute in local worker processes instead of Lambda. This provides true parallelism with timeout enforcement for local development.
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
# settings/local.py
|
|
219
|
+
LAMBDA_TASKS_LOCAL_WORKERS = 4
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The execution mode hierarchy is:
|
|
223
|
+
1. **Eager mode** (`LAMBDA_TASKS_EAGER=True`) — synchronous, in-process, no timeouts
|
|
224
|
+
2. **Async local mode** (`LOCAL_WORKERS > 0`) — async, separate processes, timeouts enforced
|
|
225
|
+
3. **SQS mode** (default) — async, Lambda workers, timeouts enforced
|
|
226
|
+
|
|
227
|
+
`LAMBDA_TASKS_LOCAL_WORKERS` and `LAMBDA_TASKS_EAGER` are mutually exclusive — setting both raises `ImproperlyConfigured`. A negative value also raises `ImproperlyConfigured`.
|
|
228
|
+
|
|
229
|
+
Key behaviours:
|
|
230
|
+
- The process pool is created lazily on first task submission and reused for the server lifetime
|
|
231
|
+
- Each worker process calls `django.setup()` once via the pool initializer
|
|
232
|
+
- Tasks are serialized as JSON strings (via `model_dump_json()`) for IPC — same path as SQS
|
|
233
|
+
- The dispatcher discards the `Future` (fire-and-forget) — worker failures are isolated
|
|
234
|
+
- `SIGALRM`-based timeouts work in worker processes because they are separate OS processes
|
|
235
|
+
- `transaction.on_commit` is respected — tasks only submit after the transaction commits
|
|
236
|
+
|
|
237
|
+
Implementation lives in `lambda_tasks/local_executor.py`:
|
|
238
|
+
- `get_pool()` — lazily creates and returns the shared `ProcessPoolExecutor`
|
|
239
|
+
- `submit_task(*, message_json: str)` — generates a UUID4 message_id and submits to the pool
|
|
240
|
+
- `_execute_in_worker(*, message_json: str, message_id: str)` — worker entry point; deserializes and calls `execute_immediately()`
|
|
241
|
+
- `_pool_initializer()` — calls `django.setup()` once per worker
|
|
242
|
+
|
|
211
243
|
## Logging
|
|
212
244
|
|
|
213
245
|
Import `task_logger` to emit log records that are automatically prefixed with the active `message_id`:
|
|
@@ -19,6 +19,7 @@ django-lambda-tasks/
|
|
|
19
19
|
│ ├── settings.py # LambdaTasksSettings (lazy Django settings reader)
|
|
20
20
|
│ ├── secret_loader.py # Resolves LAMBDA_TASKS_SECRET_* env vars at cold start
|
|
21
21
|
│ ├── environment_loader.py # Loads env vars from Secrets Manager at cold start
|
|
22
|
+
│ ├── local_executor.py # ProcessPoolExecutor for async local task execution
|
|
22
23
|
│ ├── tasks.py # Built-in maintenance tasks (cleanup_task_records)
|
|
23
24
|
│ ├── timeouts.py # TimeoutContext implementation
|
|
24
25
|
│ └── migrations/ # Django migrations for TaskRecord
|
|
@@ -39,7 +40,8 @@ django-lambda-tasks/
|
|
|
39
40
|
## Module Responsibilities
|
|
40
41
|
|
|
41
42
|
- `decorators.py` — defines `@lambda_task`; enforces kwargs-only at decoration time
|
|
42
|
-
- `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (Pydantic, SQS schema + execution logic), `SQSLambdaTask` (Pydantic, holds message + routing; `_execute()` publishes to SQS or
|
|
43
|
+
- `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (Pydantic, SQS schema + execution logic), `SQSLambdaTask` (Pydantic, holds message + routing; `_execute()` publishes to SQS, executes eagerly, or submits to the local process pool; `execute_on_commit()` registers `_execute` with `transaction.on_commit`)
|
|
44
|
+
- `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
|
|
43
45
|
- `handler.py` — Lambda entry point; cold-start init (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`
|
|
44
46
|
- `environment_loader.py` — loads env vars from a Secrets Manager secret at cold start; validates flat JSON format; idempotent via `_loaded` sentinel
|
|
45
47
|
- `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
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-lambda-tasks
|
|
3
|
+
Version: 0.4.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 outside of the request-response cycle. Tasks are defined with a decorator, enqueued to SQS on transaction commit, and executed by a Lambda handler that AWS invokes with SQS message batches. Task results, status, and metadata are persisted in the Django database.
|
|
@@ -151,6 +163,33 @@ With eager mode enabled, `.execute_on_commit()` executes the task immediately wi
|
|
|
151
163
|
|
|
152
164
|
> **Note:** Timeouts are not enforced in eager mode. `soft_timeout` and `hard_timeout` values are accepted and stored but `TimeoutContext` becomes a no-op — it checks `LAMBDA_TASKS_EAGER` internally and skips `SIGALRM` setup. This is intentional: `SIGALRM`-based timeouts require a Lambda/Unix worker process, not a Django dev server thread.
|
|
153
165
|
|
|
166
|
+
### Async local execution (development)
|
|
167
|
+
|
|
168
|
+
| Setting | Type | Default | Description |
|
|
169
|
+
|---|---|---|---|
|
|
170
|
+
| `LAMBDA_TASKS_LOCAL_WORKERS` | `int` | `0` | Number of worker processes for async local execution. When set to a positive integer, tasks run in a background process pool instead of SQS. |
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
# settings/local.py
|
|
174
|
+
LAMBDA_TASKS_LOCAL_WORKERS = 4
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Async local mode bridges the gap between eager mode and full SQS/Lambda deployment. Tasks execute in background worker processes with true parallelism and full timeout enforcement via `SIGALRM`, without requiring AWS infrastructure.
|
|
178
|
+
|
|
179
|
+
The execution mode hierarchy is:
|
|
180
|
+
1. **Eager** (`LAMBDA_TASKS_EAGER=True`) — synchronous, in-process, no timeouts
|
|
181
|
+
2. **Async local** (`LAMBDA_TASKS_LOCAL_WORKERS > 0`) — async, separate processes, timeouts enforced
|
|
182
|
+
3. **SQS** (default) — async, Lambda workers, timeouts enforced
|
|
183
|
+
|
|
184
|
+
Key characteristics:
|
|
185
|
+
- `LAMBDA_TASKS_LOCAL_WORKERS` and `LAMBDA_TASKS_EAGER` are mutually exclusive — setting both raises `ImproperlyConfigured`
|
|
186
|
+
- The process pool is created lazily on first task submission and reused for the server lifetime
|
|
187
|
+
- Each worker process calls `django.setup()` once at startup
|
|
188
|
+
- Tasks are serialized as JSON for IPC (same code path as SQS)
|
|
189
|
+
- Worker failures are isolated from the Django server process — a crashing task does not bring down the dev server
|
|
190
|
+
- `transaction.on_commit` is respected — tasks only submit after the transaction commits
|
|
191
|
+
- Timeouts (`SIGALRM`) are fully enforced because workers are separate OS processes
|
|
192
|
+
|
|
154
193
|
---
|
|
155
194
|
|
|
156
195
|
## Decorator options
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: django-lambda-tasks
|
|
3
|
-
Version: 0.3.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
|
-
|
|
13
1
|
# Django Lambda Tasks
|
|
14
2
|
|
|
15
3
|
A Django library for offloading work to AWS Lambda outside of the request-response cycle. Tasks are defined with a decorator, enqueued to SQS on transaction commit, and executed by a Lambda handler that AWS invokes with SQS message batches. Task results, status, and metadata are persisted in the Django database.
|
|
@@ -163,6 +151,33 @@ With eager mode enabled, `.execute_on_commit()` executes the task immediately wi
|
|
|
163
151
|
|
|
164
152
|
> **Note:** Timeouts are not enforced in eager mode. `soft_timeout` and `hard_timeout` values are accepted and stored but `TimeoutContext` becomes a no-op — it checks `LAMBDA_TASKS_EAGER` internally and skips `SIGALRM` setup. This is intentional: `SIGALRM`-based timeouts require a Lambda/Unix worker process, not a Django dev server thread.
|
|
165
153
|
|
|
154
|
+
### Async local execution (development)
|
|
155
|
+
|
|
156
|
+
| Setting | Type | Default | Description |
|
|
157
|
+
|---|---|---|---|
|
|
158
|
+
| `LAMBDA_TASKS_LOCAL_WORKERS` | `int` | `0` | Number of worker processes for async local execution. When set to a positive integer, tasks run in a background process pool instead of SQS. |
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
# settings/local.py
|
|
162
|
+
LAMBDA_TASKS_LOCAL_WORKERS = 4
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Async local mode bridges the gap between eager mode and full SQS/Lambda deployment. Tasks execute in background worker processes with true parallelism and full timeout enforcement via `SIGALRM`, without requiring AWS infrastructure.
|
|
166
|
+
|
|
167
|
+
The execution mode hierarchy is:
|
|
168
|
+
1. **Eager** (`LAMBDA_TASKS_EAGER=True`) — synchronous, in-process, no timeouts
|
|
169
|
+
2. **Async local** (`LAMBDA_TASKS_LOCAL_WORKERS > 0`) — async, separate processes, timeouts enforced
|
|
170
|
+
3. **SQS** (default) — async, Lambda workers, timeouts enforced
|
|
171
|
+
|
|
172
|
+
Key characteristics:
|
|
173
|
+
- `LAMBDA_TASKS_LOCAL_WORKERS` and `LAMBDA_TASKS_EAGER` are mutually exclusive — setting both raises `ImproperlyConfigured`
|
|
174
|
+
- The process pool is created lazily on first task submission and reused for the server lifetime
|
|
175
|
+
- Each worker process calls `django.setup()` once at startup
|
|
176
|
+
- Tasks are serialized as JSON for IPC (same code path as SQS)
|
|
177
|
+
- Worker failures are isolated from the Django server process — a crashing task does not bring down the dev server
|
|
178
|
+
- `transaction.on_commit` is respected — tasks only submit after the transaction commits
|
|
179
|
+
- Timeouts (`SIGALRM`) are fully enforced because workers are separate OS processes
|
|
180
|
+
|
|
166
181
|
---
|
|
167
182
|
|
|
168
183
|
## Decorator options
|