simple-module-background-tasks 0.0.34__tar.gz → 0.0.35__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 (85) hide show
  1. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/PKG-INFO +5 -5
  2. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/constants.py +8 -0
  3. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/endpoints/views.py +1 -1
  4. simple_module_background_tasks-0.0.35/background_tasks/invalidation.py +211 -0
  5. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/module.py +48 -1
  6. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/Detail.tsx +1 -1
  7. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/Index.tsx +1 -1
  8. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/Workers.tsx +1 -1
  9. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/services.py +5 -0
  10. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/settings.py +18 -0
  11. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/signals.py +4 -0
  12. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/sync_db.py +26 -6
  13. simple_module_background_tasks-0.0.35/background_tasks/tenant_context.py +104 -0
  14. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/package.json +3 -3
  15. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/pyproject.toml +5 -5
  16. simple_module_background_tasks-0.0.35/tests/test_bg_invalidation.py +293 -0
  17. simple_module_background_tasks-0.0.35/tests/test_bg_invalidation_degraded.py +197 -0
  18. simple_module_background_tasks-0.0.35/tests/test_bg_invalidation_redis.py +241 -0
  19. simple_module_background_tasks-0.0.35/tests/test_tenant_context.py +70 -0
  20. simple_module_background_tasks-0.0.35/tests/test_worker_tenancy.py +58 -0
  21. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/.gitignore +0 -0
  22. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/LICENSE +0 -0
  23. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/README.md +0 -0
  24. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/__init__.py +0 -0
  25. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/_signal_support.py +0 -0
  26. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/celery_app.py +0 -0
  27. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/contracts/__init__.py +0 -0
  28. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/contracts/events.py +0 -0
  29. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/contracts/schemas.py +0 -0
  30. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/deps.py +0 -0
  31. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/endpoints/__init__.py +0 -0
  32. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/endpoints/api_admin.py +0 -0
  33. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/filters.py +0 -0
  34. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/health.py +0 -0
  35. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/locales/en.json +0 -0
  36. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/log_context.py +0 -0
  37. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/models.py +0 -0
  38. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/DetailFacts.tsx +0 -0
  39. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
  40. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/ExecutionsTable.tsx +0 -0
  41. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/PayloadCards.tsx +0 -0
  42. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/RetryAllDialog.tsx +0 -0
  43. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
  44. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/StatusPill.tsx +0 -0
  45. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/StatusStrip.tsx +0 -0
  46. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/TaskFilters.tsx +0 -0
  47. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/TasksEmpty.tsx +0 -0
  48. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/TracebackCard.tsx +0 -0
  49. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/WorkerCard.tsx +0 -0
  50. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/WorkerEmptyStates.tsx +0 -0
  51. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/WorkerHealthBanner.tsx +0 -0
  52. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/constants.ts +0 -0
  53. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/retry.ts +0 -0
  54. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/pages/worker-health.ts +0 -0
  55. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/py.typed +0 -0
  56. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/retry_service.py +0 -0
  57. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/service.py +0 -0
  58. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/tasks.py +0 -0
  59. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/worker_inspector.py +0 -0
  60. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/background_tasks/workers_state.py +0 -0
  61. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/conftest.py +0 -0
  62. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_admin_api.py +0 -0
  63. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_background_tasks_settings.py +0 -0
  64. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_beat_schedule.py +0 -0
  65. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_bg_index_worker_presence.py +0 -0
  66. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_bg_list_item_payload.py +0 -0
  67. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_bg_page_clamp.py +0 -0
  68. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_bg_redis_health.py +0 -0
  69. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_bg_redis_url.py +0 -0
  70. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_bg_service.py +0 -0
  71. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_bg_settings_env.py +0 -0
  72. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_detail_props.py +0 -0
  73. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_index_filters.py +0 -0
  74. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_log_context.py +0 -0
  75. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_retry_failed_bulk.py +0 -0
  76. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_retry_failed_guards.py +0 -0
  77. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_retry_sweep_ordering.py +0 -0
  78. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_signals.py +0 -0
  79. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_sweep_stuck.py +0 -0
  80. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_sync_db.py +0 -0
  81. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_worker_inspector.py +0 -0
  82. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_workers_endpoints.py +0 -0
  83. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests/test_workers_uptime.py +0 -0
  84. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/tests-js/task-formatters.test.ts +0 -0
  85. {simple_module_background_tasks-0.0.34 → simple_module_background_tasks-0.0.35}/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.35
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.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
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  # simple_module_background_tasks
@@ -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"
@@ -6,9 +6,9 @@ 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,
@@ -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
+ )
@@ -152,13 +152,17 @@ 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
+ set_database_url(host.database_url, tenant_strict=multi)
165
+ set_default_tenant(None if multi else getattr(host, "default_tenant", "") or None)
162
166
  # build_celery imports `signals` for side effects and runs
