django-lambda-tasks 0.2.1__tar.gz → 0.2.2__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 (93) hide show
  1. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/steering/product.md +3 -2
  2. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/steering/structure.md +1 -1
  3. django_lambda_tasks-0.2.1/README.md → django_lambda_tasks-0.2.2/PKG-INFO +16 -2
  4. django_lambda_tasks-0.2.1/PKG-INFO → django_lambda_tasks-0.2.2/README.md +4 -14
  5. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/environment_loader.py +4 -3
  6. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/handler.py +33 -11
  7. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/pyproject.toml +1 -1
  8. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_environment_loader.py +60 -65
  9. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_handler.py +64 -16
  10. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.github/workflows/ci.yml +0 -0
  11. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.github/workflows/release.yml +0 -0
  12. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.gitignore +0 -0
  13. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  14. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  15. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  16. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  17. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  18. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  19. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  20. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  21. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  22. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  23. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  24. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  25. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  26. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  27. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  28. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  29. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/retry-delay/.config.kiro +0 -0
  30. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/retry-delay/design.md +0 -0
  31. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/retry-delay/requirements.md +0 -0
  32. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/retry-delay/tasks.md +0 -0
  33. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  34. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/rse-background-tasks/design.md +0 -0
  35. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  36. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  37. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  38. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  39. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  40. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  41. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/singleton-task/.config.kiro +0 -0
  42. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/singleton-task/design.md +0 -0
  43. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/singleton-task/requirements.md +0 -0
  44. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/singleton-task/tasks.md +0 -0
  45. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
  46. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/ssm-environment-loader/design.md +0 -0
  47. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
  48. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
  49. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/task-retry/.config.kiro +0 -0
  50. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/task-retry/design.md +0 -0
  51. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/task-retry/requirements.md +0 -0
  52. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/specs/task-retry/tasks.md +0 -0
  53. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.kiro/steering/tech.md +0 -0
  54. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.pre-commit-config.yaml +0 -0
  55. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/.vscode/settings.json +0 -0
  56. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/README.md +0 -0
  57. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/example_app/__init__.py +0 -0
  58. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/example_app/apps.py +0 -0
  59. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/example_app/tasks.py +0 -0
  60. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/example_app/urls.py +0 -0
  61. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/example_app/views.py +0 -0
  62. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/example_project/__init__.py +0 -0
  63. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/example_project/settings.py +0 -0
  64. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/example_project/urls.py +0 -0
  65. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/example_project/wsgi.py +0 -0
  66. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/example/manage.py +0 -0
  67. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/__init__.py +0 -0
  68. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/admin.py +0 -0
  69. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/apps.py +0 -0
  70. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/decorators.py +0 -0
  71. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/logging.py +0 -0
  72. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/migrations/0001_initial.py +0 -0
  73. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/migrations/__init__.py +0 -0
  74. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/models.py +0 -0
  75. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/secret_loader.py +0 -0
  76. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/settings.py +0 -0
  77. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/tasks.py +0 -0
  78. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/lambda_tasks/timeouts.py +0 -0
  79. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/conftest.py +0 -0
  80. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/settings.py +0 -0
  81. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_admin.py +0 -0
  82. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_decorator.py +0 -0
  83. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_decorators.py +0 -0
  84. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_deferred_enqueue.py +0 -0
  85. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_kwargs_only.py +0 -0
  86. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_logging.py +0 -0
  87. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_models.py +0 -0
  88. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_secret_loader.py +0 -0
  89. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_serializer.py +0 -0
  90. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_settings.py +0 -0
  91. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_tasks.py +0 -0
  92. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_timeout_validation.py +0 -0
  93. {django_lambda_tasks-0.2.1 → django_lambda_tasks-0.2.2}/tests/test_timeouts.py +0 -0
@@ -15,7 +15,7 @@ View → @lambda_task.execute_on_commit() → SQS → Lambda handler → SQSLamb
15
15
  Key modules:
16
16
  - `decorators.py` — `@lambda_task` decorator and `LambdaTaskWrapper`
