simple-module-background-tasks 0.0.35__tar.gz → 0.0.37__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 (143) hide show
  1. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/.gitignore +1 -0
  2. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/PKG-INFO +28 -5
  3. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/README.md +23 -0
  4. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/__init__.py +6 -0
  5. simple_module_background_tasks-0.0.37/background_tasks/_signal_bus.py +61 -0
  6. simple_module_background_tasks-0.0.37/background_tasks/after_commit.py +126 -0
  7. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/celery_app.py +70 -4
  8. simple_module_background_tasks-0.0.37/background_tasks/config_guards.py +220 -0
  9. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/constants.py +71 -0
  10. simple_module_background_tasks-0.0.37/background_tasks/contracts/outbox.py +40 -0
  11. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/contracts/schemas.py +2 -0
  12. simple_module_background_tasks-0.0.37/background_tasks/deps.py +51 -0
  13. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/endpoints/api_admin.py +12 -3
  14. simple_module_background_tasks-0.0.37/background_tasks/endpoints/outbox_api.py +61 -0
  15. simple_module_background_tasks-0.0.37/background_tasks/endpoints/outbox_views.py +39 -0
  16. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/endpoints/views.py +27 -5
  17. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/filters.py +14 -3
  18. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/locales/en.json +50 -5
  19. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/log_context.py +2 -2
  20. simple_module_background_tasks-0.0.37/background_tasks/models.py +153 -0
  21. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/module.py +32 -2
  22. simple_module_background_tasks-0.0.37/background_tasks/outbox_claim.py +199 -0
  23. simple_module_background_tasks-0.0.37/background_tasks/outbox_clock.py +53 -0
  24. simple_module_background_tasks-0.0.37/background_tasks/outbox_dispatch.py +250 -0
  25. simple_module_background_tasks-0.0.37/background_tasks/outbox_guard.py +113 -0
  26. simple_module_background_tasks-0.0.37/background_tasks/outbox_loop.py +130 -0
  27. simple_module_background_tasks-0.0.37/background_tasks/outbox_service.py +160 -0
  28. simple_module_background_tasks-0.0.37/background_tasks/outbox_support.py +100 -0
  29. simple_module_background_tasks-0.0.37/background_tasks/outbox_transport.py +190 -0
  30. simple_module_background_tasks-0.0.37/background_tasks/outbox_worker.py +174 -0
  31. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/Index.tsx +36 -6
  32. simple_module_background_tasks-0.0.37/background_tasks/pages/Outbox.tsx +122 -0
  33. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/Workers.tsx +6 -1
  34. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/DetailFacts.tsx +4 -0
  35. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/ExecutionRow.tsx +3 -0
  36. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/ExecutionsTable.tsx +5 -2
  37. simple_module_background_tasks-0.0.37/background_tasks/pages/components/OutboxTable.tsx +173 -0
  38. simple_module_background_tasks-0.0.37/background_tasks/pages/components/RoutesTable.tsx +60 -0
  39. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/TaskFilters.tsx +42 -1
  40. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/constants.ts +14 -0
  41. simple_module_background_tasks-0.0.37/background_tasks/pages/outbox.ts +103 -0
  42. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/retry.ts +22 -2
  43. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/retry_service.py +90 -32
  44. simple_module_background_tasks-0.0.37/background_tasks/runner.py +71 -0
  45. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/service.py +33 -5
  46. simple_module_background_tasks-0.0.37/background_tasks/settings.py +293 -0
  47. simple_module_background_tasks-0.0.37/background_tasks/settings_env_checks.py +68 -0
  48. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/signals.py +61 -58
  49. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/sync_db.py +69 -13
  50. simple_module_background_tasks-0.0.37/background_tasks/task_heartbeat.py +98 -0
  51. simple_module_background_tasks-0.0.37/background_tasks/task_routing.py +158 -0
  52. simple_module_background_tasks-0.0.37/background_tasks/tasks.py +182 -0
  53. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/tenant_context.py +37 -3
  54. simple_module_background_tasks-0.0.37/background_tasks/worker_settings.py +84 -0
  55. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/pyproject.toml +5 -5
  56. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/conftest.py +29 -0
  57. simple_module_background_tasks-0.0.37/tests/test_background_tasks_after_commit.py +262 -0
  58. simple_module_background_tasks-0.0.37/tests/test_background_tasks_heartbeat.py +158 -0
  59. simple_module_background_tasks-0.0.37/tests/test_background_tasks_settings.py +96 -0
  60. simple_module_background_tasks-0.0.37/tests/test_background_tasks_tz_columns.py +56 -0
  61. simple_module_background_tasks-0.0.37/tests/test_bg_config_guards.py +159 -0
  62. simple_module_background_tasks-0.0.37/tests/test_bg_index_has_executions.py +62 -0
  63. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_invalidation_redis.py +6 -1
  64. simple_module_background_tasks-0.0.37/tests/test_bg_outbox_eager_platform.py +102 -0
  65. simple_module_background_tasks-0.0.37/tests/test_bg_outbox_fence_guard.py +179 -0
  66. simple_module_background_tasks-0.0.37/tests/test_bg_outbox_guard_savepoint.py +148 -0
  67. simple_module_background_tasks-0.0.37/tests/test_bg_outbox_loop_interrupt.py +42 -0
  68. simple_module_background_tasks-0.0.37/tests/test_bg_outbox_publish_scope.py +122 -0
  69. simple_module_background_tasks-0.0.37/tests/test_bg_outbox_retry_policy.py +201 -0
  70. simple_module_background_tasks-0.0.37/tests/test_bg_outbox_swallowed_cancel.py +53 -0
  71. simple_module_background_tasks-0.0.37/tests/test_bg_outbox_thread_loops.py +75 -0
  72. simple_module_background_tasks-0.0.37/tests/test_bg_outbox_worker_chain.py +199 -0
  73. simple_module_background_tasks-0.0.37/tests/test_bg_quiet_sweep_unrecorded.py +62 -0
  74. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_redis_url.py +43 -2
  75. simple_module_background_tasks-0.0.37/tests/test_bg_routes_override_api.py +46 -0
  76. simple_module_background_tasks-0.0.37/tests/test_bg_runner_settings.py +171 -0
  77. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_service.py +11 -9
  78. simple_module_background_tasks-0.0.37/tests/test_bg_task_routing.py +258 -0
  79. simple_module_background_tasks-0.0.37/tests/test_bg_worker_settings.py +56 -0
  80. simple_module_background_tasks-0.0.37/tests/test_execution_tenancy.py +194 -0
  81. simple_module_background_tasks-0.0.37/tests/test_outbox_admin.py +150 -0
  82. simple_module_background_tasks-0.0.37/tests/test_outbox_concurrency.py +195 -0
  83. simple_module_background_tasks-0.0.37/tests/test_outbox_delivery.py +253 -0
  84. simple_module_background_tasks-0.0.37/tests/test_outbox_tenancy.py +95 -0
  85. simple_module_background_tasks-0.0.37/tests/test_outbox_worker_task.py +147 -0
  86. simple_module_background_tasks-0.0.37/tests/test_signal_tenant_stamp.py +99 -0
  87. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_signals.py +2 -2
  88. simple_module_background_tasks-0.0.37/tests/test_sync_db_fork.py +89 -0
  89. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_worker_tenancy.py +50 -1
  90. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_workers_endpoints.py +40 -0
  91. simple_module_background_tasks-0.0.37/tests-js/outbox.test.ts +57 -0
  92. simple_module_background_tasks-0.0.37/tests-js/retry.test.ts +31 -0
  93. simple_module_background_tasks-0.0.35/background_tasks/deps.py +0 -19
  94. simple_module_background_tasks-0.0.35/background_tasks/models.py +0 -82
  95. simple_module_background_tasks-0.0.35/background_tasks/settings.py +0 -188
  96. simple_module_background_tasks-0.0.35/background_tasks/tasks.py +0 -105
  97. simple_module_background_tasks-0.0.35/tests/test_background_tasks_settings.py +0 -27
  98. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/LICENSE +0 -0
  99. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/_signal_support.py +0 -0
  100. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/contracts/__init__.py +0 -0
  101. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/contracts/events.py +0 -0
  102. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/endpoints/__init__.py +0 -0
  103. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/health.py +0 -0
  104. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/invalidation.py +0 -0
  105. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/Detail.tsx +0 -0
  106. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/PayloadCards.tsx +0 -0
  107. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/RetryAllDialog.tsx +0 -0
  108. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
  109. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/StatusPill.tsx +0 -0
  110. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/StatusStrip.tsx +0 -0
  111. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/TasksEmpty.tsx +0 -0
  112. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/TracebackCard.tsx +0 -0
  113. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/WorkerCard.tsx +0 -0
  114. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/WorkerEmptyStates.tsx +0 -0
  115. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/WorkerHealthBanner.tsx +0 -0
  116. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/worker-health.ts +0 -0
  117. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/py.typed +0 -0
  118. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/services.py +0 -0
  119. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/worker_inspector.py +0 -0
  120. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/workers_state.py +0 -0
  121. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/package.json +0 -0
  122. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_admin_api.py +0 -0
  123. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_beat_schedule.py +0 -0
  124. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_index_worker_presence.py +0 -0
  125. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_invalidation.py +0 -0
  126. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_invalidation_degraded.py +0 -0
  127. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_list_item_payload.py +0 -0
  128. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_page_clamp.py +0 -0
  129. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_redis_health.py +0 -0
  130. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_settings_env.py +0 -0
  131. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_detail_props.py +0 -0
  132. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_index_filters.py +0 -0
  133. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_log_context.py +0 -0
  134. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_retry_failed_bulk.py +0 -0
  135. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_retry_failed_guards.py +0 -0
  136. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_retry_sweep_ordering.py +0 -0
  137. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_sweep_stuck.py +0 -0
  138. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_sync_db.py +0 -0
  139. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_tenant_context.py +0 -0
  140. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_worker_inspector.py +0 -0
  141. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_workers_uptime.py +0 -0
  142. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests-js/task-formatters.test.ts +0 -0
  143. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tsconfig.json +0 -0
