simple-module-background-tasks 0.0.33__tar.gz → 0.0.34__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.34}/PKG-INFO +5 -5
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/deps.py +2 -2
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/endpoints/api_admin.py +11 -2
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/locales/en.json +4 -2
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/StatusStrip.tsx +21 -5
- simple_module_background_tasks-0.0.34/background_tasks/retry_service.py +220 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/service.py +2 -2
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/pyproject.toml +5 -5
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_admin_api.py +29 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_service.py +19 -12
- simple_module_background_tasks-0.0.34/tests/test_retry_sweep_ordering.py +200 -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.34}/.gitignore +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/LICENSE +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/README.md +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/__init__.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/_signal_support.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/celery_app.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/constants.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/contracts/__init__.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/contracts/events.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/contracts/schemas.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/endpoints/__init__.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/endpoints/views.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/filters.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/health.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/log_context.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/models.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/module.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/Detail.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/Index.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/Workers.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/DetailFacts.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/ExecutionsTable.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/PayloadCards.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/RetryAllDialog.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/StatusPill.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/TaskFilters.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/TasksEmpty.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/TracebackCard.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/WorkerCard.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/WorkerEmptyStates.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/WorkerHealthBanner.tsx +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/constants.ts +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/retry.ts +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/worker-health.ts +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/py.typed +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/services.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/settings.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/signals.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/sync_db.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/tasks.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/workers_state.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/package.json +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/conftest.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_background_tasks_settings.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_beat_schedule.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_index_worker_presence.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_list_item_payload.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_page_clamp.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_redis_health.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_redis_url.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_settings_env.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_detail_props.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_index_filters.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_log_context.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_retry_failed_bulk.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_retry_failed_guards.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_signals.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_sweep_stuck.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_sync_db.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_workers_endpoints.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_workers_uptime.py +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests-js/task-formatters.test.ts +0 -0
- {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/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.34
|
|
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.34
|
|
27
|
+
Requires-Dist: simple-module-db==0.0.34
|
|
28
|
+
Requires-Dist: simple-module-hosting==0.0.34
|
|
29
|
+
Requires-Dist: simple-module-settings==0.0.34
|
|
30
30
|
Description-Content-Type: text/markdown
|
|
31
31
|
|
|
32
32
|
# simple_module_background_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
|
|
|
@@ -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",
|
|
@@ -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
|
/>
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Re-enqueueing work: one execution, or a filtered sweep of them.
|
|
2
|
+
|
|
3
|
+
Split from :mod:`background_tasks.service` because re-enqueueing is the only
|
|
4
|
+
thing in this module that writes to the broker, and it is where every
|
|
5
|
+
correctness question lives — what may be retried, how many at once, and what
|
|
6
|
+
must not happen twice. The read paths have none of those questions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from simple_module_core.events import EventBus
|
|
17
|
+
from simple_module_db import RequestSession
|
|
18
|
+
from sqlalchemy import Select, func, select
|
|
19
|
+
|
|
20
|
+
from background_tasks.constants import RETRY_ALL_BATCH, TaskStatus
|
|
21
|
+
from background_tasks.contracts.events import TaskRetried
|
|
22
|
+
from background_tasks.contracts.schemas import RetryFailedResult
|
|
23
|
+
from background_tasks.filters import Conditions, bulk_retry_conditions
|
|
24
|
+
from background_tasks.models import TaskExecution
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from celery import Celery
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def claim_query(conditions: Conditions, batch: int) -> Select[tuple[TaskExecution]]:
|
|
31
|
+
"""The rows one sweep takes, held for the length of its transaction.
|
|
32
|
+
|
|
33
|
+
``FOR UPDATE SKIP LOCKED`` is what makes two simultaneous sweeps disjoint.
|
|
34
|
+
The guard that is *supposed* to keep a row out of a second sweep —
|
|
35
|
+
:func:`~background_tasks.filters.bulk_retry_conditions` excluding parents
|
|
36
|
+
that already have a child — only starts working once the child row exists,
|
|
37
|
+
and a concurrent sweep reads the same batch long before that. Locking the
|
|
38
|
+
parents makes the second reader skip them instead of publishing the same
|
|
39
|
+
tasks a second time, and the rows stay held until the transaction that
|
|
40
|
+
wrote the children commits, which is the moment the child guard takes over.
|
|
41
|
+
|
|
42
|
+
``skip_locked`` rather than plain ``FOR UPDATE``: a second operator should
|
|
43
|
+
get "nothing left to sweep" immediately, not block behind a 500-row batch.
|
|
44
|
+
|
|
45
|
+
SQLite has no row locks and silently emits no clause; it also takes a
|
|
46
|
+
database-wide write lock, so the two sweeps this protects against cannot
|
|
47
|
+
overlap there in the first place.
|
|
48
|
+
"""
|
|
49
|
+
return (
|
|
50
|
+
select(TaskExecution)
|
|
51
|
+
.where(*conditions)
|
|
52
|
+
# Oldest first: the queue an operator is unwedging should come back
|
|
53
|
+
# out in the order it went in.
|
|
54
|
+
.order_by(TaskExecution.queued_at.asc().nulls_last())
|
|
55
|
+
.limit(batch)
|
|
56
|
+
.with_for_update(skip_locked=True)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RetryCoordinator:
|
|
61
|
+
"""Sends tasks again and records each fresh attempt.
|
|
62
|
+
|
|
63
|
+
The original row is immutable: a retry inserts a *new* ``TaskExecution``
|
|
64
|
+
carrying ``retried_from_id``, so the detail page can show the chain and
|
|
65
|
+
nothing rewrites history.
|
|
66
|
+
|
|
67
|
+
Takes a :class:`~simple_module_db.RequestSession` rather than a bare
|
|
68
|
+
``AsyncSession`` because it announces retries through the session's
|
|
69
|
+
post-commit hook — see :meth:`_announce_after_commit`.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, db: RequestSession, celery: Celery, event_bus: EventBus) -> None:
|
|
73
|
+
self.db = db
|
|
74
|
+
self.celery = celery
|
|
75
|
+
self.event_bus = event_bus
|
|
76
|
+
|
|
77
|
+
async def retry_one(self, row: TaskExecution) -> TaskExecution:
|
|
78
|
+
"""Re-enqueue a single execution.
|
|
79
|
+
|
|
80
|
+
Publishes inline rather than on a thread: it is one call, and keeping
|
|
81
|
+
it on the event loop keeps Celery's eager mode — which runs the task
|
|
82
|
+
body inside ``send_task`` — on the same thread the signal handlers are
|
|
83
|
+
bound to. The sweep below, which sends hundreds, goes to a thread.
|
|
84
|
+
"""
|
|
85
|
+
new_row = self._new_attempt(row, self._publish(row))
|
|
86
|
+
self.db.add(new_row)
|
|
87
|
+
await self.db.flush()
|
|
88
|
+
await self.db.refresh(new_row)
|
|
89
|
+
self._announce_after_commit([(row, new_row)])
|
|
90
|
+
return new_row
|
|
91
|
+
|
|
92
|
+
async def retry_failed(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
status: TaskStatus | None = None,
|
|
96
|
+
task_name: str | None = None,
|
|
97
|
+
queue: str | None = None,
|
|
98
|
+
limit: int | None = None,
|
|
99
|
+
) -> RetryFailedResult:
|
|
100
|
+
"""Re-enqueue the retryable executions the current view can see.
|
|
101
|
+
|
|
102
|
+
"Failed" is the operator's word for both halves of the set: a stuck
|
|
103
|
+
task is one whose worker died holding it, and it needs the same push.
|
|
104
|
+
Scope comes from :func:`bulk_retry_conditions`, which is the listing's
|
|
105
|
+
own filters plus the two sweep rules.
|
|
106
|
+
|
|
107
|
+
Capped at *limit* rows per call (default :data:`RETRY_ALL_BATCH`);
|
|
108
|
+
``remaining`` says how many eligible rows the cap left behind, so the
|
|
109
|
+
operator knows to press again. The new rows are ``pending`` and their
|
|
110
|
+
parents now have a child, so neither this pass nor the next picks up
|
|
111
|
+
its own work.
|
|
112
|
+
|
|
113
|
+
Two operators pressing the button at once used to run every task twice:
|
|
114
|
+
both read the same batch, and the child rows that would have excluded
|
|
115
|
+
it were only written afterwards. :func:`claim_query` locks the batch,
|
|
116
|
+
and the children are written *before* the tasks are published — an
|
|
117
|
+
orphaned ``pending`` row that was never sent is a far cheaper failure
|
|
118
|
+
than a task that ran twice.
|
|
119
|
+
"""
|
|
120
|
+
# Read at call time, not bound as a default argument, so the cap has
|
|
121
|
+
# exactly one source of truth that tests and operators can move.
|
|
122
|
+
batch = RETRY_ALL_BATCH if limit is None else limit
|
|
123
|
+
conditions = bulk_retry_conditions(status=status, task_name=task_name, queue=queue)
|
|
124
|
+
if conditions is None:
|
|
125
|
+
return RetryFailedResult(queued=0, remaining=0)
|
|
126
|
+
|
|
127
|
+
eligible = int(
|
|
128
|
+
(
|
|
129
|
+
await self.db.execute(
|
|
130
|
+
select(func.count()).select_from(TaskExecution).where(*conditions)
|
|
131
|
+
)
|
|
132
|
+
).scalar_one()
|
|
133
|
+
)
|
|
134
|
+
if not eligible:
|
|
135
|
+
return RetryFailedResult(queued=0, remaining=0)
|
|
136
|
+
|
|
137
|
+
rows = list((await self.db.execute(claim_query(conditions, batch))).scalars())
|
|
138
|
+
if not rows:
|
|
139
|
+
# Everything eligible is held by a sweep that got here first. Say
|
|
140
|
+
# so rather than reporting an empty queue: the work is moving, just
|
|
141
|
+
# not by this press.
|
|
142
|
+
return RetryFailedResult(queued=0, remaining=eligible)
|
|
143
|
+
|
|
144
|
+
# The broker id is reserved here rather than read back from ``send_task``
|
|
145
|
+
# so the row recording the attempt can be written first and still name
|
|
146
|
+
# the task that will carry it. Held as plain strings, not read back off
|
|
147
|
+
# the ORM objects, because the publish below runs on another thread.
|
|
148
|
+
reserved = [(row, str(uuid.uuid4())) for row in rows]
|
|
149
|
+
new_rows = [self._new_attempt(row, task_id) for row, task_id in reserved]
|
|
150
|
+
self.db.add_all(new_rows)
|
|
151
|
+
# One flush for the batch rather than one per row: the round trips are
|
|
152
|
+
# the sweep's real cost, and the rows have no reason to land singly.
|
|
153
|
+
await self.db.flush()
|
|
154
|
+
|
|
155
|
+
# One thread for the whole batch. ``send_task`` is a blocking publish,
|
|
156
|
+
# and a few hundred of them inline would stall every other request for
|
|
157
|
+
# the length of the sweep.
|
|
158
|
+
await asyncio.to_thread(
|
|
159
|
+
lambda: [self._publish(row, task_id=task_id) for row, task_id in reserved]
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
self._announce_after_commit(list(zip(rows, new_rows, strict=True)))
|
|
163
|
+
return RetryFailedResult(queued=len(rows), remaining=max(0, eligible - len(rows)))
|
|
164
|
+
|
|
165
|
+
def _publish(self, row: TaskExecution, task_id: str | None = None) -> str:
|
|
166
|
+
"""Send one task to the broker and return its Celery id.
|
|
167
|
+
|
|
168
|
+
*task_id* pre-assigns that id, for callers that wrote the row naming it
|
|
169
|
+
before sending. Omitted, the broker assigns one and the caller stamps
|
|
170
|
+
the row with what comes back.
|
|
171
|
+
|
|
172
|
+
Blocking — kombu publishes synchronously. Call it off the event loop
|
|
173
|
+
when sending more than one.
|
|
174
|
+
"""
|
|
175
|
+
options: dict[str, Any] = {
|
|
176
|
+
"args": list(row.args or []),
|
|
177
|
+
"kwargs": dict(row.kwargs or {}),
|
|
178
|
+
"queue": row.queue,
|
|
179
|
+
}
|
|
180
|
+
if task_id is not None:
|
|
181
|
+
options["task_id"] = task_id
|
|
182
|
+
return self.celery.send_task(row.task_name, **options).id
|
|
183
|
+
|
|
184
|
+
def _new_attempt(self, row: TaskExecution, celery_task_id: str) -> TaskExecution:
|
|
185
|
+
"""The row recording a fresh attempt at *row*. Not yet flushed."""
|
|
186
|
+
return TaskExecution(
|
|
187
|
+
celery_task_id=celery_task_id,
|
|
188
|
+
task_name=row.task_name,
|
|
189
|
+
status=TaskStatus.PENDING,
|
|
190
|
+
queue=row.queue,
|
|
191
|
+
args=list(row.args or []),
|
|
192
|
+
kwargs=dict(row.kwargs or {}),
|
|
193
|
+
retried_from_id=row.id,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _announce_after_commit(
|
|
197
|
+
self, retried: Sequence[tuple[TaskExecution, TaskExecution]]
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Announce each retry once its row is durable. Same event either path.
|
|
200
|
+
|
|
201
|
+
Publishing inline told subscribers about a transaction that could still
|
|
202
|
+
roll back — and for a 500-row sweep it told them nothing at all until
|
|
203
|
+
the batch was done, then everything at once. The events are *built*
|
|
204
|
+
here, while the rows are attached and their ids are known, and sent
|
|
205
|
+
from the request's post-commit hook.
|
|
206
|
+
"""
|
|
207
|
+
events = [
|
|
208
|
+
TaskRetried(
|
|
209
|
+
original_id=original.id,
|
|
210
|
+
new_id=new_row.id,
|
|
211
|
+
task_name=original.task_name,
|
|
212
|
+
)
|
|
213
|
+
for original, new_row in retried
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
async def publish() -> None:
|
|
217
|
+
for event in events:
|
|
218
|
+
await self.event_bus.publish(event)
|
|
219
|
+
|
|
220
|
+
self.db.on_commit(publish)
|
|
@@ -9,8 +9,8 @@ from typing import TYPE_CHECKING
|
|
|
9
9
|
|
|
10
10
|
from fastapi import HTTPException, status
|
|
11
11
|
from simple_module_core.events import EventBus
|
|
12
|
+
from simple_module_db import RequestSession
|
|
12
13
|
from sqlalchemy import Row, func, select
|
|
13
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
14
|
|
|
15
15
|
from background_tasks.constants import RETRYABLE_STATUSES, TaskStatus
|
|
16
16
|
from background_tasks.contracts.schemas import (
|
|
@@ -43,7 +43,7 @@ class BackgroundTaskService:
|
|
|
43
43
|
|
|
44
44
|
def __init__(
|
|
45
45
|
self,
|
|
46
|
-
db:
|
|
46
|
+
db: RequestSession,
|
|
47
47
|
celery: Celery,
|
|
48
48
|
event_bus: EventBus,
|
|
49
49
|
) -> None:
|
{simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/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.34"
|
|
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,10 +21,10 @@ 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.
|
|
27
|
-
"simple_module_settings==0.0.
|
|
24
|
+
"simple_module_core==0.0.34",
|
|
25
|
+
"simple_module_db==0.0.34",
|
|
26
|
+
"simple_module_hosting==0.0.34",
|
|
27
|
+
"simple_module_settings==0.0.34",
|
|
28
28
|
"celery[redis]>=5.4",
|
|
29
29
|
"redis>=5",
|
|
30
30
|
]
|
|
@@ -74,6 +74,35 @@ class TestListExecutions:
|
|
|
74
74
|
assert resp.status_code == 200
|
|
75
75
|
assert [i["task_name"] for i in resp.json()["items"]] == ["cleanup_old"]
|
|
76
76
|
|
|
77
|
+
async def test_the_search_is_spelled_q_like_the_page_and_the_sweep(
|
|
78
|
+
self, app, authenticated_client: httpx.AsyncClient
|
|
79
|
+
):
|
|
80
|
+
"""One filter name across all three surfaces.
|
|
81
|
+
|
|
82
|
+
The page and ``retry-failed`` both take the search as ``q``; this
|
|
83
|
+
endpoint took it as ``task_name``, so a URL copied off the screen
|
|
84
|
+
listed everything and quietly looked like a filter that matched.
|
|
85
|
+
"""
|
|
86
|
+
await _seed_failed(app, task_name="files.thumbnail")
|
|
87
|
+
await _seed_failed(app, task_name="users.invite")
|
|
88
|
+
|
|
89
|
+
resp = await authenticated_client.get(f"{ADMIN_BASE}/executions", params={"q": "files."})
|
|
90
|
+
assert resp.status_code == 200
|
|
91
|
+
assert [i["task_name"] for i in resp.json()["items"]] == ["files.thumbnail"]
|
|
92
|
+
|
|
93
|
+
async def test_the_old_task_name_spelling_still_filters(
|
|
94
|
+
self, app, authenticated_client: httpx.AsyncClient
|
|
95
|
+
):
|
|
96
|
+
"""Deprecated, not removed — an existing API caller keeps working."""
|
|
97
|
+
await _seed_failed(app, task_name="files.thumbnail")
|
|
98
|
+
await _seed_failed(app, task_name="users.invite")
|
|
99
|
+
|
|
100
|
+
resp = await authenticated_client.get(
|
|
101
|
+
f"{ADMIN_BASE}/executions", params={"task_name": "users."}
|
|
102
|
+
)
|
|
103
|
+
assert resp.status_code == 200
|
|
104
|
+
assert [i["task_name"] for i in resp.json()["items"]] == ["users.invite"]
|
|
105
|
+
|
|
77
106
|
async def test_page_past_the_end_clamps_instead_of_reporting_zero(
|
|
78
107
|
self, app, authenticated_client: httpx.AsyncClient
|
|
79
108
|
):
|
|
@@ -14,7 +14,7 @@ from background_tasks.models import TaskExecution
|
|
|
14
14
|
from background_tasks.service import BackgroundTaskService
|
|
15
15
|
from fastapi import HTTPException
|
|
16
16
|
from simple_module_core.events import EventBus
|
|
17
|
-
from
|
|
17
|
+
from simple_module_db import RequestSession, finalize_session
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _make_row(
|
|
@@ -51,14 +51,14 @@ def mock_celery() -> MagicMock:
|
|
|
51
51
|
|
|
52
52
|
@pytest.fixture
|
|
53
53
|
def service(
|
|
54
|
-
db_session:
|
|
54
|
+
db_session: RequestSession, event_bus: EventBus, mock_celery: MagicMock
|
|
55
55
|
) -> BackgroundTaskService:
|
|
56
56
|
return BackgroundTaskService(db=db_session, celery=mock_celery, event_bus=event_bus)
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
class TestList:
|
|
60
60
|
async def test_returns_paginated_rows_newest_first(
|
|
61
|
-
self, db_session:
|
|
61
|
+
self, db_session: RequestSession, service: BackgroundTaskService
|
|
62
62
|
):
|
|
63
63
|
now = datetime.now(UTC)
|
|
64
64
|
older = _make_row(task_name="demo.a", queued_at=now - timedelta(minutes=5))
|
|
@@ -71,7 +71,7 @@ class TestList:
|
|
|
71
71
|
assert [i.task_name for i in resp.items] == ["demo.b", "demo.a"]
|
|
72
72
|
|
|
73
73
|
async def test_filters_by_status(
|
|
74
|
-
self, db_session:
|
|
74
|
+
self, db_session: RequestSession, service: BackgroundTaskService
|
|
75
75
|
):
|
|
76
76
|
db_session.add_all(
|
|
77
77
|
[
|
|
@@ -85,7 +85,7 @@ class TestList:
|
|
|
85
85
|
assert [i.task_name for i in resp.items] == ["demo.bad"]
|
|
86
86
|
|
|
87
87
|
async def test_filters_by_task_name_substring(
|
|
88
|
-
self, db_session:
|
|
88
|
+
self, db_session: RequestSession, service: BackgroundTaskService
|
|
89
89
|
):
|
|
90
90
|
db_session.add_all(
|
|
91
91
|
[
|
|
@@ -103,7 +103,7 @@ class TestStatusCounts:
|
|
|
103
103
|
"""Feeds the failed/stuck ops strip above the executions table."""
|
|
104
104
|
|
|
105
105
|
async def test_counts_are_grouped_by_status(
|
|
106
|
-
self, db_session:
|
|
106
|
+
self, db_session: RequestSession, service: BackgroundTaskService
|
|
107
107
|
):
|
|
108
108
|
for status in (TaskStatus.FAILED, TaskStatus.FAILED, TaskStatus.SUCCESS):
|
|
109
109
|
db_session.add(_make_row(status=status))
|
|
@@ -117,7 +117,7 @@ class TestStatusCounts:
|
|
|
117
117
|
assert await service.status_counts() == {}
|
|
118
118
|
|
|
119
119
|
async def test_search_narrows_the_counts(
|
|
120
|
-
self, db_session:
|
|
120
|
+
self, db_session: RequestSession, service: BackgroundTaskService
|
|
121
121
|
):
|
|
122
122
|
"""The strip must describe the same rows the table is paging through."""
|
|
123
123
|
db_session.add(_make_row(task_name="orders.send_receipt", status=TaskStatus.FAILED))
|
|
@@ -128,7 +128,7 @@ class TestStatusCounts:
|
|
|
128
128
|
assert counts == {TaskStatus.FAILED.value: 1}
|
|
129
129
|
|
|
130
130
|
async def test_counts_keys_are_plain_strings(
|
|
131
|
-
self, db_session:
|
|
131
|
+
self, db_session: RequestSession, service: BackgroundTaskService
|
|
132
132
|
):
|
|
133
133
|
"""Postgres hands back the enum, SQLite a str — the page needs one shape."""
|
|
134
134
|
db_session.add(_make_row(status=TaskStatus.STUCK))
|
|
@@ -144,7 +144,7 @@ class TestGet:
|
|
|
144
144
|
assert await service.get(uuid.uuid4()) is None
|
|
145
145
|
|
|
146
146
|
async def test_returns_detail_for_existing_row(
|
|
147
|
-
self, db_session:
|
|
147
|
+
self, db_session: RequestSession, service: BackgroundTaskService
|
|
148
148
|
):
|
|
149
149
|
row = _make_row(args=[1, "two"], kwargs={"three": 3})
|
|
150
150
|
db_session.add(row)
|
|
@@ -160,7 +160,7 @@ class TestGet:
|
|
|
160
160
|
class TestRetry:
|
|
161
161
|
async def test_retry_failed_task_enqueues_and_creates_row(
|
|
162
162
|
self,
|
|
163
|
-
db_session:
|
|
163
|
+
db_session: RequestSession,
|
|
164
164
|
service: BackgroundTaskService,
|
|
165
165
|
mock_celery: MagicMock,
|
|
166
166
|
event_bus: EventBus,
|
|
@@ -194,12 +194,19 @@ class TestRetry:
|
|
|
194
194
|
assert detail.retried_from_id == original.id
|
|
195
195
|
assert detail.status == TaskStatus.PENDING
|
|
196
196
|
assert detail.celery_task_id == "new-celery-id-123"
|
|
197
|
+
# Nothing is announced while the retry is still a pending write — a
|
|
198
|
+
# subscriber acting on a transaction that rolls back acts on a retry
|
|
199
|
+
# that never happened.
|
|
200
|
+
assert received == []
|
|
201
|
+
|
|
202
|
+
await finalize_session(db_session)
|
|
203
|
+
|
|
197
204
|
assert len(received) == 1
|
|
198
205
|
assert received[0].original_id == original.id
|
|
199
206
|
assert received[0].new_id == detail.id
|
|
200
207
|
|
|
201
208
|
async def test_retry_stuck_task_is_allowed(
|
|
202
|
-
self, db_session:
|
|
209
|
+
self, db_session: RequestSession, service: BackgroundTaskService
|
|
203
210
|
):
|
|
204
211
|
row = _make_row(status=TaskStatus.STUCK)
|
|
205
212
|
db_session.add(row)
|
|
@@ -210,7 +217,7 @@ class TestRetry:
|
|
|
210
217
|
assert detail.retried_from_id == row.id
|
|
211
218
|
|
|
212
219
|
async def test_retry_rejects_non_retryable_status(
|
|
213
|
-
self, db_session:
|
|
220
|
+
self, db_session: RequestSession, service: BackgroundTaskService
|
|
214
221
|
):
|
|
215
222
|
row = _make_row(status=TaskStatus.SUCCESS)
|
|
216
223
|
db_session.add(row)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Bulk retry: what must not happen twice, and in what order it happens.
|
|
2
|
+
|
|
3
|
+
Scope lives in ``test_retry_failed_bulk.py`` and the per-press guards in
|
|
4
|
+
``test_retry_failed_guards.py``. This file covers the three properties that
|
|
5
|
+
keep two simultaneous presses from running every task twice:
|
|
6
|
+
|
|
7
|
+
* the claim locks the batch it takes, so a second sweep cannot read it;
|
|
8
|
+
* the rows recording the attempts are written *before* anything is published;
|
|
9
|
+
* the retry is announced only once those rows are durable.
|
|
10
|
+
|
|
11
|
+
**What each test actually proves.** The suite runs on SQLite, where
|
|
12
|
+
``FOR UPDATE SKIP LOCKED`` is a no-op — a two-task race here would pass with
|
|
13
|
+
the locking removed and prove nothing. So the lock is asserted on the *SQL the
|
|
14
|
+
claim compiles to* against the Postgres dialect, which is where the clause has
|
|
15
|
+
to be right; the other two are ordering invariants observable on any backend,
|
|
16
|
+
and they are what makes a lost race cheap rather than catastrophic.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from typing import Any
|
|
23
|
+
from unittest.mock import MagicMock
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
import pytest
|
|
27
|
+
from background_tasks.constants import RETRY_ALL_BATCH, TABLE_TASK_EXECUTION, TaskStatus
|
|
28
|
+
from background_tasks.contracts.events import TaskRetried
|
|
29
|
+
from background_tasks.filters import bulk_retry_conditions
|
|
30
|
+
from background_tasks.retry_service import claim_query
|
|
31
|
+
from sqlalchemy import event
|
|
32
|
+
from sqlalchemy.dialects import postgresql
|
|
33
|
+
|
|
34
|
+
ADMIN_BASE = "/api/background_tasks/admin"
|
|
35
|
+
RETRY_FAILED = f"{ADMIN_BASE}/executions/retry-failed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _compiled_claim() -> str:
|
|
39
|
+
conditions = bulk_retry_conditions()
|
|
40
|
+
assert conditions is not None
|
|
41
|
+
return str(claim_query(conditions, RETRY_ALL_BATCH).compile(dialect=postgresql.dialect()))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestTheClaimLocksItsBatch:
|
|
45
|
+
"""Asserted on the emitted SQL, not on behaviour.
|
|
46
|
+
|
|
47
|
+
SQLite has no row locks and SQLAlchemy silently drops the clause there, so
|
|
48
|
+
running two sweeps against the test database would pass either way. The
|
|
49
|
+
compiled Postgres statement is the only thing in reach of this suite that
|
|
50
|
+
can tell the fix from its absence.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def test_the_claim_takes_a_row_lock(self) -> None:
|
|
54
|
+
assert "FOR UPDATE" in _compiled_claim()
|
|
55
|
+
|
|
56
|
+
def test_a_second_sweep_skips_a_held_row_rather_than_waiting_for_it(self) -> None:
|
|
57
|
+
"""``SKIP LOCKED``: the second operator gets an answer, not a queue."""
|
|
58
|
+
assert "SKIP LOCKED" in _compiled_claim()
|
|
59
|
+
|
|
60
|
+
def test_the_claim_is_still_the_oldest_rows_first(self) -> None:
|
|
61
|
+
"""Locking must not have cost the ordering the sweep is defined by."""
|
|
62
|
+
sql = _compiled_claim()
|
|
63
|
+
assert "ORDER BY" in sql
|
|
64
|
+
assert sql.index("ORDER BY") < sql.index("LIMIT") < sql.index("FOR UPDATE")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def sweep_trace(app) -> Iterator[list[str]]:
|
|
69
|
+
"""Every insert, commit and broker publish this sweep makes, in order.
|
|
70
|
+
|
|
71
|
+
Recorded at the engine and at the Celery stub rather than inside the
|
|
72
|
+
coordinator, so the test asserts on what the database and the broker
|
|
73
|
+
actually saw and not on the shape of the code that talked to them.
|
|
74
|
+
"""
|
|
75
|
+
trace: list[str] = []
|
|
76
|
+
engine = app.state.sm.db.engine.sync_engine
|
|
77
|
+
|
|
78
|
+
def _record_statement(conn, cursor, statement, parameters, context, executemany) -> None:
|
|
79
|
+
if statement.lstrip().lower().startswith(f"insert into {TABLE_TASK_EXECUTION}"):
|
|
80
|
+
trace.append("insert")
|
|
81
|
+
|
|
82
|
+
def _record_commit(conn) -> None:
|
|
83
|
+
trace.append("commit")
|
|
84
|
+
|
|
85
|
+
def _send_task(name: str, **options: Any) -> MagicMock:
|
|
86
|
+
trace.append("publish")
|
|
87
|
+
result = MagicMock()
|
|
88
|
+
result.id = options.get("task_id", "mocked-celery-id")
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
celery = MagicMock(name="Celery")
|
|
92
|
+
celery.send_task.side_effect = _send_task
|
|
93
|
+
app.state.background_tasks.celery = celery
|
|
94
|
+
|
|
95
|
+
event.listen(engine, "before_cursor_execute", _record_statement)
|
|
96
|
+
event.listen(engine, "commit", _record_commit)
|
|
97
|
+
try:
|
|
98
|
+
yield trace
|
|
99
|
+
finally:
|
|
100
|
+
event.remove(engine, "before_cursor_execute", _record_statement)
|
|
101
|
+
event.remove(engine, "commit", _record_commit)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestRowsBeforeBroker:
|
|
105
|
+
"""The row recording an attempt exists before the attempt is sent.
|
|
106
|
+
|
|
107
|
+
Provable on any backend. It is what makes losing the race survivable: an
|
|
108
|
+
orphaned ``pending`` row nobody sent is a row an operator can see and retry,
|
|
109
|
+
while a task published against no row has already run twice by the time
|
|
110
|
+
anyone notices.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
async def test_the_retry_row_is_written_before_the_task_is_published(
|
|
114
|
+
self, sweep_trace, seed_execution, authenticated_client: httpx.AsyncClient
|
|
115
|
+
) -> None:
|
|
116
|
+
await seed_execution(status=TaskStatus.FAILED)
|
|
117
|
+
sweep_trace.clear() # Drop the seed's own insert and commit.
|
|
118
|
+
|
|
119
|
+
resp = await authenticated_client.post(RETRY_FAILED)
|
|
120
|
+
|
|
121
|
+
assert resp.json() == {"queued": 1, "remaining": 0}
|
|
122
|
+
assert sweep_trace.index("insert") < sweep_trace.index("publish")
|
|
123
|
+
|
|
124
|
+
async def test_the_whole_batch_lands_before_the_first_publish(
|
|
125
|
+
self, sweep_trace, seed_execution, authenticated_client: httpx.AsyncClient
|
|
126
|
+
) -> None:
|
|
127
|
+
"""One flush for the batch, and it is complete before anything is sent."""
|
|
128
|
+
for i in range(3):
|
|
129
|
+
await seed_execution(task_name=f"task.{i}", status=TaskStatus.FAILED)
|
|
130
|
+
sweep_trace.clear()
|
|
131
|
+
|
|
132
|
+
resp = await authenticated_client.post(RETRY_FAILED)
|
|
133
|
+
|
|
134
|
+
assert resp.json() == {"queued": 3, "remaining": 0}
|
|
135
|
+
first_publish = sweep_trace.index("publish")
|
|
136
|
+
assert sweep_trace.count("publish") == 3
|
|
137
|
+
assert sweep_trace[:first_publish].count("insert") >= 1
|
|
138
|
+
assert "insert" not in sweep_trace[first_publish:]
|
|
139
|
+
|
|
140
|
+
async def test_the_row_names_the_task_that_was_actually_sent(
|
|
141
|
+
self,
|
|
142
|
+
app,
|
|
143
|
+
sweep_trace,
|
|
144
|
+
seed_execution,
|
|
145
|
+
execution_rows,
|
|
146
|
+
authenticated_client: httpx.AsyncClient,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Writing first means reserving the broker id, not inventing one later.
|
|
149
|
+
|
|
150
|
+
If the reserved id and the published id could differ, an orphaned row
|
|
151
|
+
would be unmatchable against the broker and writing first would buy
|
|
152
|
+
nothing.
|
|
153
|
+
"""
|
|
154
|
+
original = await seed_execution(status=TaskStatus.FAILED)
|
|
155
|
+
|
|
156
|
+
await authenticated_client.post(RETRY_FAILED)
|
|
157
|
+
|
|
158
|
+
new_row = next(r for r in await execution_rows() if r.id != original.id)
|
|
159
|
+
sent = app.state.background_tasks.celery.send_task.call_args
|
|
160
|
+
assert new_row.celery_task_id == sent.kwargs["task_id"]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TestAnnouncedAfterCommit:
|
|
164
|
+
"""Subscribers hear about a retry that happened, not one that might.
|
|
165
|
+
|
|
166
|
+
Publishing inline told them about an uncommitted transaction — and for a
|
|
167
|
+
500-row sweep, told them nothing until the batch was done and then
|
|
168
|
+
everything at once, for work that could still roll back.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
async def test_the_event_fires_after_the_rows_are_committed(
|
|
172
|
+
self, app, sweep_trace, seed_execution, authenticated_client: httpx.AsyncClient
|
|
173
|
+
) -> None:
|
|
174
|
+
async def _on_retried(event_: TaskRetried) -> None:
|
|
175
|
+
sweep_trace.append("announced")
|
|
176
|
+
|
|
177
|
+
app.state.sm.event_bus.subscribe(TaskRetried, _on_retried)
|
|
178
|
+
await seed_execution(status=TaskStatus.FAILED)
|
|
179
|
+
sweep_trace.clear()
|
|
180
|
+
|
|
181
|
+
await authenticated_client.post(RETRY_FAILED)
|
|
182
|
+
|
|
183
|
+
assert "announced" in sweep_trace
|
|
184
|
+
assert sweep_trace.index("commit") < sweep_trace.index("announced")
|
|
185
|
+
|
|
186
|
+
async def test_one_event_per_retried_row(
|
|
187
|
+
self, app, sweep_trace, seed_execution, authenticated_client: httpx.AsyncClient
|
|
188
|
+
) -> None:
|
|
189
|
+
received: list[TaskRetried] = []
|
|
190
|
+
|
|
191
|
+
async def _on_retried(event_: TaskRetried) -> None:
|
|
192
|
+
received.append(event_)
|
|
193
|
+
|
|
194
|
+
app.state.sm.event_bus.subscribe(TaskRetried, _on_retried)
|
|
195
|
+
for i in range(3):
|
|
196
|
+
await seed_execution(task_name=f"task.{i}", status=TaskStatus.FAILED)
|
|
197
|
+
|
|
198
|
+
await authenticated_client.post(RETRY_FAILED)
|
|
199
|
+
|
|
200
|
+
assert sorted(e.task_name for e in received) == ["task.0", "task.1", "task.2"]
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
"""Re-enqueueing work: one execution, or a filtered sweep of them.
|
|
2
|
-
|
|
3
|
-
Split from :mod:`background_tasks.service` because re-enqueueing is the only
|
|
4
|
-
thing in this module that writes to the broker, and it is where every
|
|
5
|
-
correctness question lives — what may be retried, how many at once, and what
|
|
6
|
-
must not happen twice. The read paths have none of those questions.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import asyncio
|
|
12
|
-
from typing import TYPE_CHECKING
|
|
13
|
-
|
|
14
|
-
from simple_module_core.events import EventBus
|
|
15
|
-
from sqlalchemy import func, select
|
|
16
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
-
|
|
18
|
-
from background_tasks.constants import RETRY_ALL_BATCH, TaskStatus
|
|
19
|
-
from background_tasks.contracts.events import TaskRetried
|
|
20
|
-
from background_tasks.contracts.schemas import RetryFailedResult
|
|
21
|
-
from background_tasks.filters import bulk_retry_conditions
|
|
22
|
-
from background_tasks.models import TaskExecution
|
|
23
|
-
|
|
24
|
-
if TYPE_CHECKING:
|
|
25
|
-
from celery import Celery
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class RetryCoordinator:
|
|
29
|
-
"""Sends tasks again and records each fresh attempt.
|
|
30
|
-
|
|
31
|
-
The original row is immutable: a retry inserts a *new* ``TaskExecution``
|
|
32
|
-
carrying ``retried_from_id``, so the detail page can show the chain and
|
|
33
|
-
nothing rewrites history.
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
def __init__(self, db: AsyncSession, celery: Celery, event_bus: EventBus) -> None:
|
|
37
|
-
self.db = db
|
|
38
|
-
self.celery = celery
|
|
39
|
-
self.event_bus = event_bus
|
|
40
|
-
|
|
41
|
-
async def retry_one(self, row: TaskExecution) -> TaskExecution:
|
|
42
|
-
"""Re-enqueue a single execution.
|
|
43
|
-
|
|
44
|
-
Publishes inline rather than on a thread: it is one call, and keeping
|
|
45
|
-
it on the event loop keeps Celery's eager mode — which runs the task
|
|
46
|
-
body inside ``send_task`` — on the same thread the signal handlers are
|
|
47
|
-
bound to. The sweep below, which sends hundreds, goes to a thread.
|
|
48
|
-
"""
|
|
49
|
-
new_row = self._new_attempt(row, self._publish(row))
|
|
50
|
-
self.db.add(new_row)
|
|
51
|
-
await self.db.flush()
|
|
52
|
-
await self.db.refresh(new_row)
|
|
53
|
-
await self._announce(row, new_row)
|
|
54
|
-
return new_row
|
|
55
|
-
|
|
56
|
-
async def retry_failed(
|
|
57
|
-
self,
|
|
58
|
-
*,
|
|
59
|
-
status: TaskStatus | None = None,
|
|
60
|
-
task_name: str | None = None,
|
|
61
|
-
queue: str | None = None,
|
|
62
|
-
limit: int | None = None,
|
|
63
|
-
) -> RetryFailedResult:
|
|
64
|
-
"""Re-enqueue the retryable executions the current view can see.
|
|
65
|
-
|
|
66
|
-
"Failed" is the operator's word for both halves of the set: a stuck
|
|
67
|
-
task is one whose worker died holding it, and it needs the same push.
|
|
68
|
-
Scope comes from :func:`bulk_retry_conditions`, which is the listing's
|
|
69
|
-
own filters plus the two sweep rules.
|
|
70
|
-
|
|
71
|
-
Capped at *limit* rows per call (default :data:`RETRY_ALL_BATCH`);
|
|
72
|
-
``remaining`` says how many eligible rows the cap left behind, so the
|
|
73
|
-
operator knows to press again. The new rows are ``pending`` and their
|
|
74
|
-
parents now have a child, so neither this pass nor the next picks up
|
|
75
|
-
its own work.
|
|
76
|
-
"""
|
|
77
|
-
# Read at call time, not bound as a default argument, so the cap has
|
|
78
|
-
# exactly one source of truth that tests and operators can move.
|
|
79
|
-
batch = RETRY_ALL_BATCH if limit is None else limit
|
|
80
|
-
conditions = bulk_retry_conditions(status=status, task_name=task_name, queue=queue)
|
|
81
|
-
if conditions is None:
|
|
82
|
-
return RetryFailedResult(queued=0, remaining=0)
|
|
83
|
-
|
|
84
|
-
eligible = int(
|
|
85
|
-
(
|
|
86
|
-
await self.db.execute(
|
|
87
|
-
select(func.count()).select_from(TaskExecution).where(*conditions)
|
|
88
|
-
)
|
|
89
|
-
).scalar_one()
|
|
90
|
-
)
|
|
91
|
-
if not eligible:
|
|
92
|
-
return RetryFailedResult(queued=0, remaining=0)
|
|
93
|
-
|
|
94
|
-
rows = list(
|
|
95
|
-
(
|
|
96
|
-
await self.db.execute(
|
|
97
|
-
select(TaskExecution)
|
|
98
|
-
.where(*conditions)
|
|
99
|
-
# Oldest first: the queue an operator is unwedging should
|
|
100
|
-
# come back out in the order it went in.
|
|
101
|
-
.order_by(TaskExecution.queued_at.asc().nulls_last())
|
|
102
|
-
.limit(batch)
|
|
103
|
-
)
|
|
104
|
-
).scalars()
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
# One thread for the whole batch. ``send_task`` is a blocking publish,
|
|
108
|
-
# and a few hundred of them inline would stall every other request for
|
|
109
|
-
# the length of the sweep.
|
|
110
|
-
celery_ids = await asyncio.to_thread(lambda: [self._publish(row) for row in rows])
|
|
111
|
-
|
|
112
|
-
new_rows = [
|
|
113
|
-
self._new_attempt(row, celery_id)
|
|
114
|
-
for row, celery_id in zip(rows, celery_ids, strict=True)
|
|
115
|
-
]
|
|
116
|
-
self.db.add_all(new_rows)
|
|
117
|
-
# One flush for the batch rather than one per row: the round trips are
|
|
118
|
-
# the sweep's real cost, and the rows have no reason to land singly.
|
|
119
|
-
await self.db.flush()
|
|
120
|
-
|
|
121
|
-
for original, new_row in zip(rows, new_rows, strict=True):
|
|
122
|
-
await self._announce(original, new_row)
|
|
123
|
-
return RetryFailedResult(queued=len(rows), remaining=max(0, eligible - len(rows)))
|
|
124
|
-
|
|
125
|
-
def _publish(self, row: TaskExecution) -> str:
|
|
126
|
-
"""Send one task to the broker and return its Celery id.
|
|
127
|
-
|
|
128
|
-
Blocking — kombu publishes synchronously. Call it off the event loop
|
|
129
|
-
when sending more than one.
|
|
130
|
-
"""
|
|
131
|
-
return self.celery.send_task(
|
|
132
|
-
row.task_name,
|
|
133
|
-
args=list(row.args or []),
|
|
134
|
-
kwargs=dict(row.kwargs or {}),
|
|
135
|
-
queue=row.queue,
|
|
136
|
-
).id
|
|
137
|
-
|
|
138
|
-
def _new_attempt(self, row: TaskExecution, celery_task_id: str) -> TaskExecution:
|
|
139
|
-
"""The row recording a fresh attempt at *row*. Not yet flushed."""
|
|
140
|
-
return TaskExecution(
|
|
141
|
-
celery_task_id=celery_task_id,
|
|
142
|
-
task_name=row.task_name,
|
|
143
|
-
status=TaskStatus.PENDING,
|
|
144
|
-
queue=row.queue,
|
|
145
|
-
args=list(row.args or []),
|
|
146
|
-
kwargs=dict(row.kwargs or {}),
|
|
147
|
-
retried_from_id=row.id,
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
async def _announce(self, original: TaskExecution, new_row: TaskExecution) -> None:
|
|
151
|
-
"""Same event whichever path enqueued it — a retry is a retry."""
|
|
152
|
-
await self.event_bus.publish(
|
|
153
|
-
TaskRetried(
|
|
154
|
-
original_id=original.id,
|
|
155
|
-
new_id=new_row.id,
|
|
156
|
-
task_name=original.task_name,
|
|
157
|
-
)
|
|
158
|
-
)
|
|
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
|
|
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.33 → simple_module_background_tasks-0.0.34}/package.json
RENAMED
|
File without changes
|
{simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/conftest.py
RENAMED
|
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.33 → simple_module_background_tasks-0.0.34}/tsconfig.json
RENAMED
|
File without changes
|