17
17
  - `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (SQS schema + execution), `SQSLambdaTask` (routing + SQS publish)
18
- - `handler.py` — AWS Lambda entry point with partial-batch failure reporting
18
+ - `handler.py` — AWS Lambda entry point; cold-start init runs on first invocation (not at import time) with partial-batch failure reporting
19
19
  - `logging.py` — `task_logger` for invocation-scoped log output
20
20
  - `settings.py` — lazy `LambdaTasksSettings` reading from Django settings
21
21
 
@@ -135,7 +135,8 @@ class SQSLambdaTaskMessage(BaseModel):
135
135
  - Returns `{"batchItemFailures": [...]}` for partial-batch failure reporting
136
136
  - Only pre-execution failures (malformed message, import error, misconfiguration) are reported as `batchItemFailures` — task logic failures are caught and recorded as `FAILED` TaskRecords without raising
137
137
  - Recommended SQS queue settings: `maxReceiveCount=1` with a DLQ configured; automatic retries are not useful since task failures are not re-driven by design
138
- - Cold-start sequence: `resolve_environment()` → `resolve_secrets_into_env()` → conditional `django.setup()`
138
+ - Cold-start sequence runs inside the handler on the first invocation (not at module import time) to avoid Lambda init-duration timeouts: `resolve_environment()` → `resolve_secrets_into_env()` → conditional `django.setup()`
139
+ - A module-level `_cold_start_done` sentinel ensures the sequence runs only once; subsequent warm invocations skip it
139
140
  - Both loaders run unconditionally (outside the `DJANGO_SETTINGS_MODULE` check) — the environment secret may provide that var, and individual secrets may depend on environment-loaded vars
140
141
 
141
142
  ## Environment Loader
@@ -40,7 +40,7 @@ django-lambda-tasks/
40
40
 
41
41
  - `decorators.py` — defines `@lambda_task`; enforces kwargs-only at decoration time
42
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
- - `handler.py` — Lambda entry point; calls `resolve_environment()` then `resolve_secrets_into_env()` then conditionally `django.setup()` at cold start; processes SQS records independently; returns `batchItemFailures`
43
+ - `handler.py` — Lambda entry point; cold-start init (`resolve_environment()` `resolve_secrets_into_env()` conditional `django.setup()`) runs inside the handler on first invocation, guarded by `_cold_start_done` sentinel; processes SQS records independently; returns `batchItemFailures`
44
44
  - `environment_loader.py` — loads env vars from a Secrets Manager secret at cold start; validates flat JSON format; idempotent via `_loaded` sentinel
45
45
  - `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
46
46
  - `logging.py` — `task_logger` singleton; `message_id` set/cleared around each task execution
@@ -1,3 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-lambda-tasks
3
+ Version: 0.2.2
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.
@@ -423,6 +435,8 @@ Point your Lambda function's handler at:
423
435
  lambda_tasks.handler.handler
424
436
  ```
425
437
 
438
+ The handler performs cold-start initialisation on the first invocation (not at module import time) to avoid Lambda init-duration timeouts. The sequence is: `resolve_environment()` → `resolve_secrets_into_env()` → conditional `django.setup()`. A module-level sentinel ensures this runs only once; subsequent warm invocations skip it entirely.
439
+
426
440
  Ensure the Lambda execution environment has `DJANGO_SETTINGS_MODULE` set and that all task modules are importable (i.e. your application code is on the Python path).
427
441
 
428
442
  | Environment Variable | Required | Description |
@@ -441,7 +455,7 @@ Set any env var with the prefix `LAMBDA_TASKS_SECRET_` to a full Secrets Manager
441
455
  LAMBDA_TASKS_SECRET_DATABASE_URL=arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod:DATABASE_URL:AWSCURRENT:v1
442
456
  ```
443
457
 
444
- At cold start, before `django.setup()` is called, the handler calls `resolve_secrets_into_env()` which:
458
+ At cold start (on the first handler invocation), before `django.setup()` is called, the handler calls `resolve_secrets_into_env()` which:
445
459
 