@@ -64,3 +64,4 @@ host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
66
  .verify/
67
+ /openapi.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_background_tasks
3
- Version: 0.0.35
3
+ Version: 0.0.37
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.35
27
- Requires-Dist: simple-module-db==0.0.35
28
- Requires-Dist: simple-module-hosting==0.0.35
29
- Requires-Dist: simple-module-settings==0.0.35
26
+ Requires-Dist: simple-module-core==0.0.37
27
+ Requires-Dist: simple-module-db==0.0.37
28
+ Requires-Dist: simple-module-hosting==0.0.37
29
+ Requires-Dist: simple-module-settings==0.0.37
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
@@ -1,13 +1,19 @@
1
1
  """BackgroundTasks module — Celery + Redis task queue with admin UI."""
2
2
 
3
+ from background_tasks.after_commit import enqueue_after_commit
3
4
  from background_tasks.log_context import (
4
5
  bind_task_context,
5
6
  get_log_context,
6
7
  install_log_filter,
7
8
  )
9
+ from background_tasks.task_heartbeat import heartbeat
10
+ from background_tasks.worker_settings import settings_for
8
11
 
9
12
  __all__ = [
10
13
  "bind_task_context",
14
+ "enqueue_after_commit",
11
15
  "get_log_context",
16
+ "heartbeat",
12
17
  "install_log_filter",
18
+ "settings_for",
13
19
  ]