163
167
  # `autodiscover_tasks` across every installed module.
164
168
  services.celery = build_celery(services.settings)
@@ -172,6 +176,8 @@ class BackgroundTasksModule(ModuleBase):
172
176
  services.settings.task_default_queue,
173
177
  )
174
178
 
179
+ await self._start_invalidation_transport(app)
180
+
175
181
  # Registered here, not in register_health_checks: the Celery app does
176
182
  # not exist until this hook builds it, and the check must follow later
177
183
  # settings changes rather than pinning the boot-time instance.
@@ -199,11 +205,52 @@ class BackgroundTasksModule(ModuleBase):
199
205
  )
200
206
  )
201
207
 
208
+ @staticmethod
209
+ async def _start_invalidation_transport(app: FastAPI) -> None:
210
+ """Hand the framework bus the Redis connection this module configures.
211
+
212
+ The transport is installed here rather than in a registration hook
213
+ because it needs a running event loop for its listener task, and it is
214
+ installed by *this* module because it owns the Redis URL — a cache-owning
215
+ module opening its own connection is the duplication GH #318 exists to
216
+ avoid.
217
+
218
+ A worker process that runs Celery without the FastAPI app never reaches
219
+ this, so it publishes nothing and hears nothing. That is the same
220
+ documented cross-process limit as ``bind_event_bus`` above: a task that
221
+ invalidates a cache should do it through an API call or accept the TTL.
222
+ """
223
+ from background_tasks.invalidation import RedisInvalidationTransport
224
+
225
+ services = app.state.background_tasks
226
+ s = services.settings
227
+ if not s.broadcast_invalidations:
228
+ logger.info(
229
+ "BackgroundTasks: cross-worker cache invalidation disabled "
230
+ "(broadcast_invalidations=false) — per-process caches expire on their own TTL"
231
+ )
232
+ return
233
+
234
+ bus = app.state.sm.invalidation
235
+ transport = RedisInvalidationTransport(bus, s.broker_url, s.invalidation_channel)
236
+ await transport.start()
237
+ bus.set_transport(transport)
238
+ services.invalidation_transport = transport
239
+ logger.info(
240
+ "BackgroundTasks: invalidation transport on %r for channel(s) %s",
241
+ s.invalidation_channel,
242
+ ", ".join(bus.channels) or "(none subscribed)",
243
+ )
244
+
202
245
  async def on_shutdown(self, app: FastAPI) -> None:
203
246
  from background_tasks.signals import unbind_event_bus
204
247
  from background_tasks.sync_db import dispose_sync_engine
205
248
 
206
249
  services = app.state.background_tasks
250
+ if services.invalidation_transport is not None:
251
+ app.state.sm.invalidation.clear_transport()
252
+ await services.invalidation_transport.stop()
253
+ services.invalidation_transport = None
207
254
  if services.celery is not None:
208
255
  services.celery.close()
209
256
  unbind_event_bus()
@@ -125,5 +125,5 @@ function Detail() {
125
125
  );
126
126
  }
127
127
 
128
- Detail.layout = (page: React.ReactNode) => <AdminLayout>{page}</AdminLayout>;
128
+ Detail.layout = [AdminLayout];
129
129
  export default Detail;
@@ -228,5 +228,5 @@ function Index() {
228
228
  );
229
229
  }
230
230
 
231
- Index.layout = (page: React.ReactNode) => <AdminLayout>{page}</AdminLayout>;
231
+ Index.layout = [AdminLayout];
232
232
  export default Index;
@@ -111,5 +111,5 @@ function Workers() {
111
111
  );
112
112
  }
113
113
 
114
- Workers.layout = (page: React.ReactNode) => <AdminLayout>{page}</AdminLayout>;
114
+ Workers.layout = [AdminLayout];
115
115
  export default Workers;
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
20
20
  from celery import Celery
21
21
 
22
22
  from background_tasks.contracts.schemas import WorkerSnapshot
23
+ from background_tasks.invalidation import RedisInvalidationTransport
23
24
  from background_tasks.settings import BackgroundTasksSettings
24
25
 
25
26
 