446
460
  1. Scans all env vars for the `LAMBDA_TASKS_SECRET_` prefix
447
461
  2. Validates every reference — malformed references raise immediately so the container fails to start rather than misconfiguring Django silently
@@ -503,7 +517,7 @@ The secret value must be a flat JSON object where all keys and values are string
503
517
  }
504
518
  ```
505
519
 
506
- At cold start, before `resolve_secrets_into_env()` and `django.setup()`, the handler calls `resolve_environment()` which:
520
+ At cold start (on the first handler invocation), before `resolve_secrets_into_env()` and `django.setup()`, the handler calls `resolve_environment()` which:
507
521
 
508
522
  1. Checks for the `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` env var — if not set, does nothing
509
523
  2. Parses and validates the reference format (9 segments, non-empty version-stage and version-id)
@@ -1,15 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: django-lambda-tasks
3
- Version: 0.2.1
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.
@@ -435,6 +423,8 @@ Point your Lambda function's handler at:
435
423
  lambda_tasks.handler.handler
436
424
  ```
437
425
 
426
+ The handler performs cold-start initialisation on the first invocation (not at module import time) to avoid Lambda init-duration timeouts. The sequence is: `resolve_environment()` → `resolve_secrets_into_env()` → conditional `django.setup()`. A module-level sentinel ensures this runs only once; subsequent warm invocations skip it entirely.
427
+
438
428
  Ensure the Lambda execution environment has `DJANGO_SETTINGS_MODULE` set and that all task modules are importable (i.e. your application code is on the Python path).
439
429
 
440
430
  | Environment Variable | Required | Description |
@@ -453,7 +443,7 @@ Set any env var with the prefix `LAMBDA_TASKS_SECRET_` to a full Secrets Manager
453
443
  LAMBDA_TASKS_SECRET_DATABASE_URL=arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod:DATABASE_URL:AWSCURRENT:v1
454
444
  ```
455
445
 
456
- At cold start, before `django.setup()` is called, the handler calls `resolve_secrets_into_env()` which:
446
+ At cold start (on the first handler invocation), before `django.setup()` is called, the handler calls `resolve_secrets_into_env()` which:
457
447
 
458
448
  1. Scans all env vars for the `LAMBDA_TASKS_SECRET_` prefix
459
449
  2. Validates every reference — malformed references raise immediately so the container fails to start rather than misconfiguring Django silently
@@ -515,7 +505,7 @@ The secret value must be a flat JSON object where all keys and values are string
515
505
  }
516
506
  ```
517
507
 
518
- At cold start, before `resolve_secrets_into_env()` and `django.setup()`, the handler calls `resolve_environment()` which:
508
+ At cold start (on the first handler invocation), before `resolve_secrets_into_env()` and `django.setup()`, the handler calls `resolve_environment()` which:
519
509
 
520
510
  1. Checks for the `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` env var — if not set, does nothing
521
511
  2. Parses and validates the reference format (9 segments, non-empty version-stage and version-id)
@@ -13,9 +13,10 @@ Required value format::
13
13
  That is: ``<arn>:<version-stage>:<version-id>`` (9 colon-separated segments).
14
14
  The ARN is 7 segments, plus version-stage and version-id.
15
15
 
16
- This runs at Lambda cold start — before ``resolve_secrets_into_env()`` and
17
- before ``django.setup()`` — so that environment variables loaded from the
18
- secret are available to both the secret loader and Django configuration.
16
+ This is called by the handler on the first invocation (cold start) — before
17
+ ``resolve_secrets_into_env()`` and before ``django.setup()`` — so that
18
+ environment variables loaded from the secret are available to both the
19
+ secret loader and Django configuration.
19
20
 
20
21
  The result is cached at module level via a ``_loaded`` sentinel so that
21
22
  subsequent calls (warm invocations) are free no-ops.
@@ -4,28 +4,48 @@ AWS Lambda handler for lambda_tasks.
4
4
  Processes a batch of SQS records using partial-batch failure reporting.
