simple-module-background-tasks 0.0.2__tar.gz → 0.0.4__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 (42) hide show
  1. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/.gitignore +4 -0
  2. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/PKG-INFO +4 -4
  3. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/celery_app.py +5 -0
  4. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/constants.py +1 -1
  5. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/contracts/schemas.py +21 -0
  6. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/endpoints/api_admin.py +12 -1
  7. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/endpoints/views.py +13 -1
  8. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/module.py +1 -0
  9. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/pages/Detail.tsx +7 -5
  10. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/pages/Index.tsx +26 -18
  11. simple_module_background_tasks-0.0.4/background_tasks/pages/Workers.tsx +154 -0
  12. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/pages/constants.ts +21 -0
  13. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/settings.py +8 -0
  14. simple_module_background_tasks-0.0.4/background_tasks/worker_inspector.py +117 -0
  15. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/pyproject.toml +4 -4
  16. simple_module_background_tasks-0.0.4/tests/conftest.py +22 -0
  17. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/tests/test_admin_api.py +1 -14
  18. simple_module_background_tasks-0.0.4/tests/test_worker_inspector.py +106 -0
  19. simple_module_background_tasks-0.0.4/tests/test_workers_endpoints.py +99 -0
  20. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/LICENSE +0 -0
  21. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/README.md +0 -0
  22. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/__init__.py +0 -0
  23. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/_signal_support.py +0 -0
  24. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/contracts/__init__.py +0 -0
  25. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/contracts/events.py +0 -0
  26. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/deps.py +0 -0
  27. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/endpoints/__init__.py +0 -0
  28. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/locales/en.json +0 -0
  29. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/models.py +0 -0
  30. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
  31. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
  32. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/pages/retry.ts +0 -0
  33. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/py.typed +0 -0
  34. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/service.py +0 -0
  35. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/services.py +0 -0
  36. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/signals.py +0 -0
  37. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/sync_db.py +0 -0
  38. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/background_tasks/tasks.py +0 -0
  39. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/package.json +0 -0
  40. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/tests/test_bg_service.py +0 -0
  41. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/tests/test_signals.py +0 -0
  42. {simple_module_background_tasks-0.0.2 → simple_module_background_tasks-0.0.4}/tsconfig.json +0 -0
@@ -36,6 +36,10 @@ uploads/
36
36
  # Vite
37
37
  host/static/dist/
38
38
 
39
+ # VitePress (docs)
40
+ docs/.vitepress/cache/
41
+ docs/.vitepress/dist/
42
+
39
43
  # Auto-generated frontend module manifest (regenerated by the host at boot
40
44
  # or via `make gen-pages`).
41
45
  host/client_app/modules.manifest.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_background_tasks
3
- Version: 0.0.2
3
+ Version: 0.0.4
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.2
27
- Requires-Dist: simple-module-db==0.0.2
28
- Requires-Dist: simple-module-hosting==0.0.2
26
+ Requires-Dist: simple-module-core==0.0.4
27
+ Requires-Dist: simple-module-db==0.0.4
28
+ Requires-Dist: simple-module-hosting==0.0.4
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 = 80
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,
@@ -75,6 +75,7 @@ class BackgroundTasksModule(ModuleBase):
75
75
  order=MENU_ORDER,
76
76
  section=MenuSection.SIDEBAR,
77
77
  roles=["admin"],
78
+ group="Administration",
78
79
  )
79
80
  )
80
81
 
@@ -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 { RETRYABLE_STATUSES, STATUS_BADGE_VARIANT, type TaskStatus, VIEW_BASE } from './constants';
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
- <Select
97
- value={statusValue}
98
- onValueChange={(v) => pushFilters({ status: v, task_name: search }, 1)}
99
- >
100
- <SelectTrigger className="w-full sm:w-48">
101
- <SelectValue placeholder="Status" />
102
- </SelectTrigger>
103
- <SelectContent>
104
- <SelectItem value={STATUS_ALL}>All statuses</SelectItem>
105
- {STATUS_ORDER.map((s) => (
106
- <SelectItem key={s} value={s}>
107
- {statusLabel(s)}
108
- </SelectItem>
109
- ))}
110
- </SelectContent>
111
- </Select>
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
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_background_tasks"
3
- version = "0.0.2"
3
+ version = "0.0.4"
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.2",
25
- "simple_module_db==0.0.2",
26
- "simple_module_hosting==0.0.2",
24
+ "simple_module_core==0.0.4",
25
+ "simple_module_db==0.0.4",
26
+ "simple_module_hosting==0.0.4",
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"