simple-module-background-tasks 0.0.36__tar.gz → 0.0.38__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 (188) hide show
  1. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/.gitignore +2 -2
  2. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/PKG-INFO +28 -5
  3. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/README.md +23 -0
  4. simple_module_background_tasks-0.0.38/background_tasks/__init__.py +39 -0
  5. simple_module_background_tasks-0.0.38/background_tasks/_lifecycle.py +174 -0
  6. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/_signal_bus.py +2 -1
  7. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/_signal_support.py +52 -1
  8. simple_module_background_tasks-0.0.38/background_tasks/after_commit.py +137 -0
  9. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/celery_app.py +70 -4
  10. simple_module_background_tasks-0.0.38/background_tasks/config_guards.py +220 -0
  11. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/constants.py +96 -3
  12. simple_module_background_tasks-0.0.38/background_tasks/contracts/logs.py +31 -0
  13. simple_module_background_tasks-0.0.38/background_tasks/contracts/outbox.py +40 -0
  14. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/contracts/schemas.py +52 -3
  15. simple_module_background_tasks-0.0.38/background_tasks/db_settings.py +34 -0
  16. simple_module_background_tasks-0.0.38/background_tasks/deps.py +60 -0
  17. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/endpoints/api_admin.py +5 -4
  18. simple_module_background_tasks-0.0.38/background_tasks/endpoints/api_status.py +69 -0
  19. simple_module_background_tasks-0.0.38/background_tasks/endpoints/logs_api.py +62 -0
  20. simple_module_background_tasks-0.0.38/background_tasks/endpoints/logs_views.py +44 -0
  21. simple_module_background_tasks-0.0.38/background_tasks/endpoints/outbox_api.py +62 -0
  22. simple_module_background_tasks-0.0.38/background_tasks/endpoints/outbox_views.py +43 -0
  23. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/endpoints/views.py +18 -5
  24. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/filters.py +3 -3
  25. simple_module_background_tasks-0.0.38/background_tasks/locales/de.json +222 -0
  26. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/locales/en.json +71 -7
  27. simple_module_background_tasks-0.0.38/background_tasks/log_purge.py +16 -0
  28. simple_module_background_tasks-0.0.38/background_tasks/log_service.py +85 -0
  29. simple_module_background_tasks-0.0.38/background_tasks/log_sink.py +136 -0
  30. simple_module_background_tasks-0.0.38/background_tasks/models.py +221 -0
  31. simple_module_background_tasks-0.0.38/background_tasks/module.py +180 -0
  32. simple_module_background_tasks-0.0.38/background_tasks/outbox_claim.py +199 -0
  33. simple_module_background_tasks-0.0.38/background_tasks/outbox_clock.py +53 -0
  34. simple_module_background_tasks-0.0.38/background_tasks/outbox_dispatch.py +250 -0
  35. simple_module_background_tasks-0.0.38/background_tasks/outbox_guard.py +113 -0
  36. simple_module_background_tasks-0.0.38/background_tasks/outbox_loop.py +130 -0
  37. simple_module_background_tasks-0.0.38/background_tasks/outbox_service.py +156 -0
  38. simple_module_background_tasks-0.0.38/background_tasks/outbox_support.py +100 -0
  39. simple_module_background_tasks-0.0.38/background_tasks/outbox_sweep.py +58 -0
  40. simple_module_background_tasks-0.0.38/background_tasks/outbox_transport.py +195 -0
  41. simple_module_background_tasks-0.0.38/background_tasks/outbox_worker.py +186 -0
  42. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/Detail.tsx +28 -11
  43. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/Index.tsx +20 -14
  44. simple_module_background_tasks-0.0.38/background_tasks/pages/Logs.tsx +92 -0
  45. simple_module_background_tasks-0.0.38/background_tasks/pages/Outbox.tsx +120 -0
  46. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/Workers.tsx +21 -23
  47. simple_module_background_tasks-0.0.38/background_tasks/pages/api.generated.ts +965 -0
  48. simple_module_background_tasks-0.0.38/background_tasks/pages/api.ts +9 -0
  49. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/ExecutionsTable.tsx +2 -33
  50. simple_module_background_tasks-0.0.38/background_tasks/pages/components/LogLevelSelect.tsx +29 -0
  51. simple_module_background_tasks-0.0.38/background_tasks/pages/components/LogTable.tsx +76 -0
  52. simple_module_background_tasks-0.0.38/background_tasks/pages/components/LogsPanel.tsx +46 -0
  53. simple_module_background_tasks-0.0.38/background_tasks/pages/components/OutboxTable.tsx +154 -0
  54. simple_module_background_tasks-0.0.38/background_tasks/pages/components/ProgressCard.tsx +43 -0
  55. simple_module_background_tasks-0.0.38/background_tasks/pages/components/RoutesTable.tsx +60 -0
  56. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/WorkerHealthBanner.tsx +12 -10
  57. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/constants.ts +15 -0
  58. simple_module_background_tasks-0.0.38/background_tasks/pages/logs.ts +53 -0
  59. simple_module_background_tasks-0.0.38/background_tasks/pages/outbox.ts +94 -0
  60. simple_module_background_tasks-0.0.38/background_tasks/pages/retry.ts +111 -0
  61. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/retry_service.py +90 -39
  62. simple_module_background_tasks-0.0.38/background_tasks/runner.py +85 -0
  63. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/services.py +4 -0
  64. simple_module_background_tasks-0.0.38/background_tasks/settings.py +147 -0
  65. simple_module_background_tasks-0.0.38/background_tasks/settings_celery.py +201 -0
  66. simple_module_background_tasks-0.0.38/background_tasks/settings_env_checks.py +68 -0
  67. simple_module_background_tasks-0.0.38/background_tasks/settings_logs.py +45 -0
  68. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/signals.py +20 -44
  69. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/sync_db.py +98 -13
  70. simple_module_background_tasks-0.0.38/background_tasks/task_heartbeat.py +147 -0
  71. simple_module_background_tasks-0.0.38/background_tasks/task_routing.py +158 -0
  72. simple_module_background_tasks-0.0.38/background_tasks/tasks.py +181 -0
  73. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/tenant_context.py +86 -6
  74. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/worker_settings.py +29 -2
  75. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/package.json +2 -1
  76. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/pyproject.toml +5 -5
  77. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/conftest.py +45 -1
  78. simple_module_background_tasks-0.0.38/tests/test_background_tasks_after_commit.py +262 -0
  79. simple_module_background_tasks-0.0.38/tests/test_background_tasks_heartbeat.py +158 -0
  80. simple_module_background_tasks-0.0.38/tests/test_background_tasks_settings.py +96 -0
  81. simple_module_background_tasks-0.0.38/tests/test_bg_config_guards.py +159 -0
  82. simple_module_background_tasks-0.0.38/tests/test_bg_db_timeouts.py +95 -0
  83. simple_module_background_tasks-0.0.38/tests/test_bg_index_has_executions.py +62 -0
  84. simple_module_background_tasks-0.0.38/tests/test_bg_inprocess_sweep.py +187 -0
  85. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_invalidation.py +1 -1
  86. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_invalidation_degraded.py +12 -8
  87. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_invalidation_redis.py +49 -32
  88. simple_module_background_tasks-0.0.38/tests/test_bg_log_api.py +118 -0
  89. simple_module_background_tasks-0.0.38/tests/test_bg_log_purge.py +45 -0
  90. simple_module_background_tasks-0.0.38/tests/test_bg_log_sink.py +102 -0
  91. simple_module_background_tasks-0.0.38/tests/test_bg_log_sink_loop.py +60 -0
  92. simple_module_background_tasks-0.0.38/tests/test_bg_log_sink_nul.py +26 -0
  93. simple_module_background_tasks-0.0.38/tests/test_bg_outbox_eager_platform.py +95 -0
  94. simple_module_background_tasks-0.0.38/tests/test_bg_outbox_fence_guard.py +179 -0
  95. simple_module_background_tasks-0.0.38/tests/test_bg_outbox_guard_savepoint.py +148 -0
  96. simple_module_background_tasks-0.0.38/tests/test_bg_outbox_loop_interrupt.py +42 -0
  97. simple_module_background_tasks-0.0.38/tests/test_bg_outbox_publish_scope.py +122 -0
  98. simple_module_background_tasks-0.0.38/tests/test_bg_outbox_retry_policy.py +203 -0
  99. simple_module_background_tasks-0.0.38/tests/test_bg_outbox_swallowed_cancel.py +53 -0
  100. simple_module_background_tasks-0.0.38/tests/test_bg_outbox_thread_loops.py +93 -0
  101. simple_module_background_tasks-0.0.38/tests/test_bg_outbox_worker_chain.py +199 -0
  102. simple_module_background_tasks-0.0.38/tests/test_bg_pagination_bounds.py +51 -0
  103. simple_module_background_tasks-0.0.38/tests/test_bg_quiet_sweep_unrecorded.py +62 -0
  104. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_redis_url.py +43 -2
  105. simple_module_background_tasks-0.0.38/tests/test_bg_routes_override_api.py +46 -0
  106. simple_module_background_tasks-0.0.38/tests/test_bg_runner_settings.py +177 -0
  107. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_service.py +11 -9
  108. simple_module_background_tasks-0.0.38/tests/test_bg_startup_log_masking.py +26 -0
  109. simple_module_background_tasks-0.0.38/tests/test_bg_task_progress.py +139 -0
  110. simple_module_background_tasks-0.0.38/tests/test_bg_task_progress_audit.py +53 -0
  111. simple_module_background_tasks-0.0.38/tests/test_bg_task_routing.py +258 -0
  112. simple_module_background_tasks-0.0.38/tests/test_bg_task_status_api.py +122 -0
  113. simple_module_background_tasks-0.0.38/tests/test_bg_users_ordering.py +30 -0
  114. simple_module_background_tasks-0.0.38/tests/test_bg_worker_audit.py +129 -0
  115. simple_module_background_tasks-0.0.38/tests/test_bg_worker_audit_bookkeeping.py +96 -0
  116. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_worker_settings.py +1 -1
  117. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_execution_tenancy.py +2 -2
  118. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_log_context.py +5 -5
  119. simple_module_background_tasks-0.0.38/tests/test_outbox_admin.py +150 -0
  120. simple_module_background_tasks-0.0.38/tests/test_outbox_concurrency.py +198 -0
  121. simple_module_background_tasks-0.0.38/tests/test_outbox_delivery.py +253 -0
  122. simple_module_background_tasks-0.0.38/tests/test_outbox_tenancy.py +95 -0
  123. simple_module_background_tasks-0.0.38/tests/test_outbox_worker_task.py +147 -0
  124. simple_module_background_tasks-0.0.38/tests/test_sync_db_fork.py +89 -0
  125. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_worker_inspector.py +4 -1
  126. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_workers_endpoints.py +40 -0
  127. simple_module_background_tasks-0.0.38/tests-js/background_tasks_DetailPage.test.tsx +158 -0
  128. simple_module_background_tasks-0.0.38/tests-js/background_tasks_IndexPage.test.tsx +180 -0
  129. simple_module_background_tasks-0.0.38/tests-js/background_tasks_LogsTab.test.tsx +56 -0
  130. simple_module_background_tasks-0.0.38/tests-js/background_tasks_OutboxPage.test.tsx +177 -0
  131. simple_module_background_tasks-0.0.38/tests-js/background_tasks_WorkerHealthBanner.test.tsx +63 -0
  132. simple_module_background_tasks-0.0.38/tests-js/outbox.test.ts +50 -0
  133. simple_module_background_tasks-0.0.38/tests-js/retry.test.ts +31 -0
  134. simple_module_background_tasks-0.0.38/tests-js/stub-fetch.ts +18 -0
  135. simple_module_background_tasks-0.0.36/background_tasks/__init__.py +0 -15
  136. simple_module_background_tasks-0.0.36/background_tasks/deps.py +0 -19
  137. simple_module_background_tasks-0.0.36/background_tasks/models.py +0 -95
  138. simple_module_background_tasks-0.0.36/background_tasks/module.py +0 -258
  139. simple_module_background_tasks-0.0.36/background_tasks/pages/retry.ts +0 -98
  140. simple_module_background_tasks-0.0.36/background_tasks/settings.py +0 -188
  141. simple_module_background_tasks-0.0.36/background_tasks/tasks.py +0 -105
  142. simple_module_background_tasks-0.0.36/tests/test_background_tasks_settings.py +0 -27
  143. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/LICENSE +0 -0
  144. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/contracts/__init__.py +0 -0
  145. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/contracts/events.py +0 -0
  146. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/endpoints/__init__.py +0 -0
  147. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/health.py +0 -0
  148. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/invalidation.py +0 -0
  149. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/log_context.py +0 -0
  150. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/DetailFacts.tsx +0 -0
  151. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
  152. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/PayloadCards.tsx +0 -0
  153. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/RetryAllDialog.tsx +0 -0
  154. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
  155. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/StatusPill.tsx +0 -0
  156. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/StatusStrip.tsx +0 -0
  157. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/TaskFilters.tsx +0 -0
  158. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/TasksEmpty.tsx +0 -0
  159. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/TracebackCard.tsx +0 -0
  160. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/WorkerCard.tsx +0 -0
  161. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/WorkerEmptyStates.tsx +0 -0
  162. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/worker-health.ts +0 -0
  163. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/py.typed +0 -0
  164. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/service.py +0 -0
  165. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/worker_inspector.py +0 -0
  166. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/workers_state.py +0 -0
  167. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_admin_api.py +0 -0
  168. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_background_tasks_tz_columns.py +0 -0
  169. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_beat_schedule.py +0 -0
  170. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_index_worker_presence.py +0 -0
  171. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_list_item_payload.py +0 -0
  172. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_page_clamp.py +0 -0
  173. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_redis_health.py +0 -0
  174. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_settings_env.py +0 -0
  175. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_detail_props.py +0 -0
  176. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_index_filters.py +0 -0
  177. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_retry_failed_bulk.py +0 -0
  178. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_retry_failed_guards.py +0 -0
  179. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_retry_sweep_ordering.py +0 -0
  180. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_signal_tenant_stamp.py +0 -0
  181. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_signals.py +0 -0
  182. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_sweep_stuck.py +0 -0
  183. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_sync_db.py +0 -0
  184. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_tenant_context.py +0 -0
  185. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_worker_tenancy.py +0 -0
  186. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_workers_uptime.py +0 -0
  187. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests-js/task-formatters.test.ts +0 -0
  188. {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tsconfig.json +0 -0
@@ -7,8 +7,6 @@ build/
7
7
  .venv/
8
8
  *.egg
9
9
 
10
- # UV
11
- uv.lock
12
10
 
13
11
  # Node
14
12
  node_modules/
@@ -64,3 +62,5 @@ host/client_app/.playwright-cli/*
64
62
  .superpowers/
65
63
  .qa/
66
64
  .verify/
65
+ /openapi.json
66
+ qa-shots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_background_tasks
3
- Version: 0.0.36
3
+ Version: 0.0.38
4
4
  Summary: Celery + Redis task queue with admin UI for monitoring and retrying failed/stuck tasks
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -23,10 +23,10 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: celery[redis]>=5.4
25
25
  Requires-Dist: redis>=5
26
- Requires-Dist: simple-module-core==0.0.36
27
- Requires-Dist: simple-module-db==0.0.36
28
- Requires-Dist: simple-module-hosting==0.0.36
29
- Requires-Dist: simple-module-settings==0.0.36
26
+ Requires-Dist: simple-module-core==0.0.38
27
+ Requires-Dist: simple-module-db==0.0.38
28
+ Requires-Dist: simple-module-hosting==0.0.38
29
+ Requires-Dist: simple-module-settings==0.0.38
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  # simple_module_background_tasks
@@ -46,6 +46,8 @@ Requires a Redis broker. The broker / result-backend URLs are module settings (`
46
46
  - Zero-config task discovery — any installed module that ships a `tasks.py` has its tasks autodiscovered (`celery.autodiscover_tasks` imports `<package>.tasks` for every installed module). No per-module registration hook.
47
47
  - Admin UI at `/admin/background-tasks` — list recent runs, retry failed, inspect tracebacks (gated by the `background_tasks.view` permission).
48
48
  - `build_celery(settings)` factory in `background_tasks.celery_app`, plus `bind_task_context` / `get_log_context` / `install_log_filter` exported from the package root (import name `background_tasks`, distribution name `simple_module_background_tasks`).
49
+ - `enqueue_after_commit(db, task, *args, **kwargs)` — publish a task only once the session's transaction commits, never on rollback.
50
+ - `heartbeat()` — called from a long task body, keeps the stuck sweep from flagging it; `@shared_task(stuck_after=seconds)` sets a per-task stuck threshold. See the module docs for both.
49
51
 
50
52
  ## Usage
51
53
 
@@ -80,6 +82,27 @@ uv run celery -A scripts.run_worker:celery worker -l info
80
82
  uv run celery -A scripts.run_worker:celery beat -l info
81
83
  ```
82
84
 
85
+ ## Routing, tuning and per-worker module subsets
86
+
87
+ A module can ship a light `task_config.py` (`TASK_ROUTES`, `QUEUES`, optional
88
+ `BEAT_SCHEDULE`) that is read without importing `tasks.py`. The
89
+ `task_routes_override` setting re-routes tasks at runtime, and the Workers page
90
+ lists the effective routes. Tuning settings (`visibility_timeout_seconds`,
91
+ `worker_max_tasks_per_child`, `task_time_limit`, `worker_concurrency`, ...) and a
92
+ raw `SM_BG_TASKS_CELERY_OVERRIDES` (env only, unsafe keys refused) default to Celery's own defaults.
93
+ `SM_BG_TASKS_WORKER_QUEUES=gpu,default make worker` consumes only those queues
94
+ and imports only the modules whose `QUEUES` overlap (see `SM_BG_TASKS_INCLUDE_MODULES` /
95
+ `SM_BG_TASKS_EXCLUDE_MODULES`; env only, not DB settings). Full details in `docs/modules/background_tasks.md`.
96
+
97
+ ## Durable events
98
+
99
+ This module is the transport behind `bus.publish_durable(event, db)`: the event
100
+ is written to an outbox table in the caller's transaction and delivered by a
101
+ worker to every `bus.subscribe_durable(...)` handler, at least once, with
102
+ backoff, after the commit. Writes a handler makes through `ctx.db` land exactly
103
+ once. Undelivered and dead rows are on `/admin/background-tasks/outbox`. Full
104
+ details in `docs/modules/background_tasks.md` § Durable events.
105
+
83
106
  ## Worker log context
84
107
 
85
108
  Every worker log line automatically carries the Celery task identifiers
@@ -15,6 +15,8 @@ Requires a Redis broker. The broker / result-backend URLs are module settings (`
15
15
  - Zero-config task discovery — any installed module that ships a `tasks.py` has its tasks autodiscovered (`celery.autodiscover_tasks` imports `<package>.tasks` for every installed module). No per-module registration hook.
16
16
  - Admin UI at `/admin/background-tasks` — list recent runs, retry failed, inspect tracebacks (gated by the `background_tasks.view` permission).
17
17
  - `build_celery(settings)` factory in `background_tasks.celery_app`, plus `bind_task_context` / `get_log_context` / `install_log_filter` exported from the package root (import name `background_tasks`, distribution name `simple_module_background_tasks`).
18
+ - `enqueue_after_commit(db, task, *args, **kwargs)` — publish a task only once the session's transaction commits, never on rollback.
19
+ - `heartbeat()` — called from a long task body, keeps the stuck sweep from flagging it; `@shared_task(stuck_after=seconds)` sets a per-task stuck threshold. See the module docs for both.
18
20
 
19
21
  ## Usage
20
22
 
@@ -49,6 +51,27 @@ uv run celery -A scripts.run_worker:celery worker -l info
49
51
  uv run celery -A scripts.run_worker:celery beat -l info
50
52
  ```
51
53
 
54
+ ## Routing, tuning and per-worker module subsets
55
+
56
+ A module can ship a light `task_config.py` (`TASK_ROUTES`, `QUEUES`, optional
57
+ `BEAT_SCHEDULE`) that is read without importing `tasks.py`. The
58
+ `task_routes_override` setting re-routes tasks at runtime, and the Workers page
59
+ lists the effective routes. Tuning settings (`visibility_timeout_seconds`,
60
+ `worker_max_tasks_per_child`, `task_time_limit`, `worker_concurrency`, ...) and a
61
+ raw `SM_BG_TASKS_CELERY_OVERRIDES` (env only, unsafe keys refused) default to Celery's own defaults.
62
+ `SM_BG_TASKS_WORKER_QUEUES=gpu,default make worker` consumes only those queues
63
+ and imports only the modules whose `QUEUES` overlap (see `SM_BG_TASKS_INCLUDE_MODULES` /
64
+ `SM_BG_TASKS_EXCLUDE_MODULES`; env only, not DB settings). Full details in `docs/modules/background_tasks.md`.
65
+
66
+ ## Durable events
67
+
68
+ This module is the transport behind `bus.publish_durable(event, db)`: the event
69
+ is written to an outbox table in the caller's transaction and delivered by a
70
+ worker to every `bus.subscribe_durable(...)` handler, at least once, with
71
+ backoff, after the commit. Writes a handler makes through `ctx.db` land exactly
72
+ once. Undelivered and dead rows are on `/admin/background-tasks/outbox`. Full
73
+ details in `docs/modules/background_tasks.md` § Durable events.
74
+
52
75
  ## Worker log context
53
76
 
54
77
  Every worker log line automatically carries the Celery task identifiers
@@ -0,0 +1,39 @@
1
+ """BackgroundTasks module — Celery + Redis task queue with admin UI."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from background_tasks.after_commit import enqueue_after_commit
6
+ from background_tasks.log_context import (
7
+ bind_task_context,
8
+ get_log_context,
9
+ install_log_filter,
10
+ )
11
+
12
+ if TYPE_CHECKING:
13
+ from background_tasks.task_heartbeat import heartbeat
14
+ from background_tasks.worker_settings import settings_for
15
+
16
+ _LAZY_HEARTBEAT = "heartbeat"
17
+ _LAZY_SETTINGS = "settings_for"
18
+
19
+
20
+ def __getattr__(name: str) -> Any:
21
+ if name == _LAZY_HEARTBEAT:
22
+ from background_tasks.task_heartbeat import heartbeat
23
+
24
+ return heartbeat
25
+ if name == _LAZY_SETTINGS:
26
+ from background_tasks.worker_settings import settings_for
27
+
28
+ return settings_for
29
+ raise AttributeError(name)
30
+
31
+
32
+ __all__ = [
33
+ "bind_task_context",
34
+ "enqueue_after_commit",
35
+ "get_log_context",
36
+ "heartbeat",
37
+ "install_log_filter",
38
+ "settings_for",
39
+ ]
@@ -0,0 +1,174 @@
1
+ """Startup/shutdown wiring for the BackgroundTasks module.
2
+
3
+ ``BackgroundTasksModule`` keeps the ``on_startup`` / ``on_shutdown`` hooks and
4
+ delegates here, so the module file stays a declaration of what the module
5
+ contributes and this file owns the order things are installed and torn down.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from fastapi import FastAPI
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ async def startup(app: FastAPI, module_name: str) -> None:
20
+ """Build the Celery app, install signal handlers, hand-off to worker."""
21
+ import asyncio
22
+
23
+ from simple_module_hosting.log_sinks import redact
24
+
25
+ from background_tasks.celery_app import build_celery
26
+ from background_tasks.db_settings import worker_database_options
27
+ from background_tasks.log_sink import install_log_sink
28
+ from background_tasks.signals import bind_event_bus
29
+ from background_tasks.sync_db import configure_database, set_database_url
30
+ from background_tasks.tenant_context import set_default_tenant
31
+
32
+ services = app.state.background_tasks
33
+ # Pin the sync engine to the same URL the host's async settings
34
+ # resolved — pydantic-settings reads ``.env`` but never propagates
35
+ # to ``os.environ``, so signals would otherwise fall back to the
36
+ # SQLite default and silently drop ``TaskExecution`` rows.
37
+ host = app.state.sm.settings
38
+ multi = bool(getattr(host, "multi_tenant", False))
39
+ default_tenant = None if multi else getattr(host, "default_tenant", "") or None
40
+ set_database_url(host.database_url, tenant_strict=multi, default_tenant=default_tenant)
41
+ configure_database(worker_database_options(host, services.settings))
42
+ set_default_tenant(default_tenant)
43
+ # build_celery imports `signals` for side effects and runs
44
+ # `autodiscover_tasks` across every installed module.
45
+ services.celery = build_celery(services.settings)
46
+ # Let signal handlers hop onto the API loop to publish events.
47
+ # In a standalone worker process this bind never runs, so signals
48
+ # stay a silent no-op — that's the documented cross-process limit.
49
+ bind_event_bus(app.state.sm.event_bus, asyncio.get_running_loop())
50
+ install_durable_transport(app)
51
+ install_log_sink(services.settings)
52
+ logger.info(
53
+ "BackgroundTasks: Celery app ready (broker=%s, queue=%s)",
54
+ redact(services.settings.broker_url),
55
+ services.settings.task_default_queue,
56
+ )
57
+
58
+ await start_invalidation_transport(app)
59
+
60
+ # Registered here, not in register_health_checks: the Celery app does
61
+ # not exist until this hook builds it, and the check must follow later
62
+ # settings changes rather than pinning the boot-time instance.
63
+ from simple_module_core.health import HealthCheck
64
+
65
+ from background_tasks.health import CHECK_REDIS, build_redis_check
66
+
67
+ app.state.sm.health_registry.add(
68
+ HealthCheck(
69
+ name=CHECK_REDIS,
70
+ check=build_redis_check(app),
71
+ module=module_name,
72
+ # On demand only, like the mailer and storage checks. An
73
+ # earlier version had this on the readiness probe, reasoning
74
+ # that Redis is infrastructure this app owns rather than a
75
+ # third party. That conflates two different questions: whether
76
+ # this process can serve requests, and whether the system is
77
+ # fully functional. Failing readiness pulls the web tier out of
78
+ # the load balancer, turning "background jobs are backed up"
79
+ # into "the site is down" — and it made /health/ready report
80
+ # unhealthy on every deployment without Redis, which CI caught.
81
+ # The wizard's connection step and the settings screen's "Test
82
+ # connection" both run it explicitly, which is what it is for.
83
+ probe=False,
84
+ )
85
+ )
86
+
87
+
88
+ def install_durable_transport(app: FastAPI) -> None:
89
+ """Make ``bus.publish_durable`` write to this module's outbox (GH #459)."""
90
+ from background_tasks.constants import OutboxMode
91
+ from background_tasks.outbox_sweep import start_sweep
92
+ from background_tasks.outbox_transport import OutboxTransport
93
+ from background_tasks.outbox_worker import use_bus
94
+
95
+ bus = app.state.sm.event_bus
96
+ services = app.state.background_tasks
97
+ mode = (
98
+ OutboxMode.INPROCESS
99
+ if services.celery.conf.task_always_eager
100
+ else OutboxMode(services.settings.outbox_mode)
101
+ )
102
+ transport = OutboxTransport(services=services, bus=bus, db_state=app.state.sm.db, mode=mode)
103
+ bus.durable_transport = transport
104
+ if mode == OutboxMode.INPROCESS:
105
+ start_sweep(services, app.state.sm.db.session_factory, bus, transport.policy)
106
+ # An eager in-process dispatch delivers with the app's bus, which already
107
+ # carries every module's durable subscriptions.
108
+ use_bus(bus)
109
+
110
+
111
+ async def start_invalidation_transport(app: FastAPI) -> None:
112
+ """Hand the framework bus the Redis connection this module configures.
113
+
114
+ The transport is installed here rather than in a registration hook
115
+ because it needs a running event loop for its listener task, and it is
116
+ installed by *this* module because it owns the Redis URL — a cache-owning
117
+ module opening its own connection is the duplication GH #318 exists to
118
+ avoid.
119
+
120
+ A worker process that runs Celery without the FastAPI app never reaches
121
+ this, so it publishes nothing and hears nothing. That is the same
122
+ documented cross-process limit as ``bind_event_bus`` above: a task that
123
+ invalidates a cache should do it through an API call or accept the TTL.
124
+ """
125
+ from background_tasks.invalidation import RedisInvalidationTransport
126
+
127
+ services = app.state.background_tasks
128
+ s = services.settings
129
+ if not s.broadcast_invalidations:
130
+ logger.info(
131
+ "BackgroundTasks: cross-worker cache invalidation disabled "
132
+ "(broadcast_invalidations=false) — per-process caches expire on their own TTL"
133
+ )
134
+ return
135
+
136
+ bus = app.state.sm.invalidation
137
+ transport = RedisInvalidationTransport(bus, s.broker_url, s.invalidation_channel)
138
+ await transport.start()
139
+ bus.set_transport(transport)
140
+ services.invalidation_transport = transport
141
+ logger.info(
142
+ "BackgroundTasks: invalidation transport on %r for channel(s) %s",
143
+ s.invalidation_channel,
144
+ ", ".join(bus.channels) or "(none subscribed)",
145
+ )
146
+
147
+
148
+ async def shutdown(app: FastAPI) -> None:
149
+ from background_tasks.log_sink import uninstall_log_sink
150
+ from background_tasks.outbox_sweep import stop_sweep
151
+ from background_tasks.outbox_worker import reset_worker_state
152
+ from background_tasks.signals import unbind_event_bus
153
+ from background_tasks.sync_db import dispose_sync_engine
154
+
155
+ await stop_sweep(app.state.background_tasks)
156
+ bus = getattr(app.state.sm, "event_bus", None)
157
+ if bus is not None:
158
+ drain = getattr(bus.durable_transport, "drain", None)
159
+ if drain is not None:
160
+ # Eager deliveries still running on this loop.
161
+ await drain()
162
+ bus.durable_transport = None
163
+ reset_worker_state()
164
+ services = app.state.background_tasks
165
+ if services.invalidation_transport is not None:
166
+ app.state.sm.invalidation.clear_transport()
167
+ await services.invalidation_transport.stop()
168
+ services.invalidation_transport = None
169
+ if services.celery is not None:
170
+ services.celery.close()
171
+ unbind_event_bus()
172
+ # Before the engine goes: the final flush still needs it.
173
+ uninstall_log_sink()
174
+ dispose_sync_engine()
@@ -7,6 +7,7 @@ re-exports :func:`bind_event_bus` / :func:`unbind_event_bus`.
7
7
  from __future__ import annotations
8
8
 
9
9
  import asyncio
10
+ import concurrent.futures
10
11
  import logging
11
12
  from typing import Any
12
13
 
@@ -53,7 +54,7 @@ def publish_from_signal(event: Any) -> None:
53
54
  future.add_done_callback(_log_publish_failure)
54
55
 
55
56
 
56
- def _log_publish_failure(future: asyncio.Future[Any]) -> None:
57
+ def _log_publish_failure(future: concurrent.futures.Future[Any]) -> None:
57
58
  if future.cancelled():
58
59
  return
59
60
  exc = future.exception()
@@ -7,14 +7,20 @@ These helpers are intentionally side-effect-free except for the DB write in
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import logging
10
11
  import traceback as tb_mod
12
+ from collections.abc import Callable
11
13
  from datetime import UTC, datetime
12
14
  from typing import Any
13
15
 
14
16
  from sqlalchemy import select
15
17
  from sqlalchemy.orm import Session
16
18
 
19
+ from background_tasks.constants import INTERNAL_TASK_SWEEP_OUTBOX, TaskStatus
17
20
  from background_tasks.models import TaskExecution
21
+ from background_tasks.sync_db import sync_session
22
+
23
+ logger = logging.getLogger(__name__)
18
24
 
19
25
 
20
26
  def now_utc() -> datetime:
@@ -47,7 +53,7 @@ def upsert_by_celery_id(
47
53
  """
48
54
  row: TaskExecution | None = None
49
55
  if celery_task_id:
50
- stmt = select(TaskExecution).where(TaskExecution.celery_task_id == celery_task_id)
56
+ stmt = select(TaskExecution).where(TaskExecution.celery_task_id == celery_task_id) # ty: ignore[invalid-argument-type]
51
57
  row = session.execute(stmt).scalar_one_or_none()
52
58
  if row is None:
53
59
  row = TaskExecution(celery_task_id=celery_task_id, **defaults)
@@ -103,3 +109,48 @@ def render_traceback(einfo: Any, exception: BaseException | None) -> str | None:
103
109
  if exception is not None:
104
110
  return "".join(tb_mod.format_exception(type(exception), exception, exception.__traceback__))
105
111
  return None
112
+
113
+
114
+ # Beat ticks that fire every few seconds and are a no-op almost every time;
115
+ # recording each run buried real work (QA, GH #459). Only a run that went wrong
116
+ # is worth a row.
117
+ _UNRECORDED_WHEN_QUIET = frozenset({INTERNAL_TASK_SWEEP_OUTBOX})
118
+ _QUIET_STATUSES = frozenset({None, TaskStatus.PENDING, TaskStatus.RUNNING, TaskStatus.SUCCESS})
119
+
120
+
121
+ def apply_signal(
122
+ handler: str,
123
+ *,
124
+ celery_task_id: str | None,
125
+ defaults: dict[str, Any],
126
+ after: Callable[[TaskExecution], None] | None = None,
127
+ tenant_id: str | None = None,
128
+ owner_user_id: str | None = None,
129
+ ) -> TaskExecution | None:
130
+ """Open a sync session, upsert by celery_task_id, log on failure.
131
+
132
+ Returns the upserted row (or ``None`` if the session raised) so callers
133
+ can read DB-assigned fields like ``id`` without a second round trip.
134
+
135
+ ``tenant_id`` and ``owner_user_id`` (the message's) are written only when
136
+ known, so a signal whose message carries neither never blanks a row already
137
+ stamped.
138
+ """
139
+ if (
140
+ defaults.get("task_name") in _UNRECORDED_WHEN_QUIET
141
+ and defaults.get("status") in _QUIET_STATUSES
142
+ ):
143
+ return None
144
+ if tenant_id:
145
+ defaults = {**defaults, "tenant_id": tenant_id}
146
+ if owner_user_id:
147
+ defaults = {**defaults, "owner_user_id": owner_user_id}
148
+ try:
149
+ with sync_session() as session:
150
+ row = upsert_by_celery_id(session, celery_task_id=celery_task_id, defaults=defaults)
151
+ if after is not None:
152
+ after(row)
153
+ return row
154
+ except Exception:
155
+ logger.exception("%s failed for task_id=%s", handler, celery_task_id)
156
+ return None
@@ -0,0 +1,137 @@
1
+ """Publish to the broker only once the transaction that justifies it commits.
2
+
3
+ A task enqueued inside a transaction races that transaction: the worker can
4
+ pick the message up before the row it refers to is visible (eager mode runs
5
+ it *inside* ``send_task``), and if the commit then fails the task is already
6
+ queued against nothing. Both are fixed the same way — hold the publish until
7
+ the commit has landed, and drop it if the transaction rolls back (GH #452).
8
+
9
+ Two delivery paths, picked per session:
10
+
11
+ - **A request's session** (``get_db``) — the publish goes through
12
+ :meth:`~simple_module_db.RequestSession.on_commit`, which the framework runs
13
+ after the request's commit and before the response is sent, and discards on
14
+ rollback or commit failure.
15
+ - **Any other session** — a worker's sync session, a CLI script's own
16
+ ``async with session.begin()`` — has nobody to run ``on_commit`` callbacks,
17
+ so the publish hangs off SQLAlchemy's ``after_commit`` event instead and is
18
+ dropped when the outermost transaction ends without committing.
19
+
20
+ The broker id is reserved up front and returned, so the caller can write it
21
+ onto its own row in the same transaction: the row names the task before the
22
+ task exists, never the other way round.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import logging
29
+ import uuid
30
+ from collections.abc import Callable, Sequence
31
+ from typing import Any
32
+
33
+ from simple_module_db import RequestSession, bind_current_tenant, mark_written
34
+ from simple_module_db.transaction import SESSION_START_KEY
35
+ from sqlalchemy import event
36
+ from sqlalchemy.ext.asyncio import AsyncSession
37
+ from sqlalchemy.orm import Session, SessionTransaction
38
+
39
+
40
+ class _LazyCurrentApp:
41
+ """``celery.current_app``, imported on first use so importing this module stays light."""
42
+
43
+ def __getattr__(self, name: str) -> Any:
44
+ from celery import current_app as _current_app
45
+
46
+ return getattr(_current_app, name)
47
+
48
+
49
+ current_app: Any = _LazyCurrentApp()
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ type Publish = Callable[[], object]
54
+
55
+ _PENDING_KEY = "background_tasks_after_commit"
56
+ _HOOKED_KEY = "background_tasks_after_commit_hooked"
57
+
58
+
59
+ def enqueue_after_commit(db: AsyncSession | Session, task: Any, *args: Any, **kwargs: Any) -> str:
60
+ """Send *task* once *db*'s transaction commits; never if it rolls back.
61
+
62
+ *task* is a Celery task (``send_receipt``), a signature
63
+ (``send_receipt.s().set(queue="mail")``, for routing options), or a
64
+ registered task name. ``*args`` / ``**kwargs`` are the task's own
65
+ arguments. Returns the Celery task id the message will carry.
66
+
67
+ Publishes as the tenant bound *now*, so a task enqueued in a request runs
68
+ for that request's tenant even when the commit happens elsewhere.
69
+ """
70
+ task_id = str(uuid.uuid4())
71
+
72
+ def publish() -> None:
73
+ if isinstance(task, str):
74
+ current_app.send_task(task, args=list(args), kwargs=kwargs, task_id=task_id)
75
+ else:
76
+ task.apply_async(args=args, kwargs=kwargs, task_id=task_id)
77
+
78
+ after_commit(db, [bind_current_tenant(publish)])
79
+ return task_id
80
+
81
+
82
+ def after_commit(
83
+ db: AsyncSession | Session, publishes: Sequence[Publish], *, offload: bool = False
84
+ ) -> None:
85
+ """Run each of *publishes* after *db* commits, in order. Internal.
86
+
87
+ *offload* sends the batch to a thread when the request path runs it: kombu
88
+ publishes block, and hundreds of them on the event loop stall every other
89
+ request. Left off for a single publish so Celery's eager mode — which runs
90
+ the task body inside the publish — stays on the loop's thread. One failed
91
+ publish is logged and does not stop the rest: the rows are already
92
+ committed, and an unsent ``pending`` row is visible and retryable.
93
+ """
94
+ batch = list(publishes)
95
+ if isinstance(db, RequestSession) and SESSION_START_KEY in db.info:
96
+ # A request that wrote nothing else is rolled back and its ``on_commit``
97
+ # callbacks discarded, which would drop this publish silently. Queueing a
98
+ # task is itself work that must survive to the commit.
99
+ mark_written(db)
100
+ if offload:
101
+ db.on_commit(lambda: asyncio.to_thread(_run_all, batch))
102
+ else:
103
+ db.on_commit(lambda: _run_all(batch))
104
+ return
105
+ sync = db.sync_session if isinstance(db, AsyncSession) else db
106
+ sync.info.setdefault(_PENDING_KEY, []).extend(batch)
107
+ _hook(sync)
108
+
109
+
110
+ def _run_all(batch: Sequence[Publish]) -> None:
111
+ for publish in batch:
112
+ try:
113
+ publish()
114
+ except Exception:
115
+ logger.exception("background_tasks.after_commit.publish_failed")
116
+
117
+
118
+ def _hook(session: Session) -> None:
119
+ """Attach the commit/rollback listeners to *session* once."""
120
+ if session.info.get(_HOOKED_KEY):
121
+ return
122
+ session.info[_HOOKED_KEY] = True
123
+
124
+ def on_commit(sess: Session) -> None:
125
+ _run_all(sess.info.pop(_PENDING_KEY, []))
126
+
127
+ def on_end(sess: Session, transaction: SessionTransaction) -> None:
128
+ # ``after_commit`` already took the batch on the commit path; anything
129
+ # still queued when the outermost transaction ends was rolled back.
130
+ if transaction.parent is None:
131
+ sess.info.pop(_PENDING_KEY, None)
132
+
133
+ event.listen(session, "after_commit", on_commit)
134
+ event.listen(session, "after_transaction_end", on_end)
135
+
136
+
137
+ __all__ = ["enqueue_after_commit"]
@@ -23,12 +23,18 @@ from celery import Celery
23
23
  from celery.schedules import schedule
24
24
  from simple_module_core.discovery import ENTRY_POINT_GROUP
25
25
 
26
+ from background_tasks.config_guards import celery_overrides_from_env
26
27
  from background_tasks.constants import (
28
+ BEAT_PURGE_OLD,
29
+ BEAT_SWEEP_OUTBOX,
30
+ BEAT_SWEEP_STUCK,
27
31
  INTERNAL_TASK_PURGE_OLD,
32
+ INTERNAL_TASK_SWEEP_OUTBOX,
28
33
  INTERNAL_TASK_SWEEP_STUCK,
29
34
  MODULE_NAME,
30
35
  )
31
36
  from background_tasks.settings import BackgroundTasksSettings
37
+ from background_tasks.task_routing import load_task_configs, merge_routes, select_packages
32
38
 
33
39
  logger = logging.getLogger(__name__)
34
40
 
@@ -86,25 +92,70 @@ def _collect_module_beat_schedules(packages: list[str]) -> dict:
86
92
  return merged
87
93
 
88
94
 
95
+ # setting name -> Celery conf key (they differ only for the memory cap, which
96
+ # Celery names without the unit although it is KiB).
97
+ _TUNING_KEYS = {
98
+ "worker_max_tasks_per_child": "worker_max_tasks_per_child",
99
+ "worker_max_memory_per_child_kb": "worker_max_memory_per_child",
100
+ "task_reject_on_worker_lost": "task_reject_on_worker_lost",
101
+ "task_soft_time_limit": "task_soft_time_limit",
102
+ "task_time_limit": "task_time_limit",
103
+ "worker_concurrency": "worker_concurrency",
104
+ }
105
+
106
+
107
+ def _tuning_conf(settings: BackgroundTasksSettings) -> dict:
108
+ """Celery conf for the optional tuning fields; ``None`` keeps Celery's default."""
109
+ conf = {
110
+ key: getattr(settings, name)
111
+ for name, key in _TUNING_KEYS.items()
112
+ if getattr(settings, name) is not None
113
+ }
114
+ if settings.visibility_timeout_seconds is not None:
115
+ conf["broker_transport_options"] = {
116
+ "visibility_timeout": settings.visibility_timeout_seconds
117
+ }
118
+ return conf
119
+
120
+
121
+ def describe_routes(settings: BackgroundTasksSettings) -> list[dict[str, str]]:
122
+ """Effective ``[{pattern, queue, module}]`` routes, for the Workers admin page."""
123
+ configs = load_task_configs(_discover_task_packages())
124
+ return merge_routes(configs, settings.task_routes_override)[1]
125
+
126
+
89
127
  def build_celery(settings: BackgroundTasksSettings) -> Celery:
90
128
  """Construct a Celery app wired to the project's Redis broker."""
91
129
  celery = Celery(MODULE_NAME)
92
130
 
93
- packages = _discover_task_packages()
131
+ all_packages = _discover_task_packages()
132
+ # Routes and beat entries come from the light ``task_config`` of *every*
133
+ # module; only the heavy ``tasks.py`` imports are narrowed to this
134
+ # process's subset (GH #450).
135
+ configs = load_task_configs(all_packages)
136
+ packages = select_packages(all_packages, configs, settings)
137
+ task_routes, _ = merge_routes(configs, settings.task_routes_override)
94
138
 
95
- # The two internal entries always ship; modules contribute more via their
139
+ # The internal entries always ship; modules contribute more via their
96
140
  # own ``tasks.BEAT_SCHEDULE``. ``setdefault`` keeps the built-ins authoritative
97
141
  # if a module reuses one of their entry names. See GH #199.
98
142
  beat_schedule = {
99
- "background-tasks-sweep-stuck": {
143
+ BEAT_SWEEP_STUCK: {
100
144
  "task": INTERNAL_TASK_SWEEP_STUCK,
101
145
  "schedule": schedule(settings.stuck_sweep_interval_seconds),
102
146
  },
103
- "background-tasks-purge-old": {
147
+ BEAT_PURGE_OLD: {
104
148
  "task": INTERNAL_TASK_PURGE_OLD,
105
149
  "schedule": schedule(settings.purge_interval_seconds),
106
150
  },
151
+ # Delivers durable events whose post-commit dispatch never ran (a crash
152
+ # between commit and publish) and retries failed ones (GH #459).
153
+ BEAT_SWEEP_OUTBOX: {
154
+ "task": INTERNAL_TASK_SWEEP_OUTBOX,
155
+ "schedule": schedule(settings.outbox_sweep_interval_seconds),
156
+ },
107
157
  }
158
+ builtin_beat = frozenset(beat_schedule)
108
159
  for name, entry in _collect_module_beat_schedules(packages).items():
109
160
  if name in beat_schedule:
110
161
  logger.warning(
@@ -112,6 +163,15 @@ def build_celery(settings: BackgroundTasksSettings) -> Celery:
112
163
  )
113
164
  continue
114
165
  beat_schedule[name] = entry
166
+ # ``task_config.BEAT_SCHEDULE`` wins over the same entry in ``tasks.py``.
167
+ for pkg in sorted(configs):
168
+ for name, entry in configs[pkg].beat_schedule.items():
169
+ if name in builtin_beat:
170
+ logger.warning(
171
+ "task_config beat entry %r clashes with a built-in; built-in wins", name
172
+ )
173
+ continue
174
+ beat_schedule[name] = entry
115
175
 
116
176
  celery.conf.update(
117
177
  broker_url=settings.broker_url,
@@ -138,7 +198,13 @@ def build_celery(settings: BackgroundTasksSettings) -> Celery:
138
198
  broker_connection_retry_on_startup=True,
139
199
  beat_schedule=beat_schedule,
140
200
  beat_scheduler="celery.beat:PersistentScheduler",
201
+ task_routes=task_routes,
141
202
  )
203
+ celery.conf.update(_tuning_conf(settings))
204
+ overrides = celery_overrides_from_env()
205
+ if overrides:
206
+ logger.info("Applying celery_overrides: %s", sorted(overrides))
207
+ celery.conf.update(overrides)
142
208
 
143
209
  # Autodiscover across every installed simple_module — a module just ships
144
210
  # a `tasks.py` and the worker picks it up.