5
5
  Each record is processed independently — a failure in one record does not
6
6
  prevent processing of other records.
7
+
8
+ Cold-start initialisation (environment loading, secret resolution, Django
9
+ setup) runs inside the handler on the first invocation rather than at module
10
+ import time. This keeps the Lambda init phase fast and avoids init-duration
11
+ timeouts. The sequence is guarded by a module-level sentinel so subsequent
12
+ warm invocations skip it.
7
13
  """
8
14
 
9
15
  import logging
10
16
  import os
11
17
 
12
- import django
13
- from django.apps import apps
14
-
15
18
  from lambda_tasks.environment_loader import resolve_environment
16
19
  from lambda_tasks.secret_loader import resolve_secrets_into_env
17
20
 
18
- # Both loaders are idempotent and run unconditionally before the
19
- # DJANGO_SETTINGS_MODULE check — the environment secret may provide
20
- # that var, and individual secrets may depend on environment-loaded vars.
21
- resolve_environment()
22
- resolve_secrets_into_env()
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _cold_start_done: bool = False
23
24
 
24
- if os.environ.get("DJANGO_SETTINGS_MODULE") and not apps.ready:
25
- django.setup()
26
25
 
26
+ def _perform_cold_start() -> None:
27
+ """Run one-time initialisation: env loading, secrets, Django setup.
27
28
 
28
- logger = logging.getLogger(__name__)
29
+ Both loaders are idempotent and run unconditionally — the environment
30
+ secret may provide DJANGO_SETTINGS_MODULE, and individual secrets may
31
+ depend on environment-loaded vars.
32
+ """
33
+ global _cold_start_done
34
+
35
+ if _cold_start_done:
36
+ return
37
+
38
+ resolve_environment()
39
+ resolve_secrets_into_env()
40
+
41
+ if os.environ.get("DJANGO_SETTINGS_MODULE"):
42
+ import django
43
+ from django.apps import apps
44
+
45
+ if not apps.ready:
46
+ django.setup()
47
+
48
+ _cold_start_done = True
29
49
 
30
50
 
31
51
  def handler(event: dict, context: object) -> dict:
@@ -34,6 +54,8 @@ def handler(event: dict, context: object) -> dict:
34
54
  Returns a partial-batch failure report so AWS only re-drives failed records.
35
55
  Signature is fixed by AWS and uses two args only.
36
56
  """
57
+ _perform_cold_start()
58
+
37
59
  # Local import due to AppRegistryNotReady
38
60
  from lambda_tasks.models import SQSLambdaTaskMessage
39
61
 
@@ -7,7 +7,7 @@ packages = ["lambda_tasks"]
7
7
 
8
8
  [project]
9
9
  name = "django-lambda-tasks"
10
- version = "0.2.1"
10
+ version = "0.2.2"
11
11
  description = "Run async tasks in a lambda function"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.10"
@@ -551,34 +551,42 @@ class TestPropertyIdempotentExecution:
551
551
 
552
552
 
553
553
  class TestHandlerColdStartOrdering:
554
- """Verify handler.py cold-start calls environment loader, then secrets, then conditionally Django."""
554
+ """Verify handler cold-start runs environment loader, then secrets, then conditionally Django."""
555
+
556
+ def _invoke_handler(self) -> None:
557
+ """Invoke the handler with a minimal valid event to trigger cold-start."""
558
+ import json
559
+
560
+ from lambda_tasks.handler import handler
561
+
562
+ body = json.dumps({"task_name": "some.task", "kwargs": {}})
563
+ event = {"Records": [{"messageId": "msg-1", "body": body}]}
564
+ handler(event=event, context=None)
555
565
 
556
566
  def test_resolve_environment_called_before_resolve_secrets_into_env(
557
567
  self,
558
568
  monkeypatch,
559
569
  ) -> None:
560
570
  """resolve_environment() runs before resolve_secrets_into_env()."""
561
- import sys
571
+ import lambda_tasks.handler as handler_module
562
572
 
563
573
  call_order: list[str] = []
