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.
Files changed (101) hide show
  1. django_lambda_tasks-0.4.0/.kiro/specs/async-local-execution/.config.kiro +1 -0
  2. django_lambda_tasks-0.4.0/.kiro/specs/async-local-execution/design.md +281 -0
  3. django_lambda_tasks-0.4.0/.kiro/specs/async-local-execution/requirements.md +97 -0
  4. django_lambda_tasks-0.4.0/.kiro/specs/async-local-execution/tasks.md +103 -0
  5. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/steering/product.md +33 -1
  6. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/steering/structure.md +3 -1
  7. django_lambda_tasks-0.3.0/README.md → django_lambda_tasks-0.4.0/PKG-INFO +39 -0
  8. django_lambda_tasks-0.3.0/PKG-INFO → django_lambda_tasks-0.4.0/README.md +27 -12
  9. django_lambda_tasks-0.4.0/example/example_app/tasks.py +18 -0
  10. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_project/settings.py +16 -2
  11. django_lambda_tasks-0.4.0/lambda_tasks/local_executor.py +45 -0
  12. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/models.py +3 -0
  13. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/settings.py +14 -0
  14. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/pyproject.toml +1 -1
  15. django_lambda_tasks-0.4.0/tests/test_local_executor.py +646 -0
  16. django_lambda_tasks-0.3.0/example/example_app/tasks.py +0 -6
  17. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.github/workflows/ci.yml +0 -0
  18. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.github/workflows/release.yml +0 -0
  19. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.gitignore +0 -0
  20. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  21. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  22. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  23. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  24. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  25. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  26. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  27. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  28. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  29. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  30. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  31. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  32. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  33. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  34. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  35. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  36. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/retry-delay/.config.kiro +0 -0
  37. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/retry-delay/design.md +0 -0
  38. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/retry-delay/requirements.md +0 -0
  39. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/retry-delay/tasks.md +0 -0
  40. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  41. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks/design.md +0 -0
  42. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  43. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  44. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  45. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  46. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  47. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  48. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/singleton-task/.config.kiro +0 -0
  49. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/singleton-task/design.md +0 -0
  50. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/singleton-task/requirements.md +0 -0
  51. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/singleton-task/tasks.md +0 -0
  52. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
  53. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ssm-environment-loader/design.md +0 -0
  54. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
  55. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
  56. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/task-retry/.config.kiro +0 -0
  57. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/task-retry/design.md +0 -0
  58. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/task-retry/requirements.md +0 -0
  59. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/specs/task-retry/tasks.md +0 -0
  60. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.kiro/steering/tech.md +0 -0
  61. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.pre-commit-config.yaml +0 -0
  62. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/.vscode/settings.json +0 -0
  63. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/README.md +0 -0
  64. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_app/__init__.py +0 -0
  65. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_app/apps.py +0 -0
  66. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_app/urls.py +0 -0
  67. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_app/views.py +0 -0
  68. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_project/__init__.py +0 -0
  69. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_project/urls.py +0 -0
  70. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/example_project/wsgi.py +0 -0
  71. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/example/manage.py +0 -0
  72. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/__init__.py +0 -0
  73. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/admin.py +0 -0
  74. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/apps.py +0 -0
  75. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/decorators.py +0 -0
  76. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/environment_loader.py +0 -0
  77. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/handler.py +0 -0
  78. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/logging.py +0 -0
  79. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/migrations/0001_initial.py +0 -0
  80. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
  81. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/migrations/__init__.py +0 -0
  82. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/secret_loader.py +0 -0
  83. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/tasks.py +0 -0
  84. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/lambda_tasks/timeouts.py +0 -0
  85. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/conftest.py +0 -0
  86. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/settings.py +0 -0
  87. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_admin.py +0 -0
  88. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_decorator.py +0 -0
  89. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_decorators.py +0 -0
  90. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_deferred_enqueue.py +0 -0
  91. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_environment_loader.py +0 -0
  92. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_handler.py +0 -0
  93. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_kwargs_only.py +0 -0
  94. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_logging.py +0 -0
  95. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_models.py +0 -0
  96. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_secret_loader.py +0 -0
  97. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_serializer.py +0 -0
  98. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_settings.py +0 -0
  99. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_tasks.py +0 -0
  100. {django_lambda_tasks-0.3.0 → django_lambda_tasks-0.4.0}/tests/test_timeout_validation.py +0 -0
  101. {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 executes eagerly; `execute_on_commit()` registers `_execute` with `transaction.on_commit`)
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