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.
Files changed (79) hide show
  1. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/PKG-INFO +5 -5
  2. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/deps.py +2 -2
  3. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/endpoints/api_admin.py +11 -2
  4. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/locales/en.json +4 -2
  5. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/StatusStrip.tsx +21 -5
  6. simple_module_background_tasks-0.0.34/background_tasks/retry_service.py +220 -0
  7. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/service.py +2 -2
  8. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/pyproject.toml +5 -5
  9. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_admin_api.py +29 -0
  10. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_service.py +19 -12
  11. simple_module_background_tasks-0.0.34/tests/test_retry_sweep_ordering.py +200 -0
  12. simple_module_background_tasks-0.0.33/background_tasks/retry_service.py +0 -158
  13. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/.gitignore +0 -0
  14. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/LICENSE +0 -0
  15. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/README.md +0 -0
  16. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/__init__.py +0 -0
  17. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/_signal_support.py +0 -0
  18. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/celery_app.py +0 -0
  19. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/constants.py +0 -0
  20. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/contracts/__init__.py +0 -0
  21. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/contracts/events.py +0 -0
  22. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/contracts/schemas.py +0 -0
  23. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/endpoints/__init__.py +0 -0
  24. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/endpoints/views.py +0 -0
  25. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/filters.py +0 -0
  26. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/health.py +0 -0
  27. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/log_context.py +0 -0
  28. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/models.py +0 -0
  29. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/module.py +0 -0
  30. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/Detail.tsx +0 -0
  31. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/Index.tsx +0 -0
  32. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/Workers.tsx +0 -0
  33. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/DetailFacts.tsx +0 -0
  34. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
  35. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/ExecutionsTable.tsx +0 -0
  36. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/PayloadCards.tsx +0 -0
  37. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/RetryAllDialog.tsx +0 -0
  38. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
  39. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/StatusPill.tsx +0 -0
  40. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/TaskFilters.tsx +0 -0
  41. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/TasksEmpty.tsx +0 -0
  42. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/TracebackCard.tsx +0 -0
  43. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/WorkerCard.tsx +0 -0
  44. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/WorkerEmptyStates.tsx +0 -0
  45. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/components/WorkerHealthBanner.tsx +0 -0
  46. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/constants.ts +0 -0
  47. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/retry.ts +0 -0
  48. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/pages/worker-health.ts +0 -0
  49. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/py.typed +0 -0
  50. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/services.py +0 -0
  51. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/settings.py +0 -0
  52. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/signals.py +0 -0
  53. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/sync_db.py +0 -0
  54. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/tasks.py +0 -0
  55. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/worker_inspector.py +0 -0
  56. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/background_tasks/workers_state.py +0 -0
  57. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/package.json +0 -0
  58. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/conftest.py +0 -0
  59. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_background_tasks_settings.py +0 -0
  60. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_beat_schedule.py +0 -0
  61. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_index_worker_presence.py +0 -0
  62. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_list_item_payload.py +0 -0
  63. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_page_clamp.py +0 -0
  64. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_redis_health.py +0 -0
  65. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_redis_url.py +0 -0
  66. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_bg_settings_env.py +0 -0
  67. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_detail_props.py +0 -0
  68. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_index_filters.py +0 -0
  69. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_log_context.py +0 -0
  70. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_retry_failed_bulk.py +0 -0
  71. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_retry_failed_guards.py +0 -0
  72. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_signals.py +0 -0
  73. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_sweep_stuck.py +0 -0
  74. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_sync_db.py +0 -0
  75. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_worker_inspector.py +0 -0
  76. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_workers_endpoints.py +0 -0
  77. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests/test_workers_uptime.py +0 -0
  78. {simple_module_background_tasks-0.0.33 → simple_module_background_tasks-0.0.34}/tests-js/task-formatters.test.ts +0 -0
  79. {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.33
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.33
27
- Requires-Dist: simple-module-db==0.0.33
28
- Requires-Dist: simple-module-hosting==0.0.33
29
- Requires-Dist: simple-module-settings==0.0.33
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: AsyncSession = Depends(get_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
- task_name: str | None = Query(default=None),
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
- "succeeded_24h": "Succeeded 24h",
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
- * "Succeeded 24h" is the only windowed figure, and deliberately so — the other
33
- * four are states something is in *right now*, while success is only
34
- * interesting as a rate. An all-time success total is a number that never goes
35
- * down and therefore never says anything.
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.succeeded_24h,
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: AsyncSession,
46
+ db: RequestSession,
47
47
  celery: Celery,
48
48
  event_bus: EventBus,
49
49
  ) -> None:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_background_tasks"
3
- version = "0.0.33"
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.33",
25
- "simple_module_db==0.0.33",
26
- "simple_module_hosting==0.0.33",
27
- "simple_module_settings==0.0.33",
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 sqlalchemy.ext.asyncio import AsyncSession
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: AsyncSession, event_bus: EventBus, mock_celery: MagicMock
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: AsyncSession, service: BackgroundTaskService
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: AsyncSession, service: BackgroundTaskService
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: AsyncSession, service: BackgroundTaskService
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: AsyncSession, service: BackgroundTaskService
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: AsyncSession, service: BackgroundTaskService
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: AsyncSession, service: BackgroundTaskService
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: AsyncSession, service: BackgroundTaskService
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: AsyncSession,
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: AsyncSession, service: BackgroundTaskService
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: AsyncSession, service: BackgroundTaskService
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
- )