@@ -0,0 +1,61 @@
1
+ """Bridge from Celery's sync signal thread to the app's async event bus.
2
+
3
+ Split out of :mod:`.signals` to keep it under the 300-line cap; ``signals``
4
+ re-exports :func:`bind_event_bus` / :func:`unbind_event_bus`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from typing import Any
12
+
13
+ from simple_module_core.events import EventBus
14
+
15
+ logger = logging.getLogger("background_tasks.signals")
16
+
17
+
18
+ _bus: EventBus | None = None
19
+ _loop: asyncio.AbstractEventLoop | None = None
20
+
21
+
22
+ def bind_event_bus(bus: EventBus, loop: asyncio.AbstractEventLoop) -> None:
23
+ """Bind an event bus + its running loop so signals can publish events.
24
+
25
+ Signals fire on the Celery sync thread; `run_coroutine_threadsafe`
26
+ bridges back to ``loop`` so handlers run on the API event loop
27
+ regardless of which thread triggered the signal.
28
+ """
29
+ global _bus, _loop
30
+ _bus = bus
31
+ _loop = loop
32
+
33
+
34
+ def unbind_event_bus() -> None:
35
+ """Drop the bound bus — called from ``on_shutdown`` so tests stay isolated."""
36
+ global _bus, _loop
37
+ _bus = None
38
+ _loop = None
39
+
40
+
41
+ def publish_from_signal(event: Any) -> None:
42
+ """Dispatch ``event`` onto the bound bus without blocking the signal thread."""
43
+ if _bus is None or _loop is None:
44
+ return
45
+ try:
46
+ future = asyncio.run_coroutine_threadsafe(_bus.publish(event), _loop)
47
+ except RuntimeError:
48
+ # Loop has stopped (shutdown race). The DB row is already written.
49
+ logger.debug("Event bus loop is not running; skipping %s", type(event).__name__)
50
+ return
51
+ # Surface subscriber exceptions — run_coroutine_threadsafe otherwise only
52
+ # logs them when the Future is GC'd, which happens far from the failure.
53
+ future.add_done_callback(_log_publish_failure)
54
+
55
+
56
+ def _log_publish_failure(future: asyncio.Future[Any]) -> None:
57
+ if future.cancelled():
58
+ return
59
+ exc = future.exception()
60
+ if exc is not None:
61
+ logger.error("Event publish raised: %s", exc, exc_info=exc)
@@ -0,0 +1,126 @@
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 celery import current_app
34
+ from simple_module_db import RequestSession, bind_current_tenant, mark_written
35
+ from simple_module_db.transaction import SESSION_START_KEY
36
+ from sqlalchemy import event
37
+ from sqlalchemy.ext.asyncio import AsyncSession
38
+ from sqlalchemy.orm import Session, SessionTransaction
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ type Publish = Callable[[], object]
43
+
44
+ _PENDING_KEY = "background_tasks_after_commit"
45
+ _HOOKED_KEY = "background_tasks_after_commit_hooked"
46
+
47
+
48
+ def enqueue_after_commit(db: AsyncSession | Session, task: Any, *args: Any, **kwargs: Any) -> str:
49
+ """Send *task* once *db*'s transaction commits; never if it rolls back.
50
+
51
+ *task* is a Celery task (``send_receipt``), a signature
52
+ (``send_receipt.s().set(queue="mail")``, for routing options), or a
53
+ registered task name. ``*args`` / ``**kwargs`` are the task's own
54
+ arguments. Returns the Celery task id the message will carry.
55
+
56
+ Publishes as the tenant bound *now*, so a task enqueued in a request runs
57
+ for that request's tenant even when the commit happens elsewhere.
58
+ """
59
+ task_id = str(uuid.uuid4())
60
+
61
+ def publish() -> None:
62
+ if isinstance(task, str):
63
+ current_app.send_task(task, args=list(args), kwargs=kwargs, task_id=task_id)
64
+ else:
65
+ task.apply_async(args=args, kwargs=kwargs, task_id=task_id)
66
+
67
+ after_commit(db, [bind_current_tenant(publish)])
68
+ return task_id
69
+
70
+
71
+ def after_commit(
72
+ db: AsyncSession | Session, publishes: Sequence[Publish], *, offload: bool = False
73
+ ) -> None:
74
+ """Run each of *publishes* after *db* commits, in order. Internal.
75
+
76
+ *offload* sends the batch to a thread when the request path runs it: kombu
77
+ publishes block, and hundreds of them on the event loop stall every other
78
+ request. Left off for a single publish so Celery's eager mode — which runs
79
+ the task body inside the publish — stays on the loop's thread. One failed
80
+ publish is logged and does not stop the rest: the rows are already
81
+ committed, and an unsent ``pending`` row is visible and retryable.
82
+ """
83
+ batch = list(publishes)
84
+ if isinstance(db, RequestSession) and SESSION_START_KEY in db.info:
85
+ # A request that wrote nothing else is rolled back and its ``on_commit``
86
+ # callbacks discarded, which would drop this publish silently. Queueing a
87
+ # task is itself work that must survive to the commit.
88
+ mark_written(db)
89
+ if offload:
90
+ db.on_commit(lambda: asyncio.to_thread(_run_all, batch))
91
+ else:
92
+ db.on_commit(lambda: _run_all(batch))
93
+ return
94
+ sync = db.sync_session if isinstance(db, AsyncSession) else db
95
+ sync.info.setdefault(_PENDING_KEY, []).extend(batch)
96
+ _hook(sync)
97
+
98
+
99
+ def _run_all(batch: Sequence[Publish]) -> None:
100
+ for publish in batch:
101
+ try:
102
+ publish()
103
+ except Exception:
104
+ logger.exception("background_tasks.after_commit.publish_failed")
105
+
106
+
107
+ def _hook(session: Session) -> None:
108
+ """Attach the commit/rollback listeners to *session* once."""
109
+ if session.info.get(_HOOKED_KEY):
110
+ return
111
+ session.info[_HOOKED_KEY] = True
112
+
113
+ def on_commit(sess: Session) -> None:
114
+ _run_all(sess.info.pop(_PENDING_KEY, []))
115
+
116
+ def on_end(sess: Session, transaction: SessionTransaction) -> None:
117
+ # ``after_commit`` already took the batch on the commit path; anything
118
+ # still queued when the outermost transaction ends was rolled back.
119
+ if transaction.parent is None:
120
+ sess.info.pop(_PENDING_KEY, None)
121
+
122
+ event.listen(session, "after_commit", on_commit)
123
+ event.listen(session, "after_transaction_end", on_end)
124
+
125
+
126
+ __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.
@@ -0,0 +1,220 @@
1
+ """Guards for operator-supplied Celery configuration.
2
+
3
+ Celery conf can name serializers (``pickle``), dotted paths it imports on boot
4
+ (``imports``, ``worker_pool``, ``beat_scheduler`` ...) and signing material, so
5
+ a raw conf dict is a code-execution surface for whoever can write it. Two
6
+ inputs reach ``build_celery``:
7
+
8
+ - ``SM_BG_TASKS_CELERY_OVERRIDES`` (JSON object): environment only, so it is
9
+ controlled by whoever deploys the process and never by an admin-UI editor.
10
+ It is read here rather than being a ``BackgroundTasksSettings`` field, because
11
+ every field of that class is stored in, and hydrated from, the database.
12
+ Dangerous keys are refused even from here.
13
+ - ``SM_BG_TASKS_WORKER_QUEUES`` / ``_INCLUDE_MODULES`` / ``_EXCLUDE_MODULES``
14
+ (comma-separated): which queues and modules *this one worker process*
15
+ serves. Environment only for the same reason: one stored value would
16
+ re-scope every worker. All three go through the helpers below.
17
+ - ``task_routes_override`` (DB-backed, admin-editable): only names queues, and
18
+ is reduced to plain queue names and route dicts with a closed key set. A route
19
+ dict is merged into ``apply_async`` options, so ``serializer``, ``link`` and
20
+ friends must not be expressible.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import os
28
+ import re
29
+ from dataclasses import dataclass
30
+ from typing import Any
31
+
32
+ from background_tasks.constants import ENV_PREFIX
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ ENV_CELERY_OVERRIDES = f"{ENV_PREFIX}CELERY_OVERRIDES"
37
+ ENV_WORKER_QUEUES = f"{ENV_PREFIX}WORKER_QUEUES"
38
+ ENV_INCLUDE_MODULES = f"{ENV_PREFIX}INCLUDE_MODULES"
39
+ ENV_EXCLUDE_MODULES = f"{ENV_PREFIX}EXCLUDE_MODULES"
40
+
41
+ # Conf keys that choose a (de)serializer or load code by dotted path / file.
42
+ _DENIED_KEYS = frozenset(
43
+ {
44
+ "accept_content",
45
+ "task_serializer",
46
+ "result_serializer",
47
+ "result_accept_content",
48
+ "event_serializer",
49
+ "imports",
50
+ "include",
51
+ "worker_pool",
52
+ "worker_autoscaler",
53
+ "worker_consumer",
54
+ "worker_timer",
55
+ "beat_scheduler",
56
+ "beat_schedule",
57
+ "beat_schedule_filename",
58
+ "task_routes",
59
+ "task_annotations",
60
+ "task_queues",
61
+ "task_cls",
62
+ "broker_transport",
63
+ "result_backend_transport_options",
64
+ "worker_state_db",
65
+ }
66
+ )
67
+ _DENIED_SUBSTRINGS = ("hijack", "serializer", "accept_content", "pickle")
68
+ _DENIED_PREFIXES = ("security_", "worker_pool_", "pool_")
69
+ _DENIED_SUFFIXES = ("_cls", "_class")
70
+
71
+
72
+ def is_denied_key(key: str) -> bool:
73
+ """True for a conf key that must not be settable from configuration."""
74
+ # Celery also resolves the old ``CELERY_*`` spellings.
75
+ k = key.strip().lower().removeprefix("celery_")
76
+ return (
77
+ k in _DENIED_KEYS
78
+ or k.startswith(_DENIED_PREFIXES)
79
+ or k.endswith(_DENIED_SUFFIXES)
80
+ or any(s in k for s in _DENIED_SUBSTRINGS)
81
+ )
82
+
83
+
84
+ def filter_celery_overrides(raw: object, *, source: str = "celery_overrides") -> dict[str, Any]:
85
+ """Drop (and log) denied keys; non-dict input yields ``{}``."""
86
+ if not isinstance(raw, dict):
87
+ if raw not in (None, {}):
88
+ logger.error("%s must be a JSON object; ignoring it", source)
89
+ return {}
90
+ safe: dict[str, Any] = {}
91
+ for key, value in raw.items():
92
+ if not isinstance(key, str) or is_denied_key(key):
93
+ logger.error(
94
+ "%s: refusing Celery conf key %r (serializer, import and code-loading "
95
+ "keys cannot be set from configuration); ignoring it",
96
+ source,
97
+ key,
98
+ )
99
+ continue
100
+ safe[key] = value
101
+ return safe
102
+
103
+
104
+ def _env_text(name: str) -> str:
105
+ return os.environ.get(name, "").strip()
106
+
107
+
108
+ def env_csv(name: str) -> list[str]:
109
+ """A comma-separated environment variable as a list (``[]`` when unset)."""
110
+ return [part.strip() for part in _env_text(name).split(",") if part.strip()]
111
+
112
+
113
+ def env_json_object(name: str) -> dict[str, Any]:
114
+ """A JSON-object environment variable (``{}`` when unset or unusable, with a log)."""
115
+ raw = _env_text(name)
116
+ if not raw:
117
+ return {}
118
+ try:
119
+ parsed = json.loads(raw)
120
+ except ValueError as exc:
121
+ logger.error("%s is not valid JSON (%s); ignoring it", name, exc)
122
+ return {}
123
+ if not isinstance(parsed, dict):
124
+ logger.error("%s must be a JSON object; ignoring it", name)
125
+ return {}
126
+ return parsed
127
+
128
+
129
+ def celery_overrides_from_env() -> dict[str, Any]:
130
+ """The vetted ``SM_BG_TASKS_CELERY_OVERRIDES`` mapping (``{}`` when unset)."""
131
+ return filter_celery_overrides(
132
+ env_json_object(ENV_CELERY_OVERRIDES), source=ENV_CELERY_OVERRIDES
133
+ )
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class WorkerScope:
138
+ """What this process serves; see :func:`worker_scope_from_env`."""
139
+
140
+ queues: frozenset[str] = frozenset()
141
+ include_modules: frozenset[str] = frozenset()
142
+ exclude_modules: frozenset[str] = frozenset()
143
+
144
+
145
+ def worker_scope_from_env() -> WorkerScope:
146
+ """Per-process queue/module subset (GH #450), from the environment only."""
147
+ return WorkerScope(
148
+ queues=frozenset(env_csv(ENV_WORKER_QUEUES)),
149
+ include_modules=frozenset(env_csv(ENV_INCLUDE_MODULES)),
150
+ exclude_modules=frozenset(env_csv(ENV_EXCLUDE_MODULES)),
151
+ )
152
+
153
+
154
+ _QUEUE_RE = re.compile(r"^[A-Za-z0-9_.:\-]{1,255}$")
155
+ _ROUTE_KEYS = frozenset({"queue", "exchange", "routing_key", "priority"})
156
+
157
+
158
+ def _safe_route(value: object) -> object | None:
159
+ if isinstance(value, str):
160
+ return value if _QUEUE_RE.match(value) else None
161
+ if not isinstance(value, dict) or set(value) - _ROUTE_KEYS:
162
+ return None
163
+ queue = value.get("queue")
164
+ if not isinstance(queue, str) or not _QUEUE_RE.match(queue):
165
+ return None
166
+ for key in ("exchange", "routing_key"):
167
+ if key in value and not (isinstance(value[key], str) and _QUEUE_RE.match(value[key])):
168
+ return None
169
+ priority = value.get("priority")
170
+ if "priority" in value and (isinstance(priority, bool) or not isinstance(priority, int)):
171
+ return None
172
+ return dict(value)
173
+
174
+
175
+ def sanitize_task_routes(raw: object) -> dict[str, Any]:
176
+ """Keep only ``{pattern: queue-name | {queue, exchange, routing_key, priority}}``."""
177
+ if not isinstance(raw, dict):
178
+ return {}
179
+ clean: dict[str, Any] = {}
180
+ for pattern, value in raw.items():
181
+ route = _safe_route(value)
182
+ if not isinstance(pattern, str) or not pattern.strip() or route is None:
183
+ logger.error(
184
+ "task_routes_override: refusing entry %r (needs a task pattern and a queue "
185
+ "name or a route dict using only %s); ignoring it",
186
+ pattern,
187
+ sorted(_ROUTE_KEYS),
188
+ )
189
+ continue
190
+ clean[pattern] = route
191
+ return clean
192
+
193
+
194
+ def parse_task_routes(raw: object) -> dict[str, Any]:
195
+ """Strict form of :func:`sanitize_task_routes` for user input.
196
+
197
+ Accepts a mapping or a JSON string of one (what the admin settings text box
198
+ submits) and raises ``ValueError`` naming the first bad entry instead of
199
+ silently dropping it, so the editor can show why a save was refused.
200
+ """
201
+ if isinstance(raw, str):
202
+ if not raw.strip():
203
+ return {}
204
+ try:
205
+ raw = json.loads(raw)
206
+ except ValueError as exc:
207
+ raise ValueError(f"task_routes_override is not valid JSON: {exc}") from exc
208
+ if raw is None:
209
+ return {}
210
+ if not isinstance(raw, dict):
211
+ raise ValueError(
212
+ 'task_routes_override must be a JSON object such as {"reports.*": "heavy"}'
213
+ )
214
+ for pattern, value in raw.items():
215
+ if not isinstance(pattern, str) or not pattern.strip() or _safe_route(value) is None:
216
+ raise ValueError(
217
+ f"task_routes_override entry {pattern!r} needs a task pattern and a queue "
218
+ f"name or a route dict using only {sorted(_ROUTE_KEYS)}"
219
+ )
220
+ return dict(raw)