simple-module-background-tasks 0.0.34__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.34 → simple_module_background_tasks-0.0.36}/PKG-INFO +5 -5
  2. {simple_module_background_tasks-0.0.34 → 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.34 → simple_module_background_tasks-0.0.36}/background_tasks/constants.py +12 -0
  5. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/contracts/schemas.py +2 -0
  6. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/endpoints/api_admin.py +12 -3
  7. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/endpoints/views.py +14 -3
  8. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/filters.py +14 -3
  9. simple_module_background_tasks-0.0.36/background_tasks/invalidation.py +211 -0
  10. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/locales/en.json +9 -3
  11. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/log_context.py +2 -2
  12. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/models.py +13 -0
  13. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/module.py +49 -1
  14. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/Detail.tsx +1 -1
  15. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/Index.tsx +28 -6
  16. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/Workers.tsx +1 -1
  17. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/DetailFacts.tsx +4 -0
  18. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/ExecutionRow.tsx +3 -0
  19. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/ExecutionsTable.tsx +5 -2
  20. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/TaskFilters.tsx +42 -1
  21. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/constants.ts +4 -0
  22. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/retry.ts +5 -1
  23. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/retry_service.py +16 -3
  24. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/service.py +33 -5
  25. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/services.py +5 -0
  26. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/settings.py +18 -0
  27. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/signals.py +47 -57
  28. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/sync_db.py +44 -8
  29. simple_module_background_tasks-0.0.36/background_tasks/tenant_context.py +132 -0
  30. simple_module_background_tasks-0.0.36/background_tasks/worker_settings.py +57 -0
  31. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/package.json +3 -3
  32. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/pyproject.toml +5 -5
  33. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/conftest.py +2 -0
  34. simple_module_background_tasks-0.0.36/tests/test_background_tasks_tz_columns.py +56 -0
  35. simple_module_background_tasks-0.0.36/tests/test_bg_invalidation.py +293 -0
  36. simple_module_background_tasks-0.0.36/tests/test_bg_invalidation_degraded.py +197 -0
  37. simple_module_background_tasks-0.0.36/tests/test_bg_invalidation_redis.py +241 -0
  38. simple_module_background_tasks-0.0.36/tests/test_bg_worker_settings.py +56 -0
  39. simple_module_background_tasks-0.0.36/tests/test_execution_tenancy.py +194 -0
  40. simple_module_background_tasks-0.0.36/tests/test_signal_tenant_stamp.py +99 -0
  41. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_signals.py +2 -2
  42. simple_module_background_tasks-0.0.36/tests/test_tenant_context.py +70 -0
  43. simple_module_background_tasks-0.0.36/tests/test_worker_tenancy.py +107 -0
  44. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/.gitignore +0 -0
  45. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/LICENSE +0 -0
  46. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/README.md +0 -0
  47. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/_signal_support.py +0 -0
  48. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/celery_app.py +0 -0
  49. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/contracts/__init__.py +0 -0
  50. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/contracts/events.py +0 -0
  51. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/deps.py +0 -0
  52. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/endpoints/__init__.py +0 -0
  53. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/health.py +0 -0
  54. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/PayloadCards.tsx +0 -0
  55. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/RetryAllDialog.tsx +0 -0
  56. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
  57. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/StatusPill.tsx +0 -0
  58. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/StatusStrip.tsx +0 -0
  59. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/TasksEmpty.tsx +0 -0
  60. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/TracebackCard.tsx +0 -0
  61. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/WorkerCard.tsx +0 -0
  62. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/WorkerEmptyStates.tsx +0 -0
  63. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/components/WorkerHealthBanner.tsx +0 -0
  64. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/pages/worker-health.ts +0 -0
  65. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/py.typed +0 -0
  66. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/tasks.py +0 -0
  67. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/worker_inspector.py +0 -0
  68. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/background_tasks/workers_state.py +0 -0
  69. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_admin_api.py +0 -0
  70. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_background_tasks_settings.py +0 -0
  71. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_beat_schedule.py +0 -0
  72. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_bg_index_worker_presence.py +0 -0
  73. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_bg_list_item_payload.py +0 -0
  74. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_bg_page_clamp.py +0 -0
  75. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_bg_redis_health.py +0 -0
  76. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_bg_redis_url.py +0 -0
  77. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_bg_service.py +0 -0
  78. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_bg_settings_env.py +0 -0
  79. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_detail_props.py +0 -0
  80. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_index_filters.py +0 -0
  81. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_log_context.py +0 -0
  82. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_retry_failed_bulk.py +0 -0
  83. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_retry_failed_guards.py +0 -0
  84. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_retry_sweep_ordering.py +0 -0
  85. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_sweep_stuck.py +0 -0
  86. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_sync_db.py +0 -0
  87. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_worker_inspector.py +0 -0
  88. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_workers_endpoints.py +0 -0
  89. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests/test_workers_uptime.py +0 -0
  90. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.36}/tests-js/task-formatters.test.ts +0 -0
  91. {simple_module_background_tasks-0.0.34 → 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.34
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.34
27
- Requires-Dist: simple-module-db==0.0.34
28
- Requires-Dist: simple-module-hosting==0.0.34
29
- Requires-Dist: simple-module-settings==0.0.34
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)
@@ -87,6 +87,14 @@ DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS = 60
87
87
  DEFAULT_PURGE_INTERVAL_SECONDS = 60 * 60 * 24
