celery-liveops 0.1.0__py3-none-any.whl
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.
- celery_liveops/__init__.py +205 -0
- celery_liveops/config.py +166 -0
- celery_liveops/contrib/__init__.py +1 -0
- celery_liveops/contrib/fastapi.py +119 -0
- celery_liveops/locks.py +258 -0
- celery_liveops/logs.py +274 -0
- celery_liveops/presence.py +341 -0
- celery_liveops/py.typed +0 -0
- celery_liveops/queues.py +156 -0
- celery_liveops/scale.py +146 -0
- celery_liveops/snapshots.py +190 -0
- celery_liveops/store.py +86 -0
- celery_liveops/watchdog.py +198 -0
- celery_liveops-0.1.0.dist-info/METADATA +315 -0
- celery_liveops-0.1.0.dist-info/RECORD +17 -0
- celery_liveops-0.1.0.dist-info/WHEEL +4 -0
- celery_liveops-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""celery-liveops -- see inside a long-running Celery task while it runs.
|
|
2
|
+
|
|
3
|
+
A Celery task that takes eight hours is a black box. The result backend tells you
|
|
4
|
+
what happened *after* it happened; ``flower`` tells you a task is running, not
|
|
5
|
+
what it is doing. This library fills that gap with five pieces that share one
|
|
6
|
+
Redis connection and one rule -- **observability must never break the job it is
|
|
7
|
+
observing**:
|
|
8
|
+
|
|
9
|
+
- ``logs`` -- the task's log lines, readable live from another process
|
|
10
|
+
- ``presence`` -- whether a run still has a living process behind it
|
|
11
|
+
- ``watchdog`` -- a hard deadline per *task*, not per container
|
|
12
|
+
- ``locks`` -- the Redis locks a dead process left behind, named and releasable
|
|
13
|
+
- ``queues`` -- queue depth, and the queue nobody is consuming
|
|
14
|
+
- ``scale`` -- resize a running worker, and keep that size across restarts
|
|
15
|
+
|
|
16
|
+
Quick start::
|
|
17
|
+
|
|
18
|
+
from celery_liveops import install, capture_logs
|
|
19
|
+
|
|
20
|
+
install(watchdog={"crawl": 3600}, watchdog_enabled=True)
|
|
21
|
+
|
|
22
|
+
@app.task(bind=True)
|
|
23
|
+
def crawl(self, url):
|
|
24
|
+
with capture_logs(self.request.id):
|
|
25
|
+
log.info("fetching %s", url) # visible live, from your API
|
|
26
|
+
|
|
27
|
+
And on the reading side::
|
|
28
|
+
|
|
29
|
+
from celery_liveops import read_any_logs, is_alive
|
|
30
|
+
|
|
31
|
+
read_any_logs(task_id) # the terminal, live or archived
|
|
32
|
+
is_alive(task_id) # or is this row an orphan?
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import Any, Dict, Optional
|
|
37
|
+
|
|
38
|
+
from .config import Settings, configure, reset, settings
|
|
39
|
+
from .locks import (
|
|
40
|
+
LockSpec,
|
|
41
|
+
clear_registry,
|
|
42
|
+
list_locks,
|
|
43
|
+
lock_for,
|
|
44
|
+
lock_state,
|
|
45
|
+
locks_owned_by,
|
|
46
|
+
register_lock,
|
|
47
|
+
registered_locks,
|
|
48
|
+
release_locks,
|
|
49
|
+
)
|
|
50
|
+
from .logs import (
|
|
51
|
+
LiveLogHandler,
|
|
52
|
+
active_keys,
|
|
53
|
+
cap_log,
|
|
54
|
+
capture_logs,
|
|
55
|
+
clear_logs,
|
|
56
|
+
clear_orphan_logs,
|
|
57
|
+
current_key,
|
|
58
|
+
install_logging,
|
|
59
|
+
read_any_logs,
|
|
60
|
+
read_logs,
|
|
61
|
+
read_orphan_logs,
|
|
62
|
+
)
|
|
63
|
+
from .presence import (
|
|
64
|
+
alive_among,
|
|
65
|
+
clear_alive,
|
|
66
|
+
heartbeat,
|
|
67
|
+
install_presence,
|
|
68
|
+
is_alive,
|
|
69
|
+
mark_alive,
|
|
70
|
+
worker_id,
|
|
71
|
+
workers,
|
|
72
|
+
)
|
|
73
|
+
from .queues import (
|
|
74
|
+
bind_app,
|
|
75
|
+
consumers_by_queue,
|
|
76
|
+
has_consumer,
|
|
77
|
+
orphan_queues,
|
|
78
|
+
queue_depth,
|
|
79
|
+
)
|
|
80
|
+
from .scale import (
|
|
81
|
+
apply_target,
|
|
82
|
+
install_boot_scale,
|
|
83
|
+
max_concurrency,
|
|
84
|
+
pool_size,
|
|
85
|
+
queues_of_process,
|
|
86
|
+
resize_pool,
|
|
87
|
+
set_autoscale_ceiling,
|
|
88
|
+
)
|
|
89
|
+
from .snapshots import (
|
|
90
|
+
clear_snapshot,
|
|
91
|
+
read_snapshot,
|
|
92
|
+
set_gate,
|
|
93
|
+
snapshot,
|
|
94
|
+
snapshot_stats,
|
|
95
|
+
)
|
|
96
|
+
from .store import client as redis_client
|
|
97
|
+
from .store import reset_client
|
|
98
|
+
from .watchdog import (
|
|
99
|
+
deadline_for,
|
|
100
|
+
deadlines,
|
|
101
|
+
install_watchdog,
|
|
102
|
+
safe_stop_at,
|
|
103
|
+
set_deadline,
|
|
104
|
+
set_deadlines,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
__version__ = "0.1.0"
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"__version__",
|
|
111
|
+
# config
|
|
112
|
+
"Settings",
|
|
113
|
+
"configure",
|
|
114
|
+
"settings",
|
|
115
|
+
"reset",
|
|
116
|
+
"redis_client",
|
|
117
|
+
"reset_client",
|
|
118
|
+
"install",
|
|
119
|
+
# logs
|
|
120
|
+
"LiveLogHandler",
|
|
121
|
+
"capture_logs",
|
|
122
|
+
"current_key",
|
|
123
|
+
"install_logging",
|
|
124
|
+
"read_logs",
|
|
125
|
+
"read_orphan_logs",
|
|
126
|
+
"read_any_logs",
|
|
127
|
+
"clear_logs",
|
|
128
|
+
"clear_orphan_logs",
|
|
129
|
+
"cap_log",
|
|
130
|
+
"active_keys",
|
|
131
|
+
# presence
|
|
132
|
+
"install_presence",
|
|
133
|
+
"is_alive",
|
|
134
|
+
"alive_among",
|
|
135
|
+
"workers",
|
|
136
|
+
"worker_id",
|
|
137
|
+
"mark_alive",
|
|
138
|
+
"clear_alive",
|
|
139
|
+
"heartbeat",
|
|
140
|
+
# watchdog
|
|
141
|
+
"install_watchdog",
|
|
142
|
+
"set_deadline",
|
|
143
|
+
"set_deadlines",
|
|
144
|
+
"deadlines",
|
|
145
|
+
"deadline_for",
|
|
146
|
+
"safe_stop_at",
|
|
147
|
+
# locks
|
|
148
|
+
"LockSpec",
|
|
149
|
+
"register_lock",
|
|
150
|
+
"registered_locks",
|
|
151
|
+
"clear_registry",
|
|
152
|
+
"lock_for",
|
|
153
|
+
"list_locks",
|
|
154
|
+
"release_locks",
|
|
155
|
+
"lock_state",
|
|
156
|
+
"locks_owned_by",
|
|
157
|
+
# queues
|
|
158
|
+
"bind_app",
|
|
159
|
+
"queue_depth",
|
|
160
|
+
"consumers_by_queue",
|
|
161
|
+
"has_consumer",
|
|
162
|
+
"orphan_queues",
|
|
163
|
+
# scale
|
|
164
|
+
"apply_target",
|
|
165
|
+
"resize_pool",
|
|
166
|
+
"set_autoscale_ceiling",
|
|
167
|
+
"install_boot_scale",
|
|
168
|
+
"pool_size",
|
|
169
|
+
"queues_of_process",
|
|
170
|
+
"max_concurrency",
|
|
171
|
+
# snapshots
|
|
172
|
+
"snapshot",
|
|
173
|
+
"read_snapshot",
|
|
174
|
+
"clear_snapshot",
|
|
175
|
+
"snapshot_stats",
|
|
176
|
+
"set_gate",
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def install(
|
|
181
|
+
app: Any = None,
|
|
182
|
+
logger: Any = None,
|
|
183
|
+
presence: bool = True,
|
|
184
|
+
watchdog: Optional[Dict[str, int]] = None,
|
|
185
|
+
watchdog_enabled: Optional[bool] = None,
|
|
186
|
+
context_extractor: Any = None,
|
|
187
|
+
**config_overrides: Any,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Wire everything up in one call, from inside your worker process.
|
|
190
|
+
|
|
191
|
+
``watchdog`` is the deadline registry (``{task_name: seconds}``). It is armed
|
|
192
|
+
only when ``watchdog_enabled`` is true, because it kills processes.
|
|
193
|
+
|
|
194
|
+
Safe to call in a web process too: presence and the watchdog attach to Celery
|
|
195
|
+
signals that simply never fire there.
|
|
196
|
+
"""
|
|
197
|
+
if config_overrides:
|
|
198
|
+
configure(**config_overrides)
|
|
199
|
+
if app is not None:
|
|
200
|
+
bind_app(app)
|
|
201
|
+
install_logging(logger)
|
|
202
|
+
if presence:
|
|
203
|
+
install_presence(context_extractor=context_extractor)
|
|
204
|
+
if watchdog or watchdog_enabled is not None:
|
|
205
|
+
install_watchdog(deadlines=watchdog, enabled=watchdog_enabled)
|
celery_liveops/config.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Runtime configuration for celery-liveops.
|
|
2
|
+
|
|
3
|
+
Every knob has a working default, so `import celery_liveops` alone is enough for a
|
|
4
|
+
local Redis on ``localhost:6379``. Anything can be overridden by environment
|
|
5
|
+
variable (twelve-factor deployments) or by an explicit :func:`configure` call
|
|
6
|
+
(tests, embedding the library in an app that already owns its Redis client).
|
|
7
|
+
|
|
8
|
+
The one rule this module enforces: **nothing here may raise at import time**. The
|
|
9
|
+
library rides inside logging handlers and Celery signals, both of which run in
|
|
10
|
+
places where an exception is either swallowed or fatal to a worker boot.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass, field, replace
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _env_int(name: str, default: int) -> int:
|
|
20
|
+
try:
|
|
21
|
+
return int(os.environ[name])
|
|
22
|
+
except (KeyError, TypeError, ValueError):
|
|
23
|
+
return default
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _env_float(name: str, default: float) -> float:
|
|
27
|
+
try:
|
|
28
|
+
return float(os.environ[name])
|
|
29
|
+
except (KeyError, TypeError, ValueError):
|
|
30
|
+
return default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
34
|
+
raw = os.environ.get(name)
|
|
35
|
+
if raw is None:
|
|
36
|
+
return default
|
|
37
|
+
return raw.strip().lower() not in ("0", "false", "no", "off", "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Settings:
|
|
42
|
+
"""Immutable snapshot of the library's configuration."""
|
|
43
|
+
|
|
44
|
+
#: Redis connection used for every live channel (logs, presence, snapshots).
|
|
45
|
+
redis_url: str = field(
|
|
46
|
+
default_factory=lambda: os.environ.get("LIVEOPS_REDIS_URL", "redis://localhost:6379/0")
|
|
47
|
+
)
|
|
48
|
+
#: Prefix for every key this library writes. Change it to share one Redis
|
|
49
|
+
#: between several applications without them stepping on each other.
|
|
50
|
+
key_prefix: str = field(
|
|
51
|
+
default_factory=lambda: os.environ.get("LIVEOPS_KEY_PREFIX", "liveops")
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# ── Live log buffer ───────────────────────────────────────────────────────
|
|
55
|
+
#: Hard cap on lines kept per run. Peak memory per run is roughly
|
|
56
|
+
#: ``max_lines * max_line_length`` -- deliberately conservative, because this
|
|
57
|
+
#: buffer lives in the same Redis your broker results may be using.
|
|
58
|
+
max_lines: int = field(default_factory=lambda: _env_int("LIVEOPS_MAX_LINES", 1500))
|
|
59
|
+
#: Cap per line, so one giant record cannot blow the whole budget.
|
|
60
|
+
max_line_length: int = field(default_factory=lambda: _env_int("LIVEOPS_MAX_LINE_LENGTH", 2000))
|
|
61
|
+
#: Backstop expiry for a live buffer whose task died without cleaning up.
|
|
62
|
+
log_ttl: int = field(default_factory=lambda: _env_int("LIVEOPS_LOG_TTL", 3600))
|
|
63
|
+
#: Expiry for the *previous attempt's* buffer (see `logs.capture_logs`).
|
|
64
|
+
orphan_ttl: int = field(default_factory=lambda: _env_int("LIVEOPS_ORPHAN_TTL", 900))
|
|
65
|
+
#: Cap applied by :func:`celery_liveops.cap_log` before you archive a run's
|
|
66
|
+
#: terminal into your own database.
|
|
67
|
+
max_archive_chars: int = field(
|
|
68
|
+
default_factory=lambda: _env_int("LIVEOPS_MAX_ARCHIVE_CHARS", 200_000)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# ── Presence ──────────────────────────────────────────────────────────────
|
|
72
|
+
#: How long a presence key survives without a refresh. Generous relative to
|
|
73
|
+
#: `presence_refresh` on purpose: one network hiccup must not mark a healthy
|
|
74
|
+
#: run as dead.
|
|
75
|
+
presence_ttl: int = field(default_factory=lambda: _env_int("LIVEOPS_PRESENCE_TTL", 90))
|
|
76
|
+
#: Refresh interval of the daemon thread that keeps presence warm.
|
|
77
|
+
presence_refresh: int = field(default_factory=lambda: _env_int("LIVEOPS_PRESENCE_REFRESH", 20))
|
|
78
|
+
#: TTL of the per-worker heartbeat and "what am I running" keys.
|
|
79
|
+
worker_ttl: int = field(default_factory=lambda: _env_int("LIVEOPS_WORKER_TTL", 60))
|
|
80
|
+
|
|
81
|
+
# ── Watchdog ──────────────────────────────────────────────────────────────
|
|
82
|
+
#: Master switch. Off by default: a library that kills processes must be
|
|
83
|
+
#: opted into explicitly, never enabled by the mere act of installing it.
|
|
84
|
+
watchdog_enabled: bool = field(
|
|
85
|
+
default_factory=lambda: _env_bool("LIVEOPS_WATCHDOG_ENABLED", False)
|
|
86
|
+
)
|
|
87
|
+
#: Deadline for tasks with no registered deadline of their own.
|
|
88
|
+
default_deadline: int = field(
|
|
89
|
+
default_factory=lambda: _env_int("LIVEOPS_DEFAULT_DEADLINE", 900)
|
|
90
|
+
)
|
|
91
|
+
#: Fraction of the deadline at which a cooperative task should stop itself.
|
|
92
|
+
#: The remaining 10% pays for teardown: close the browser, write a
|
|
93
|
+
#: checkpoint, mark the run finished.
|
|
94
|
+
safe_fraction: float = field(default_factory=lambda: _env_float("LIVEOPS_SAFE_FRACTION", 0.9))
|
|
95
|
+
|
|
96
|
+
# ── Snapshots ─────────────────────────────────────────────────────────────
|
|
97
|
+
#: Only the *latest* frame is kept, so this is a short "is it still on the
|
|
98
|
+
#: login page?" TTL, not a history.
|
|
99
|
+
snapshot_ttl: int = field(default_factory=lambda: _env_int("LIVEOPS_SNAPSHOT_TTL", 300))
|
|
100
|
+
#: Minimum seconds between two captures. Grabbing a screenshot is a
|
|
101
|
+
#: synchronous round-trip to the browser; unthrottled it competes with the
|
|
102
|
+
#: work you are trying to watch.
|
|
103
|
+
snapshot_min_interval: float = field(
|
|
104
|
+
default_factory=lambda: _env_float("LIVEOPS_SNAPSHOT_MIN_INTERVAL", 1.2)
|
|
105
|
+
)
|
|
106
|
+
snapshots_enabled: bool = field(
|
|
107
|
+
default_factory=lambda: _env_bool("LIVEOPS_SNAPSHOTS_ENABLED", True)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# ── Broker introspection ──────────────────────────────────────────────────
|
|
111
|
+
#: Timeout for `celery inspect`. Short on purpose: a worker that cannot
|
|
112
|
+
#: answer in two seconds is busy or dead, and either way the number you want
|
|
113
|
+
#: is what the *others* answered.
|
|
114
|
+
inspect_timeout: float = field(
|
|
115
|
+
default_factory=lambda: _env_float("LIVEOPS_INSPECT_TIMEOUT", 2.0)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_settings = Settings()
|
|
120
|
+
_redis_client: Any = None
|
|
121
|
+
_redis_factory: Optional[Callable[[], Any]] = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def configure(
|
|
125
|
+
*,
|
|
126
|
+
redis_client: Any = None,
|
|
127
|
+
redis_factory: Optional[Callable[[], Any]] = None,
|
|
128
|
+
**overrides: Any,
|
|
129
|
+
) -> Settings:
|
|
130
|
+
"""Override configuration at runtime.
|
|
131
|
+
|
|
132
|
+
``redis_client`` lets an application hand over the connection it already
|
|
133
|
+
manages (pool sizing, TLS, sentinel) instead of having this library open a
|
|
134
|
+
second one. ``redis_factory`` does the same lazily, which is what you want
|
|
135
|
+
when the client must be created after a fork.
|
|
136
|
+
|
|
137
|
+
Any remaining keyword is a :class:`Settings` field::
|
|
138
|
+
|
|
139
|
+
configure(key_prefix="billing", max_lines=500, watchdog_enabled=True)
|
|
140
|
+
"""
|
|
141
|
+
global _settings, _redis_client, _redis_factory
|
|
142
|
+
|
|
143
|
+
if overrides:
|
|
144
|
+
unknown = set(overrides) - {f for f in Settings.__dataclass_fields__}
|
|
145
|
+
if unknown:
|
|
146
|
+
raise TypeError(f"unknown setting(s): {', '.join(sorted(unknown))}")
|
|
147
|
+
_settings = replace(_settings, **overrides)
|
|
148
|
+
|
|
149
|
+
if redis_client is not None or redis_factory is not None:
|
|
150
|
+
_redis_client = redis_client
|
|
151
|
+
_redis_factory = redis_factory
|
|
152
|
+
|
|
153
|
+
return _settings
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def settings() -> Settings:
|
|
157
|
+
"""The configuration in force right now."""
|
|
158
|
+
return _settings
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def reset() -> None:
|
|
162
|
+
"""Restore defaults (re-reading the environment). Mainly for tests."""
|
|
163
|
+
global _settings, _redis_client, _redis_factory
|
|
164
|
+
_settings = Settings()
|
|
165
|
+
_redis_client = None
|
|
166
|
+
_redis_factory = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional integrations."""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""A ready-made FastAPI router exposing the read side of celery-liveops.
|
|
2
|
+
|
|
3
|
+
Mount it and you have the panel's backend::
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, FastAPI
|
|
6
|
+
from celery_liveops.contrib.fastapi import liveops_router
|
|
7
|
+
|
|
8
|
+
app = FastAPI()
|
|
9
|
+
app.include_router(liveops_router(dependencies=[Depends(require_operator)]))
|
|
10
|
+
|
|
11
|
+
**Authentication is your job and this router will not let you forget it.** It
|
|
12
|
+
exposes a task's log output, a screenshot of what it is looking at, and a
|
|
13
|
+
``DELETE`` on infrastructure locks -- so ``liveops_router()`` refuses to build
|
|
14
|
+
without either ``dependencies=[...]`` or an explicit ``public=True``. Releasing a
|
|
15
|
+
lock is still guarded by the allowlist in :mod:`celery_liveops.locks`; the raw
|
|
16
|
+
key travels from the browser, and without that check an arbitrary ``DEL`` against
|
|
17
|
+
production Redis would be one POST away.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import List, Optional, Sequence
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from fastapi import APIRouter, Body, HTTPException
|
|
25
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"celery_liveops.contrib.fastapi requires fastapi: pip install 'celery-liveops[fastapi]'"
|
|
28
|
+
) from exc
|
|
29
|
+
|
|
30
|
+
from .. import locks as _locks
|
|
31
|
+
from .. import presence as _presence
|
|
32
|
+
from .. import queues as _queues
|
|
33
|
+
from .. import snapshots as _snapshots
|
|
34
|
+
from ..logs import read_any_logs
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def liveops_router(
|
|
38
|
+
prefix: str = "/liveops",
|
|
39
|
+
tags: Optional[Sequence[str]] = None,
|
|
40
|
+
dependencies: Optional[Sequence] = None,
|
|
41
|
+
public: bool = False,
|
|
42
|
+
allow_release: bool = True,
|
|
43
|
+
) -> APIRouter:
|
|
44
|
+
"""Build the router.
|
|
45
|
+
|
|
46
|
+
``allow_release=False`` keeps everything read-only, which is the right
|
|
47
|
+
setting for a dashboard that anyone on the team can open.
|
|
48
|
+
"""
|
|
49
|
+
if not dependencies and not public:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"liveops_router exposes task logs, screenshots and lock release. "
|
|
52
|
+
"Pass dependencies=[Depends(your_auth)], or public=True if you have "
|
|
53
|
+
"already put authentication in front of this app."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
router = APIRouter(
|
|
57
|
+
prefix=prefix,
|
|
58
|
+
tags=list(tags) if tags else ["liveops"],
|
|
59
|
+
dependencies=list(dependencies) if dependencies else None,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@router.get("/runs/{task_id}/logs")
|
|
63
|
+
def get_logs(task_id: str) -> dict:
|
|
64
|
+
"""The run's terminal: the live buffer, or the archived previous attempt."""
|
|
65
|
+
return {
|
|
66
|
+
"task_id": task_id,
|
|
67
|
+
"alive": _presence.is_alive(task_id),
|
|
68
|
+
"logs": read_any_logs(task_id),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@router.get("/runs/{task_id}/snapshot")
|
|
72
|
+
def get_snapshot(task_id: str) -> dict:
|
|
73
|
+
"""The latest frame captured for this run, base64-encoded PNG."""
|
|
74
|
+
image = _snapshots.read_snapshot(task_id)
|
|
75
|
+
if image is None:
|
|
76
|
+
raise HTTPException(status_code=404, detail="No snapshot for this run.")
|
|
77
|
+
return {"task_id": task_id, "image_base64": image}
|
|
78
|
+
|
|
79
|
+
@router.post("/runs/alive")
|
|
80
|
+
def post_alive(task_ids: List[str] = Body(..., embed=True)) -> dict:
|
|
81
|
+
"""Which of these runs still have a living process behind them.
|
|
82
|
+
|
|
83
|
+
A POST because a live table asks about a page of ids at once, and one
|
|
84
|
+
round trip beats one request per row.
|
|
85
|
+
"""
|
|
86
|
+
return {"alive": sorted(_presence.alive_among(task_ids))}
|
|
87
|
+
|
|
88
|
+
@router.get("/workers")
|
|
89
|
+
def get_workers() -> dict:
|
|
90
|
+
"""Worker processes that have sent a heartbeat recently."""
|
|
91
|
+
return {"workers": _presence.workers()}
|
|
92
|
+
|
|
93
|
+
@router.get("/locks")
|
|
94
|
+
def get_locks() -> dict:
|
|
95
|
+
"""Registered locks currently held, with their remaining TTL."""
|
|
96
|
+
return {"locks": _locks.list_locks()}
|
|
97
|
+
|
|
98
|
+
if allow_release:
|
|
99
|
+
|
|
100
|
+
@router.post("/locks/release")
|
|
101
|
+
def post_release(keys: List[str] = Body(..., embed=True)) -> dict:
|
|
102
|
+
"""Release held locks. Keys outside the catalogue are refused, not fatal."""
|
|
103
|
+
return _locks.release_locks(keys)
|
|
104
|
+
|
|
105
|
+
@router.get("/queues")
|
|
106
|
+
def get_queues() -> dict:
|
|
107
|
+
"""Who consumes what, and which declared queue nobody consumes."""
|
|
108
|
+
consumers = _queues.consumers_by_queue()
|
|
109
|
+
return {
|
|
110
|
+
"consumers": consumers,
|
|
111
|
+
"orphans": _queues.orphan_queues(),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@router.get("/queues/{queue}/depth")
|
|
115
|
+
def get_depth(queue: str) -> dict:
|
|
116
|
+
"""Messages waiting in a queue. ``null`` means the broker did not answer -- not zero."""
|
|
117
|
+
return {"queue": queue, "depth": _queues.queue_depth(queue)}
|
|
118
|
+
|
|
119
|
+
return router
|