django-lambda-tasks 0.6.7__tar.gz → 0.7.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 (105) hide show
  1. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/steering/product.md +1 -0
  2. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/steering/structure.md +1 -1
  3. django_lambda_tasks-0.6.7/README.md → django_lambda_tasks-0.7.0/PKG-INFO +14 -0
  4. django_lambda_tasks-0.6.7/PKG-INFO → django_lambda_tasks-0.7.0/README.md +2 -12
  5. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/handler.py +2 -0
  6. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/pyproject.toml +1 -1
  7. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_handler.py +76 -0
  8. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.github/workflows/ci.yml +0 -0
  9. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.github/workflows/release.yml +0 -0
  10. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.gitignore +0 -0
  11. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/async-local-execution/.config.kiro +0 -0
  12. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/async-local-execution/design.md +0 -0
  13. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/async-local-execution/requirements.md +0 -0
  14. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/async-local-execution/tasks.md +0 -0
  15. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  16. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  17. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  18. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  19. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  20. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  21. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  22. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  23. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  24. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  25. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  26. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  27. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  28. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  29. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  30. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  31. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/retry-delay/.config.kiro +0 -0
  32. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/retry-delay/design.md +0 -0
  33. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/retry-delay/requirements.md +0 -0
  34. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/retry-delay/tasks.md +0 -0
  35. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  36. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/rse-background-tasks/design.md +0 -0
  37. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  38. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  39. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  40. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  41. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  42. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  43. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/singleton-task/.config.kiro +0 -0
  44. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/singleton-task/design.md +0 -0
  45. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/singleton-task/requirements.md +0 -0
  46. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/singleton-task/tasks.md +0 -0
  47. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
  48. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/ssm-environment-loader/design.md +0 -0
  49. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
  50. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
  51. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/task-retry/.config.kiro +0 -0
  52. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/task-retry/design.md +0 -0
  53. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/task-retry/requirements.md +0 -0
  54. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/specs/task-retry/tasks.md +0 -0
  55. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.kiro/steering/tech.md +0 -0
  56. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.pre-commit-config.yaml +0 -0
  57. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/.vscode/settings.json +0 -0
  58. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/README.md +0 -0
  59. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/example_app/__init__.py +0 -0
  60. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/example_app/apps.py +0 -0
  61. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/example_app/tasks.py +0 -0
  62. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/example_app/urls.py +0 -0
  63. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/example_app/views.py +0 -0
  64. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/example_project/__init__.py +0 -0
  65. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/example_project/settings.py +0 -0
  66. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/example_project/urls.py +0 -0
  67. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/example_project/wsgi.py +0 -0
  68. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/example/manage.py +0 -0
  69. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/__init__.py +0 -0
  70. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/admin.py +0 -0
  71. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/apps.py +0 -0
  72. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/checks.py +0 -0
  73. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/decorators.py +0 -0
  74. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/environment_loader.py +0 -0
  75. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/local_executor.py +0 -0
  76. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/logging.py +0 -0
  77. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/migrations/0001_initial.py +0 -0
  78. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
  79. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/migrations/__init__.py +0 -0
  80. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/models.py +0 -0
  81. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/secret_loader.py +0 -0
  82. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/settings.py +0 -0
  83. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/tasks.py +0 -0
  84. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/lambda_tasks/timeouts.py +0 -0
  85. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/conftest.py +0 -0
  86. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/settings.py +0 -0
  87. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_admin.py +0 -0
  88. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_batch.py +0 -0
  89. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_batch_worker.py +0 -0
  90. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_decorator.py +0 -0
  91. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_decorators.py +0 -0
  92. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_deferred_enqueue.py +0 -0
  93. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_environment_loader.py +0 -0
  94. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_kwargs_only.py +0 -0
  95. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_local_executor.py +0 -0
  96. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_logging.py +0 -0
  97. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_memory_limit.py +0 -0
  98. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_models.py +0 -0
  99. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_noop_execution.py +0 -0
  100. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_secret_loader.py +0 -0
  101. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_serializer.py +0 -0
  102. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_settings.py +0 -0
  103. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_tasks.py +0 -0
  104. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_timeout_validation.py +0 -0
  105. {django_lambda_tasks-0.6.7 → django_lambda_tasks-0.7.0}/tests/test_timeouts.py +0 -0
@@ -143,6 +143,7 @@ class SQSLambdaTaskMessage(BaseModel):
143
143
 
144
144
  `handler(event, context)` in `handler.py`:
145
145
  - Processes each SQS record independently
146
+ - Calls `django.db.close_old_connections()` before each record to close database connections that have become unusable between warm invocations (idle timeouts, RDS failovers, network interruptions)
146
147
  - Passes `record["messageId"]` as `message_id` to `execute_immediately()` — this becomes the `TaskRecord` primary key, enabling deduplication on redelivery
147
148
  - Returns `{"batchItemFailures": [...]}` for partial-batch failure reporting
148
149
  - Only pre-execution failures (malformed message, import error, misconfiguration) are reported as `batchItemFailures` — task logic failures are caught and recorded as `FAILED` TaskRecords without raising