@@ -35,3 +36,7 @@ class BackgroundTasksServices:
35
36
  # until something has. Read it for reporting, never as a live reading —
36
37
  # ``polled_at`` says how old it is.
37
38
  last_worker_snapshot: WorkerSnapshot | None = None
39
+ # The cross-worker cache-invalidation transport this module installs on the
40
+ # framework bus, kept only so ``on_shutdown`` can stop its listener task.
41
+ # ``None`` when ``broadcast_invalidations`` is off.
42
+ invalidation_transport: RedisInvalidationTransport | None = None
@@ -41,6 +41,7 @@ from simple_module_core.environments import NON_PROD_ENVIRONMENTS
41
41
 
42
42
  from background_tasks.constants import (
43
43
  DEFAULT_BROKER_URL,
44
+ DEFAULT_INVALIDATION_CHANNEL,
44
45
  DEFAULT_MAX_RETRIES,
45
46
  DEFAULT_PURGE_INTERVAL_SECONDS,
46
47
  DEFAULT_QUEUE,
@@ -87,6 +88,23 @@ class BackgroundTasksSettings(BaseSettings):
87
88
  )
88
89
  task_default_queue: str = Field(default=DEFAULT_QUEUE, json_schema_extra=_CELERY_RESTART)
89
90
 
91
+ # ── Cross-process cache invalidation (GH #318) ──────────
92
+ # This module owns the Redis connection, so it installs the transport that
93
+ # carries the framework InvalidationBus between workers. Off means every
94
+ # per-process cache — ``users``' revocation counter above all — stays stale
95
+ # in the *other* workers until its own TTL expires, which is the behaviour
96
+ # that predates the transport. Left on by default because a deployment with
97
+ # Redis configured is the one that has several workers to keep in step; the
98
+ # test suite turns it off, having no Redis to reach.
99
+ broadcast_invalidations: bool = Field(
100
+ default=True, json_schema_extra={"requires_restart": True, "group": "Celery"}
101
+ )
102
+ invalidation_channel: str = Field(
103
+ default=DEFAULT_INVALIDATION_CHANNEL,
104
+ min_length=1,
105
+ json_schema_extra={"requires_restart": True, "group": "Celery"},
106
+ )
107
+
90
108
  # Run tasks synchronously inside the calling process. Tests flip it on via
91
109
  # ``SM_BG_TASKS_TASK_ALWAYS_EAGER`` or by passing it explicitly, either of
92
110
  # which works without DB-backed hydration (which never fires for suites
@@ -41,6 +41,7 @@ from background_tasks.contracts.events import TaskFailed
41
41
  from background_tasks.log_context import signal_task_finished, signal_task_started
42
42
  from background_tasks.models import TaskExecution
43
43
  from background_tasks.sync_db import sync_session
44
+ from background_tasks.tenant_context import release_tenant, restore_tenant, stamp_tenant
44
45
 
45
46
  logger = logging.getLogger(__name__)
46
47
 
@@ -126,6 +127,7 @@ def on_task_publish(
126
127
  **_kwargs: Any,
127
128
  ) -> None:
128
129
  """Record a row the moment a task is pushed onto the broker."""
130
+ stamp_tenant(headers)
129
131
  task_id = (headers or {}).get("id")
130
132
  task_name = sender or (headers or {}).get("task") or "unknown"
131
133
 
@@ -179,6 +181,7 @@ def on_task_prerun(
179
181
  },
180
182
  )
181
183
  signal_task_started(task_id=task_id, task_name=name)
184
+ restore_tenant(task_id=task_id, task=task)
182
185
 
183
186
 
184
187
  @signals.task_postrun.connect
@@ -199,6 +202,7 @@ def on_task_postrun(
199
202
  celery_task_id=task_id,
200
203
  defaults={"task_name": task_name_of(sender, task), "heartbeat_at": now_utc()},
201
204
  )
205
+ release_tenant(task_id=task_id)
202
206
  signal_task_finished(task_id=task_id)
203
207
 
204
208
 
@@ -18,6 +18,8 @@ import os
18
18
  from collections.abc import Iterator
19
19
  from contextlib import contextmanager
20
20
 
21
+ from simple_module_db.listeners import attach_session_listeners
22
+ from simple_module_db.query_filter import EngineTenancy, bind_engine_policy
21
23
  from sqlalchemy import create_engine
22
24
  from sqlalchemy.engine import Engine
23
25
  from sqlalchemy.orm import Session, sessionmaker
@@ -27,6 +29,13 @@ logger = logging.getLogger(__name__)
27
29
  _engine: Engine | None = None
