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.
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/.gitignore +2 -2
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/PKG-INFO +28 -5
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/README.md +23 -0
- simple_module_background_tasks-0.0.38/background_tasks/__init__.py +39 -0
- simple_module_background_tasks-0.0.38/background_tasks/_lifecycle.py +174 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/_signal_bus.py +2 -1
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/_signal_support.py +52 -1
- simple_module_background_tasks-0.0.38/background_tasks/after_commit.py +137 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/celery_app.py +70 -4
- simple_module_background_tasks-0.0.38/background_tasks/config_guards.py +220 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/constants.py +96 -3
- simple_module_background_tasks-0.0.38/background_tasks/contracts/logs.py +31 -0
- simple_module_background_tasks-0.0.38/background_tasks/contracts/outbox.py +40 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/contracts/schemas.py +52 -3
- simple_module_background_tasks-0.0.38/background_tasks/db_settings.py +34 -0
- simple_module_background_tasks-0.0.38/background_tasks/deps.py +60 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/endpoints/api_admin.py +5 -4
- simple_module_background_tasks-0.0.38/background_tasks/endpoints/api_status.py +69 -0
- simple_module_background_tasks-0.0.38/background_tasks/endpoints/logs_api.py +62 -0
- simple_module_background_tasks-0.0.38/background_tasks/endpoints/logs_views.py +44 -0
- simple_module_background_tasks-0.0.38/background_tasks/endpoints/outbox_api.py +62 -0
- simple_module_background_tasks-0.0.38/background_tasks/endpoints/outbox_views.py +43 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/endpoints/views.py +18 -5
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/filters.py +3 -3
- simple_module_background_tasks-0.0.38/background_tasks/locales/de.json +222 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/locales/en.json +71 -7
- simple_module_background_tasks-0.0.38/background_tasks/log_purge.py +16 -0
- simple_module_background_tasks-0.0.38/background_tasks/log_service.py +85 -0
- simple_module_background_tasks-0.0.38/background_tasks/log_sink.py +136 -0
- simple_module_background_tasks-0.0.38/background_tasks/models.py +221 -0
- simple_module_background_tasks-0.0.38/background_tasks/module.py +180 -0
- simple_module_background_tasks-0.0.38/background_tasks/outbox_claim.py +199 -0
- simple_module_background_tasks-0.0.38/background_tasks/outbox_clock.py +53 -0
- simple_module_background_tasks-0.0.38/background_tasks/outbox_dispatch.py +250 -0
- simple_module_background_tasks-0.0.38/background_tasks/outbox_guard.py +113 -0
- simple_module_background_tasks-0.0.38/background_tasks/outbox_loop.py +130 -0
- simple_module_background_tasks-0.0.38/background_tasks/outbox_service.py +156 -0
- simple_module_background_tasks-0.0.38/background_tasks/outbox_support.py +100 -0
- simple_module_background_tasks-0.0.38/background_tasks/outbox_sweep.py +58 -0
- simple_module_background_tasks-0.0.38/background_tasks/outbox_transport.py +195 -0
- simple_module_background_tasks-0.0.38/background_tasks/outbox_worker.py +186 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/Detail.tsx +28 -11
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/Index.tsx +20 -14
- simple_module_background_tasks-0.0.38/background_tasks/pages/Logs.tsx +92 -0
- simple_module_background_tasks-0.0.38/background_tasks/pages/Outbox.tsx +120 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/Workers.tsx +21 -23
- simple_module_background_tasks-0.0.38/background_tasks/pages/api.generated.ts +965 -0
- simple_module_background_tasks-0.0.38/background_tasks/pages/api.ts +9 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/ExecutionsTable.tsx +2 -33
- simple_module_background_tasks-0.0.38/background_tasks/pages/components/LogLevelSelect.tsx +29 -0
- simple_module_background_tasks-0.0.38/background_tasks/pages/components/LogTable.tsx +76 -0
- simple_module_background_tasks-0.0.38/background_tasks/pages/components/LogsPanel.tsx +46 -0
- simple_module_background_tasks-0.0.38/background_tasks/pages/components/OutboxTable.tsx +154 -0
- simple_module_background_tasks-0.0.38/background_tasks/pages/components/ProgressCard.tsx +43 -0
- simple_module_background_tasks-0.0.38/background_tasks/pages/components/RoutesTable.tsx +60 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/WorkerHealthBanner.tsx +12 -10
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/constants.ts +15 -0
- simple_module_background_tasks-0.0.38/background_tasks/pages/logs.ts +53 -0
- simple_module_background_tasks-0.0.38/background_tasks/pages/outbox.ts +94 -0
- simple_module_background_tasks-0.0.38/background_tasks/pages/retry.ts +111 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/retry_service.py +90 -39
- simple_module_background_tasks-0.0.38/background_tasks/runner.py +85 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/services.py +4 -0
- simple_module_background_tasks-0.0.38/background_tasks/settings.py +147 -0
- simple_module_background_tasks-0.0.38/background_tasks/settings_celery.py +201 -0
- simple_module_background_tasks-0.0.38/background_tasks/settings_env_checks.py +68 -0
- simple_module_background_tasks-0.0.38/background_tasks/settings_logs.py +45 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/signals.py +20 -44
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/sync_db.py +98 -13
- simple_module_background_tasks-0.0.38/background_tasks/task_heartbeat.py +147 -0
- simple_module_background_tasks-0.0.38/background_tasks/task_routing.py +158 -0
- simple_module_background_tasks-0.0.38/background_tasks/tasks.py +181 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/tenant_context.py +86 -6
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/worker_settings.py +29 -2
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/package.json +2 -1
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/pyproject.toml +5 -5
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/conftest.py +45 -1
- simple_module_background_tasks-0.0.38/tests/test_background_tasks_after_commit.py +262 -0
- simple_module_background_tasks-0.0.38/tests/test_background_tasks_heartbeat.py +158 -0
- simple_module_background_tasks-0.0.38/tests/test_background_tasks_settings.py +96 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_config_guards.py +159 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_db_timeouts.py +95 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_index_has_executions.py +62 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_inprocess_sweep.py +187 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_invalidation.py +1 -1
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_invalidation_degraded.py +12 -8
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_invalidation_redis.py +49 -32
- simple_module_background_tasks-0.0.38/tests/test_bg_log_api.py +118 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_log_purge.py +45 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_log_sink.py +102 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_log_sink_loop.py +60 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_log_sink_nul.py +26 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_outbox_eager_platform.py +95 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_outbox_fence_guard.py +179 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_outbox_guard_savepoint.py +148 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_outbox_loop_interrupt.py +42 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_outbox_publish_scope.py +122 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_outbox_retry_policy.py +203 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_outbox_swallowed_cancel.py +53 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_outbox_thread_loops.py +93 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_outbox_worker_chain.py +199 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_pagination_bounds.py +51 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_quiet_sweep_unrecorded.py +62 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_redis_url.py +43 -2
- simple_module_background_tasks-0.0.38/tests/test_bg_routes_override_api.py +46 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_runner_settings.py +177 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_service.py +11 -9
- simple_module_background_tasks-0.0.38/tests/test_bg_startup_log_masking.py +26 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_task_progress.py +139 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_task_progress_audit.py +53 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_task_routing.py +258 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_task_status_api.py +122 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_users_ordering.py +30 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_worker_audit.py +129 -0
- simple_module_background_tasks-0.0.38/tests/test_bg_worker_audit_bookkeeping.py +96 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_worker_settings.py +1 -1
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_execution_tenancy.py +2 -2
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_log_context.py +5 -5
- simple_module_background_tasks-0.0.38/tests/test_outbox_admin.py +150 -0
- simple_module_background_tasks-0.0.38/tests/test_outbox_concurrency.py +198 -0
- simple_module_background_tasks-0.0.38/tests/test_outbox_delivery.py +253 -0
- simple_module_background_tasks-0.0.38/tests/test_outbox_tenancy.py +95 -0
- simple_module_background_tasks-0.0.38/tests/test_outbox_worker_task.py +147 -0
- simple_module_background_tasks-0.0.38/tests/test_sync_db_fork.py +89 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_worker_inspector.py +4 -1
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_workers_endpoints.py +40 -0
- simple_module_background_tasks-0.0.38/tests-js/background_tasks_DetailPage.test.tsx +158 -0
- simple_module_background_tasks-0.0.38/tests-js/background_tasks_IndexPage.test.tsx +180 -0
- simple_module_background_tasks-0.0.38/tests-js/background_tasks_LogsTab.test.tsx +56 -0
- simple_module_background_tasks-0.0.38/tests-js/background_tasks_OutboxPage.test.tsx +177 -0
- simple_module_background_tasks-0.0.38/tests-js/background_tasks_WorkerHealthBanner.test.tsx +63 -0
- simple_module_background_tasks-0.0.38/tests-js/outbox.test.ts +50 -0
- simple_module_background_tasks-0.0.38/tests-js/retry.test.ts +31 -0
- simple_module_background_tasks-0.0.38/tests-js/stub-fetch.ts +18 -0
- simple_module_background_tasks-0.0.36/background_tasks/__init__.py +0 -15
- simple_module_background_tasks-0.0.36/background_tasks/deps.py +0 -19
- simple_module_background_tasks-0.0.36/background_tasks/models.py +0 -95
- simple_module_background_tasks-0.0.36/background_tasks/module.py +0 -258
- simple_module_background_tasks-0.0.36/background_tasks/pages/retry.ts +0 -98
- simple_module_background_tasks-0.0.36/background_tasks/settings.py +0 -188
- simple_module_background_tasks-0.0.36/background_tasks/tasks.py +0 -105
- simple_module_background_tasks-0.0.36/tests/test_background_tasks_settings.py +0 -27
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/LICENSE +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/contracts/__init__.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/contracts/events.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/endpoints/__init__.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/health.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/invalidation.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/log_context.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/DetailFacts.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/PayloadCards.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/RetryAllDialog.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/StatusPill.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/StatusStrip.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/TaskFilters.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/TasksEmpty.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/TracebackCard.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/WorkerCard.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/components/WorkerEmptyStates.tsx +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/pages/worker-health.ts +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/py.typed +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/service.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/background_tasks/workers_state.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_admin_api.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_background_tasks_tz_columns.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_beat_schedule.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_index_worker_presence.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_list_item_payload.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_page_clamp.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_redis_health.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_bg_settings_env.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_detail_props.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_index_filters.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_retry_failed_bulk.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_retry_failed_guards.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_retry_sweep_ordering.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_signal_tenant_stamp.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_signals.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_sweep_stuck.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_sync_db.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_tenant_context.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_worker_tenancy.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests/test_workers_uptime.py +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/tests-js/task-formatters.test.ts +0 -0
- {simple_module_background_tasks-0.0.36 → simple_module_background_tasks-0.0.38}/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.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.
|
|
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.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:
|
|
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
|
-
|
|
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.
|