564
574
 
565
575
  monkeypatch.delenv("DJANGO_SETTINGS_MODULE", raising=False)
566
576
 
567
- with (
568
- patch(
569
- "lambda_tasks.environment_loader.resolve_environment",
570
- side_effect=lambda: call_order.append("resolve_environment"),
571
- ),
572
- patch(
573
- "lambda_tasks.secret_loader.resolve_secrets_into_env",
574
- side_effect=lambda: call_order.append("resolve_secrets_into_env"),
575
- ),
576
- patch("django.setup"),
577
- patch("django.apps.apps.ready", new=False),
578
- ):
579
- # Remove cached handler module so reload triggers cold-start code
580
- sys.modules.pop("lambda_tasks.handler", None)
581
- import lambda_tasks.handler # noqa: F401
577
+ monkeypatch.setattr(
578
+ handler_module,
579
+ "resolve_environment",
580
+ lambda: call_order.append("resolve_environment"),
581
+ )
582
+ monkeypatch.setattr(
583
+ handler_module,
584
+ "resolve_secrets_into_env",
585
+ lambda: call_order.append("resolve_secrets_into_env"),
586
+ )
587
+
588
+ handler_module._cold_start_done = False
589
+ self._invoke_handler()
582
590
 
583
591
  assert call_order.index("resolve_environment") < call_order.index(
584
592
  "resolve_secrets_into_env"
@@ -592,27 +600,27 @@ class TestHandlerColdStartOrdering:
592
600
  monkeypatch,
593
601
  ) -> None:
594
602
  """Both loaders run even when DJANGO_SETTINGS_MODULE is unset."""
595
- import sys
603
+ import lambda_tasks.handler as handler_module
596
604
 
597
605
  call_order: list[str] = []
598
606
 
599
- # Ensure DJANGO_SETTINGS_MODULE is NOT set — loaders must still run
600
607
  monkeypatch.delenv("DJANGO_SETTINGS_MODULE", raising=False)
601
608
 
602
- with (
603
- patch(
604
- "lambda_tasks.environment_loader.resolve_environment",
605
- side_effect=lambda: call_order.append("resolve_environment"),
606
- ),
607
- patch(
608
- "lambda_tasks.secret_loader.resolve_secrets_into_env",
609
- side_effect=lambda: call_order.append("resolve_secrets_into_env"),
610
- ),
611
- patch("django.setup") as mock_django_setup,
612
- patch("django.apps.apps.ready", new=False),
613
- ):
614
- sys.modules.pop("lambda_tasks.handler", None)
615
- import lambda_tasks.handler # noqa: F401
609
+ monkeypatch.setattr(
610
+ handler_module,
611
+ "resolve_environment",
612
+ lambda: call_order.append("resolve_environment"),
613
+ )
614
+ monkeypatch.setattr(
615
+ handler_module,
616
+ "resolve_secrets_into_env",
617
+ lambda: call_order.append("resolve_secrets_into_env"),
618
+ )
619
+
620
+ handler_module._cold_start_done = False
621
+
622
+ with patch("django.setup") as mock_django_setup:
623
+ self._invoke_handler()
616
624
 
617
625
  assert (
618
626
  "resolve_environment" in call_order
@@ -620,7 +628,6 @@ class TestHandlerColdStartOrdering:
620
628
  assert (
621
629
  "resolve_secrets_into_env" in call_order
622
630
  ), "resolve_secrets_into_env was not called when DJANGO_SETTINGS_MODULE is unset"
623
- # django.setup() should NOT have been called since DJANGO_SETTINGS_MODULE is unset
624
631
  mock_django_setup.assert_not_called()
625
632
 
626
633
  def test_django_setup_called_when_settings_module_set_and_apps_not_ready(
@@ -628,22 +635,19 @@ class TestHandlerColdStartOrdering:
628
635
  monkeypatch,
629
636
  ) -> None:
630
637
  """django.setup() called when DJANGO_SETTINGS_MODULE is set and apps.ready is False."""
631
- import sys
638
+ import lambda_tasks.handler as handler_module
632
639
 
633
640
  monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "tests.settings")