28
30
  _session_factory: sessionmaker[Session] | None = None
29
31
  _url_override: str | None = None
32
+ _tenant_strict: bool = False
33
+
34
+
35
+ class WorkerSession(Session):
36
+ """This engine's own session class, so the entity listeners attached to it
37
+ never double up with a ``DatabaseState``'s (each state has its own
38
+ ``Session`` subclass; listeners on the base class would fire for both)."""
30
39
 
31
40
 
32
41
  def _sync_url(async_url: str) -> str:
@@ -38,7 +47,7 @@ def _sync_url(async_url: str) -> str:
38
47
  return async_url.replace("+aiosqlite", "").replace("+asyncpg", "+psycopg2")
39
48
 
40
49
 
41
- def set_database_url(url: str | None) -> None:
50
+ def set_database_url(url: str | None, *, tenant_strict: bool = False) -> None:
42
51
  """Pin the URL used to build the sync engine.
43
52
 
44
53
  The web process loads ``.env`` via pydantic-settings, but those values
@@ -48,11 +57,15 @@ def set_database_url(url: str | None) -> None:
48
57
  calls this with the resolved ``settings.database_url`` so signals use
49
58
  the same DB the app is on. Pass ``None`` to clear the override (used in
50
59
  tests + on shutdown).
60
+
61
+ ``tenant_strict`` mirrors the host's ``multi_tenant``: task bodies get the
62
+ same fail-closed tenant rules as request code.
51
63
  """
52
- global _url_override, _engine, _session_factory
53
- if _url_override == url:
64
+ global _url_override, _engine, _session_factory, _tenant_strict
65
+ if _url_override == url and _tenant_strict == tenant_strict:
54
66
  return
55
67
  _url_override = url
68
+ _tenant_strict = tenant_strict
56
69
  if _engine is not None:
57
70
  _engine.dispose()
58
71
  _engine = None
@@ -68,7 +81,13 @@ def _resolve_url() -> str:
68
81
  def _build_engine() -> Engine:
69
82
  sync_url = _sync_url(_resolve_url())
70
83
  # Small pool — signals fire sequentially per worker process.
71
- return create_engine(sync_url, pool_pre_ping=True, pool_size=2, max_overflow=3)
84
+ engine = create_engine(sync_url, pool_pre_ping=True, pool_size=2, max_overflow=3)
85
+ # A worker never runs create_app, so without this its sessions would have
86
+ # no tenant filter at all: the tenant restored around a task body would
87
+ # scope nothing (#371).
88
+ attach_session_listeners(WorkerSession)
89
+ bind_engine_policy(engine, EngineTenancy(tenant_strict=_tenant_strict))
90
+ return engine
72
91
 
73
92
 
74
93
  def get_sync_session_factory() -> sessionmaker[Session]:
@@ -76,7 +95,7 @@ def get_sync_session_factory() -> sessionmaker[Session]:
76
95
  global _engine, _session_factory
77
96
  if _session_factory is None:
78
97
  _engine = _build_engine()
79
- _session_factory = sessionmaker(bind=_engine, expire_on_commit=False)
98
+ _session_factory = sessionmaker(bind=_engine, class_=WorkerSession, expire_on_commit=False)
80
99
  return _session_factory
81
100
 
82
101
 
@@ -87,12 +106,13 @@ def dispose_sync_engine() -> None:
87
106
  restarts within one process (test runners, uvicorn dev reload) don't
88
107
  accumulate engines against the old DB URL.
