simple-module-background-tasks 0.0.35__tar.gz → 0.0.36__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/PKG-INFO +5 -5
  2. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/__init__.py +2 -0
  3. simple_module_background_tasks-0.0.36/background_tasks/_signal_bus.py +61 -0
  4. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/constants.py +4 -0
  5. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/contracts/schemas.py +2 -0
  6. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/endpoints/api_admin.py +12 -3
  7. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/endpoints/views.py +13 -2
  8. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/filters.py +14 -3
  9. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/locales/en.json +9 -3
  10. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/log_context.py +2 -2
  11. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/models.py +13 -0
  12. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/module.py +3 -2
  13. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/Index.tsx +27 -5
  14. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/DetailFacts.tsx +4 -0
  15. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/ExecutionRow.tsx +3 -0
  16. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/ExecutionsTable.tsx +5 -2
  17. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/TaskFilters.tsx +42 -1
  18. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/constants.ts +4 -0
  19. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/retry.ts +5 -1
  20. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/retry_service.py +16 -3
  21. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/service.py +33 -5
  22. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/signals.py +44 -58
  23. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/sync_db.py +23 -7
  24. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/tenant_context.py +31 -3
  25. simple_module_background_tasks-0.0.36/background_tasks/worker_settings.py +57 -0
  26. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/pyproject.toml +5 -5
  27. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/conftest.py +2 -0
  28. simple_module_background_tasks-0.0.36/tests/test_background_tasks_tz_columns.py +56 -0
  29. simple_module_background_tasks-0.0.36/tests/test_bg_worker_settings.py +56 -0
  30. simple_module_background_tasks-0.0.36/tests/test_execution_tenancy.py +194 -0
  31. simple_module_background_tasks-0.0.36/tests/test_signal_tenant_stamp.py +99 -0
  32. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_signals.py +2 -2
  33. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_worker_tenancy.py +50 -1
  34. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/.gitignore +0 -0
  35. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/LICENSE +0 -0
  36. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/README.md +0 -0
  37. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/_signal_support.py +0 -0
  38. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/celery_app.py +0 -0
  39. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/contracts/__init__.py +0 -0
  40. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/contracts/events.py +0 -0
  41. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/deps.py +0 -0
  42. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/endpoints/__init__.py +0 -0
  43. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/health.py +0 -0
  44. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/invalidation.py +0 -0
  45. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/Detail.tsx +0 -0
  46. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/Workers.tsx +0 -0
  47. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/PayloadCards.tsx +0 -0
  48. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/RetryAllDialog.tsx +0 -0
  49. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
  50. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/StatusPill.tsx +0 -0
  51. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/StatusStrip.tsx +0 -0
  52. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/TasksEmpty.tsx +0 -0
  53. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/TracebackCard.tsx +0 -0
  54. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/WorkerCard.tsx +0 -0
  55. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/WorkerEmptyStates.tsx +0 -0
  56. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/WorkerHealthBanner.tsx +0 -0
  57. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/pages/worker-health.ts +0 -0
  58. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/py.typed +0 -0
  59. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/services.py +0 -0
  60. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/settings.py +0 -0
  61. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/tasks.py +0 -0
  62. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/worker_inspector.py +0 -0
  63. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/background_tasks/workers_state.py +0 -0
  64. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/package.json +0 -0
  65. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_admin_api.py +0 -0
  66. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_background_tasks_settings.py +0 -0
  67. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_beat_schedule.py +0 -0
  68. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_bg_index_worker_presence.py +0 -0
  69. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_bg_invalidation.py +0 -0
  70. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_bg_invalidation_degraded.py +0 -0
  71. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_bg_invalidation_redis.py +0 -0
  72. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_bg_list_item_payload.py +0 -0
  73. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_bg_page_clamp.py +0 -0
  74. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_bg_redis_health.py +0 -0
  75. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_bg_redis_url.py +0 -0
  76. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_bg_service.py +0 -0
  77. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_bg_settings_env.py +0 -0
  78. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_detail_props.py +0 -0
  79. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_index_filters.py +0 -0
  80. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_log_context.py +0 -0
  81. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_retry_failed_bulk.py +0 -0
  82. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_retry_failed_guards.py +0 -0
  83. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_retry_sweep_ordering.py +0 -0
  84. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_sweep_stuck.py +0 -0
  85. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_sync_db.py +0 -0
  86. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_tenant_context.py +0 -0
  87. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_worker_inspector.py +0 -0
  88. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_workers_endpoints.py +0 -0
  89. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests/test_workers_uptime.py +0 -0
  90. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/tests-js/task-formatters.test.ts +0 -0
  91. {simple_module_background_tasks-0.0.35 → simple_module_background_tasks-0.0.36}/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.35
