django-lambda-tasks 0.4.8__tar.gz → 0.4.9__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 (103) hide show
  1. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/steering/product.md +10 -1
  2. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/steering/structure.md +2 -1
  3. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/PKG-INFO +1 -1
  4. django_lambda_tasks-0.4.9/lambda_tasks/apps.py +22 -0
  5. django_lambda_tasks-0.4.9/lambda_tasks/local_executor.py +151 -0
  6. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/pyproject.toml +1 -1
  7. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_local_executor.py +209 -0
  8. django_lambda_tasks-0.4.8/lambda_tasks/apps.py +0 -8
  9. django_lambda_tasks-0.4.8/lambda_tasks/local_executor.py +0 -78
  10. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.github/workflows/ci.yml +0 -0
  11. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.github/workflows/release.yml +0 -0
  12. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.gitignore +0 -0
  13. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/async-local-execution/.config.kiro +0 -0
  14. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/async-local-execution/design.md +0 -0
  15. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/async-local-execution/requirements.md +0 -0
  16. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/async-local-execution/tasks.md +0 -0
  17. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  18. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  19. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  20. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  21. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  22. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  23. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  24. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  25. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  26. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  27. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  28. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  29. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  30. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  31. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  32. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  33. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/retry-delay/.config.kiro +0 -0
  34. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/retry-delay/design.md +0 -0
  35. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/retry-delay/requirements.md +0 -0
  36. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/retry-delay/tasks.md +0 -0
  37. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  38. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/rse-background-tasks/design.md +0 -0
  39. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  40. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  41. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  42. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  43. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  44. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  45. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/singleton-task/.config.kiro +0 -0
  46. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/singleton-task/design.md +0 -0
  47. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/singleton-task/requirements.md +0 -0
  48. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/singleton-task/tasks.md +0 -0
  49. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
  50. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/ssm-environment-loader/design.md +0 -0
  51. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
  52. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
  53. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/task-retry/.config.kiro +0 -0
  54. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/task-retry/design.md +0 -0
  55. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/task-retry/requirements.md +0 -0
  56. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/specs/task-retry/tasks.md +0 -0
  57. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.kiro/steering/tech.md +0 -0
  58. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.pre-commit-config.yaml +0 -0
  59. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/.vscode/settings.json +0 -0
  60. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/README.md +0 -0
  61. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/README.md +0 -0
  62. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/example_app/__init__.py +0 -0
  63. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/example_app/apps.py +0 -0
  64. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/example_app/tasks.py +0 -0
  65. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/example_app/urls.py +0 -0
  66. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/example_app/views.py +0 -0
  67. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/example_project/__init__.py +0 -0
  68. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/example_project/settings.py +0 -0
  69. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/example_project/urls.py +0 -0
  70. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/example_project/wsgi.py +0 -0
  71. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/example/manage.py +0 -0
  72. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/__init__.py +0 -0
  73. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/admin.py +0 -0
  74. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/decorators.py +0 -0
  75. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/environment_loader.py +0 -0
  76. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/handler.py +0 -0
  77. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/logging.py +0 -0
  78. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/migrations/0001_initial.py +0 -0
  79. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
  80. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/migrations/__init__.py +0 -0
  81. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/models.py +0 -0
  82. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/secret_loader.py +0 -0
  83. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/settings.py +0 -0
  84. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/tasks.py +0 -0
  85. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/lambda_tasks/timeouts.py +0 -0
  86. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/conftest.py +0 -0
  87. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/settings.py +0 -0
  88. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_admin.py +0 -0
  89. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_decorator.py +0 -0
  90. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_decorators.py +0 -0
  91. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_deferred_enqueue.py +0 -0
  92. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_environment_loader.py +0 -0
  93. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_handler.py +0 -0
  94. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_kwargs_only.py +0 -0
  95. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_logging.py +0 -0
  96. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_memory_limit.py +0 -0
  97. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_models.py +0 -0
  98. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_secret_loader.py +0 -0
  99. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_serializer.py +0 -0
  100. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_settings.py +0 -0
  101. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_tasks.py +0 -0
  102. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_timeout_validation.py +0 -0
  103. {django_lambda_tasks-0.4.8 → django_lambda_tasks-0.4.9}/tests/test_timeouts.py +0 -0
