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.
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/.gitignore +1 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/PKG-INFO +28 -5
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/README.md +23 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/__init__.py +6 -0
- simple_module_background_tasks-0.0.37/background_tasks/_signal_bus.py +61 -0
- simple_module_background_tasks-0.0.37/background_tasks/after_commit.py +126 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/celery_app.py +70 -4
- simple_module_background_tasks-0.0.37/background_tasks/config_guards.py +220 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/constants.py +71 -0
- simple_module_background_tasks-0.0.37/background_tasks/contracts/outbox.py +40 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/contracts/schemas.py +2 -0
- simple_module_background_tasks-0.0.37/background_tasks/deps.py +51 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/endpoints/api_admin.py +12 -3
- simple_module_background_tasks-0.0.37/background_tasks/endpoints/outbox_api.py +61 -0
- simple_module_background_tasks-0.0.37/background_tasks/endpoints/outbox_views.py +39 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/endpoints/views.py +27 -5
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/filters.py +14 -3
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/locales/en.json +50 -5
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/log_context.py +2 -2
- simple_module_background_tasks-0.0.37/background_tasks/models.py +153 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/module.py +32 -2
- simple_module_background_tasks-0.0.37/background_tasks/outbox_claim.py +199 -0
- simple_module_background_tasks-0.0.37/background_tasks/outbox_clock.py +53 -0
- simple_module_background_tasks-0.0.37/background_tasks/outbox_dispatch.py +250 -0
- simple_module_background_tasks-0.0.37/background_tasks/outbox_guard.py +113 -0
- simple_module_background_tasks-0.0.37/background_tasks/outbox_loop.py +130 -0
- simple_module_background_tasks-0.0.37/background_tasks/outbox_service.py +160 -0
- simple_module_background_tasks-0.0.37/background_tasks/outbox_support.py +100 -0
- simple_module_background_tasks-0.0.37/background_tasks/outbox_transport.py +190 -0
- simple_module_background_tasks-0.0.37/background_tasks/outbox_worker.py +174 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/Index.tsx +36 -6
- simple_module_background_tasks-0.0.37/background_tasks/pages/Outbox.tsx +122 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/Workers.tsx +6 -1
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/DetailFacts.tsx +4 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/ExecutionRow.tsx +3 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/ExecutionsTable.tsx +5 -2
- simple_module_background_tasks-0.0.37/background_tasks/pages/components/OutboxTable.tsx +173 -0
- simple_module_background_tasks-0.0.37/background_tasks/pages/components/RoutesTable.tsx +60 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/TaskFilters.tsx +42 -1
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/constants.ts +14 -0
- simple_module_background_tasks-0.0.37/background_tasks/pages/outbox.ts +103 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/retry.ts +22 -2
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/retry_service.py +90 -32
- simple_module_background_tasks-0.0.37/background_tasks/runner.py +71 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/service.py +33 -5
- simple_module_background_tasks-0.0.37/background_tasks/settings.py +293 -0
- simple_module_background_tasks-0.0.37/background_tasks/settings_env_checks.py +68 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/signals.py +61 -58
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/sync_db.py +69 -13
- simple_module_background_tasks-0.0.37/background_tasks/task_heartbeat.py +98 -0
- simple_module_background_tasks-0.0.37/background_tasks/task_routing.py +158 -0
- simple_module_background_tasks-0.0.37/background_tasks/tasks.py +182 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/tenant_context.py +37 -3
- simple_module_background_tasks-0.0.37/background_tasks/worker_settings.py +84 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/pyproject.toml +5 -5
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/conftest.py +29 -0
- simple_module_background_tasks-0.0.37/tests/test_background_tasks_after_commit.py +262 -0
- simple_module_background_tasks-0.0.37/tests/test_background_tasks_heartbeat.py +158 -0
- simple_module_background_tasks-0.0.37/tests/test_background_tasks_settings.py +96 -0
- simple_module_background_tasks-0.0.37/tests/test_background_tasks_tz_columns.py +56 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_config_guards.py +159 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_index_has_executions.py +62 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_invalidation_redis.py +6 -1
- simple_module_background_tasks-0.0.37/tests/test_bg_outbox_eager_platform.py +102 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_outbox_fence_guard.py +179 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_outbox_guard_savepoint.py +148 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_outbox_loop_interrupt.py +42 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_outbox_publish_scope.py +122 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_outbox_retry_policy.py +201 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_outbox_swallowed_cancel.py +53 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_outbox_thread_loops.py +75 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_outbox_worker_chain.py +199 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_quiet_sweep_unrecorded.py +62 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_redis_url.py +43 -2
- simple_module_background_tasks-0.0.37/tests/test_bg_routes_override_api.py +46 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_runner_settings.py +171 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_service.py +11 -9
- simple_module_background_tasks-0.0.37/tests/test_bg_task_routing.py +258 -0
- simple_module_background_tasks-0.0.37/tests/test_bg_worker_settings.py +56 -0
- simple_module_background_tasks-0.0.37/tests/test_execution_tenancy.py +194 -0
- simple_module_background_tasks-0.0.37/tests/test_outbox_admin.py +150 -0
- simple_module_background_tasks-0.0.37/tests/test_outbox_concurrency.py +195 -0
- simple_module_background_tasks-0.0.37/tests/test_outbox_delivery.py +253 -0
- simple_module_background_tasks-0.0.37/tests/test_outbox_tenancy.py +95 -0
- simple_module_background_tasks-0.0.37/tests/test_outbox_worker_task.py +147 -0
- simple_module_background_tasks-0.0.37/tests/test_signal_tenant_stamp.py +99 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_signals.py +2 -2
- simple_module_background_tasks-0.0.37/tests/test_sync_db_fork.py +89 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_worker_tenancy.py +50 -1
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_workers_endpoints.py +40 -0
- simple_module_background_tasks-0.0.37/tests-js/outbox.test.ts +57 -0
- simple_module_background_tasks-0.0.37/tests-js/retry.test.ts +31 -0
- simple_module_background_tasks-0.0.35/background_tasks/deps.py +0 -19
- simple_module_background_tasks-0.0.35/background_tasks/models.py +0 -82
- simple_module_background_tasks-0.0.35/background_tasks/settings.py +0 -188
- simple_module_background_tasks-0.0.35/background_tasks/tasks.py +0 -105
- simple_module_background_tasks-0.0.35/tests/test_background_tasks_settings.py +0 -27
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/LICENSE +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/_signal_support.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/contracts/__init__.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/contracts/events.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/endpoints/__init__.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/health.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/invalidation.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/Detail.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/PayloadCards.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/RetryAllDialog.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/StatusPill.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/StatusStrip.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/TasksEmpty.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/TracebackCard.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/WorkerCard.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/WorkerEmptyStates.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/components/WorkerHealthBanner.tsx +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/pages/worker-health.ts +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/py.typed +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/services.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/background_tasks/workers_state.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/package.json +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_admin_api.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_beat_schedule.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_index_worker_presence.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_invalidation.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_invalidation_degraded.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_list_item_payload.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_page_clamp.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_redis_health.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_bg_settings_env.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_detail_props.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_index_filters.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_log_context.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_retry_failed_bulk.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_retry_failed_guards.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_retry_sweep_ordering.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_sweep_stuck.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_sync_db.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_tenant_context.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests/test_workers_uptime.py +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tests-js/task-formatters.test.ts +0 -0
- {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.37}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_background_tasks
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
27
|
-
Requires-Dist: simple-module-db==0.0.
|
|
28
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
29
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
143
|
+
BEAT_SWEEP_STUCK: {
|
|
100
144
|
"task": INTERNAL_TASK_SWEEP_STUCK,
|
|
101
145
|
"schedule": schedule(settings.stuck_sweep_interval_seconds),
|
|
102
146
|
},
|
|
103
|
-
|
|
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)
|