3
+ Version: 0.0.36
4
4
  Summary: Celery + Redis task queue with admin UI for monitoring and retrying failed/stuck tasks
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -23,10 +23,10 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: celery[redis]>=5.4
25
25
  Requires-Dist: redis>=5
26
- Requires-Dist: simple-module-core==0.0.35
27
- Requires-Dist: simple-module-db==0.0.35
28
- Requires-Dist: simple-module-hosting==0.0.35
29
- Requires-Dist: simple-module-settings==0.0.35
26
+ Requires-Dist: simple-module-core==0.0.36
27
+ Requires-Dist: simple-module-db==0.0.36
28
+ Requires-Dist: simple-module-hosting==0.0.36
29
+ Requires-Dist: simple-module-settings==0.0.36
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  # simple_module_background_tasks
@@ -5,9 +5,11 @@ from background_tasks.log_context import (
5
5
  get_log_context,
6
6
  install_log_filter,
7
7
  )
8
+ from background_tasks.worker_settings import settings_for
8
9
 
9
10
  __all__ = [
10
11
  "bind_task_context",
11
12
  "get_log_context",
12
13
  "install_log_filter",
14
+ "settings_for",
13
15
  ]
@@ -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)
@@ -102,3 +102,7 @@ INTERNAL_TASK_PURGE_OLD = "background_tasks.purge_old_executions"
102
102
  # Harmless round-trip task; only wired into CI smoke tests and local dev —
103
103
  # not scheduled, not invoked by other modules.
104
104
  DEMO_ECHO_TASK = "background_tasks.demo_echo"
105
+
106
+ TENANT_ID_MAX_LENGTH = 50
107
+ # Filter value meaning "executions with no tenant" (beat / platform publishes).
108
+ PLATFORM_TENANT_FILTER = "__platform__"
@@ -28,6 +28,7 @@ class TaskExecutionListItem(SQLModel):
28
28
  task_name: str
29
29
  status: TaskStatus
30
30
  queue: str
31
+ tenant_id: str | None = None
31
32
  args: list[Any] = []
32
33
  kwargs: dict[str, Any] = {}
33
34
  retries: int
@@ -49,6 +50,7 @@ class TaskExecutionDetail(SQLModel):
49
50
  task_name: str
50
51
  status: TaskStatus
51
52
  queue: str
53
+ tenant_id: str | None = None
52
54
  args: list[Any] = []
53
55
  kwargs: dict[str, Any] = {}
54
56
  result: dict[str, Any] | None = None