@@ -246,7 +246,16 @@ Implementation lives in `lambda_tasks/local_executor.py`:
246
246
  - `get_pool()` — lazily creates and returns the shared `ProcessPoolExecutor`
247
247
  - `submit_task(*, message_json: str)` — generates a UUID4 message_id and submits to the pool
248
248
  - `_execute_in_worker(*, message_json: str, message_id: str)` — worker entry point; deserializes and calls `execute_immediately()`
249
- - `_pool_initializer()` — calls `django.setup()` once per worker
249
+ - `_pool_initializer()` — calls `django.setup()` once per worker and sets `SIGINT` to `SIG_IGN` so workers ignore Ctrl+C
250
+ - `_install_shutdown_handlers()` — installs main-thread `SIGINT`/`SIGTERM` handlers that release the pool, then chain to the previous handler
251
+
252
+ ### Shutdown and the Ctrl+C semaphore-leak race
253
+
254
+ `runserver` runs the development server in an autoreloader **child** process spawned via `subprocess.run()`. On Ctrl+C the terminal delivers `SIGINT` to the whole process group; the autoreloader **parent** unwinds out of `subprocess.run` and immediately calls `process.kill()` (`SIGKILL`) on the child. The pool's POSIX semaphores must be unlinked before that `SIGKILL` lands, otherwise multiprocessing's `resource_tracker` prints `There appear to be N leaked semaphore objects to clean up at shutdown`.
255
+
256
+ Relying on `atexit` alone loses this race in applications with a heavy shutdown sequence (many `atexit` handlers, open DB connections), because `atexit` runs only after the full interpreter unwind — `SIGKILL` arrives first. To win the race, `LambdaTasksConfig.ready()` calls `_install_shutdown_handlers()` when `LOCAL_WORKERS > 0`. The handler shuts the pool down as its **first** action, then chains to the previously installed handler so normal shutdown behaviour (`KeyboardInterrupt`, autoreloader exit) is preserved. `atexit` registration remains as a fallback for non-signal exits. Handler installation is idempotent and only effective on the main thread (`signal.signal` raises off the main thread, in which case it is a no-op).
257
+
258
+ `_shutdown_pool()` does **not** use `pool.shutdown(wait=True)`. `wait=True` blocks on joining the worker processes, and a worker that ran a heavy `django.setup()` is slow to exit — `concurrent.futures` does not unlink the pool's queue semaphores until the workers actually die, so the parent's near-instant `SIGKILL` still wins and the semaphores leak. Instead `_shutdown_pool()` **terminates the worker processes first** (a near-instant signal to children we own), then calls `pool.shutdown(wait=False, cancel_futures=True)`. With the workers already gone, the queue semaphores are unlinked in milliseconds — fast enough to beat the parent's `SIGKILL`.
250
259
 
251
260
  ## Logging
252
261
 
@@ -41,13 +41,14 @@ django-lambda-tasks/
41
41
 
42
42
  - `decorators.py` — defines `@lambda_task`; enforces kwargs-only at decoration time
43
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
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 (and sets `SIGINT` to `SIG_IGN` so workers ignore Ctrl+C); `_install_shutdown_handlers()` installs main-thread `SIGINT`/`SIGTERM` handlers that release the pool before chaining to the previous handler
45
45
  - `handler.py` — Lambda entry point; cold-start init (memory limit → temporary log handler → `resolve_environment()` → `resolve_secrets_into_env()` → handler removed → conditional `django.setup()`) runs inside the handler on first invocation, guarded by `_cold_start_done` sentinel; processes SQS records independently; returns `batchItemFailures`
46
46
  - `environment_loader.py` — loads env vars from a Secrets Manager secret at cold start; validates flat JSON format; idempotent via `_loaded` sentinel
47
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
48
48
  - `logging.py` — `task_logger` singleton; `message_id` set/cleared around each task execution
49
49
  - `settings.py` — `LambdaTasksSettings` instantiated fresh per use (reads live Django settings)
