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.
@@ -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)
@@ -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