simple-module-background-tasks 0.0.23__tar.gz → 0.0.25__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 (47) hide show
  1. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/PKG-INFO +8 -8
  2. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/README.md +3 -3
  3. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/module.py +1 -1
  4. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/worker_inspector.py +27 -5
  5. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/pyproject.toml +5 -5
  6. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tests/test_worker_inspector.py +83 -0
  7. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/.gitignore +0 -0
  8. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/LICENSE +0 -0
  9. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/__init__.py +0 -0
  10. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/_signal_support.py +0 -0
  11. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/celery_app.py +0 -0
  12. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/constants.py +0 -0
  13. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/contracts/__init__.py +0 -0
  14. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/contracts/events.py +0 -0
  15. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/contracts/schemas.py +0 -0
  16. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/deps.py +0 -0
  17. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/endpoints/__init__.py +0 -0
  18. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/endpoints/api_admin.py +0 -0
  19. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/endpoints/views.py +0 -0
  20. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/locales/en.json +0 -0
  21. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/log_context.py +0 -0
  22. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/models.py +0 -0
  23. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/pages/Detail.tsx +0 -0
  24. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/pages/Index.tsx +0 -0
  25. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/pages/Workers.tsx +0 -0
  26. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
  27. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
  28. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/pages/constants.ts +0 -0
  29. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/pages/retry.ts +0 -0
  30. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/py.typed +0 -0
  31. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/service.py +0 -0
  32. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/services.py +0 -0
  33. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/settings.py +0 -0
  34. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/signals.py +0 -0
  35. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/sync_db.py +0 -0
  36. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/background_tasks/tasks.py +0 -0
  37. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/package.json +0 -0
  38. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tests/conftest.py +0 -0
  39. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tests/test_admin_api.py +0 -0
  40. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tests/test_beat_schedule.py +0 -0
  41. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tests/test_bg_service.py +0 -0
  42. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tests/test_log_context.py +0 -0
  43. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tests/test_signals.py +0 -0
  44. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tests/test_sweep_stuck.py +0 -0
  45. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tests/test_sync_db.py +0 -0
  46. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tests/test_workers_endpoints.py +0 -0
  47. {simple_module_background_tasks-0.0.23 → simple_module_background_tasks-0.0.25}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_background_tasks
3
- Version: 0.0.23
3
+ Version: 0.0.25
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.23
27
- Requires-Dist: simple-module-db==0.0.23
28
- Requires-Dist: simple-module-hosting==0.0.23
29
- Requires-Dist: simple-module-settings==0.0.23
26
+ Requires-Dist: simple-module-core==0.0.25
27
+ Requires-Dist: simple-module-db==0.0.25
28
+ Requires-Dist: simple-module-hosting==0.0.25
29
+ Requires-Dist: simple-module-settings==0.0.25
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  # simple_module_background_tasks
@@ -57,8 +57,7 @@ from celery import shared_task
57
57
 
58
58
 
59
59
  @shared_task(name="reports.generate")
60
- def generate_report(report_id: int) -> None:
61
- ...
60
+ def generate_report(report_id: int) -> None: ...
62
61
  ```
63
62
 
64
63
  Declaring `background_tasks` as a `depends_on` ensures the Celery app is built before your tasks run:
@@ -100,10 +99,11 @@ domain `job_id` that named a Celery task is the canonical example):
100
99
  from background_tasks import bind_task_context
101
100
  from celery import shared_task
102
101
 
102
+
103
103
  @shared_task
104
104
  def process_dataset(job_id: int) -> None:
105
105
  with bind_task_context(job_id=job_id):
106
- logger.info("starting ingest") # now carries job_id too
106
+ logger.info("starting ingest") # now carries job_id too
107
107
  ```
108
108
 
109
109
  Bindings nest cleanly and restore on exit. structlog users can mount the
@@ -26,8 +26,7 @@ from celery import shared_task
26
26
 
27
27
 
28
28
  @shared_task(name="reports.generate")
29
- def generate_report(report_id: int) -> None:
30
- ...
29
+ def generate_report(report_id: int) -> None: ...
31
30
  ```
32
31
 
33
32
  Declaring `background_tasks` as a `depends_on` ensures the Celery app is built before your tasks run:
@@ -69,10 +68,11 @@ domain `job_id` that named a Celery task is the canonical example):
69
68
  from background_tasks import bind_task_context
70
69
  from celery import shared_task
71
70
 
71
+
72
72
  @shared_task
73
73
  def process_dataset(job_id: int) -> None:
74
74
  with bind_task_context(job_id=job_id):
75
- logger.info("starting ingest") # now carries job_id too
75
+ logger.info("starting ingest") # now carries job_id too
76
76
  ```
