simple-module-background-tasks 0.0.33__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.
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/PKG-INFO +5 -5
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/constants.py +8 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/deps.py +2 -2
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/endpoints/api_admin.py +11 -2
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/endpoints/views.py +1 -1
- simple_module_background_tasks-0.0.35/background_tasks/invalidation.py +211 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/locales/en.json +4 -2
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/module.py +48 -1
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/Detail.tsx +1 -1
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/Index.tsx +1 -1
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/Workers.tsx +1 -1
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/StatusStrip.tsx +21 -5
- simple_module_background_tasks-0.0.35/background_tasks/retry_service.py +220 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/service.py +2 -2
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/services.py +5 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/settings.py +18 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/signals.py +4 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/sync_db.py +26 -6
- simple_module_background_tasks-0.0.35/background_tasks/tenant_context.py +104 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/package.json +3 -3
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/pyproject.toml +5 -5
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_admin_api.py +29 -0
- simple_module_background_tasks-0.0.35/tests/test_bg_invalidation.py +293 -0
- simple_module_background_tasks-0.0.35/tests/test_bg_invalidation_degraded.py +197 -0
- simple_module_background_tasks-0.0.35/tests/test_bg_invalidation_redis.py +241 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_bg_service.py +19 -12
- simple_module_background_tasks-0.0.35/tests/test_retry_sweep_ordering.py +200 -0
- simple_module_background_tasks-0.0.35/tests/test_tenant_context.py +70 -0
- simple_module_background_tasks-0.0.35/tests/test_worker_tenancy.py +58 -0
- simple_module_background_tasks-0.0.33/background_tasks/retry_service.py +0 -158
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/.gitignore +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/LICENSE +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/README.md +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/__init__.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/_signal_support.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/celery_app.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/contracts/__init__.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/contracts/events.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/contracts/schemas.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/endpoints/__init__.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/filters.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/health.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/log_context.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/models.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/DetailFacts.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/ExecutionsTable.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/PayloadCards.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/RetryAllDialog.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/StatusPill.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/TaskFilters.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/TasksEmpty.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/TracebackCard.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/WorkerCard.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/WorkerEmptyStates.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/components/WorkerHealthBanner.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/constants.ts +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/retry.ts +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/pages/worker-health.ts +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/py.typed +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/tasks.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/background_tasks/workers_state.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/conftest.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_background_tasks_settings.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_beat_schedule.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_bg_index_worker_presence.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_bg_list_item_payload.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_bg_page_clamp.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_bg_redis_health.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_bg_redis_url.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_bg_settings_env.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_detail_props.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_index_filters.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_log_context.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_retry_failed_bulk.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_retry_failed_guards.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_signals.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_sweep_stuck.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_sync_db.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_workers_endpoints.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests/test_workers_uptime.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.35}/tests-js/task-formatters.test.ts +0 -0
- {simple_module_background_tasks-0.0.33 → 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.
|
|
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.
|
|
27
|
-
Requires-Dist: simple-module-db==0.0.
|
|
28
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
29
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
26
|
+
Requires-Dist: simple-module-core==0.0.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"
|
|
@@ -4,15 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from fastapi import Depends, Request
|
|
6
6
|
from simple_module_core.events import EventBus
|
|
7
|
+
from simple_module_db import RequestSession
|
|
7
8
|
from simple_module_db.deps import get_db
|
|
8
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
9
|
|
|
10
10
|
from background_tasks.service import BackgroundTaskService
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
async def get_background_task_service(
|
|
14
14
|
request: Request,
|
|
15
|
-
db:
|
|
15
|
+
db: RequestSession = Depends(get_db),
|
|
16
16
|
) -> BackgroundTaskService:
|
|
17
17
|
services = request.app.state.background_tasks
|
|
18
18
|
bus: EventBus = request.app.state.sm.event_bus
|
|
@@ -34,14 +34,23 @@ router = APIRouter(
|
|
|
34
34
|
@router.get("/executions", response_model=TaskExecutionListResponse)
|
|
35
35
|
async def list_executions(
|
|
36
36
|
status: TaskStatus | None = Query(default=None),
|
|
37
|
-
|
|
37
|
+
q: str | None = Query(default=None),
|
|
38
|
+
task_name: str | None = Query(default=None, deprecated=True),
|
|
38
39
|
queue: str | None = Query(default=None),
|
|
39
40
|
page: int = Query(default=1, ge=1),
|
|
40
41
|
per_page: int = Query(default=20, ge=1, le=200),
|
|
41
42
|
service: BackgroundTaskService = Depends(get_background_task_service),
|
|
42
43
|
) -> TaskExecutionListResponse:
|
|
44
|
+
"""List executions, filtered exactly as the page filters them.
|
|
45
|
+
|
|
46
|
+
The task-name search is ``q`` — the same name the page and the sweep use,
|
|
47
|
+
so an operator can move a URL between the three and keep their filter. This
|
|
48
|
+
endpoint originally spelled it ``task_name``, which meant a link copied off
|
|
49
|
+
the screen silently listed *everything*; that spelling is still accepted so
|
|
50
|
+
existing API callers keep working, and ``q`` wins when both are given.
|
|
51
|
+
"""
|
|
43
52
|
return await service.list(
|
|
44
|
-
status=status, task_name=task_name, queue=queue, page=page, per_page=per_page
|
|
53
|
+
status=status, task_name=q or task_name, queue=queue, page=page, per_page=per_page
|
|
45
54
|
)
|
|
46
55
|
|
|
47
56
|
|
|
@@ -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
|
+
)
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
"strip": {
|
|
13
13
|
"queued": "Queued",
|
|
14
14
|
"running": "Running",
|
|
15
|
-
"
|
|
15
|
+
"succeeded": "Succeeded",
|
|
16
16
|
"failed": "Failed",
|
|
17
|
-
"stuck": "Stuck"
|
|
17
|
+
"stuck": "Stuck",
|
|
18
|
+
"window_all": "All time",
|
|
19
|
+
"window_24h": "Last 24h"
|
|
18
20
|
},
|
|
19
21
|
"filters": {
|
|
20
22
|
"status_label": "Filter by status",
|
|
@@ -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
|
-
|
|
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()
|
|
@@ -21,6 +21,8 @@ interface Tile {
|
|
|
21
21
|
/** Status this tile narrows the table to. */
|
|
22
22
|
filter: TaskStatus;
|
|
23
23
|
labelKey: StripLabelKey;
|
|
24
|
+
/** The period the figure covers, spelled out under it. */
|
|
25
|
+
windowKey: StripLabelKey;
|
|
24
26
|
count: (counts: StatusCounts) => number;
|
|
25
27
|
tone: Tone;
|
|
26
28
|
}
|
|
@@ -29,21 +31,30 @@ interface Tile {
|
|
|
29
31
|
* The five numbers that describe a queue, left to right in the order work
|
|
30
32
|
* moves through it: waiting, running, done, broken, wedged.
|
|
31
33
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* Succeeded is the only windowed figure, and deliberately so — the other four
|
|
35
|
+
* are states something is in *right now*, while success is only interesting as
|
|
36
|
+
* a rate. An all-time success total is a number that never goes down and
|
|
37
|
+
* therefore never says anything.
|
|
38
|
+
*
|
|
39
|
+
* That makes the row mixed units, which is why every tile carries its period
|
|
40
|
+
* rather than only the odd one out: side by side and unlabelled, a 24-hour
|
|
41
|
+
* success count reading lower than the table's total for the same filter looks
|
|
42
|
+
* like a bug, and nothing on screen said otherwise.
|
|
36
43
|
*/
|
|
44
|
+
const ALL_TIME = keys.background_tasks.strip.window_all;
|
|
45
|
+
|
|
37
46
|
const TILES: Tile[] = [
|
|
38
47
|
{
|
|
39
48
|
filter: TASK_STATUS.PENDING,
|
|
40
49
|
labelKey: keys.background_tasks.strip.queued,
|
|
50
|
+
windowKey: ALL_TIME,
|
|
41
51
|
count: (c) => c[TASK_STATUS.PENDING] ?? 0,
|
|
42
52
|
tone: 'default',
|
|
43
53
|
},
|
|
44
54
|
{
|
|
45
55
|
filter: TASK_STATUS.RUNNING,
|
|
46
56
|
labelKey: keys.background_tasks.strip.running,
|
|
57
|
+
windowKey: ALL_TIME,
|
|
47
58
|
count: (c) => c[TASK_STATUS.RUNNING] ?? 0,
|
|
48
59
|
tone: 'default',
|
|
49
60
|
},
|
|
@@ -52,19 +63,22 @@ const TILES: Tile[] = [
|
|
|
52
63
|
// windowed: the tile is how an operator reaches the successes at all, and
|
|
53
64
|
// the segmented control below has no option for them.
|
|
54
65
|
filter: TASK_STATUS.SUCCESS,
|
|
55
|
-
labelKey: keys.background_tasks.strip.
|
|
66
|
+
labelKey: keys.background_tasks.strip.succeeded,
|
|
67
|
+
windowKey: keys.background_tasks.strip.window_24h,
|
|
56
68
|
count: (c) => c.success_24h ?? 0,
|
|
57
69
|
tone: 'default',
|
|
58
70
|
},
|
|
59
71
|
{
|
|
60
72
|
filter: TASK_STATUS.FAILED,
|
|
61
73
|
labelKey: keys.background_tasks.strip.failed,
|
|
74
|
+
windowKey: ALL_TIME,
|
|
62
75
|
count: (c) => c[TASK_STATUS.FAILED] ?? 0,
|
|
63
76
|
tone: 'destructive',
|
|
64
77
|
},
|
|
65
78
|
{
|
|
66
79
|
filter: TASK_STATUS.STUCK,
|
|
67
80
|
labelKey: keys.background_tasks.strip.stuck,
|
|
81
|
+
windowKey: ALL_TIME,
|
|
68
82
|
count: (c) => c[TASK_STATUS.STUCK] ?? 0,
|
|
69
83
|
tone: 'warning',
|
|
70
84
|
},
|
|
@@ -101,6 +115,8 @@ export function StatusStrip({ counts, active, onSelect }: Props) {
|
|
|
101
115
|
<StatCard
|
|
102
116
|
label={t(tile.labelKey)}
|
|
103
117
|
value={count.toLocaleString()}
|
|
118
|
+
delta={t(tile.windowKey)}
|
|
119
|
+
deltaTone="secondary"
|
|
104
120
|
tone={tone}
|
|
105
121
|
className="h-full"
|
|
106
122
|
/>
|