641
+ monkeypatch.setattr(handler_module, "resolve_environment", lambda: None)
642
+ monkeypatch.setattr(handler_module, "resolve_secrets_into_env", lambda: None)
643
+
644
+ handler_module._cold_start_done = False
634
645
 
635
646
  with (
636
- patch(
637
- "lambda_tasks.environment_loader.resolve_environment",
638
- ),
639
- patch(
640
- "lambda_tasks.secret_loader.resolve_secrets_into_env",
641
- ),
642
647
  patch("django.setup") as mock_django_setup,
643
648
  patch("django.apps.apps.ready", new=False),
644
649
  ):
645
- sys.modules.pop("lambda_tasks.handler", None)
646
- import lambda_tasks.handler # noqa: F401
650
+ self._invoke_handler()
647
651
 
648
652
  mock_django_setup.assert_called_once()
649
653
 
@@ -652,22 +656,19 @@ class TestHandlerColdStartOrdering:
652
656
  monkeypatch,
653
657
  ) -> None:
654
658
  """django.setup() is NOT called when apps.ready is True."""
655
- import sys
659
+ import lambda_tasks.handler as handler_module
656
660
 
657
661
  monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "tests.settings")
662
+ monkeypatch.setattr(handler_module, "resolve_environment", lambda: None)
663
+ monkeypatch.setattr(handler_module, "resolve_secrets_into_env", lambda: None)
664
+
665
+ handler_module._cold_start_done = False
658
666
 
659
667
  with (
660
- patch(
661
- "lambda_tasks.environment_loader.resolve_environment",
662
- ),
663
- patch(
664
- "lambda_tasks.secret_loader.resolve_secrets_into_env",
665
- ),
666
668
  patch("django.setup") as mock_django_setup,
667
669
  patch("django.apps.apps.ready", new=True),
668
670
  ):
669
- sys.modules.pop("lambda_tasks.handler", None)
670
- import lambda_tasks.handler # noqa: F401
671
+ self._invoke_handler()
671
672
 
672
673
  mock_django_setup.assert_not_called()
673
674
 
@@ -676,21 +677,15 @@ class TestHandlerColdStartOrdering:
676
677
  monkeypatch,
677
678
  ) -> None:
678
679
  """django.setup() is NOT called when DJANGO_SETTINGS_MODULE is unset."""
679
- import sys
680
+ import lambda_tasks.handler as handler_module
680
681
 
681
682
  monkeypatch.delenv("DJANGO_SETTINGS_MODULE", raising=False)
683
+ monkeypatch.setattr(handler_module, "resolve_environment", lambda: None)
684
+ monkeypatch.setattr(handler_module, "resolve_secrets_into_env", lambda: None)
682
685
 
683
- with (
684
- patch(
685
- "lambda_tasks.environment_loader.resolve_environment",
686
- ),
687
- patch(
688
- "lambda_tasks.secret_loader.resolve_secrets_into_env",
689
- ),
690
- patch("django.setup") as mock_django_setup,
691
- patch("django.apps.apps.ready", new=False),
692
- ):
693
- sys.modules.pop("lambda_tasks.handler", None)
694
- import lambda_tasks.handler # noqa: F401
686
+ handler_module._cold_start_done = False
687
+
688
+ with patch("django.setup") as mock_django_setup:
689
+ self._invoke_handler()
695
690
 
696
691
  mock_django_setup.assert_not_called()
@@ -271,33 +271,44 @@ def test_property_11_batch_records_processed_independently(flags):
271
271
 
272
272
 
273
273
  def test_property_4_django_setup_before_execute_task(monkeypatch):
274
+ """Cold-start init (resolve_environment, resolve_secrets, django.setup) runs
275
+ inside the handler on first invocation, not at module import time."""
274
276
  import importlib
275
277
 
276
278
  import django.apps
277
279
 
278
280
  import lambda_tasks.handler as handler_module
279
281
 