@@ -43,7 +43,7 @@ django-lambda-tasks/
43
43
  - `decorators.py` — defines `LambdaTaskWrapper` and `@lambda_task` decorator factory; enforces kwargs-only at decoration time; validates timeouts (> 0, soft < hard) at decoration time; upper-bound timeout validation deferred to execution time based on queue type
44
44
  - `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (Pydantic, SQS schema + execution logic), `SQSLambdaTask` (Pydantic, holds message + routing; `_execute()` inspects queue config to publish to SQS, submit to Batch via `submit_batch_job`, execute eagerly, or submit to the local process pool; `execute_on_commit()` registers `_execute` with `transaction.on_commit`)
45
45
  - `local_executor.py` — `ProcessPoolExecutor`-based async local execution; `get_pool()` lazily creates a module-level pool; `submit_task()` fire-and-forget submission; `_execute_in_worker()` deserializes and runs the task in a child process; `_pool_initializer()` calls `django.setup()` per worker (and sets `SIGINT` to `SIG_IGN` so workers ignore Ctrl+C); `_install_shutdown_handlers()` installs main-thread `SIGINT`/`SIGTERM` handlers that release the pool before chaining to the previous handler
46
- - `handler.py` — Lambda entry point (`handler(event, context)`); cold-start init (memory limit → temporary log handler → `resolve_environment()` → `resolve_secrets_into_env()` → handler removed → conditional `django.setup()`) runs inside the handler on first invocation, guarded by `_cold_start_done` sentinel; processes SQS records independently; returns `batchItemFailures`. Also provides `main()` as the AWS Batch container entry point (`python -m lambda_tasks.handler`) — reads `LAMBDA_TASKS_MESSAGE` env var, constructs a synthetic SQS event, and delegates to `handler()`
46
+ - `handler.py` — Lambda entry point (`handler(event, context)`); cold-start init (memory limit → temporary log handler → `resolve_environment()` → `resolve_secrets_into_env()` → handler removed → conditional `django.setup()`) runs inside the handler on first invocation, guarded by `_cold_start_done` sentinel; calls `db.close_old_connections()` before each SQS record to handle stale connections on warm starts; processes SQS records independently; returns `batchItemFailures`. Also provides `main()` as the AWS Batch container entry point (`python -m lambda_tasks.handler`) — reads `LAMBDA_TASKS_MESSAGE` env var, constructs a synthetic SQS event, and delegates to `handler()`
47
47
  - `environment_loader.py` — loads env vars from a Secrets Manager secret at cold start; validates flat JSON format; idempotent via `_loaded` sentinel
48
48
  - `secret_loader.py` — resolves `LAMBDA_TASKS_SECRET_*` env vars from Secrets Manager before Django starts; validates format, detects conflicts, batches API calls; idempotent via `_loaded` sentinel
49
49
  - `logging.py` — `task_logger` singleton; `message_id` set/cleared around each task execution
@@ -1,3 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-lambda-tasks
3
+ Version: 0.7.0
4
+ Summary: Run async tasks in a lambda function
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: awslambdaric
7
+ Requires-Dist: boto3
8
+ Requires-Dist: django
9
+ Requires-Dist: pydantic
10
+ Requires-Dist: redis
11
+ Description-Content-Type: text/markdown
12
+
1
13
  # Django Lambda Tasks
2
14
 
3
15
  A Django library for offloading work to AWS Lambda or AWS Batch outside of the request-response cycle. Tasks are defined with a decorator, enqueued to SQS on transaction commit, and executed by a Lambda handler or Batch container. Task results, status, and metadata are persisted in the Django database.
@@ -535,6 +547,8 @@ lambda_tasks.handler.handler
535
547
 
536
548
  The handler performs cold-start initialisation on the first invocation (not at module import time) to avoid Lambda init-duration timeouts. The sequence is: temporary log handler attached → `resolve_environment()` → `resolve_secrets_into_env()` → log handler removed → conditional `django.setup()`. A module-level sentinel ensures this runs only once; subsequent warm invocations skip it entirely.
537
549
 
550
+ Before processing each SQS record, the handler calls `django.db.close_old_connections()` to close any database connections that have become unusable between invocations. Lambda containers are reused across invocations (warm starts), but database connections may be closed server-side due to idle timeouts, RDS failovers, or network interruptions. Without this, the first database query in a task would fail with `OperationalError: the connection is closed`.
551
+
538
552
  Ensure the Lambda execution environment has `DJANGO_SETTINGS_MODULE` set and that all task modules are importable (i.e. your application code is on the Python path).
539
553
 
540
554
  | Environment Variable | Required | Description |
@@ -1,15 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: django-lambda-tasks
3
- Version: 0.6.7
4
- Summary: Run async tasks in a lambda function
5
- Requires-Python: >=3.10
6
- Requires-Dist: awslambdaric
7
- Requires-Dist: boto3
8
- Requires-Dist: django
9
- Requires-Dist: pydantic
10
- Requires-Dist: redis
11
- Description-Content-Type: text/markdown
12
-
13
1
  # Django Lambda Tasks
14
2
 
15
3
  A Django library for offloading work to AWS Lambda or AWS Batch outside of the request-response cycle. Tasks are defined with a decorator, enqueued to SQS on transaction commit, and executed by a Lambda handler or Batch container. Task results, status, and metadata are persisted in the Django database.
@@ -547,6 +535,8 @@ lambda_tasks.handler.handler
547
535
 
548
536
  The handler performs cold-start initialisation on the first invocation (not at module import time) to avoid Lambda init-duration timeouts. The sequence is: temporary log handler attached → `resolve_environment()` → `resolve_secrets_into_env()` → log handler removed → conditional `django.setup()`. A module-level sentinel ensures this runs only once; subsequent warm invocations skip it entirely.
549
537
 
538
+ Before processing each SQS record, the handler calls `django.db.close_old_connections()` to close any database connections that have become unusable between invocations. Lambda containers are reused across invocations (warm starts), but database connections may be closed server-side due to idle timeouts, RDS failovers, or network interruptions. Without this, the first database query in a task would fail with `OperationalError: the connection is closed`.
539
+
550
540
  Ensure the Lambda execution environment has `DJANGO_SETTINGS_MODULE` set and that all task modules are importable (i.e. your application code is on the Python path).
551
541
 
552
542
  | Environment Variable | Required | Description |
@@ -21,6 +21,7 @@ import urllib.request
21
21
  import uuid
22
22
 
23
23
  import django
24
+ from django import db
24
25
  from django.apps import apps
25
26
 
26
27
  from lambda_tasks.environment_loader import resolve_environment
@@ -132,6 +133,7 @@ def handler(event: dict, context: object) -> dict:
132
133
 
133
134
  for record in event["Records"]:
134
135
  try:
136
+ db.close_old_connections()
135
137
  SQSLambdaTaskMessage.model_validate_json(
136
138
  record["body"]
137
139
  ).execute_immediately(message_id=record["messageId"])
@@ -7,7 +7,7 @@ packages = ["lambda_tasks"]
7
7
 
8
8
  [project]
9
9
  name = "django-lambda-tasks"
10
- version = "0.6.7"
10
+ version = "0.7.0"
11
11
  description = "Run async tasks in a lambda function"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.10"
@@ -26,6 +26,14 @@ from hypothesis import strategies as st
26
26
 
27
27
  from lambda_tasks.handler import handler
28
28
 
29
+
30
+ @pytest.fixture(autouse=True)
31
+ def _patch_close_old_connections():
32
+ """Prevent close_old_connections from triggering the DB access guard in unit tests."""
33
+ with patch("lambda_tasks.handler.db.close_old_connections"):
34
+ yield
35
+
36
+
29
37
  # ---------------------------------------------------------------------------
30
38
  # Signature test
31
39
  # ---------------------------------------------------------------------------
@@ -457,3 +465,71 @@ class TestColdStartLogging:
457
465
 
458
466
  assert lambda_tasks_logger.handlers == []
459
467
  assert lambda_tasks_logger.level == logging.NOTSET
468
+
469
+
470
+ # ---------------------------------------------------------------------------
471
+ # Stale connection handling
472
+ # ---------------------------------------------------------------------------
473
+
474
+
475
+ class TestHandlerClosesOldConnections:
476
+ def test_close_old_connections_called_before_each_record(self):
477
+ """close_old_connections() is called before processing each SQS record."""
478
+ call_order = []
479
+
480
+ def _track_close():
481
+ call_order.append("close")
482
+
483
+ def _make_message(body: str):
484
+ msg = MagicMock()
485
+
486
+ def _execute(**kwargs):
487
+ call_order.append("execute")
488
+
489
+ msg.execute_immediately.side_effect = _execute
490
+ return msg
491
+
492
+ records = [
493
+ _make_record("msg-1", _valid_body()),
494
+ _make_record("msg-2", _valid_body()),
495
+ ]
496
+
497
+ with (
498
+ patch(
499
+ "lambda_tasks.handler.db.close_old_connections",
500
+ side_effect=_track_close,
501
+ ),
502
+ _patch_model_validate(_make_message),
503
+ ):
504
+ handler(event={"Records": records}, context=None)
505
+
506
+ assert call_order == ["close", "execute", "close", "execute"]
507
+
508
+ def test_close_old_connections_called_even_when_record_fails(self):
509
+ """close_old_connections() is called before each record, including ones that fail."""
510
+ close_count = 0
511
+
512
+ def _track_close():
513
+ nonlocal close_count
514
+ close_count += 1
515
+
516
+ def _make_message(body: str):
517
+ msg = MagicMock()
518
+ msg.execute_immediately.side_effect = RuntimeError("boom")
519
+ return msg
520
+
521
+ records = [
522
+ _make_record("msg-1", _valid_body()),
523
+ _make_record("msg-2", _valid_body()),
524
+ ]
525
+
526
+ with (
527
+ patch(
528
+ "lambda_tasks.handler.db.close_old_connections",
529
+ side_effect=_track_close,
530
+ ),
531
+ _patch_model_validate(_make_message),
532
+ ):
533
+ handler(event={"Records": records}, context=None)
534
+
535
+ assert close_count == 2