77
77
 
78
78
  Bindings nest cleanly and restore on exit. structlog users can mount the
@@ -71,7 +71,7 @@ class BackgroundTasksModule(ModuleBase):
71
71
  registry.add(
72
72
  MenuItem(
73
73
  label=MENU_LABEL,
74
- url=VIEW_PREFIX,
74
+ url=f"{VIEW_PREFIX}/",
75
75
  icon=MENU_ICON,
76
76
  order=MENU_ORDER,
77
77
  section=MenuSection.SIDEBAR,
@@ -9,6 +9,7 @@ instead of the endpoint returning a 5xx.
9
9
  from __future__ import annotations
10
10
 
11
11
  import logging
12
+ from concurrent.futures import ThreadPoolExecutor
12
13
  from datetime import UTC, datetime
13
14
  from typing import TYPE_CHECKING, Any
14
15
 
@@ -23,6 +24,10 @@ if TYPE_CHECKING:
23
24
  logger = logging.getLogger(__name__)
24
25
 
25
26
 
27
+ # The four independent inspect broadcasts, issued concurrently.
28
+ _PROBES = ("ping", "stats", "active_queues", "active")
29
+
30
+
26
31
  class WorkerInspector:
27
32
  """Synchronous adapter; call from async code via ``asyncio.to_thread``."""
28
33
 
@@ -30,6 +35,11 @@ class WorkerInspector:
30
35
  self.celery = celery
31
36
  self.timeout = timeout
32
37
 
38
+ def _probe(self, name: str) -> dict:
39
+ """Run one inspect broadcast on its own handle. ``None`` means nobody replied."""
40
+ inspect = self.celery.control.inspect(timeout=self.timeout)
41
+ return getattr(inspect, name)() or {}
42
+
33
43
  def snapshot(self) -> WorkerSnapshot:
34
44
  polled_at = datetime.now(UTC)
35
45
 
@@ -48,12 +58,24 @@ class WorkerInspector:
48
58
  error=str(exc),
49
59
  )
50
60
 
51
- inspect = self.celery.control.inspect(timeout=self.timeout)
61
+ # Each inspect call broadcasts and then waits the *full* timeout for
62
+ # replies — it cannot know whether a slow worker is still coming. Run
63
+ # sequentially that is 4 x timeout, so with the broker up and no
64
+ # workers this page took ~4s: precisely the state an admin opens it to
65
+ # diagnose. The four probes are independent, so issue them together
66
+ # and the page costs one timeout instead of four.
67
+ #
68
+ # Each gets its own inspect handle rather than sharing one across
69
+ # threads. Results are still merged by hostname afterwards, so a
70
+ # worker that answers some probes but not others (a degraded worker)
71
+ # is still reported.
52
72
  try:
53
- ping = inspect.ping() or {}
54
- stats = inspect.stats() or {}
55
- queues = inspect.active_queues() or {}
56
- active = inspect.active() or {}
73
+ with ThreadPoolExecutor(max_workers=len(_PROBES)) as pool:
74
+ futures = {name: pool.submit(self._probe, name) for name in _PROBES}
75
+ ping = futures["ping"].result()
76
+ stats = futures["stats"].result()
77
+ queues = futures["active_queues"].result()
78
+ active = futures["active"].result()
57
79
  except (OperationalError, RedisError, ConnectionError, OSError) as exc:
58
80
  logger.info("inspect() failed mid-call: %s", exc)
59
81
  return WorkerSnapshot(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_background_tasks"
3
- version = "0.0.23"
3
+ version = "0.0.25"
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.23",
25
- "simple_module_db==0.0.23",
26
- "simple_module_hosting==0.0.23",
27
- "simple_module_settings==0.0.23",
24
+ "simple_module_core==0.0.25",
25
+ "simple_module_db==0.0.25",
26
+ "simple_module_hosting==0.0.25",
27
+ "simple_module_settings==0.0.25",
28
28
  "celery[redis]>=5.4",
29
29
  "redis>=5",
30
30
  ]
@@ -2,10 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import time
5
6
  from unittest.mock import MagicMock
6
7
 
7
8
  from celery import Celery
8
9
 
10
+ PROBE_DELAY = 0.25
11
+ # Sequential would be 4x PROBE_DELAY; allow well under that but above one probe.
12
+ SEQUENTIAL_SLACK = 2.5
13
+
9
14
 
10
15
  def _make_celery_with_dead_broker() -> Celery:
11
16
  """Build a Celery app pointed at an unreachable broker.
@@ -104,3 +109,81 @@ def test_redis_error_during_probe_returns_unreachable_snapshot():
104
109
  assert snapshot.workers == []
105
110
  assert snapshot.error is not None
106
111
  assert "invalid password" in snapshot.error
112
+
113
+
114
+ def _celery_with_live_broker_no_workers() -> tuple[Celery, MagicMock]:
115
+ """Broker reachable, but no worker ever answers — the common outage case."""
116
+ celery = MagicMock(spec=Celery)
117
+ celery.connection.return_value.ensure_connection.return_value = None
118
+ inspect = celery.control.inspect.return_value
119
+ # Celery returns None when nobody replies before the timeout.
120
+ inspect.ping.return_value = None
121
+ inspect.stats.return_value = None
122
+ inspect.active_queues.return_value = None
123
+ inspect.active.return_value = None
124
+ return celery, inspect
125
+
126
+
127
+ def test_probes_run_concurrently_not_sequentially():
128
+ """Four sequential probes cost 4 x timeout; concurrent ones cost about one.
129
+
130
+ With the broker up and no workers running, every inspect call waits its
131
+ full timeout for replies that never come. Sequentially that made
132
+ /admin/background-tasks/workers take ~4s — exactly the state an admin
133
+ opens that page to diagnose.
134
+
135
+ Each mocked probe sleeps for PROBE_DELAY. Sequential would be 4x that;
136
+ the assertion allows generous slack so this cannot flake on a loaded box
137
+ while still failing outright if the calls go back to being serial.
138
+ """
139
+ from background_tasks.worker_inspector import WorkerInspector
140
+
141
+ celery, _ = _celery_with_live_broker_no_workers()
142
+ inspect = celery.control.inspect.return_value
143
+ for name in ("ping", "stats", "active_queues", "active"):
144
+ getattr(inspect, name).side_effect = lambda *_, **__: (time.sleep(PROBE_DELAY), None)[1]
145
+
146
+ start = time.perf_counter()
147
+ snapshot = WorkerInspector(celery, timeout=PROBE_DELAY).snapshot()
148
+ elapsed = time.perf_counter() - start
149
+
150
+ assert elapsed < PROBE_DELAY * SEQUENTIAL_SLACK, (
151
+ f"probes took {elapsed:.2f}s; {PROBE_DELAY * 4:.2f}s means they are still sequential"
152
+ )
153
+ assert snapshot.broker_reachable is True
154
+ assert snapshot.workers == []
155
+
156
+
157
+ def test_all_four_probes_are_still_issued_when_nobody_replies():
158
+ """Concurrency must not drop probes — a worker that answers only stats
159
+ (degraded, not answering ping) must still be reported. See
160
+ test_worker_in_stats_but_not_ping_is_offline."""
161
+ from background_tasks.worker_inspector import WorkerInspector
162
+
163
+ celery, inspect = _celery_with_live_broker_no_workers()
164
+ WorkerInspector(celery, timeout=0.05).snapshot()
165
+
166
+ inspect.ping.assert_called_once()
167
+ inspect.stats.assert_called_once()
168
+ inspect.active_queues.assert_called_once()
169
+ inspect.active.assert_called_once()
170
+
171
+
172
+ def test_workers_present_still_collects_every_detail():
173
+ """The short-circuit must not skip the calls when workers do reply."""
174
+ from background_tasks.worker_inspector import WorkerInspector
175
+
176
+ celery = MagicMock(spec=Celery)
177
+ celery.connection.return_value.ensure_connection.return_value = None
178
+ inspect = celery.control.inspect.return_value
179
+ inspect.ping.return_value = {"celery@host-a": {"ok": "pong"}}
180
+ inspect.stats.return_value = {"celery@host-a": {"pool": {"max-concurrency": 2}}}
181
+ inspect.active_queues.return_value = {"celery@host-a": [{"name": "default"}]}
182
+ inspect.active.return_value = {"celery@host-a": []}
183
+
184
+ snapshot = WorkerInspector(celery, timeout=1.0).snapshot()
185
+
186
+ inspect.stats.assert_called_once()
187
+ inspect.active_queues.assert_called_once()
188
+ inspect.active.assert_called_once()
189
+ assert [w.hostname for w in snapshot.workers] == ["celery@host-a"]