88
88
  DEFAULT_RETENTION_DAYS = 14
89
89
  DEFAULT_MAX_RETRIES = 3
90
+ # Redis pub/sub channel carrying the framework's cache invalidations. Namespaced
91
+ # so that two apps on one Redis *server* do not each act on the other's messages.
92
+ # The server, not the database: pub/sub ignores the logical DB index, so DBs 4 and
93
+ # 5 of one instance share this channel. Mostly that costs pointless reads, but it
94
+ # is not unconditionally harmless — an emptied cache is also what exposes
95
+ # ``UsersAuthProvider._version_still_current``'s fail-open branch, so rename it
96
+ # per app rather than relying on the DB number.
97
+ DEFAULT_INVALIDATION_CHANNEL = "simple_module.invalidation"
90
98
 
91
99
  # ── Internal task names ─────────────────────────────────────────
92
100
  INTERNAL_TASK_SWEEP_STUCK = "background_tasks.sweep_stuck_tasks"
@@ -94,3 +102,7 @@ INTERNAL_TASK_PURGE_OLD = "background_tasks.purge_old_executions"
94
102
  # Harmless round-trip task; only wired into CI smoke tests and local dev —
95
103
  # not scheduled, not invoked by other modules.
96
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(
@@ -6,12 +6,13 @@ import asyncio
6
6
  import uuid
7
7
 
8
8
  from fastapi import APIRouter, Depends, HTTPException, Query, Request
9
- from inertia import InertiaResponse
10
9
  from simple_module_hosting.inertia_deps import InertiaDep
11
10
  from simple_module_hosting.permissions import RequiresPermission
11
+ 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
  ]
