simple-module-background-tasks 0.0.3__tar.gz → 0.0.5__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.3 → simple_module_background_tasks-0.0.5}/PKG-INFO +4 -4
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/celery_app.py +5 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/constants.py +1 -1
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/contracts/schemas.py +21 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/endpoints/api_admin.py +12 -1
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/endpoints/views.py +13 -1
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/module.py +1 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/pages/Detail.tsx +7 -5
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/pages/Index.tsx +26 -18
- simple_module_background_tasks-0.0.5/background_tasks/pages/Workers.tsx +154 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/pages/constants.ts +21 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/settings.py +8 -0
- simple_module_background_tasks-0.0.5/background_tasks/worker_inspector.py +117 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/pyproject.toml +4 -4
- simple_module_background_tasks-0.0.5/tests/conftest.py +22 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/tests/test_admin_api.py +1 -14
- simple_module_background_tasks-0.0.5/tests/test_worker_inspector.py +106 -0
- simple_module_background_tasks-0.0.5/tests/test_workers_endpoints.py +99 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/.gitignore +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/LICENSE +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/README.md +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/__init__.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/_signal_support.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/contracts/__init__.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/contracts/events.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/deps.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/endpoints/__init__.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/locales/en.json +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/models.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/pages/retry.ts +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/py.typed +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/service.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/services.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/signals.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/sync_db.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/background_tasks/tasks.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/package.json +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/tests/test_bg_service.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/tests/test_signals.py +0 -0
- {simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_background_tasks
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.5
|
|
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,9 +23,9 @@ 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.
|
|
26
|
+
Requires-Dist: simple-module-core==0.0.5
|
|
27
|
+
Requires-Dist: simple-module-db==0.0.5
|
|
28
|
+
Requires-Dist: simple-module-hosting==0.0.5
|
|
29
29
|
Description-Content-Type: text/markdown
|
|
30
30
|
|
|
31
31
|
# simple_module_background_tasks
|
|
@@ -55,6 +55,11 @@ def build_celery(settings: BackgroundTasksSettings) -> Celery:
|
|
|
55
55
|
broker_url=settings.broker_url,
|
|
56
56
|
result_backend=settings.result_backend,
|
|
57
57
|
task_default_queue=settings.task_default_queue,
|
|
58
|
+
# Run tasks synchronously inside the calling process. Tests
|
|
59
|
+
# toggle this on (via ``SM_BG_TASKS_TASK_ALWAYS_EAGER=true``) so
|
|
60
|
+
# ``task.delay()`` doesn't reach for a real broker.
|
|
61
|
+
task_always_eager=settings.task_always_eager,
|
|
62
|
+
task_eager_propagates=settings.task_eager_propagates,
|
|
58
63
|
# ``task_track_started`` gives us the ``STARTED`` state so
|
|
59
64
|
# ``task_prerun`` can flip our row to ``running``.
|
|
60
65
|
task_track_started=True,
|
|
@@ -32,7 +32,7 @@ ADMIN_ROUTER_PREFIX = "/admin"
|
|
|
32
32
|
# ── Menu ────────────────────────────────────────────────────────
|
|
33
33
|
MENU_LABEL = "Background Tasks"
|
|
34
34
|
MENU_ICON = "activity"
|
|
35
|
-
MENU_ORDER =
|
|
35
|
+
MENU_ORDER = 120
|
|
36
36
|
|
|
37
37
|
# ── Page identifiers ────────────────────────────────────────────
|
|
38
38
|
# Kept as literals at the call site (see endpoints/views.py) so
|
|
@@ -64,3 +64,24 @@ class TaskExecutionListResponse(SQLModel):
|
|
|
64
64
|
per_page: int
|
|
65
65
|
status: TaskStatus | None = None
|
|
66
66
|
task_name: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class WorkerInfo(SQLModel):
|
|
70
|
+
"""One Celery worker as reported by ``celery.control.inspect()``."""
|
|
71
|
+
|
|
72
|
+
hostname: str
|
|
73
|
+
online: bool
|
|
74
|
+
queues: list[str] = []
|
|
75
|
+
active_task_count: int = 0
|
|
76
|
+
pool_size: int | None = None
|
|
77
|
+
total_processed: int | None = None
|
|
78
|
+
software: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WorkerSnapshot(SQLModel):
|
|
82
|
+
"""Point-in-time picture of every worker known to the broker."""
|
|
83
|
+
|
|
84
|
+
broker_reachable: bool
|
|
85
|
+
polled_at: datetime
|
|
86
|
+
workers: list[WorkerInfo] = []
|
|
87
|
+
error: str | None = None
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import uuid
|
|
6
7
|
|
|
7
|
-
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
8
9
|
from simple_module_hosting.permissions import RequiresPermission
|
|
9
10
|
|
|
10
11
|
from background_tasks.constants import (
|
|
@@ -17,9 +18,11 @@ from background_tasks.constants import (
|
|
|
17
18
|
from background_tasks.contracts.schemas import (
|
|
18
19
|
TaskExecutionDetail,
|
|
19
20
|
TaskExecutionListResponse,
|
|
21
|
+
WorkerSnapshot,
|
|
20
22
|
)
|
|
21
23
|
from background_tasks.deps import get_background_task_service
|
|
22
24
|
from background_tasks.service import BackgroundTaskService
|
|
25
|
+
from background_tasks.worker_inspector import WorkerInspector
|
|
23
26
|
|
|
24
27
|
router = APIRouter(
|
|
25
28
|
prefix=ADMIN_ROUTER_PREFIX,
|
|
@@ -60,3 +63,11 @@ async def retry_execution(
|
|
|
60
63
|
service: BackgroundTaskService = Depends(get_background_task_service),
|
|
61
64
|
) -> TaskExecutionDetail:
|
|
62
65
|
return await service.retry(execution_id)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.get("/workers", response_model=WorkerSnapshot)
|
|
69
|
+
async def get_workers(request: Request) -> WorkerSnapshot:
|
|
70
|
+
"""Live snapshot of every Celery worker reachable via the broker."""
|
|
71
|
+
celery = request.app.state.background_tasks.celery
|
|
72
|
+
inspector = WorkerInspector(celery)
|
|
73
|
+
return await asyncio.to_thread(inspector.snapshot)
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import uuid
|
|
6
7
|
|
|
7
|
-
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
8
9
|
from inertia import InertiaResponse
|
|
9
10
|
from simple_module_hosting.inertia_deps import InertiaDep
|
|
10
11
|
from simple_module_hosting.permissions import RequiresPermission
|
|
@@ -15,6 +16,7 @@ from background_tasks.constants import (
|
|
|
15
16
|
)
|
|
16
17
|
from background_tasks.deps import get_background_task_service
|
|
17
18
|
from background_tasks.service import BackgroundTaskService
|
|
19
|
+
from background_tasks.worker_inspector import WorkerInspector
|
|
18
20
|
|
|
19
21
|
router = APIRouter(dependencies=[Depends(RequiresPermission(PERM_VIEW))])
|
|
20
22
|
|
|
@@ -52,6 +54,16 @@ async def index(
|
|
|
52
54
|
)
|
|
53
55
|
|
|
54
56
|
|
|
57
|
+
@router.get("/workers", response_model=None)
|
|
58
|
+
async def workers(inertia: InertiaDep, request: Request) -> InertiaResponse:
|
|
59
|
+
celery = request.app.state.background_tasks.celery
|
|
60
|
+
snapshot = await asyncio.to_thread(WorkerInspector(celery).snapshot)
|
|
61
|
+
return await inertia.render(
|
|
62
|
+
"BackgroundTasks/Workers",
|
|
63
|
+
{"snapshot": snapshot.model_dump(mode="json")},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
55
67
|
@router.get("/{execution_id}", response_model=None)
|
|
56
68
|
async def detail(
|
|
57
69
|
execution_id: str,
|
|
@@ -8,7 +8,13 @@ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedL
|
|
|
8
8
|
import { ArrowLeft, RefreshCcw } from 'lucide-react';
|
|
9
9
|
import type { ReactNode } from 'react';
|
|
10
10
|
import { RetryConfirmDialog } from './components/RetryConfirmDialog';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
formatTs,
|
|
13
|
+
RETRYABLE_STATUSES,
|
|
14
|
+
STATUS_BADGE_VARIANT,
|
|
15
|
+
type TaskStatus,
|
|
16
|
+
VIEW_BASE,
|
|
17
|
+
} from './constants';
|
|
12
18
|
import { retryExecution } from './retry';
|
|
13
19
|
|
|
14
20
|
interface Execution {
|
|
@@ -44,10 +50,6 @@ function pretty(value: unknown): string {
|
|
|
44
50
|
}
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
function formatTs(ts: string | null): string {
|
|
48
|
-
return ts ? new Date(ts).toLocaleString() : '—';
|
|
49
|
-
}
|
|
50
|
-
|
|
51
53
|
function JsonCard({ title, children }: { title: string; children: ReactNode }) {
|
|
52
54
|
return (
|
|
53
55
|
<Card className="p-4">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { router, usePage } from '@inertiajs/react';
|
|
1
|
+
import { Link, router, usePage } from '@inertiajs/react';
|
|
2
2
|
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
3
3
|
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
4
4
|
import { Card } from '@simple-module-py/ui/components/ui/card';
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from '@simple-module-py/ui/components/ui/table';
|
|
21
21
|
import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions';
|
|
22
22
|
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
23
|
-
import { Activity, Search } from 'lucide-react';
|
|
23
|
+
import { Activity, Search, ServerCog } from 'lucide-react';
|
|
24
24
|
import { useEffect, useState } from 'react';
|
|
25
25
|
import { ExecutionRow, statusLabel } from './components/ExecutionRow';
|
|
26
26
|
import { STATUS_ORDER, VIEW_BASE } from './constants';
|
|
@@ -93,22 +93,30 @@ function Index() {
|
|
|
93
93
|
className="pl-9"
|
|
94
94
|
/>
|
|
95
95
|
</div>
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
{
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
96
|
+
<div className="flex items-center gap-2">
|
|
97
|
+
<Select
|
|
98
|
+
value={statusValue}
|
|
99
|
+
onValueChange={(v) => pushFilters({ status: v, task_name: search }, 1)}
|
|
100
|
+
>
|
|
101
|
+
<SelectTrigger className="w-full sm:w-48">
|
|
102
|
+
<SelectValue placeholder="Status" />
|
|
103
|
+
</SelectTrigger>
|
|
104
|
+
<SelectContent>
|
|
105
|
+
<SelectItem value={STATUS_ALL}>All statuses</SelectItem>
|
|
106
|
+
{STATUS_ORDER.map((s) => (
|
|
107
|
+
<SelectItem key={s} value={s}>
|
|
108
|
+
{statusLabel(s)}
|
|
109
|
+
</SelectItem>
|
|
110
|
+
))}
|
|
111
|
+
</SelectContent>
|
|
112
|
+
</Select>
|
|
113
|
+
<Button variant="outline" asChild>
|
|
114
|
+
<Link href={`${VIEW_BASE}/workers`}>
|
|
115
|
+
<ServerCog className="mr-2 size-4" />
|
|
116
|
+
Workers
|
|
117
|
+
</Link>
|
|
118
|
+
</Button>
|
|
119
|
+
</div>
|
|
112
120
|
</div>
|
|
113
121
|
|
|
114
122
|
<Card>
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Link, usePage } from '@inertiajs/react';
|
|
2
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
3
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
4
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
5
|
+
import { Card } from '@simple-module-py/ui/components/ui/card';
|
|
6
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
7
|
+
import { ArrowLeft, RefreshCw, ServerCrash, ServerOff } from 'lucide-react';
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
import { toast } from 'sonner';
|
|
10
|
+
import { API_BASE, formatTs, VIEW_BASE, type WorkerInfo, type WorkerSnapshot } from './constants';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
snapshot: WorkerSnapshot;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function WorkerCard({ worker }: { worker: WorkerInfo }) {
|
|
17
|
+
return (
|
|
18
|
+
<Card className="p-4">
|
|
19
|
+
<div className="flex items-start justify-between gap-4">
|
|
20
|
+
<div className="flex items-start gap-3">
|
|
21
|
+
<span
|
|
22
|
+
role="img"
|
|
23
|
+
className={`mt-1.5 size-2.5 rounded-full ${
|
|
24
|
+
worker.online ? 'bg-green-500' : 'bg-muted-foreground'
|
|
25
|
+
}`}
|
|
26
|
+
aria-label={worker.online ? 'online' : 'offline'}
|
|
27
|
+
/>
|
|
28
|
+
<div>
|
|
29
|
+
<h3 className="font-medium">{worker.hostname}</h3>
|
|
30
|
+
{worker.software && <p className="text-xs text-muted-foreground">{worker.software}</p>}
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<Badge variant={worker.online ? 'secondary' : 'outline'}>
|
|
34
|
+
{worker.online ? 'Online' : 'Offline'}
|
|
35
|
+
</Badge>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
|
39
|
+
<div>
|
|
40
|
+
<dt className="text-muted-foreground">Active</dt>
|
|
41
|
+
<dd className="font-medium">{worker.active_task_count}</dd>
|
|
42
|
+
</div>
|
|
43
|
+
<div>
|
|
44
|
+
<dt className="text-muted-foreground">Pool</dt>
|
|
45
|
+
<dd className="font-medium">{worker.pool_size ?? '—'}</dd>
|
|
46
|
+
</div>
|
|
47
|
+
<div>
|
|
48
|
+
<dt className="text-muted-foreground">Processed</dt>
|
|
49
|
+
<dd className="font-medium">{worker.total_processed ?? '—'}</dd>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="col-span-2 sm:col-span-1">
|
|
52
|
+
<dt className="text-muted-foreground">Queues</dt>
|
|
53
|
+
<dd className="flex flex-wrap gap-1">
|
|
54
|
+
{worker.queues.length === 0 ? (
|
|
55
|
+
<span className="text-muted-foreground">—</span>
|
|
56
|
+
) : (
|
|
57
|
+
worker.queues.map((q) => (
|
|
58
|
+
<Badge key={q} variant="outline" className="font-normal">
|
|
59
|
+
{q}
|
|
60
|
+
</Badge>
|
|
61
|
+
))
|
|
62
|
+
)}
|
|
63
|
+
</dd>
|
|
64
|
+
</div>
|
|
65
|
+
</dl>
|
|
66
|
+
</Card>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function Workers() {
|
|
71
|
+
const { snapshot: initial } = usePage<{ props: Props }>().props as unknown as Props;
|
|
72
|
+
const [snapshot, setSnapshot] = useState<WorkerSnapshot>(initial);
|
|
73
|
+
const [loading, setLoading] = useState(false);
|
|
74
|
+
|
|
75
|
+
async function refresh() {
|
|
76
|
+
setLoading(true);
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(`${API_BASE}/workers`, {
|
|
79
|
+
headers: { Accept: 'application/json' },
|
|
80
|
+
});
|
|
81
|
+
if (res.ok) {
|
|
82
|
+
setSnapshot((await res.json()) as WorkerSnapshot);
|
|
83
|
+
} else {
|
|
84
|
+
toast.error(`Failed to refresh workers (HTTP ${res.status})`);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
toast.error('Failed to refresh workers');
|
|
88
|
+
} finally {
|
|
89
|
+
setLoading(false);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<PageShell title="Workers" description="Celery workers connected to the broker.">
|
|
95
|
+
<div className="mb-4 flex items-center justify-between gap-3">
|
|
96
|
+
<Button variant="outline" size="sm" asChild>
|
|
97
|
+
<Link href={VIEW_BASE}>
|
|
98
|
+
<ArrowLeft className="mr-2 size-4" />
|
|
99
|
+
Back to executions
|
|
100
|
+
</Link>
|
|
101
|
+
</Button>
|
|
102
|
+
<div className="flex items-center gap-3">
|
|
103
|
+
<span className="text-xs text-muted-foreground">
|
|
104
|
+
Last updated {formatTs(snapshot.polled_at)}
|
|
105
|
+
</span>
|
|
106
|
+
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
|
|
107
|
+
<RefreshCw className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`} />
|
|
108
|
+
Refresh
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{!snapshot.broker_reachable ? (
|
|
114
|
+
<Card className="p-6">
|
|
115
|
+
<div className="flex items-start gap-3">
|
|
116
|
+
<ServerCrash className="size-5 text-destructive" />
|
|
117
|
+
<div>
|
|
118
|
+
<h3 className="font-medium">Broker unreachable</h3>
|
|
119
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
120
|
+
{snapshot.error ?? 'No error message reported.'}
|
|
121
|
+
</p>
|
|
122
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
123
|
+
Check the <code>SM_BG_TASKS_BROKER_URL</code> setting and confirm the broker process
|
|
124
|
+
is running.
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</Card>
|
|
129
|
+
) : snapshot.workers.length === 0 ? (
|
|
130
|
+
<Card className="p-6">
|
|
131
|
+
<div className="flex items-start gap-3">
|
|
132
|
+
<ServerOff className="size-5 text-muted-foreground" />
|
|
133
|
+
<div>
|
|
134
|
+
<h3 className="font-medium">No workers connected</h3>
|
|
135
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
136
|
+
The broker is reachable but no Celery workers are responding. Start one with{' '}
|
|
137
|
+
<code>uv run python scripts/run_worker.py</code>.
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</Card>
|
|
142
|
+
) : (
|
|
143
|
+
<div className="grid gap-3">
|
|
144
|
+
{snapshot.workers.map((w) => (
|
|
145
|
+
<WorkerCard key={w.hostname} worker={w} />
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</PageShell>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
Workers.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
154
|
+
export default Workers;
|
|
@@ -4,6 +4,27 @@
|
|
|
4
4
|
export const API_BASE = '/api/background_tasks/admin';
|
|
5
5
|
export const VIEW_BASE = '/admin/background-tasks';
|
|
6
6
|
|
|
7
|
+
export function formatTs(ts: string | null): string {
|
|
8
|
+
return ts ? new Date(ts).toLocaleString() : '—';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WorkerInfo {
|
|
12
|
+
hostname: string;
|
|
13
|
+
online: boolean;
|
|
14
|
+
queues: string[];
|
|
15
|
+
active_task_count: number;
|
|
16
|
+
pool_size: number | null;
|
|
17
|
+
total_processed: number | null;
|
|
18
|
+
software: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WorkerSnapshot {
|
|
22
|
+
broker_reachable: boolean;
|
|
23
|
+
polled_at: string;
|
|
24
|
+
workers: WorkerInfo[];
|
|
25
|
+
error: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
7
28
|
export const TASK_STATUS = {
|
|
8
29
|
PENDING: 'pending',
|
|
9
30
|
RUNNING: 'running',
|
|
@@ -22,6 +22,7 @@ import os
|
|
|
22
22
|
|
|
23
23
|
from pydantic import Field, model_validator
|
|
24
24
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
25
|
+
from simple_module_core.dotenv import env_bool
|
|
25
26
|
from simple_module_core.environments import NON_PROD_ENVIRONMENTS
|
|
26
27
|
|
|
27
28
|
from background_tasks.constants import (
|
|
@@ -47,6 +48,13 @@ class BackgroundTasksSettings(BaseSettings):
|
|
|
47
48
|
result_backend: str = Field(default=DEFAULT_RESULT_BACKEND, json_schema_extra=_CELERY_RESTART)
|
|
48
49
|
task_default_queue: str = Field(default=DEFAULT_QUEUE, json_schema_extra=_CELERY_RESTART)
|
|
49
50
|
|
|
51
|
+
# Run tasks synchronously inside the calling process. Read at
|
|
52
|
+
# module-import time so tests can flip it on via ``SM_BG_TASKS_*``
|
|
53
|
+
# without going through DB-backed hydration (which never fires for
|
|
54
|
+
# suites that don't use the FastAPI lifespan).
|
|
55
|
+
task_always_eager: bool = env_bool("SM_BG_TASKS_TASK_ALWAYS_EAGER")
|
|
56
|
+
task_eager_propagates: bool = True
|
|
57
|
+
|
|
50
58
|
# A task that has been ``running`` longer than this without a heartbeat is
|
|
51
59
|
# flipped to ``stuck`` by the beat sweep. 5 min is long enough to cover
|
|
52
60
|
# normal slow jobs while still surfacing wedged workers within one UI
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Read-only adapter around ``celery.control.inspect()``.
|
|
2
|
+
|
|
3
|
+
Produces a :class:`WorkerSnapshot` for the admin Workers page. All broker
|
|
4
|
+
errors are caught and surfaced through ``snapshot.broker_reachable`` /
|
|
5
|
+
``snapshot.error`` so the page can render a clear operator-facing state
|
|
6
|
+
instead of the endpoint returning a 5xx.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from kombu.exceptions import OperationalError
|
|
16
|
+
from redis.exceptions import RedisError
|
|
17
|
+
|
|
18
|
+
from background_tasks.contracts.schemas import WorkerInfo, WorkerSnapshot
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from celery import Celery
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WorkerInspector:
|
|
27
|
+
"""Synchronous adapter; call from async code via ``asyncio.to_thread``."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, celery: Celery, *, timeout: float = 1.0) -> None:
|
|
30
|
+
self.celery = celery
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
|
|
33
|
+
def snapshot(self) -> WorkerSnapshot:
|
|
34
|
+
polled_at = datetime.now(UTC)
|
|
35
|
+
|
|
36
|
+
# Probe the broker first so we can distinguish "broker down" from
|
|
37
|
+
# "broker up but no workers replied". Without this, both look like
|
|
38
|
+
# ``inspect.*() == None``.
|
|
39
|
+
try:
|
|
40
|
+
with self.celery.connection() as conn:
|
|
41
|
+
conn.ensure_connection(max_retries=1, timeout=self.timeout)
|
|
42
|
+
except (OperationalError, RedisError, ConnectionError, OSError) as exc:
|
|
43
|
+
logger.info("Broker unreachable: %s", exc)
|
|
44
|
+
return WorkerSnapshot(
|
|
45
|
+
broker_reachable=False,
|
|
46
|
+
polled_at=polled_at,
|
|
47
|
+
workers=[],
|
|
48
|
+
error=str(exc),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
inspect = self.celery.control.inspect(timeout=self.timeout)
|
|
52
|
+
try:
|
|
53
|
+
ping = inspect.ping() or {}
|
|
54
|
+
stats = inspect.stats() or {}
|
|
55
|
+
queues = inspect.active_queues() or {}
|
|
56
|
+
active = inspect.active() or {}
|
|
57
|
+
except (OperationalError, RedisError, ConnectionError, OSError) as exc:
|
|
58
|
+
logger.info("inspect() failed mid-call: %s", exc)
|
|
59
|
+
return WorkerSnapshot(
|
|
60
|
+
broker_reachable=False,
|
|
61
|
+
polled_at=polled_at,
|
|
62
|
+
workers=[],
|
|
63
|
+
error=str(exc),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
hostnames = sorted(set(ping) | set(stats) | set(queues) | set(active))
|
|
67
|
+
workers = [
|
|
68
|
+
_build_worker_info(
|
|
69
|
+
hostname=h,
|
|
70
|
+
pinged=h in ping,
|
|
71
|
+
stats=stats.get(h) or {},
|
|
72
|
+
queues=queues.get(h) or [],
|
|
73
|
+
active=active.get(h) or [],
|
|
74
|
+
)
|
|
75
|
+
for h in hostnames
|
|
76
|
+
]
|
|
77
|
+
return WorkerSnapshot(
|
|
78
|
+
broker_reachable=True,
|
|
79
|
+
polled_at=polled_at,
|
|
80
|
+
workers=workers,
|
|
81
|
+
error=None,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build_worker_info(
|
|
86
|
+
*,
|
|
87
|
+
hostname: str,
|
|
88
|
+
pinged: bool,
|
|
89
|
+
stats: dict[str, Any],
|
|
90
|
+
queues: list[dict[str, Any]],
|
|
91
|
+
active: list[dict[str, Any]],
|
|
92
|
+
) -> WorkerInfo:
|
|
93
|
+
pool = stats.get("pool") or {}
|
|
94
|
+
pool_size = pool.get("max-concurrency")
|
|
95
|
+
if pool_size is None and isinstance(pool.get("processes"), list):
|
|
96
|
+
pool_size = len(pool["processes"])
|
|
97
|
+
|
|
98
|
+
total = stats.get("total")
|
|
99
|
+
total_processed: int | None = None
|
|
100
|
+
if isinstance(total, dict):
|
|
101
|
+
total_processed = sum(int(v) for v in total.values() if isinstance(v, int | float))
|
|
102
|
+
elif isinstance(total, int):
|
|
103
|
+
total_processed = total
|
|
104
|
+
|
|
105
|
+
sw_ident = stats.get("sw_ident")
|
|
106
|
+
sw_ver = stats.get("sw_ver")
|
|
107
|
+
software = f"{sw_ident}:{sw_ver}" if sw_ident and sw_ver else (sw_ident or sw_ver)
|
|
108
|
+
|
|
109
|
+
return WorkerInfo(
|
|
110
|
+
hostname=hostname,
|
|
111
|
+
online=pinged,
|
|
112
|
+
queues=[q.get("name", "") for q in queues if q.get("name")],
|
|
113
|
+
active_task_count=len(active),
|
|
114
|
+
pool_size=pool_size,
|
|
115
|
+
total_processed=total_processed,
|
|
116
|
+
software=software,
|
|
117
|
+
)
|
{simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/pyproject.toml
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_background_tasks"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.5"
|
|
4
4
|
description = "Celery + Redis task queue with admin UI for monitoring and retrying failed/stuck tasks"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,9 +21,9 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
24
|
+
"simple_module_core==0.0.5",
|
|
25
|
+
"simple_module_db==0.0.5",
|
|
26
|
+
"simple_module_hosting==0.0.5",
|
|
27
27
|
"celery[redis]>=5.4",
|
|
28
28
|
"redis>=5",
|
|
29
29
|
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared fixtures for background_tasks tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
async def _stub_celery(app) -> None:
|
|
12
|
+
"""Replace the real Celery instance with a MagicMock.
|
|
13
|
+
|
|
14
|
+
Opt-in via ``pytestmark = pytest.mark.usefixtures("_stub_celery")`` at
|
|
15
|
+
module scope — not autouse, because signal tests deliberately exercise
|
|
16
|
+
an unstarted app and would be broken by the implicit ``app`` dependency.
|
|
17
|
+
``send_task.return_value.id`` is pre-set so retry flows that read it
|
|
18
|
+
(see ``test_admin_api.py``) work without further setup.
|
|
19
|
+
"""
|
|
20
|
+
celery = MagicMock(name="Celery")
|
|
21
|
+
celery.send_task.return_value.id = "mocked-celery-id"
|
|
22
|
+
app.state.background_tasks.celery = celery
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import uuid
|
|
6
6
|
from datetime import UTC, datetime
|
|
7
|
-
from unittest.mock import MagicMock
|
|
8
7
|
|
|
9
8
|
import httpx
|
|
10
9
|
import pytest
|
|
@@ -13,19 +12,7 @@ from background_tasks.models import TaskExecution
|
|
|
13
12
|
|
|
14
13
|
ADMIN_BASE = "/api/background_tasks/admin"
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
@pytest.fixture(autouse=True)
|
|
18
|
-
async def _stub_celery(app) -> None:
|
|
19
|
-
"""Replace the real Celery instance with a MagicMock for the admin suite.
|
|
20
|
-
|
|
21
|
-
``BackgroundTasksModule.on_startup`` builds a live Celery app which tries
|
|
22
|
-
to talk to a real Redis broker on the first ``send_task`` call. The admin
|
|
23
|
-
tests only care that ``retry`` flows through the API — mocking keeps the
|
|
24
|
-
test hermetic.
|
|
25
|
-
"""
|
|
26
|
-
celery = MagicMock(name="Celery")
|
|
27
|
-
celery.send_task.return_value.id = "mocked-celery-id"
|
|
28
|
-
app.state.background_tasks.celery = celery
|
|
15
|
+
pytestmark = pytest.mark.usefixtures("_stub_celery")
|
|
29
16
|
|
|
30
17
|
|
|
31
18
|
async def _seed_failed(app, **overrides) -> TaskExecution:
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Unit tests for WorkerInspector — wraps celery.control.inspect()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
from celery import Celery
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _make_celery_with_dead_broker() -> Celery:
|
|
11
|
+
"""Build a Celery app pointed at an unreachable broker.
|
|
12
|
+
|
|
13
|
+
Port 1 is reserved/refused, so any connection attempt fails fast.
|
|
14
|
+
"""
|
|
15
|
+
app = Celery("test", broker="redis://127.0.0.1:1/0", backend="redis://127.0.0.1:1/1")
|
|
16
|
+
app.conf.broker_connection_retry_on_startup = False
|
|
17
|
+
return app
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_dead_broker_returns_unreachable_snapshot():
|
|
21
|
+
from background_tasks.worker_inspector import WorkerInspector
|
|
22
|
+
|
|
23
|
+
inspector = WorkerInspector(_make_celery_with_dead_broker(), timeout=0.2)
|
|
24
|
+
snapshot = inspector.snapshot()
|
|
25
|
+
|
|
26
|
+
assert snapshot.broker_reachable is False
|
|
27
|
+
assert snapshot.workers == []
|
|
28
|
+
assert snapshot.error is not None
|
|
29
|
+
assert snapshot.polled_at is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_inspect_payloads_are_merged_into_worker_info():
|
|
33
|
+
from background_tasks.worker_inspector import WorkerInspector
|
|
34
|
+
|
|
35
|
+
celery = MagicMock(spec=Celery)
|
|
36
|
+
# Probe call: ensure_connection succeeds (broker reachable).
|
|
37
|
+
celery.connection.return_value.ensure_connection.return_value = None
|
|
38
|
+
|
|
39
|
+
inspect = celery.control.inspect.return_value
|
|
40
|
+
inspect.ping.return_value = {"celery@host-a": {"ok": "pong"}}
|
|
41
|
+
inspect.stats.return_value = {
|
|
42
|
+
"celery@host-a": {
|
|
43
|
+
"pool": {"max-concurrency": 4, "processes": [1, 2, 3, 4]},
|
|
44
|
+
"total": {"demo.task": 17},
|
|
45
|
+
"broker": {"transport": "redis"},
|
|
46
|
+
"sw_ident": "py-celery",
|
|
47
|
+
"sw_ver": "5.3.6",
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
inspect.active_queues.return_value = {
|
|
51
|
+
"celery@host-a": [{"name": "default"}, {"name": "high"}],
|
|
52
|
+
}
|
|
53
|
+
inspect.active.return_value = {
|
|
54
|
+
"celery@host-a": [{"id": "task-1"}, {"id": "task-2"}],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
snapshot = WorkerInspector(celery, timeout=0.1).snapshot()
|
|
58
|
+
|
|
59
|
+
assert snapshot.broker_reachable is True
|
|
60
|
+
assert snapshot.error is None
|
|
61
|
+
assert len(snapshot.workers) == 1
|
|
62
|
+
w = snapshot.workers[0]
|
|
63
|
+
assert w.hostname == "celery@host-a"
|
|
64
|
+
assert w.online is True
|
|
65
|
+
assert w.queues == ["default", "high"]
|
|
66
|
+
assert w.active_task_count == 2
|
|
67
|
+
assert w.pool_size == 4
|
|
68
|
+
assert w.total_processed == 17
|
|
69
|
+
assert w.software == "py-celery:5.3.6"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_worker_in_stats_but_not_ping_is_offline():
|
|
73
|
+
from background_tasks.worker_inspector import WorkerInspector
|
|
74
|
+
|
|
75
|
+
celery = MagicMock(spec=Celery)
|
|
76
|
+
celery.connection.return_value.ensure_connection.return_value = None
|
|
77
|
+
inspect = celery.control.inspect.return_value
|
|
78
|
+
inspect.ping.return_value = {} # no replies
|
|
79
|
+
inspect.stats.return_value = {"celery@host-b": {"pool": {"max-concurrency": 2}}}
|
|
80
|
+
inspect.active_queues.return_value = {}
|
|
81
|
+
inspect.active.return_value = {}
|
|
82
|
+
|
|
83
|
+
snapshot = WorkerInspector(celery, timeout=0.1).snapshot()
|
|
84
|
+
|
|
85
|
+
assert snapshot.broker_reachable is True
|
|
86
|
+
assert len(snapshot.workers) == 1
|
|
87
|
+
assert snapshot.workers[0].hostname == "celery@host-b"
|
|
88
|
+
assert snapshot.workers[0].online is False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_redis_error_during_probe_returns_unreachable_snapshot():
|
|
92
|
+
"""A redis-py exception (e.g. auth failure) is converted, not propagated."""
|
|
93
|
+
from background_tasks.worker_inspector import WorkerInspector
|
|
94
|
+
from redis.exceptions import AuthenticationError
|
|
95
|
+
|
|
96
|
+
celery = MagicMock(spec=Celery)
|
|
97
|
+
celery.connection.return_value.__enter__.return_value.ensure_connection.side_effect = (
|
|
98
|
+
AuthenticationError("invalid password")
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
snapshot = WorkerInspector(celery, timeout=0.1).snapshot()
|
|
102
|
+
|
|
103
|
+
assert snapshot.broker_reachable is False
|
|
104
|
+
assert snapshot.workers == []
|
|
105
|
+
assert snapshot.error is not None
|
|
106
|
+
assert "invalid password" in snapshot.error
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""End-to-end tests for the Workers endpoints (Inertia view + JSON admin)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
from background_tasks import worker_inspector as wi
|
|
10
|
+
from background_tasks.contracts.schemas import WorkerInfo, WorkerSnapshot
|
|
11
|
+
|
|
12
|
+
JSON_BASE = "/api/background_tasks/admin"
|
|
13
|
+
VIEW_BASE = "/admin/background-tasks"
|
|
14
|
+
|
|
15
|
+
pytestmark = pytest.mark.usefixtures("_stub_celery")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def fake_snapshot() -> WorkerSnapshot:
|
|
20
|
+
return WorkerSnapshot(
|
|
21
|
+
broker_reachable=True,
|
|
22
|
+
polled_at=datetime(2026, 5, 1, 12, 0, tzinfo=UTC),
|
|
23
|
+
workers=[
|
|
24
|
+
WorkerInfo(
|
|
25
|
+
hostname="celery@host-a",
|
|
26
|
+
online=True,
|
|
27
|
+
queues=["default"],
|
|
28
|
+
active_task_count=1,
|
|
29
|
+
pool_size=4,
|
|
30
|
+
total_processed=42,
|
|
31
|
+
software="py-celery:5.3.6",
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
error=None,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestWorkersJsonEndpoint:
|
|
39
|
+
async def test_unauthenticated_request_is_rejected(self, client: httpx.AsyncClient):
|
|
40
|
+
resp = await client.get(f"{JSON_BASE}/workers", follow_redirects=False)
|
|
41
|
+
assert resp.status_code in {302, 401, 403}
|
|
42
|
+
|
|
43
|
+
async def test_returns_snapshot_payload(
|
|
44
|
+
self,
|
|
45
|
+
monkeypatch,
|
|
46
|
+
authenticated_client: httpx.AsyncClient,
|
|
47
|
+
fake_snapshot: WorkerSnapshot,
|
|
48
|
+
):
|
|
49
|
+
monkeypatch.setattr(wi.WorkerInspector, "snapshot", lambda self: fake_snapshot)
|
|
50
|
+
|
|
51
|
+
resp = await authenticated_client.get(f"{JSON_BASE}/workers")
|
|
52
|
+
assert resp.status_code == 200
|
|
53
|
+
body = resp.json()
|
|
54
|
+
assert body["broker_reachable"] is True
|
|
55
|
+
assert len(body["workers"]) == 1
|
|
56
|
+
assert body["workers"][0]["hostname"] == "celery@host-a"
|
|
57
|
+
assert body["workers"][0]["pool_size"] == 4
|
|
58
|
+
|
|
59
|
+
async def test_unreachable_broker_is_200_with_error_field(
|
|
60
|
+
self,
|
|
61
|
+
monkeypatch,
|
|
62
|
+
authenticated_client: httpx.AsyncClient,
|
|
63
|
+
):
|
|
64
|
+
unreachable = WorkerSnapshot(
|
|
65
|
+
broker_reachable=False,
|
|
66
|
+
polled_at=datetime(2026, 5, 1, 12, 0, tzinfo=UTC),
|
|
67
|
+
workers=[],
|
|
68
|
+
error="Connection refused",
|
|
69
|
+
)
|
|
70
|
+
monkeypatch.setattr(wi.WorkerInspector, "snapshot", lambda self: unreachable)
|
|
71
|
+
|
|
72
|
+
resp = await authenticated_client.get(f"{JSON_BASE}/workers")
|
|
73
|
+
assert resp.status_code == 200
|
|
74
|
+
body = resp.json()
|
|
75
|
+
assert body["broker_reachable"] is False
|
|
76
|
+
assert body["error"] == "Connection refused"
|
|
77
|
+
assert body["workers"] == []
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestWorkersInertiaView:
|
|
81
|
+
async def test_renders_page_with_snapshot_prop(
|
|
82
|
+
self,
|
|
83
|
+
monkeypatch,
|
|
84
|
+
authenticated_client: httpx.AsyncClient,
|
|
85
|
+
fake_snapshot: WorkerSnapshot,
|
|
86
|
+
):
|
|
87
|
+
monkeypatch.setattr(wi.WorkerInspector, "snapshot", lambda self: fake_snapshot)
|
|
88
|
+
|
|
89
|
+
# Inertia returns JSON when X-Inertia is present.
|
|
90
|
+
resp = await authenticated_client.get(
|
|
91
|
+
f"{VIEW_BASE}/workers",
|
|
92
|
+
headers={"X-Inertia": "true", "Accept": "application/json"},
|
|
93
|
+
)
|
|
94
|
+
assert resp.status_code == 200
|
|
95
|
+
body = resp.json()
|
|
96
|
+
assert body["component"] == "BackgroundTasks/Workers"
|
|
97
|
+
snapshot = body["props"]["snapshot"]
|
|
98
|
+
assert snapshot["broker_reachable"] is True
|
|
99
|
+
assert snapshot["workers"][0]["hostname"] == "celery@host-a"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_background_tasks-0.0.3 → simple_module_background_tasks-0.0.5}/tests/test_signals.py
RENAMED
|
File without changes
|
|
File without changes
|