@@ -37,6 +37,7 @@ async def list_executions(
37
37
  q: str | None = Query(default=None),
38
38
  task_name: str | None = Query(default=None, deprecated=True),
39
39
  queue: str | None = Query(default=None),
40
+ tenant_id: str | None = Query(default=None),
40
41
  page: int = Query(default=1, ge=1),
41
42
  per_page: int = Query(default=20, ge=1, le=200),
42
43
  service: BackgroundTaskService = Depends(get_background_task_service),
@@ -50,7 +51,12 @@ async def list_executions(
50
51
  existing API callers keep working, and ``q`` wins when both are given.
51
52
  """
52
53
  return await service.list(
53
- status=status, task_name=q or task_name, queue=queue, page=page, per_page=per_page
54
+ status=status,
55
+ task_name=q or task_name,
56
+ queue=queue,
57
+ tenant_id=tenant_id,
58
+ page=page,
59
+ per_page=per_page,
54
60
  )
55
61
 
56
62
 
@@ -74,15 +80,18 @@ async def retry_failed_executions(
74
80
  status: TaskStatus | None = Query(default=None),
75
81
  task_name: str | None = Query(default=None, alias="q"),
76
82
  queue: str | None = Query(default=None),
83
+ tenant_id: str | None = Query(default=None),
77
84
  service: BackgroundTaskService = Depends(get_background_task_service),
78
85
  ) -> RetryFailedResult:
79
86
  """Re-enqueue every failed or stuck execution the current filter can see.
80
87
 
81
- Takes the same three filters as the listing — status, search, queue — so
88
+ Takes the same filters as the listing — status, search, queue, tenant — so
82
89
  the sweep covers exactly the rows the operator is looking at and nothing
83
90
  else. Capped per call; the response says how many eligible rows are left.
84
91
  """
85
- return await service.retry_failed(status=status, task_name=task_name, queue=queue)
92
+ return await service.retry_failed(
93
+ status=status, task_name=task_name, queue=queue, tenant_id=tenant_id
94
+ )
86
95
 
87
96
 
88
97
  @router.post(
@@ -12,6 +12,7 @@ from simple_module_inertia import InertiaResponse
12
12
 
13
13
  from background_tasks.constants import (
14
14
  PERM_VIEW,
15
+ PLATFORM_TENANT_FILTER,
15
16
  TaskStatus,
16
17
  )
17
18
  from background_tasks.deps import get_background_task_service
@@ -53,6 +54,7 @@ async def index(
53
54
  status: TaskStatus | None = Query(default=None),
54
55
  task_name: str = Query(default="", alias="q"),
55
56
  queue: str = Query(default=""),
57
+ tenant_id: str = Query(default="", alias="tenant"),
56
58
  page: int = Query(default=1),
57
59
  service: BackgroundTaskService = Depends(get_background_task_service),
58
60
  ) -> InertiaResponse:
@@ -60,22 +62,28 @@ async def index(
60
62
  status=status,
61
63
  task_name=task_name or None,
62
64
  queue=queue or None,
65
+ tenant_id=tenant_id or None,
63
66
  page=page,
64
67
  per_page=PER_PAGE,
65
68
  )
66
69
  # A filtered-empty list says nothing about the fleet, so don't pay to poll
67
70
  # it: the screen already knows to blame the filter.
68
- unfiltered_and_empty = response.total == 0 and not task_name and not queue and status is None
71
+ unfiltered_and_empty = (
72
+ response.total == 0 and not task_name and not queue and not tenant_id and status is None
73
+ )
69
74
 
70
75
  async def strip_data() -> tuple[dict[str, int], list[str]]:
71
76
  """Everything above the table. Serial: one session, one connection."""
72
- counts = await service.status_counts(task_name=task_name or None, queue=queue or None)
77
+ counts = await service.status_counts(
78
+ task_name=task_name or None, queue=queue or None, tenant_id=tenant_id or None
79
+ )
73
80
  # The strip's third tile is a windowed throughput reading rather than a
74
81
  # status total, so it needs its own count — see `success_count_since`.
75
82
  counts["success_24h"] = await service.success_count_since(
76
83
  hours=SUCCESS_WINDOW_HOURS,
77
84
  task_name=task_name or None,
78
85
  queue=queue or None,
86
+ tenant_id=tenant_id or None,
79
87
  )
80
88
  # Deliberately unfiltered: the dropdown is how an operator leaves the
81
89
  # queue they are in, so it keeps offering the ones they are not in.
@@ -97,6 +105,8 @@ async def index(
97
105
  "success_24h": counts["success_24h"],
98
106
  },
99
107
  "queues": queues,
108
+ "tenant_ids": await service.tenant_ids(),
109
+ "platform_tenant_value": PLATFORM_TENANT_FILTER,
100
110
  "worker_presence": presence,
101
111
  "pagination": {
102
112
  "page": response.page,
@@ -107,6 +117,7 @@ async def index(
107
117
  "status": status.value if status else "",
108
118
  "task_name": task_name,
109
119
  "queue": queue,
120
+ "tenant": tenant_id,
110
121
  },
111
122
  },
112
123
  )
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  from simple_module_db import LIKE_ESCAPE_CHAR, like_contains_pattern
12
12
  from sqlalchemy import ColumnElement, select
13
13
 
14
- from background_tasks.constants import RETRYABLE_STATUSES, TaskStatus
14
+ from background_tasks.constants import PLATFORM_TENANT_FILTER, RETRYABLE_STATUSES, TaskStatus
15
15
  from background_tasks.models import TaskExecution
16
16
 
17
17
  Conditions = list[ColumnElement[bool]]
@@ -22,9 +22,19 @@ def execution_filters(
22
22
  status: TaskStatus | None = None,
23
23
  task_name: str | None = None,
24
24
  queue: str | None = None,
25
+ tenant_id: str | None = None,
25
26
  ) -> Conditions:
26
- """The three axes the executions screen filters on."""
27
+ """The axes the executions screen filters on.
28
+
29
+ *tenant_id* is a tenant id, or :data:`PLATFORM_TENANT_FILTER` for
30
+ executions published with no tenant. The screen is platform-wide; tenant is
31
+ a filter, not a scope.
32
+ """
27
33
  conditions: Conditions = []
34
+ if tenant_id == PLATFORM_TENANT_FILTER:
35
+ conditions.append(TaskExecution.tenant_id.is_(None))
36
+ elif tenant_id:
37
+ conditions.append(TaskExecution.tenant_id == tenant_id)
28
38
  if status is not None:
29
39
  conditions.append(TaskExecution.status == status)
30
40
  if task_name:
@@ -41,6 +51,7 @@ def bulk_retry_conditions(
41
51
  status: TaskStatus | None = None,
42
52
  task_name: str | None = None,
43
53
  queue: str | None = None,
54
+ tenant_id: str | None = None,
44
55
  ) -> Conditions | None:
45
56
  """What the bulk sweep may touch, or ``None`` when it may touch nothing.
46
57
 
@@ -64,6 +75,6 @@ def bulk_retry_conditions(
64
75
  already_retried = select(child.c.id).where(child.c.retried_from_id == TaskExecution.id).exists()
65
76
  return [
66
77
  TaskExecution.status.in_(sorted(wanted)),
67
- *execution_filters(task_name=task_name, queue=queue),
78
+ *execution_filters(task_name=task_name, queue=queue, tenant_id=tenant_id),
68
79
  ~already_retried,
69
80
  ]
@@ -26,7 +26,11 @@
26
26
  "stuck": "Stuck",
27
27
  "queue_label": "Queue:",
28
28
  "queue_all": "all",
29
- "queue_aria": "Filter by queue"
29
+ "queue_aria": "Filter by queue",
30
+ "tenant_label": "Tenant:",
31
+ "tenant_all": "all",
32
+ "tenant_platform": "Platform",
33
+ "tenant_aria": "Filter by tenant"
30
34
  },
31
35
  "status": {
32
36
  "queued": "queued",
@@ -44,7 +48,8 @@
44
48
  "queued_at": "Queued",
45
49
  "duration": "Duration",
46
50
  "actions": "Actions",
47
- "retry": "Retry"
51
+ "retry": "Retry",
52
+ "tenant": "Tenant"
48
53
  },
49
54
  "detail": {
50
55
  "head_title": "Task detail",
@@ -69,7 +74,8 @@
69
74
  "traceback": "Traceback",
70
75
  "copy": "Copy",
71
76
  "copied": "Copied",
72
- "no_traceback": "No traceback recorded."
77
+ "no_traceback": "No traceback recorded.",
78
+ "tenant": "Tenant"
73
79
  },
74
80
  "retry_dialog": {
75
81
  "title": "Retry {name}?",
@@ -10,7 +10,7 @@ Celery signals plus arbitrary domain identifiers via
10
10
  from __future__ import annotations
11
11
 
12
12
  import logging
13
- from collections.abc import Iterator, Mapping
13
+ from collections.abc import Generator, Mapping
14
14
  from contextlib import contextmanager
15
15
  from contextvars import ContextVar, Token
16
16
  from types import MappingProxyType
@@ -46,7 +46,7 @@ def get_log_context() -> dict[str, Any]:
46
46
 
47
47
 
48
48
  @contextmanager
49
- def bind_task_context(**identifiers: Any) -> Iterator[None]:
49
+ def bind_task_context(**identifiers: Any) -> Generator[None]:
50
50
  """Layer ``identifiers`` onto the current task's log context.
51
51
 
52
52
  Nests cleanly. Raises ``ValueError`` if any key collides with a
@@ -15,6 +15,7 @@ from background_tasks.constants import (
15
15
  DEFAULT_QUEUE,
16
16
  MODULE_NAME,
17
17
  TABLE_TASK_EXECUTION,
18
+ TENANT_ID_MAX_LENGTH,
18
19
  TaskStatus,
19
20
  )
20
21
 
@@ -48,6 +49,12 @@ class TaskExecution(Base, AuditMixin, table=True): # ty: ignore[unsupported-bas
48
49
  )
49
50
  queue: str = Field(default=DEFAULT_QUEUE, max_length=64)
50
51
 
52
+ # The tenant the task was published for; NULL for beat and other platform
53
+ # publishes. Deliberately not MultiTenantMixin: those rows have no tenant
54
+ # and strict mode would raise on their insert. The admin screens are
55
+ # platform-wide and filter on this column.
56
+ tenant_id: str | None = Field(default=None, index=True, max_length=TENANT_ID_MAX_LENGTH)
57
+
51
58
  args: list[Any] = Field(default_factory=list, sa_column=Column(JSON))
52
59
  kwargs: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
53
60
  result: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
@@ -79,4 +86,10 @@ class TaskExecution(Base, AuditMixin, table=True): # ty: ignore[unsupported-bas
79
86
  "status",
80
87
  "queued_at",
81
88
  ),
89
+ Index(
90
+ "ix_background_tasks_task_execution_tenant_status_queued",
91
+ "tenant_id",
92
+ "status",
93
+ "queued_at",
94
+ ),
82
95
  )
@@ -161,8 +161,9 @@ class BackgroundTasksModule(ModuleBase):
161
161
  # SQLite default and silently drop ``TaskExecution`` rows.
162
162
  host = app.state.sm.settings
163
163
  multi = bool(getattr(host, "multi_tenant", False))
164
- set_database_url(host.database_url, tenant_strict=multi)
165
- set_default_tenant(None if multi else getattr(host, "default_tenant", "") or None)
164
+ default_tenant = None if multi else getattr(host, "default_tenant", "") or None
165
+ set_database_url(host.database_url, tenant_strict=multi, default_tenant=default_tenant)
166
+ set_default_tenant(default_tenant)
166
167
  # build_celery imports `signals` for side effects and runs
167
168
  # `autodiscover_tasks` across every installed module.
168
169
  services.celery = build_celery(services.settings)
@@ -12,7 +12,7 @@ import { type StatusCounts, StatusStrip } from './components/StatusStrip';
12
12
  import { TaskFilters } from './components/TaskFilters';
13
13
  import { TasksEmptyRow, type WorkerPresence } from './components/TasksEmpty';
14
14
  import { WorkerHealthBanner } from './components/WorkerHealthBanner';
15
- import { QUEUE_ALL, STATUS_ALL, TASK_STATUS, VIEW_BASE } from './constants';
15
+ import { QUEUE_ALL, STATUS_ALL, TASK_STATUS, TENANT_ALL, VIEW_BASE } from './constants';
16
16
  import { type Execution, retryAllFailed, retryExecution } from './retry';
17
17
 
18
18
  interface Pagination {
@@ -25,6 +25,7 @@ interface Filters {
25
25
  status: string;
26
26
  task_name: string;
27
27
  queue: string;
28
+ tenant: string;
28
29
  }
29
30
 
30
31
  interface Props {
@@ -34,6 +35,10 @@ interface Props {
34
35
  status_counts: StatusCounts;
35
36
  /** Every queue that has run work, for the dropdown. */
36
37
  queues: string[];
38
+ /** Tenants that have published work, for the dropdown. */
39
+ tenant_ids: string[];
40
+ /** Filter value selecting executions with no tenant. */
41
+ platform_tenant_value: string;
37
42
  /** Null unless the unfiltered list came back empty — see the index view. */
38
43
  worker_presence: WorkerPresence | null;
39
44
  }
@@ -47,6 +52,7 @@ function pushFilters(filters: Filters, page: number): void {
47
52
  if (filters.task_name) params.q = filters.task_name;
48
53
  if (filters.status && filters.status !== STATUS_ALL) params.status = filters.status;
49
54
  if (filters.queue && filters.queue !== QUEUE_ALL) params.queue = filters.queue;
55
+ if (filters.tenant && filters.tenant !== TENANT_ALL) params.tenant = filters.tenant;
50
56
  if (page > 1) params.page = String(page);
51
57
  router.get(VIEW_BASE, params, { preserveState: true, preserveScroll: true });
52
58
  }
@@ -58,6 +64,8 @@ function Index() {
58
64
  filters: initialFilters,
59
65
  status_counts: statusCounts,
60
66
  queues,
67
+ tenant_ids: tenantIds,
68
+ platform_tenant_value: platformTenantValue,
61
69
  worker_presence: workerPresence,
62
70
  } = usePage<{ props: Props }>().props as unknown as Props;
63
71
 
@@ -71,16 +79,21 @@ function Index() {
71
79
  const [busy, setBusy] = useState(false);
72
80
  const statusValue = initialFilters.status || STATUS_ALL;
73
81
  const queueValue = initialFilters.queue || QUEUE_ALL;
82
+ const tenantValue = initialFilters.tenant || TENANT_ALL;
74
83
  // Derived from the server-confirmed filters, not the live `search` input:
75
84
  // `workerPresence` reflects the last committed request, so mixing it with
76
85
  // unsubmitted local state would flash the wrong empty-state copy during the
77
86
  // debounce window between a keystroke and the resulting navigation.
78
87
  const isFiltered =
79
- !!(initialFilters.task_name ?? '') || statusValue !== STATUS_ALL || queueValue !== QUEUE_ALL;
88
+ !!(initialFilters.task_name ?? '') ||
89
+ statusValue !== STATUS_ALL ||
90
+ queueValue !== QUEUE_ALL ||
91
+ tenantValue !== TENANT_ALL;
80
92
  const filters: Filters = {
81
93
  status: statusValue,
82
94
  task_name: search,
83
95
  queue: queueValue,
96
+ tenant: tenantValue,
84
97
  };
85
98
 
86
99
  // Work that is supposed to be moving. Counting `running` too is deliberate:
@@ -101,7 +114,7 @@ function Index() {
101
114
  // clear, and an armed flag would swallow the user's next keystroke.
102
115
  if (search !== '') skipNextDebounceRef.current = true;
103
116
  setSearch('');
104
- pushFilters({ status: STATUS_ALL, task_name: '', queue: QUEUE_ALL }, 1);
117
+ pushFilters({ status: STATUS_ALL, task_name: '', queue: QUEUE_ALL, tenant: TENANT_ALL }, 1);
105
118
  }
106
119
 
107
120
  // Debounce search: any change from the server-provided value kicks off a
@@ -113,11 +126,15 @@ function Index() {
113
126
  }
114
127
  if (search === (initialFilters.task_name ?? '')) return;
115
128
  const timeout = setTimeout(
116
- () => pushFilters({ status: statusValue, task_name: search, queue: queueValue }, 1),
129
+ () =>
130
+ pushFilters(
131
+ { status: statusValue, task_name: search, queue: queueValue, tenant: tenantValue },
132
+ 1,
133
+ ),
117
134
  300,
118
135
  );
119
136
  return () => clearTimeout(timeout);
120
- }, [search, initialFilters.task_name, statusValue, queueValue]);
137
+ }, [search, initialFilters.task_name, statusValue, queueValue, tenantValue]);
121
138
 
122
139
  async function handleRetry() {
123
140
  if (!retryTarget) return;
@@ -134,6 +151,7 @@ function Index() {
134
151
  status: statusValue,
135
152
  taskName: initialFilters.task_name ?? '',
136
153
  queue: queueValue,
154
+ tenant: tenantValue,
137
155
  });
138
156
  setBusy(false);
139
157
  setRetryAllOpen(false);
@@ -188,6 +206,10 @@ function Index() {
188
206
  queue={queueValue}
189
207
  onQueueChange={(queue) => pushFilters({ ...filters, queue }, 1)}
190
208
  queues={queues ?? []}
209
+ tenant={tenantValue}
210
+ onTenantChange={(tenant) => pushFilters({ ...filters, tenant }, 1)}
211
+ tenantIds={tenantIds ?? []}
212
+ platformTenantValue={platformTenantValue}
191
213
  />
192
214
 
193
215
  <ExecutionsTable
@@ -45,6 +45,10 @@ export function DetailFacts({ execution, className }: Props) {
45
45
 
46
46
  const facts: Fact[] = [
47
47
  { label: t(keys.background_tasks.detail.queue), value: execution.queue },
48
+ {
49
+ label: t(keys.background_tasks.detail.tenant),
50
+ value: execution.tenant_id ?? t(keys.background_tasks.filters.tenant_platform),
51
+ },
48
52
  {
49
53
  label: t(keys.background_tasks.detail.worker),
50
54
  value: execution.worker ? (
@@ -43,6 +43,9 @@ export function ExecutionRow({ execution, canRetry, onRetry }: Props) {
43
43
  <TableCell className="hidden text-sm text-muted-foreground md:table-cell">
44
44
  {execution.queue}
45
45
  </TableCell>
46
+ <TableCell className="hidden font-mono text-xs text-muted-foreground md:table-cell">
47
+ {execution.tenant_id ?? t(keys.background_tasks.filters.tenant_platform)}
48
+ </TableCell>
46
49
  <TableCell className="hidden text-sm text-muted-foreground lg:table-cell">
47
50
  {ago(execution.queued_at)}
48
51
  </TableCell>
@@ -12,8 +12,8 @@ import type { ReactNode } from 'react';
12
12
  import type { Execution } from '../retry';
13
13
  import { ExecutionRow } from './ExecutionRow';
14
14
 
15
- /** Task, Status, Queue, Queued, Duration, Actions. */
16
- export const COLUMN_COUNT = 6;
15
+ /** Task, Status, Queue, Tenant, Queued, Duration, Actions. */
16
+ export const COLUMN_COUNT = 7;
17
17
 
18
18
  // Same header treatment as the other admin tables (users, audit log, flags).
19
19
  const TH = 'text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground';
@@ -63,6 +63,9 @@ export function ExecutionsTable({
63
63
  <TableHead className={`${TH} hidden md:table-cell`}>
64
64
  {t(keys.background_tasks.table.queue)}
65
65
  </TableHead>
66
+ <TableHead className={`${TH} hidden md:table-cell`}>
67
+ {t(keys.background_tasks.table.tenant)}
68
+ </TableHead>
66
69
  <TableHead className={`${TH} hidden lg:table-cell`}>
67
70
  {t(keys.background_tasks.table.queued_at)}
68
71
  </TableHead>
@@ -9,7 +9,13 @@ import {
9
9
  SelectValue,
10
10
  } from '@simple-module-py/ui/components/ui/select';
11
11
  import { Search } from 'lucide-react';
12
- import { QUEUE_ALL, SEGMENT_LABEL_KEY, SEGMENT_STATUSES, STATUS_ALL } from '../constants';
12
+ import {
13
+ QUEUE_ALL,
14
+ SEGMENT_LABEL_KEY,
15
+ SEGMENT_STATUSES,
16
+ STATUS_ALL,
17
+ TENANT_ALL,
18
+ } from '../constants';
13
19
 
14
20
  interface Props {
15
21
  search: string;
@@ -22,6 +28,13 @@ interface Props {
22
28
  onQueueChange: (next: string) => void;
23
29
  /** Every queue that has run work, for the dropdown. */
24
30
  queues: string[];
31
+ /** Active tenant filter, or `TENANT_ALL`. */
32
+ tenant: string;
33
+ onTenantChange: (next: string) => void;
34
+ /** Tenants that have published work, for the dropdown. */
35
+ tenantIds: string[];
36
+ /** Filter value selecting executions with no tenant (platform publishes). */
37
+ platformTenantValue: string;
25
38
  }
26
39
 
27
40
  /**
@@ -40,6 +53,10 @@ export function TaskFilters({
40
53
  queue,
41
54
  onQueueChange,
42
55
  queues,
56
+ tenant,
57
+ onTenantChange,
58
+ tenantIds,
59
+ platformTenantValue,
43
60
  }: Props) {
44
61
  const { t } = useT();
45
62
  const options = SEGMENT_STATUSES.map((value) => ({
@@ -87,6 +104,30 @@ export function TaskFilters({
87
104
  ))}
88
105
  </SelectContent>
89
106
  </Select>
107
+ <Select value={tenant || TENANT_ALL} onValueChange={onTenantChange}>
108
+ <SelectTrigger
109
+ aria-label={t(keys.background_tasks.filters.tenant_aria)}
110
+ className="gap-1.5 max-lg:min-h-11"
111
+ >
112
+ <span className="text-muted-foreground">
113
+ {t(keys.background_tasks.filters.tenant_label)}
114
+ </span>
115
+ <SelectValue />
116
+ </SelectTrigger>
117
+ <SelectContent>
118
+ <SelectItem value={TENANT_ALL}>
119
+ {t(keys.background_tasks.filters.tenant_all)}
120
+ </SelectItem>
121
+ <SelectItem value={platformTenantValue}>
122
+ {t(keys.background_tasks.filters.tenant_platform)}
123
+ </SelectItem>
124
+ {tenantIds.map((id) => (
125
+ <SelectItem key={id} value={id} className="font-mono">
126
+ {id}
127
+ </SelectItem>
128
+ ))}
129
+ </SelectContent>
130
+ </Select>
90
131
  </div>
91
132
  </div>
92
133
  );
@@ -95,6 +95,7 @@ export interface TaskDetail {
95
95
  task_name: string;
96
96
  status: TaskStatus;
97
97
  queue: string;
98
+ tenant_id: string | null;
98
99
  args: unknown[];
99
100
  kwargs: Record<string, unknown>;
100
101
  result: Record<string, unknown> | null;
@@ -192,6 +193,9 @@ export const STATUS_ALL = 'all';
192
193
  */
193
194
  export const QUEUE_ALL = '__all__';
194
195
 
196
+ /** Sentinel for "no tenant filter" in the tenant dropdown. */
197
+ export const TENANT_ALL = '__all__';
198
+
195
199
  /**
196
200
  * The four the segmented control offers: everything, plus the three states an
197
201
  * operator triages. `pending`, `success`, `revoked` and `retrying` stay
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { keys, t } from '@simple-module-py/i18n';
6
6
  import { toast } from 'sonner';
7
- import { API_BASE, QUEUE_ALL, STATUS_ALL, type TaskStatus } from './constants';
7
+ import { API_BASE, QUEUE_ALL, STATUS_ALL, type TaskStatus, TENANT_ALL } from './constants';
8
8
 
9
9
  export interface Execution {
10
10
  id: string;
@@ -12,6 +12,8 @@ export interface Execution {
12
12
  task_name: string;
13
13
  status: TaskStatus;
14
14
  queue: string;
15
+ /** Tenant that published the task; null for beat / platform publishes. */
16
+ tenant_id: string | null;
15
17
  // Shown by the retry confirm before it re-enqueues them — see the list schema.
16
18
  args: unknown[];
17
19
  kwargs: Record<string, unknown>;
@@ -63,11 +65,13 @@ export async function retryAllFailed(filters: {
63
65
  status: string;
64
66
  taskName: string;
65
67
  queue: string;
68
+ tenant: string;
66
69
  }): Promise<number | null> {
67
70
  const params = new URLSearchParams();
68
71
  if (filters.status && filters.status !== STATUS_ALL) params.set('status', filters.status);
69
72
  if (filters.taskName) params.set('q', filters.taskName);
70
73
  if (filters.queue && filters.queue !== QUEUE_ALL) params.set('queue', filters.queue);
74
+ if (filters.tenant && filters.tenant !== TENANT_ALL) params.set('tenant_id', filters.tenant);
71
75
  const query = params.toString();
72
76
  try {
73
77
  const res = await fetch(`${API_BASE}/executions/retry-failed${query ? `?${query}` : ''}`, {