89
108
  """
90
- global _engine, _session_factory, _url_override
109
+ global _engine, _session_factory, _url_override, _tenant_strict
91
110
  if _engine is not None:
92
111
  _engine.dispose()
93
112
  _engine = None
94
113
  _session_factory = None
95
114
  _url_override = None
115
+ _tenant_strict = False
96
116
 
97
117
 
98
118
  @contextmanager
@@ -0,0 +1,104 @@
1
+ """Carry the enqueuing request's tenant into the Celery task that runs later.
2
+
3
+ A task body runs in a worker with no request, so ``current_tenant_id`` is
4
+ unset there — under strict isolation any tenant-scoped query would raise, and
5
+ without it the query would read every tenant. The tenant is stamped onto the
6
+ message headers at publish time and restored around the task body.
7
+
8
+ Platform tasks that legitimately span tenants (sweeps, purges) wrap their
9
+ work in ``simple_module_db.all_tenants()`` instead.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from contextvars import Token
16
+ from typing import Any
17
+
18
+ from simple_module_db import TenantIsolationError, current_tenant_id, is_valid_tenant_id
19
+
20
+ TENANT_HEADER = "sm_tenant_id"
21
+
22
+ _log = logging.getLogger(__name__)
23
+ _tokens: dict[str, Token[str | None]] = {}
24
+ # Single-tenant hosts (``default_tenant``): tasks with no tenant on the message
25
+ # — beat jobs, tasks enqueued outside a request — run as that tenant.
26
+ _default_tenant: str | None = None
27
+
28
+
29
+ def set_default_tenant(tenant_id: str | None) -> None:
30
+ global _default_tenant
31
+ _default_tenant = tenant_id or None
32
+
33
+
34
+ def stamp_tenant(headers: dict[str, Any] | None) -> None:
35
+ """Record the current tenant on an outgoing message (publish side).
36
+
37
+ An explicit ``headers={"sm_tenant_id": ...}`` passed to ``send_task`` is
38
+ kept only when no tenant is bound (platform code enqueueing work for a
39
+ tenant) or it names the bound tenant: request code must not be able to
40
+ schedule work as another tenant.
41
+ """
42
+ if headers is None:
43
+ return
44
+ tenant_id = current_tenant_id.get()
45
+ explicit = headers.get(TENANT_HEADER)
46
+ if explicit:
47
+ if not is_valid_tenant_id(explicit):
48
+ raise TenantIsolationError(f"Invalid tenant id on task headers: {explicit!r}")
49
+ if tenant_id is not None and explicit != tenant_id:
50
+ raise TenantIsolationError(
51
+ f"Cannot enqueue a task for tenant '{explicit}' in context of tenant '{tenant_id}'"
52
+ )
53
+ return
54
+ if tenant_id is not None:
55
+ headers[TENANT_HEADER] = tenant_id
56
+
57
+
58
+ def _tenant_of(task: Any) -> str | None:
59
+ request = getattr(task, "request", None)
60
+ if request is None:
61
+ return None
62
+ value = getattr(request, TENANT_HEADER, None)
63
+ if value is None and isinstance(getattr(request, "headers", None), dict):
64
+ value = request.headers.get(TENANT_HEADER)
65
+ if not value:
66
+ return None
67
+ if not is_valid_tenant_id(value):
68
+ # Never run a task body as a malformed tenant; it runs unscoped and,
69
+ # under strict mode, fails closed on its first tenant-scoped query.
70
+ _log.warning("Ignoring invalid tenant id on task message: %r", str(value)[:80])
71
+ return None
72
+ return str(value)
73
+
74
+
75
+ def restore_tenant(*, task_id: str | None, task: Any) -> None:
76
+ """Enter the message's tenant for the task body (prerun)."""
77
+ if not task_id:
78
+ return
79
+ tenant_id = _tenant_of(task) or _default_tenant
80
+ if tenant_id is not None:
81
+ _tokens[task_id] = current_tenant_id.set(tenant_id)
82
+
83
+
84
+ def release_tenant(*, task_id: str | None) -> None:
85
+ """Leave the tenant entered by :func:`restore_tenant` (postrun)."""
86
+ token = _tokens.pop(task_id, None) if task_id else None
87
+ if token is None:
88
+ return
89
+ try:
90
+ current_tenant_id.reset(token)
91
+ except ValueError:
92
+ # Token from another context (exotic pool patching): fall back to
93
+ # clearing, so the next task on this worker never inherits a tenant.
94
+ _log.debug("Tenant reset skipped for task_id=%s; clearing", task_id)
95
+ current_tenant_id.set(None)
96
+
97
+
98
+ __all__ = [
99
+ "TENANT_HEADER",
100
+ "release_tenant",
101
+ "restore_tenant",
102
+ "set_default_tenant",
103
+ "stamp_tenant",
104
+ ]
@@ -6,14 +6,14 @@
6
6
  "peerDependencies": {
7
7
  "react": "^19.0.0",
8
8
  "react-dom": "^19.0.0",
9
- "@inertiajs/react": "^2.0.0",
9
+ "@inertiajs/react": "^3.7.0",
10
10
  "@simple-module-py/ui": "*"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@simple-module-py/tsconfig": "*"
14
14
  },
15
15
  "dependencies": {
16
- "lucide-react": "^1.28.0",
17
- "sonner": "^2.0.7"
16
+ "lucide-react": "^1.43.0",
17
+ "sonner": "^2.0.8"
18
18
  }
19
19
  }