dirigent-cli 0.9.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.
dirigent_cli/health.py ADDED
@@ -0,0 +1,294 @@
1
+ """Process-side health checks: the instance this shell resolves, as seen from here.
2
+
3
+ Each check answers one question without a token: does the configured database answer and
4
+ hold the right schema, are workers beating, are schedules firing on time, does the server
5
+ answer. Bare, ``dg health`` checks the whole instance -- and a machine with no instance on
6
+ it says so in one line instead of reporting the absence of everything, part by part.
7
+
8
+ A named check asserts its component is here, so finding none of it fails; that is the form
9
+ a container's ``HEALTHCHECK`` runs, where the environment is the instance and exactly one
10
+ component lives. The named ``worker`` check is scoped to this hostname for the same reason,
11
+ while the bare form asks about every worker the instance has.
12
+ """
13
+
14
+ import asyncio
15
+ import socket
16
+ from collections import Counter
17
+ from collections.abc import Awaitable, Callable, Sequence
18
+ from datetime import datetime, timedelta
19
+ from typing import Any, Literal
20
+
21
+ import httpx2
22
+ import sqlalchemy as sa
23
+ from pydantic import BaseModel, ConfigDict
24
+ from sqlalchemy.ext.asyncio import AsyncSession
25
+
26
+ from dirigent_cli.profiles import ProfileError, resolve_endpoint
27
+ from dirigent_client.enums import WorkerStatus
28
+ from dirigent_core.config import Settings, redacted_url
29
+ from dirigent_core.database import create_engine, create_session_factory
30
+ from dirigent_core.migrations import current_revision_async, head_revision
31
+ from dirigent_core.models import Schedule, Worker, utcnow
32
+
33
+ #: How many beats a worker may miss before it counts as not working.
34
+ MISSED_BEATS = 6
35
+
36
+ READY_TIMEOUT = 5.0
37
+ LIVE_PATH = "/health"
38
+ READY_PATH = "/health/ready"
39
+ HTTP_OK = 200
40
+
41
+ type Verdict = Literal["healthy", "unhealthy", "absent"]
42
+ """What a check decided. ``absent`` is "there is none of this", not "it is broken"."""
43
+
44
+ type Probe = Literal["liveness", "readiness"]
45
+ """Whether a check asked "are you there" or "can you do your job"."""
46
+
47
+
48
+ class Check(BaseModel):
49
+ """What one check found: which check it was, what it decided, and why."""
50
+
51
+ model_config = ConfigDict(frozen=True)
52
+
53
+ check: str
54
+ status: Verdict
55
+ detail: str
56
+ probe: Probe = "readiness"
57
+
58
+ def failed(self, *, asserted: bool) -> bool:
59
+ """Report whether this ends the command in a failure.
60
+
61
+ Absent fails only when the caller named the component, because naming it is the
62
+ assertion that it should be there.
63
+ """
64
+ return self.status == "unhealthy" or (asserted and self.status == "absent")
65
+
66
+
67
+ def where_database(settings: Settings) -> str:
68
+ """Name the database a person would recognise: the file for SQLite, the URL otherwise."""
69
+ target = settings.sqlite_path
70
+ return str(target) if target is not None else redacted_url(settings)
71
+
72
+
73
+ def named_server(*, url: str | None = None, profile: str | None = None) -> str | None:
74
+ """The server this shell names through a flag, ``DG_URL`` or a profile, or None.
75
+
76
+ None means nothing was named, so the only server worth asking about is this host's own
77
+ loopback -- which is what a container is.
78
+ """
79
+ try:
80
+ endpoint = resolve_endpoint(url=url, profile=profile)
81
+ except ProfileError:
82
+ return None
83
+ return None if endpoint.source == "default" else endpoint.url
84
+
85
+
86
+ async def latest_heartbeat(session: AsyncSession, hostname: str) -> datetime | None:
87
+ """Read the most recent heartbeat any worker on this host has written."""
88
+ # Must match on hostname, not worker name: a worker names itself hostname-pid, and this
89
+ # check runs in a different process with a different pid.
90
+ rows = await session.execute(sa.select(Worker.last_seen_at).where(Worker.hostname == hostname))
91
+ return max(rows.scalars(), default=None)
92
+
93
+
94
+ def is_beating(seen: datetime | None, *, heartbeat: timedelta, now: datetime | None = None) -> bool:
95
+ """Say whether a heartbeat that old still counts as a worker that is working."""
96
+ if seen is None:
97
+ return False
98
+ return (now or utcnow()) - seen < heartbeat * MISSED_BEATS
99
+
100
+
101
+ async def worker_check(session: AsyncSession, settings: Settings, hostname: str | None = None) -> Check:
102
+ """Report whether workers are beating: this host's when one is named, the instance's else.
103
+
104
+ A clean shutdown writes ``stopped``, so a stopped worker is history rather than a
105
+ fault: only workers that claim to be alive and have gone silent count against this.
106
+ """
107
+ query = sa.select(Worker.last_seen_at, Worker.status)
108
+ if hostname is not None:
109
+ query = query.where(Worker.hostname == hostname)
110
+ rows = (await session.execute(query)).all()
111
+ place = f" on {hostname}" if hostname is not None else ""
112
+ if not rows:
113
+ return Check(check="worker", status="absent", detail=f"no worker has ever registered{place}")
114
+ alive = [seen for seen, status in rows if status != WorkerStatus.STOPPED]
115
+ if not alive:
116
+ return Check(check="worker", status="absent", detail=f"every worker{place} has stopped")
117
+ beating = [seen for seen in alive if is_beating(seen, heartbeat=settings.heartbeat)]
118
+ if not beating:
119
+ quiet = int((utcnow() - max(alive)).total_seconds())
120
+ noun = f"the worker{place}" if len(alive) == 1 else f"all {len(alive)} workers{place}"
121
+ return Check(check="worker", status="unhealthy", detail=f"{noun} went silent, the last {quiet}s ago")
122
+ if hostname is not None:
123
+ return Check(check="worker", status="healthy", detail=f"a worker on {hostname} is beating")
124
+ return Check(check="worker", status="healthy", detail=f"{len(beating)} of {len(alive)} workers beating")
125
+
126
+
127
+ async def scheduler_check(session: AsyncSession, settings: Settings, *, now: datetime | None = None) -> Check:
128
+ """Report whether the schedules that should have fired have fired.
129
+
130
+ Leadership is a session-scoped advisory lock, so no other process can see who holds it.
131
+ What is visible is the work: a schedule later than the misfire grace means nothing is
132
+ ticking, whichever process was meant to be doing it.
133
+ """
134
+ moment = now or utcnow()
135
+ rows = await session.execute(
136
+ sa.select(Schedule.next_fire_at).where(Schedule.paused.is_(False), Schedule.next_fire_at.is_not(None))
137
+ )
138
+ waiting = sorted(when for when in rows.scalars() if when is not None)
139
+ if not waiting:
140
+ return Check(check="scheduler", status="healthy", detail="no schedule is waiting to fire")
141
+ threshold = moment - settings.scheduler_misfire_grace
142
+ overdue = [when for when in waiting if when < threshold]
143
+ if overdue:
144
+ late = int((moment - overdue[0]).total_seconds())
145
+ return Check(
146
+ check="scheduler",
147
+ status="unhealthy",
148
+ detail=f"{len(overdue)} of {len(waiting)} schedules overdue, the oldest by {late}s: nothing is firing them",
149
+ )
150
+ return Check(check="scheduler", status="healthy", detail=f"{len(waiting)} schedules waiting, none overdue")
151
+
152
+
153
+ def server_check(
154
+ settings: Settings, *, server: str | None = None, probe: Probe = "readiness", timeout: float = READY_TIMEOUT
155
+ ) -> Check:
156
+ """Ask the server -- the one this shell names, or this host's own -- if it is alive or ready.
157
+
158
+ Liveness answers without touching a dependency, so it separates "the process is gone"
159
+ from "the process is up and cannot reach its database" -- which is the case anyone
160
+ actually cares about.
161
+ """
162
+ base = server or f"http://127.0.0.1:{settings.port}"
163
+ where = base.split("://", 1)[-1]
164
+ path = READY_PATH if probe == "readiness" else LIVE_PATH
165
+ try:
166
+ response = httpx2.get(f"{base}{path}", timeout=timeout)
167
+ except httpx2.ConnectError:
168
+ detail = f"nothing is answering at {where}" if server else f"nothing is serving {where}"
169
+ return Check(check="server", status="absent", detail=detail, probe=probe)
170
+ except httpx2.HTTPError as error:
171
+ return Check(
172
+ check="server", status="unhealthy", detail=f"the server at {where}: {type(error).__name__}", probe=probe
173
+ )
174
+ if response.status_code != HTTP_OK:
175
+ return Check(
176
+ check="server",
177
+ status="unhealthy",
178
+ detail=f"the server at {where} answered {response.status_code} to {path}",
179
+ probe=probe,
180
+ )
181
+ word = "ready" if probe == "readiness" else "alive"
182
+ return Check(check="server", status="healthy", detail=f"the server at {where} is {word}", probe=probe)
183
+
184
+
185
+ async def database_check(settings: Settings) -> Check:
186
+ """Say whether the configured database answers, and holds the schema this code expects.
187
+
188
+ Reachable is not the same as usable: a database nobody has migrated answers ``SELECT 1``
189
+ and has no tables, and one left behind by a half-finished upgrade answers everything and
190
+ is missing a column. Both are what a readiness check is for.
191
+ """
192
+ where = where_database(settings)
193
+ # Opening a SQLite URL creates the file, so a machine with no instance on it would be
194
+ # given an empty database by the command that came to look at one.
195
+ target = settings.sqlite_path
196
+ if target is not None and not target.exists():
197
+ return Check(check="database", status="absent", detail=f"no database at {target}")
198
+ try:
199
+ stamped = await current_revision_async(settings)
200
+ except Exception as error: # any driver error means the same thing to whoever asked
201
+ return Check(check="database", status="unhealthy", detail=f"{where} is unreachable: {type(error).__name__}")
202
+ if stamped is None:
203
+ return Check(
204
+ check="database",
205
+ status="unhealthy",
206
+ detail=f"the database at {where} answers, but holds no schema; run `dg db upgrade`",
207
+ )
208
+ head = head_revision(settings)
209
+ if head is not None and stamped != head:
210
+ return Check(
211
+ check="database",
212
+ status="unhealthy",
213
+ detail=f"the database at {where} is at schema {stamped}, and this dirigent expects {head};"
214
+ " run `dg db upgrade`",
215
+ )
216
+ return Check(check="database", status="healthy", detail=f"the database at {where} answers, schema {stamped}")
217
+
218
+
219
+ async def _opened[T](settings: Settings, work: Callable[[AsyncSession], Awaitable[T]]) -> T:
220
+ """Run one piece of work against the configured database, and close it again."""
221
+ engine = create_engine(settings)
222
+ try:
223
+ async with create_session_factory(engine)() as session:
224
+ return await work(session)
225
+ finally:
226
+ await engine.dispose()
227
+
228
+
229
+ async def _instance(settings: Settings) -> list[Check]:
230
+ """Run every database-backed check: the database itself, then what it knows."""
231
+ database = await database_check(settings)
232
+ if database.status != "healthy":
233
+ # Workers and schedules cannot be read through a database with no schema, and
234
+ # three failures where there is one fault reads as three faults.
235
+ return [database]
236
+ engine = create_engine(settings)
237
+ try:
238
+ async with create_session_factory(engine)() as session:
239
+ return [database, await worker_check(session, settings), await scheduler_check(session, settings)]
240
+ finally:
241
+ await engine.dispose()
242
+
243
+
244
+ def every_check(settings: Settings, *, server: str | None = None) -> list[Check]:
245
+ """Run every check of the instance this shell resolves, in the order an operator reads.
246
+
247
+ A database that is simply not there ends it early: on a machine with no instance and no
248
+ named server there is nothing else worth probing, and the verdict says so in one line.
249
+ """
250
+ found = asyncio.run(_instance(settings))
251
+ if found[0].status == "absent" and server is None:
252
+ return found
253
+ return [*found, server_check(settings, server=server)]
254
+
255
+
256
+ def worker_health(settings: Settings, hostname: str | None = None) -> Check:
257
+ """Run the worker check for this host, which is what a worker's HEALTHCHECK asserts."""
258
+ host = hostname or socket.gethostname()
259
+ return asyncio.run(_opened(settings, lambda session: worker_check(session, settings, host)))
260
+
261
+
262
+ def scheduler_health(settings: Settings) -> Check:
263
+ """Run the scheduler check against the configured database."""
264
+ return asyncio.run(_opened(settings, lambda session: scheduler_check(session, settings)))
265
+
266
+
267
+ def database_health(settings: Settings) -> Check:
268
+ """Run the database check against the configured database."""
269
+ return asyncio.run(database_check(settings))
270
+
271
+
272
+ def verdict(checks: Sequence[Check]) -> dict[str, Any]:
273
+ """Sum up what was found, so the last line is the answer rather than an addition problem."""
274
+ counted = Counter(check.status for check in checks)
275
+ broken = [check.check for check in checks if check.status == "unhealthy"]
276
+ absent = {check.check: check for check in checks if check.status == "absent"}
277
+ if broken:
278
+ message = f"{len(broken)} of {len(checks)} checks failed: {', '.join(broken)}"
279
+ elif "database" in absent:
280
+ message = f"there is no instance here: {absent['database'].detail}, and no server was named"
281
+ elif {"worker", "server"} <= absent.keys():
282
+ message = "an instance's database is here, and nothing is running against it"
283
+ elif absent:
284
+ message = f"nothing is broken; there is no {', no '.join(absent)}"
285
+ else:
286
+ message = "everything checked is healthy"
287
+ return {
288
+ "level": "error" if broken else "info",
289
+ "message": message,
290
+ "checked": len(checks),
291
+ "healthy": counted["healthy"],
292
+ "absent": counted["absent"],
293
+ "unhealthy": counted["unhealthy"],
294
+ }