@@ -0,0 +1,211 @@
1
+ """Redis pub/sub transport for the framework's :class:`InvalidationBus`.
2
+
3
+ This module owns the app's Redis connection already — one URL, one set of
4
+ settings, one health check. Cross-process cache invalidation needs exactly that
5
+ connection and nothing else, so it is installed from here rather than opened a
6
+ second time inside whichever module happens to keep a cache (GH #318). A module
7
+ → framework import is fine; the reverse is what ``SM009`` forbids, which is why
8
+ the bus itself knows nothing about Redis.
9
+
10
+ Every framework channel shares one Redis channel. The volume is a handful of
11
+ messages per revocation — ``users`` is the only publisher today — and
12
+ ``Invalidation.channel`` routes
13
+ inside the receiving process, so one subscription is less machinery for the same
14
+ result — and it means a module that adds a channel after boot needs no
15
+ resubscribe.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import contextlib
22
+ import logging
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from simple_module_core.invalidation import InvalidationBus
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ __all__ = ["RedisInvalidationTransport"]
31
+
32
+ INITIAL_BACKOFF_SECONDS = 1.0
33
+ MAX_BACKOFF_SECONDS = 30.0
34
+
35
+ SUBSCRIBE_TIMEOUT_SECONDS = 5.0
36
+ """Ceiling on the one-shot SUBSCRIBE at the top of each listener attempt.
37
+
38
+ A separate bound from :data:`PUBLISH_TIMEOUT_SECONDS` because it is not in a
39
+ request's way — only a wedged listener's. Needed because ``socket_connect_timeout``
40
+ bounds the *connect* and nothing else: against a broker that completes the TCP
41
+ handshake and then never answers, ``subscribe`` waits on a read that has no
42
+ deadline, so the listener hangs before it has ever raised, ``_log_failure`` never
43
+ runs, and the reconnect loop below never gets a turn. Cross-process invalidation
44
+ is then silently and permanently off on this worker, while the boot log says it is
45
+ on — the exact "does nothing and nobody learns" failure this whole file is
46
+ supposed to avoid. Found by QA, not by the original tests.
47
+ """
48
+
49
+ READ_TIMEOUT_SECONDS = 10.0
50
+ """``socket_timeout`` for the client: a bound on any read that has started.
51
+
52
+ Cannot simply be small: the same option applies to the subscription, and a quiet
53
+ channel must not be torn down for being quiet. That is why the listener polls
54
+ :meth:`redis.asyncio.client.PubSub.get_message` with its own short timeout instead
55
+ of blocking in ``listen()`` — polling returns ``None`` on an idle channel without
56
+ ever starting a read, so this deadline only bites when bytes stop arriving
57
+ mid-message.
58
+ """
59
+
60
+ HEALTH_CHECK_INTERVAL_SECONDS = 15.0
61
+ """How often redis-py PINGs an otherwise idle subscription.
62
+
63
+ The half-open socket — ESTABLISHED at both ends, carrying nothing, as a NAT or
64
+ load balancer idle-drop leaves it — is invisible to every timeout above, because
65
+ no read is ever attempted. redis-py's health check is what turns it into an error:
66
+ the PING's reply read is bounded by ``socket_timeout``, so a mute peer raises into
67
+ the reconnect loop within roughly this interval.
68
+ """
69
+
70
+ LISTEN_POLL_SECONDS = 1.0
71
+ """How long each ``get_message`` waits before returning ``None`` and looping.
72
+
73
+ Also the granularity at which the health check and cancellation get a turn.
74
+ """
75
+
76
+ PUBLISH_TIMEOUT_SECONDS = 1.0
77
+ """Ceiling on how long a publish may hold up the request that triggered it.
78
+
79
+ ``publish`` is awaited inside the response cycle — a password change's
80
+ ``db.on_commit`` callback runs before the response is sent — and redis-py
81
+ defaults to *no* socket timeout, so a Redis that accepts connections but stops
82
+ answering would hang that request indefinitely rather than degrade the fan-out.
83
+ One second is far more than a local round trip needs and far less than a user
84
+ waits. Exceeding it raises, which the bus logs and swallows: the local eviction
85
+ has already happened, so the cost of the timeout is the same staleness window
86
+ that existed before this transport.
87
+ """
88
+
89
+
90
+ class RedisInvalidationTransport:
91
+ """Publishes to, and listens on, one Redis pub/sub channel.
92
+
93
+ Satisfies ``simple_module_core.invalidation.InvalidationTransport``
94
+ structurally — no import of the Protocol at runtime, so this file costs
95
+ nothing when the bus is unused.
96
+ """
97
+
98
+ def __init__(self, bus: InvalidationBus, url: str, channel: str) -> None:
99
+ self._bus = bus
100
+ self._url = url
101
+ self._channel = channel
102
+ self._client = None
103
+ self._listener: asyncio.Task | None = None
104
+ self._warned = False
105
+
106
+ async def start(self) -> None:
107
+ """Open the client and start the listener task.
108
+
109
+ Does **not** wait for a successful connection, and does not raise if
110
+ Redis is down. Invalidation is an accelerator over a TTL that already
111
+ bounds staleness, so an unreachable broker must degrade the app to
112
+ per-process caching — its behaviour before this existed — rather than
113
+ fail the boot of a web worker that can otherwise serve every request.
114
+ ``/health`` already reports Redis; this logs a warning naming the
115
+ consequence so the two agree.
116
+ """
117
+ import redis.asyncio as aioredis
118
+
119
+ # Four deadlines, each covering a failure the others do not. Connect
120
+ # covers a host that swallows SYN packets; socket_timeout covers a read
121
+ # that starts and stalls; keepalive and the health check cover a socket
122
+ # that is open at both ends and carrying nothing. Without the last two a
123
+ # half-open connection leaves this worker permanently and silently
124
+ # without cross-process invalidation.
125
+ self._client = aioredis.from_url(
126
+ self._url,
127
+ socket_connect_timeout=PUBLISH_TIMEOUT_SECONDS,
128
+ socket_timeout=READ_TIMEOUT_SECONDS,
129
+ socket_keepalive=True,
130
+ health_check_interval=HEALTH_CHECK_INTERVAL_SECONDS,
131
+ )
132
+ self._listener = asyncio.create_task(self._listen(), name="sm-invalidation-listener")
133
+
134
+ async def publish(self, message: str) -> None:
135
+ """Broadcast one encoded invalidation. Raises — the bus logs it.
136
+
137
+ Bounded by :data:`PUBLISH_TIMEOUT_SECONDS`, because this runs inside the
138
+ response cycle of the request that made the write.
139
+ """
140
+ if self._client is None:
141
+ raise RuntimeError("RedisInvalidationTransport.start() was not called")
142
+ async with asyncio.timeout(PUBLISH_TIMEOUT_SECONDS):
143
+ await self._client.publish(self._channel, message)
144
+
145
+ async def stop(self) -> None:
146
+ """Cancel the listener and close the client."""
147
+ if self._listener is not None:
148
+ self._listener.cancel()
149
+ with contextlib.suppress(asyncio.CancelledError):
150
+ await self._listener
151
+ self._listener = None
152
+ if self._client is not None:
153
+ with contextlib.suppress(Exception):
154
+ await self._client.aclose()
155
+ self._client = None
156
+
157
+ async def _listen(self) -> None:
158
+ """Subscribe and hand every message to the bus, reconnecting forever.
159
+
160
+ Reconnects rather than giving up after the first failure: a Redis that
161
+ is briefly unreachable during a deploy would otherwise leave this worker
162
+ with cross-process invalidation permanently off, which is the original
163
+ bug wearing a warning message. Backoff is capped so a long outage does
164
+ not turn into a reconnect storm when it ends.
165
+
166
+ Only the *first* failure of a run logs at warning. A worker that
167
+ outlives an hour of downtime should not have written an hour of
168
+ identical lines, but the state still has to be visible once.
169
+ """
170
+ backoff = INITIAL_BACKOFF_SECONDS
171
+ while True:
172
+ pubsub = None
173
+ try:
174
+ pubsub = self._client.pubsub()
175
+ async with asyncio.timeout(SUBSCRIBE_TIMEOUT_SECONDS):
176
+ await pubsub.subscribe(self._channel)
177
+ if self._warned:
178
+ logger.info("Invalidation listener reconnected to %r", self._channel)
179
+ self._warned = False
180
+ backoff = INITIAL_BACKOFF_SECONDS
181
+ while True:
182
+ # Polling rather than ``listen()``: an idle channel returns
183
+ # None instead of blocking in an unbounded read, which is what
184
+ # lets redis-py run its health check and lets cancellation
185
+ # land promptly at shutdown.
186
+ raw = await pubsub.get_message(timeout=LISTEN_POLL_SECONDS)
187
+ if raw is None or raw.get("type") != "message":
188
+ continue
189
+ await self._bus.deliver(raw["data"])
190
+ except asyncio.CancelledError:
191
+ raise
192
+ except Exception as exc:
193
+ self._log_failure(exc)
194
+ await asyncio.sleep(backoff)
195
+ backoff = min(backoff * 2, MAX_BACKOFF_SECONDS)
196
+ finally:
197
+ if pubsub is not None:
198
+ with contextlib.suppress(Exception):
199
+ await pubsub.aclose()
200
+
201
+ def _log_failure(self, exc: Exception) -> None:
202
+ """Warn once per outage, then stay quiet about the same outage."""
203
+ if self._warned:
204
+ logger.debug("Invalidation listener still down: %s", exc)
205
+ return
206
+ self._warned = True
207
+ logger.warning(
208
+ "Invalidation listener lost Redis (%s); other workers keep cached "
209
+ "values until they expire. Retrying.",
210
+ exc,
211
+ )
@@ -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
  )
@@ -152,13 +152,18 @@ class BackgroundTasksModule(ModuleBase):
152
152
  from background_tasks.celery_app import build_celery
153
153
  from background_tasks.signals import bind_event_bus
154
154
  from background_tasks.sync_db import set_database_url
155
+ from background_tasks.tenant_context import set_default_tenant
155
156
 
156
157
  services = app.state.background_tasks
157
158
  # Pin the sync engine to the same URL the host's async settings
158
159
  # resolved — pydantic-settings reads ``.env`` but never propagates
159
160
  # to ``os.environ``, so signals would otherwise fall back to the
160
161
  # SQLite default and silently drop ``TaskExecution`` rows.
161
- set_database_url(app.state.sm.settings.database_url)
162
+ host = app.state.sm.settings
163
+ multi = bool(getattr(host, "multi_tenant", False))
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)
162
167
  # build_celery imports `signals` for side effects and runs
163
168
  # `autodiscover_tasks` across every installed module.
164
169
  services.celery = build_celery(services.settings)
@@ -172,6 +177,8 @@ class BackgroundTasksModule(ModuleBase):
172
177
  services.settings.task_default_queue,
173
178
  )
174
179
 
180
+ await self._start_invalidation_transport(app)
181
+
175
182
  # Registered here, not in register_health_checks: the Celery app does
176
183
  # not exist until this hook builds it, and the check must follow later
177
184
  # settings changes rather than pinning the boot-time instance.
@@ -199,11 +206,52 @@ class BackgroundTasksModule(ModuleBase):
199
206
  )
200
207
  )
201
208
 
209
+ @staticmethod
210
+ async def _start_invalidation_transport(app: FastAPI) -> None:
211
+ """Hand the framework bus the Redis connection this module configures.
212
+
213
+ The transport is installed here rather than in a registration hook
214
+ because it needs a running event loop for its listener task, and it is
215
+ installed by *this* module because it owns the Redis URL — a cache-owning
216
+ module opening its own connection is the duplication GH #318 exists to
217
+ avoid.
218
+
219
+ A worker process that runs Celery without the FastAPI app never reaches
220
+ this, so it publishes nothing and hears nothing. That is the same
221
+ documented cross-process limit as ``bind_event_bus`` above: a task that
222
+ invalidates a cache should do it through an API call or accept the TTL.
223
+ """
224
+ from background_tasks.invalidation import RedisInvalidationTransport
225
+
226
+ services = app.state.background_tasks
227
+ s = services.settings
228
+ if not s.broadcast_invalidations:
229
+ logger.info(
230
+ "BackgroundTasks: cross-worker cache invalidation disabled "
231
+ "(broadcast_invalidations=false) — per-process caches expire on their own TTL"
232
+ )
233
+ return
234
+
235
+ bus = app.state.sm.invalidation
236
+ transport = RedisInvalidationTransport(bus, s.broker_url, s.invalidation_channel)
237
+ await transport.start()
238
+ bus.set_transport(transport)
239
+ services.invalidation_transport = transport
240
+ logger.info(
241
+ "BackgroundTasks: invalidation transport on %r for channel(s) %s",
242
+ s.invalidation_channel,
243
+ ", ".join(bus.channels) or "(none subscribed)",
244
+ )
245
+
202
246
  async def on_shutdown(self, app: FastAPI) -> None:
203
247
  from background_tasks.signals import unbind_event_bus
204
248
  from background_tasks.sync_db import dispose_sync_engine
205
249
 
206
250
  services = app.state.background_tasks
251
+ if services.invalidation_transport is not None:
252
+ app.state.sm.invalidation.clear_transport()
253
+ await services.invalidation_transport.stop()
254
+ services.invalidation_transport = None
207
255
  if services.celery is not None:
208
256
  services.celery.close()
209
257
  unbind_event_bus()