50
50
  - `admin.py` — Django admin registration for `TaskRecord`
51
+ - `apps.py` — Django `AppConfig`; `ready()` installs the local-executor shutdown signal handlers when `LOCAL_WORKERS > 0`
51
52
  - `tasks.py` — built-in maintenance tasks; `cleanup_task_records` deletes old `TaskRecord` rows
52
53
 
53
54
  ## Conventions
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-lambda-tasks
3
- Version: 0.4.8
3
+ Version: 0.4.9
4
4
  Summary: Run async tasks in a lambda function
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: awslambdaric
@@ -0,0 +1,22 @@
1
+ """Django AppConfig for the lambda_tasks library."""
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class LambdaTasksConfig(AppConfig):
7
+ name = "lambda_tasks"
8
+ verbose_name = "Lambda Tasks"
9
+
10
+ def ready(self) -> None:
11
+ """Install pool shutdown signal handlers in async-local mode.
12
+
13
+ Only relevant when LOCAL_WORKERS > 0 (development async execution). In
14
+ that mode the process pool's POSIX semaphores must be released promptly
15
+ on Ctrl+C, before Django's autoreloader parent SIGKILLs this child. See
16
+ ``local_executor._install_shutdown_handlers`` for the full rationale.
17
+ """
18
+ from lambda_tasks.local_executor import _install_shutdown_handlers
19
+ from lambda_tasks.settings import LambdaTasksSettings
20
+
21
+ if LambdaTasksSettings().LOCAL_WORKERS > 0:
22
+ _install_shutdown_handlers()
@@ -0,0 +1,151 @@
1
+ """Process pool executor for async local task execution."""
2
+
3
+ import atexit
4
+ import logging
5
+ import signal
6
+ import threading
7
+ import uuid
8
+ from concurrent.futures import Future, ProcessPoolExecutor
9
+
10
+ from lambda_tasks.settings import LambdaTasksSettings
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _pool: ProcessPoolExecutor | None = None
15
+ _handlers_installed: bool = False
16
+
17
+
18
+ def _pool_initializer() -> None:
19
+ """Run once per worker process.
20
+
21
+ Ignores SIGINT so that Ctrl+C is handled exclusively by the parent
22
+ process, which releases the pool via its SIGINT/SIGTERM handler (and
23
+ atexit as a fallback). This prevents workers from being killed
24
+ mid-operation and leaking semaphores.
25
+
26
+ Then sets up Django for task execution.
27
+ """
28
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
29
+
30
+ import django
31
+
32
+ django.setup()
33
+
34
+
35
+ def _shutdown_pool() -> None:
36
+ """Shut down the pool, releasing its POSIX semaphores promptly.
37
+
38
+ On shutdown we must unlink the pool's semaphores quickly, because under
39
+ ``runserver`` the autoreloader parent SIGKILLs this child almost immediately
40
+ after Ctrl+C (see ``_install_shutdown_handlers``). ``pool.shutdown(wait=True)``
41
+ blocks on joining the worker processes — if a worker is slow to exit (e.g.
42
+ it ran a heavy ``django.setup()``), the semaphores are not unlinked until
43
+ the worker dies, and the SIGKILL wins the race, leaking the semaphores.
44
+
45
+ To avoid that, terminate the worker processes first (a near-instant
46
+ SIGTERM/SIGKILL to children we own), then shut the pool down without
47
+ waiting. With the workers already gone, ``concurrent.futures`` releases the
48
+ queue semaphores immediately.
49
+ """
50
+ global _pool
51
+ if _pool is not None:
52
+ pool = _pool
53
+ _pool = None
54
+ processes = getattr(pool, "_processes", None)
55
+ if processes:
56
+ for process in list(processes.values()):
57
+ process.terminate()
58
+ pool.shutdown(wait=False, cancel_futures=True)
59
+
60
+
61
+ def _install_shutdown_handlers() -> None:
62
+ """Install SIGINT/SIGTERM handlers that release the pool promptly.
63
+
64
+ Under Django's ``runserver`` autoreloader the development server runs in a
65
+ child process spawned by ``subprocess.run()``. On Ctrl+C the terminal
66
+ delivers SIGINT to the whole process group; the autoreloader parent unwinds
67
+ out of ``subprocess.run`` and immediately calls ``process.kill()``
68
+ (SIGKILL) on this child. That is a race: this process must unlink the
69
+ pool's POSIX semaphores before the SIGKILL lands, otherwise multiprocessing's
70
+ ``resource_tracker`` reports them as leaked at shutdown.
71
+
72
+ Relying on ``atexit`` loses that race in applications with a heavy shutdown
73
+ sequence, because ``atexit`` runs only after the full interpreter unwind.
74
+ Instead we shut the pool down as the very first action of the signal
75
+ handler, then chain to the previously installed handler so normal shutdown
76
+ behaviour (KeyboardInterrupt, autoreloader exit) is preserved.
77
+
78
+ Idempotent and only effective on the main thread — ``signal.signal`` raises
79
+ ``ValueError`` off the main thread, in which case this is a no-op.
80
+ """
81
+ global _handlers_installed
82
+ if _handlers_installed:
83
+ return
84
+
85
+ if threading.current_thread() is not threading.main_thread():
86
+ return
87
+
88
+ def make_handler(previous): # type: ignore[no-untyped-def]
89
+ def handler(signum, frame): # type: ignore[no-untyped-def]
90
+ # Release the pool's semaphores first, before the autoreloader
91
+ # parent can SIGKILL us.
92
+ _shutdown_pool()
93
+ if callable(previous):
94
+ previous(signum, frame)
95
+ elif previous == signal.SIG_DFL:
96
+ signal.signal(signum, signal.SIG_DFL)
97
+ signal.raise_signal(signum)
98
+ # previous == SIG_IGN: nothing to chain to.
99
+
100
+ return handler
101
+
102
+ try:
103
+ for signum in (signal.SIGINT, signal.SIGTERM):
104
+ previous = signal.getsignal(signum)
105
+ signal.signal(signum, make_handler(previous))
106
+ except ValueError:
107
+ # Not on the main thread; cannot install signal handlers here.
108
+ return
109
+
110
+ _handlers_installed = True
111
+
112
+
113
+ def get_pool() -> ProcessPoolExecutor:
114
+ """Return the shared ProcessPoolExecutor, creating it on first call."""
115
+ global _pool
116
+ if _pool is None:
117
+ conf = LambdaTasksSettings()
118
+ _pool = ProcessPoolExecutor(
119
+ max_workers=conf.LOCAL_WORKERS,
120
+ initializer=_pool_initializer,
121
+ )
122
+ atexit.register(_shutdown_pool)
123
+ return _pool
124
+
125
+
126
+ def _execute_in_worker(*, message_json: str, message_id: str) -> None:
127
+ """Worker entry point. Deserializes and executes the task.
128
+
129
+ Runs in a child process. Django is already set up via the pool initializer.
130
+ """
131
+ from lambda_tasks.models import SQSLambdaTaskMessage
132
+
133
+ message = SQSLambdaTaskMessage.model_validate_json(message_json)
134
+ message.execute_immediately(message_id=message_id)
135
+
136
+
137
+ def _log_worker_exception(future: Future) -> None: # type: ignore[type-arg]
138
+ """Callback attached to each worker future. Logs unhandled exceptions."""
139
+ exception = future.exception()
140
+ if exception is not None:
141
+ logger.error("Worker process raised an exception", exc_info=exception)
142
+
143
+
144
+ def submit_task(*, message_json: str) -> None:
145
+ """Submit a task to the process pool. Fire-and-forget."""
146
+ pool = get_pool()
147
+ message_id = str(uuid.uuid4())
148
+ future = pool.submit(
149
+ _execute_in_worker, message_json=message_json, message_id=message_id
150
+ )
151
+ future.add_done_callback(_log_worker_exception)
@@ -7,7 +7,7 @@ packages = ["lambda_tasks"]
7
7
 