280
- call_order = []
282
+ call_order: list[str] = []
281
283
 
282
284
  monkeypatch.setattr(django.apps.apps, "ready", False)
283
285
  monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "tests.settings")
284
286
 
287
+ def spy_resolve_environment():
288
+ call_order.append("resolve_environment")
289
+
290
+ def spy_resolve_secrets():
291
+ call_order.append("resolve_secrets_into_env")
292
+
285
293
  def spy_setup(*args, **kwargs):
286
294
  call_order.append("django.setup")
287
295
 
288
- monkeypatch.setattr(django, "setup", spy_setup)
289
-
290
296
  def spy_model_validate(body):
291
297
  call_order.append("execute_task")
292
298
  return MagicMock()
293
299
 
300
+ monkeypatch.setattr(
301
+ "lambda_tasks.handler.resolve_environment", spy_resolve_environment
302
+ )
303
+ monkeypatch.setattr(
304
+ "lambda_tasks.handler.resolve_secrets_into_env", spy_resolve_secrets
305
+ )
306
+ monkeypatch.setattr(django, "setup", spy_setup)
294
307
  monkeypatch.setattr(
295
308
  "lambda_tasks.models.SQSLambdaTaskMessage.model_validate_json",
296
309
  spy_model_validate,
297
310
  )
298
311
 
299
- importlib.reload(handler_module)
300
-
301
312
  body = json.dumps(
302
313
  {
303
314
  "task_name": "some.task",
@@ -305,17 +316,54 @@ def test_property_4_django_setup_before_execute_task(monkeypatch):
305
316
  }
306
317
  )
307
318
  event = {"Records": [{"messageId": "msg-1", "body": body}]}
319
+
320
+ # Reset the handler's cold-start guard so it runs init again
321
+ handler_module._cold_start_done = False
308
322
  handler_module.handler(event=event, context=None)
309
323
 
310
- assert (
311
- "django.setup" in call_order
312
- ), f"django.setup was never called; call_order={call_order}"
313
- setup_idx = call_order.index("django.setup")
314
- execute_idx = (
315
- call_order.index("execute_task")
316
- if "execute_task" in call_order
317
- else len(call_order)
324
+ assert call_order == [
325
+ "resolve_environment",
326
+ "resolve_secrets_into_env",
327
+ "django.setup",
328
+ "execute_task",
329
+ ], f"Unexpected call order: {call_order}"
330
+
331
+
332
+ def test_property_4_cold_start_runs_only_once(monkeypatch):
333
+ """The cold-start init sequence runs only on the first invocation."""
334
+ import django.apps
335
+
336
+ import lambda_tasks.handler as handler_module
337
+
338
+ call_count = {"setup": 0}
339
+
340
+ monkeypatch.setattr(django.apps.apps, "ready", False)
341
+ monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "tests.settings")
342
+
343
+ def spy_resolve_environment():
344
+ pass
345
+
346
+ def spy_resolve_secrets():
347
+ pass
348
+
349
+ def spy_setup(*args, **kwargs):
350
+ call_count["setup"] += 1
351
+
352
+ monkeypatch.setattr(
353
+ "lambda_tasks.handler.resolve_environment", spy_resolve_environment
318
354
  )
319
- assert (
320
- setup_idx < execute_idx
321
- ), f"Expected django.setup before execute_task, got order: {call_order}"
355
+ monkeypatch.setattr(
356
+ "lambda_tasks.handler.resolve_secrets_into_env", spy_resolve_secrets
357
+ )
358
+ monkeypatch.setattr(django, "setup", spy_setup)
359
+
360
+ with _patch_model_validate(side_effect=lambda body: MagicMock()):
361
+ handler_module._cold_start_done = False
362
+ handler_module.handler(
363
+ event={"Records": [_make_record("m1", _valid_body())]}, context=None
364
+ )
365
+ handler_module.handler(
366
+ event={"Records": [_make_record("m2", _valid_body())]}, context=None
367
+ )
368
+
369
+ assert call_count["setup"] == 1