8
8
  [project]
9
9
  name = "django-lambda-tasks"
10
- version = "0.4.8"
10
+ version = "0.4.9"
11
11
  description = "Run async tasks in a lambda function"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.10"
@@ -679,3 +679,212 @@ class TestWorkerExceptionIsolation:
679
679
 
680
680
  # Pool.submit was called twice — pool did not crash
681
681
  assert mock_pool.submit.call_count == 2
682
+
683
+
684
+ # ---------------------------------------------------------------------------
685
+ # Shutdown signal handlers (Ctrl+C / SIGINT race with the autoreloader parent)
686
+ # ---------------------------------------------------------------------------
687
+
688
+ import signal as signal_module
689
+
690
+
691
+ @pytest.fixture()
692
+ def _reset_signal_handlers():
693
+ """Save/restore SIGINT and SIGTERM handlers and the installed sentinel."""
694
+ import lambda_tasks.local_executor
695
+
696
+ original_sigint = signal_module.getsignal(signal_module.SIGINT)
697
+ original_sigterm = signal_module.getsignal(signal_module.SIGTERM)
698
+ original_flag = lambda_tasks.local_executor._handlers_installed
699
+ lambda_tasks.local_executor._handlers_installed = False
700
+ try:
701
+ yield
702
+ finally:
703
+ signal_module.signal(signal_module.SIGINT, original_sigint)
704
+ signal_module.signal(signal_module.SIGTERM, original_sigterm)
705
+ lambda_tasks.local_executor._handlers_installed = original_flag
706
+
707
+
708
+ class TestShutdownSignalHandlers:
709
+ """Tests for the SIGINT/SIGTERM handlers that release the pool promptly.
710
+
711
+ Under Django's autoreloader the server runs in a child spawned by
712
+ subprocess.run(); on Ctrl+C the parent SIGKILLs the child. The child must
713
+ release the pool's semaphores before that SIGKILL lands, so cleanup happens
714
+ at the front of signal handling rather than via atexit.
715
+ """
716
+
717
+ @pytest.mark.usefixtures("_reset_signal_handlers")
718
+ def test_installs_sigint_handler(self):
719
+ """_install_shutdown_handlers() replaces the SIGINT handler with our own."""
720
+ from lambda_tasks.local_executor import _install_shutdown_handlers
721
+
722
+ _install_shutdown_handlers()
723
+
724
+ handler = signal_module.getsignal(signal_module.SIGINT)
725
+ assert callable(handler)
726
+ assert handler != signal_module.default_int_handler
727
+
728
+ @pytest.mark.usefixtures("_reset_signal_handlers")
729
+ def test_handler_shuts_pool_down_then_chains_to_previous(self):
730
+ """The installed handler calls _shutdown_pool() then the previous handler."""
731
+ import lambda_tasks.local_executor as local_executor
732
+
733
+ call_order = []
734
+
735
+ def previous_handler(signum, frame):
736
+ call_order.append("previous")
737
+
738
+ signal_module.signal(signal_module.SIGINT, previous_handler)
739
+
740
+ with patch.object(
741
+ local_executor,
742
+ "_shutdown_pool",
743
+ side_effect=lambda: call_order.append("shutdown"),
744
+ ) as mock_shutdown:
745
+ local_executor._install_shutdown_handlers()
746
+ installed = signal_module.getsignal(signal_module.SIGINT)
747
+ installed(signal_module.SIGINT, None)
748
+
749
+ mock_shutdown.assert_called_once()
750
+ assert call_order == ["shutdown", "previous"]
751
+
752
+ @pytest.mark.usefixtures("_reset_signal_handlers")
753
+ def test_idempotent_does_not_double_wrap(self):
754
+ """Calling twice keeps a single wrapper (no nested chaining of our own handler)."""
755
+ from lambda_tasks.local_executor import _install_shutdown_handlers
756
+
757
+ _install_shutdown_handlers()
758
+ first = signal_module.getsignal(signal_module.SIGINT)
759
+ _install_shutdown_handlers()
760
+ second = signal_module.getsignal(signal_module.SIGINT)
761
+
762
+ assert first is second
763
+
764
+ @pytest.mark.usefixtures("_reset_signal_handlers")
765
+ def test_noop_outside_main_thread(self):
766
+ """Installation is a no-op when not on the main thread (signal.signal would raise)."""
767
+ import threading
768
+
769
+ import lambda_tasks.local_executor as local_executor
770
+
771
+ results = {}
772
+
773
+ def worker():
774
+ local_executor._install_shutdown_handlers()
775
+ results["installed"] = local_executor._handlers_installed
776
+
777
+ thread = threading.Thread(target=worker)
778
+ thread.start()
779
+ thread.join()
780
+
781
+ assert results["installed"] is False
782
+
783
+
784
+ class TestAppConfigReady:
785
+ """AppConfig.ready() installs the shutdown handlers only in async-local mode."""
786
+
787
+ def test_ready_installs_handlers_when_local_workers_positive(self, settings):
788
+ from lambda_tasks.apps import LambdaTasksConfig
789
+
790
+ settings.LAMBDA_TASKS_LOCAL_WORKERS = 2
791
+ settings.LAMBDA_TASKS_EAGER = False
792
+
793
+ config = LambdaTasksConfig.create("lambda_tasks")
794
+ with patch(
795
+ "lambda_tasks.local_executor._install_shutdown_handlers"
796
+ ) as mock_install:
797
+ config.ready()
798
+
799
+ mock_install.assert_called_once()
800
+
801
+ def test_ready_does_not_install_handlers_when_local_workers_zero(self, settings):
802
+ from lambda_tasks.apps import LambdaTasksConfig
803
+
804
+ settings.LAMBDA_TASKS_LOCAL_WORKERS = 0
805
+ settings.LAMBDA_TASKS_EAGER = False
806
+
807
+ config = LambdaTasksConfig.create("lambda_tasks")
808
+ with patch(
809
+ "lambda_tasks.local_executor._install_shutdown_handlers"
810
+ ) as mock_install:
811
+ config.ready()
812
+
813
+ mock_install.assert_not_called()
814
+
815
+
816
+ # ---------------------------------------------------------------------------
817
+ # _shutdown_pool() terminate-first behaviour
818
+ # ---------------------------------------------------------------------------
819
+
820
+
821
+ class TestShutdownPool:
822
+ """_shutdown_pool() must release semaphores promptly to win the SIGKILL race.
823
+
824
+ pool.shutdown(wait=True) blocks on joining worker processes; if a worker is
825
+ slow to exit, the autoreloader parent SIGKILLs this process before the
826
+ semaphores are unlinked. So _shutdown_pool() terminates the workers first,
827
+ then shuts down without waiting.
828
+ """
829
+
830
+ def test_shutdown_terminates_workers_before_shutdown(self):
831
+ """_shutdown_pool() calls terminate() on each worker, then shutdown(wait=False)."""
832
+ import lambda_tasks.local_executor as local_executor
833
+
834
+ call_order = []
835
+
836
+ process_a = MagicMock()
837
+ process_a.terminate.side_effect = lambda: call_order.append("terminate_a")
838
+ process_b = MagicMock()
839
+ process_b.terminate.side_effect = lambda: call_order.append("terminate_b")
840
+
841
+ mock_pool = MagicMock()
842
+ mock_pool._processes = {"a": process_a, "b": process_b}
843
+ mock_pool.shutdown.side_effect = lambda **kwargs: call_order.append(
844
+ f"shutdown:{kwargs}"
845
+ )
846
+
847
+ original_pool = local_executor._pool
848
+ local_executor._pool = mock_pool
849
+ try:
850
+ local_executor._shutdown_pool()
851
+ finally:
852
+ local_executor._pool = original_pool
853
+
854
+ process_a.terminate.assert_called_once()
855
+ process_b.terminate.assert_called_once()
856
+ # Both terminates happen before the shutdown call.
857
+ assert call_order.index("terminate_a") < call_order.index(
858
+ "shutdown:{'wait': False, 'cancel_futures': True}"
859
+ )
860
+ assert call_order.index("terminate_b") < call_order.index(
861
+ "shutdown:{'wait': False, 'cancel_futures': True}"
862
+ )
863
+ mock_pool.shutdown.assert_called_once_with(wait=False, cancel_futures=True)
864
+
865
+ def test_shutdown_clears_module_pool(self):
866
+ """_shutdown_pool() resets the module-level _pool to None."""
867
+ import lambda_tasks.local_executor as local_executor
868
+
869
+ mock_pool = MagicMock()
870
+ mock_pool._processes = {}
871
+
872
+ original_pool = local_executor._pool
873
+ local_executor._pool = mock_pool
874
+ try:
875
+ local_executor._shutdown_pool()
876
+ assert local_executor._pool is None
877
+ finally:
878
+ local_executor._pool = original_pool
879
+
880
+ def test_shutdown_is_noop_when_no_pool(self):
881
+ """_shutdown_pool() does nothing when no pool exists."""
882
+ import lambda_tasks.local_executor as local_executor
883
+
884
+ original_pool = local_executor._pool
885
+ local_executor._pool = None
886
+ try:
887
+ local_executor._shutdown_pool() # must not raise
888
+ assert local_executor._pool is None
889
+ finally:
890
+ local_executor._pool = original_pool
@@ -1,8 +0,0 @@
1
- """Django AppConfig for the lambda_tasks library."""
2
-
3
- from django.apps import AppConfig
4
-
5
-
6
- class LambdaTasksConfig(AppConfig):
7
- name = "lambda_tasks"
8
- verbose_name = "Lambda Tasks"
@@ -1,78 +0,0 @@
1
- """Process pool executor for async local task execution."""
2
-
3
- import atexit
4
- import logging
5
- import signal
6
- import uuid
7
- from concurrent.futures import Future, ProcessPoolExecutor
8
-
9
- from lambda_tasks.settings import LambdaTasksSettings
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
- _pool: ProcessPoolExecutor | None = None
14
-
15
-
16
- def _pool_initializer() -> None:
17
- """Run once per worker process.
18
-
19
- Ignores SIGINT so that Ctrl+C is handled exclusively by the parent
20
- process, which shuts down the pool cleanly via atexit. This prevents
21
- workers from being killed mid-operation and leaking semaphores.
22
-
23
- Then sets up Django for task execution.
24
- """
25
- signal.signal(signal.SIGINT, signal.SIG_IGN)
26
-
27
- import django
28
-
29
- django.setup()
30
-
31
-
32
- def _shutdown_pool() -> None:
33
- """Shut down the pool at interpreter exit to release semaphores."""
34
- global _pool
35
- if _pool is not None:
36
- _pool.shutdown(wait=True, cancel_futures=True)
37
- _pool = None
38
-
39
-
40
- def get_pool() -> ProcessPoolExecutor:
41
- """Return the shared ProcessPoolExecutor, creating it on first call."""
42
- global _pool
43
- if _pool is None:
44
- conf = LambdaTasksSettings()
45
- _pool = ProcessPoolExecutor(
46
- max_workers=conf.LOCAL_WORKERS,
47
- initializer=_pool_initializer,
48
- )
49
- atexit.register(_shutdown_pool)
50
- return _pool
51
-
52
-
53
- def _execute_in_worker(*, message_json: str, message_id: str) -> None:
54
- """Worker entry point. Deserializes and executes the task.
55
-
56
- Runs in a child process. Django is already set up via the pool initializer.
57
- """
58
- from lambda_tasks.models import SQSLambdaTaskMessage
59
-
60
- message = SQSLambdaTaskMessage.model_validate_json(message_json)
61
- message.execute_immediately(message_id=message_id)
62
-
63
-
64
- def _log_worker_exception(future: Future) -> None: # type: ignore[type-arg]
65
- """Callback attached to each worker future. Logs unhandled exceptions."""
66
- exception = future.exception()
67
- if exception is not None:
68
- logger.error("Worker process raised an exception", exc_info=exception)
69
-
70
-
71
- def submit_task(*, message_json: str) -> None:
72
- """Submit a task to the process pool. Fire-and-forget."""
73
- pool = get_pool()
74
- message_id = str(uuid.uuid4())
75
- future = pool.submit(
76
- _execute_in_worker, message_json=message_json, message_id=message_id
77
- )
78
- future.add_done_callback(_log_worker_exception)