glab-cli-extras 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.
- glab_cli_extras/__init__.py +3 -0
- glab_cli_extras/artifacts/__init__.py +0 -0
- glab_cli_extras/artifacts/job_logs.py +95 -0
- glab_cli_extras/artifacts/threads.py +147 -0
- glab_cli_extras/cli/__init__.py +0 -0
- glab_cli_extras/cli/watch.py +426 -0
- glab_cli_extras/daemon/__init__.py +0 -0
- glab_cli_extras/daemon/__main__.py +10 -0
- glab_cli_extras/daemon/engine.py +491 -0
- glab_cli_extras/daemon/lifecycle.py +186 -0
- glab_cli_extras/daemon/locks.py +85 -0
- glab_cli_extras/daemon/scheduler.py +52 -0
- glab_cli_extras/daemon/server.py +79 -0
- glab_cli_extras/daemon/transport.py +185 -0
- glab_cli_extras/events.py +165 -0
- glab_cli_extras/gitlab/__init__.py +0 -0
- glab_cli_extras/gitlab/budget.py +135 -0
- glab_cli_extras/gitlab/client.py +225 -0
- glab_cli_extras/gitlab/glab.py +175 -0
- glab_cli_extras/gitlab/queries/__init__.py +11 -0
- glab_cli_extras/gitlab/queries/branch_pipelines.graphql +8 -0
- glab_cli_extras/gitlab/queries/mr.graphql +42 -0
- glab_cli_extras/gitlab/queries/pipeline.graphql +38 -0
- glab_cli_extras/gitlab/refs.py +151 -0
- glab_cli_extras/paths.py +79 -0
- glab_cli_extras/py.typed +0 -0
- glab_cli_extras/resources/__init__.py +19 -0
- glab_cli_extras/resources/base.py +146 -0
- glab_cli_extras/resources/branch.py +265 -0
- glab_cli_extras/resources/mr.py +445 -0
- glab_cli_extras/resources/mr_list.py +150 -0
- glab_cli_extras/resources/pipeline.py +319 -0
- glab_cli_extras/serde.py +75 -0
- glab_cli_extras/watch/__init__.py +0 -0
- glab_cli_extras/watch/changelog.py +86 -0
- glab_cli_extras/watch/claude.py +193 -0
- glab_cli_extras/watch/client.py +316 -0
- glab_cli_extras/watch/sinks.py +83 -0
- glab_cli_extras/watch/streams.py +80 -0
- glab_cli_extras-0.1.0.dist-info/METADATA +230 -0
- glab_cli_extras-0.1.0.dist-info/RECORD +44 -0
- glab_cli_extras-0.1.0.dist-info/WHEEL +4 -0
- glab_cli_extras-0.1.0.dist-info/entry_points.txt +2 -0
- glab_cli_extras-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""The polling engine: subscriptions with leases, refcounted resources, per-resource event logs, and the loop that
|
|
2
|
+
polls resources under each host's budget.
|
|
3
|
+
|
|
4
|
+
The daemon serves the engine over its socket. `--no-daemon` runs the same engine inside the client process.
|
|
5
|
+
|
|
6
|
+
- **Resources** are shared: two subscriptions to the same resource use one poller, which does the union of the work
|
|
7
|
+
their filters need. A resource can also be held by a parent resource (an MR holds its head pipeline); the parent
|
|
8
|
+
then receives the child's events in its own log.
|
|
9
|
+
- **Event log:** each resource keeps its last 500 events with sequence numbers. A new subscriber starts at the head of
|
|
10
|
+
the log and gets a private `watch_started` with the current snapshot, never the backlog.
|
|
11
|
+
- **Leases:** every client poll renews all of that client's subscriptions. A subscription not renewed within the lease
|
|
12
|
+
timeout is dropped, so a killed client never keeps a poller alive.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import contextlib
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from collections import deque
|
|
23
|
+
from collections.abc import Callable, Mapping
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from glab_cli_extras.artifacts.job_logs import LOGS_MODES, LogsMode, merge_logs_modes
|
|
29
|
+
from glab_cli_extras.daemon.scheduler import Scheduler
|
|
30
|
+
from glab_cli_extras.events import (
|
|
31
|
+
LIFECYCLE_KINDS,
|
|
32
|
+
WATCH_DEGRADED,
|
|
33
|
+
WATCH_ENDED,
|
|
34
|
+
WATCH_STARTED,
|
|
35
|
+
Event,
|
|
36
|
+
RawEvent,
|
|
37
|
+
utc_now_iso,
|
|
38
|
+
)
|
|
39
|
+
from glab_cli_extras.gitlab.client import (
|
|
40
|
+
ApiError,
|
|
41
|
+
GitLabClient,
|
|
42
|
+
NotFound,
|
|
43
|
+
RateLimited,
|
|
44
|
+
TransportError,
|
|
45
|
+
Unauthorized,
|
|
46
|
+
)
|
|
47
|
+
from glab_cli_extras.gitlab.glab import GlabNotFoundError, GlabRunner, run_glab
|
|
48
|
+
from glab_cli_extras.gitlab.refs import ResourceRef
|
|
49
|
+
from glab_cli_extras.resources import POLLERS
|
|
50
|
+
from glab_cli_extras.resources.base import PollContext, Poller, PollerOptions, PushFeedCache
|
|
51
|
+
|
|
52
|
+
log = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
EVENT_LOG_SIZE = 500
|
|
55
|
+
DEFAULT_LEASE_TIMEOUT = 20.0
|
|
56
|
+
DEFAULT_CLIENT_POLL_MS = 5000
|
|
57
|
+
MAX_INFLIGHT_PER_HOST = 4
|
|
58
|
+
MAX_ERROR_BACKOFF = 300.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EngineError(Exception):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Subscription:
|
|
67
|
+
id: str
|
|
68
|
+
client_id: str
|
|
69
|
+
ref: ResourceRef
|
|
70
|
+
events: frozenset[str]
|
|
71
|
+
logs: LogsMode
|
|
72
|
+
renewed_at: float
|
|
73
|
+
started: bool = False
|
|
74
|
+
private: list[Event] = field(default_factory=list)
|
|
75
|
+
sent_snapshot_hash: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Resource:
|
|
80
|
+
ref: ResourceRef
|
|
81
|
+
poller: Poller[Any]
|
|
82
|
+
log: deque[Event] = field(default_factory=lambda: deque(maxlen=EVENT_LOG_SIZE))
|
|
83
|
+
seq: int = 0
|
|
84
|
+
subscriptions: set[str] = field(default_factory=set)
|
|
85
|
+
parents: set[str] = field(default_factory=set)
|
|
86
|
+
children: set[str] = field(default_factory=set)
|
|
87
|
+
polled: bool = False
|
|
88
|
+
polling: bool = False
|
|
89
|
+
ended: bool = False
|
|
90
|
+
end_event: Event | None = None
|
|
91
|
+
degraded: str | None = None
|
|
92
|
+
failures: int = 0
|
|
93
|
+
polls: int = 0
|
|
94
|
+
last_poll_at: float | None = None
|
|
95
|
+
last_error: str | None = None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def key(self) -> str:
|
|
99
|
+
return self.ref.key
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def held(self) -> bool:
|
|
103
|
+
return bool(self.subscriptions or self.parents)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Engine:
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
*,
|
|
110
|
+
cache_root: Path,
|
|
111
|
+
runner: GlabRunner = run_glab,
|
|
112
|
+
clock: Callable[[], float] = time.time,
|
|
113
|
+
lease_timeout: float = DEFAULT_LEASE_TIMEOUT,
|
|
114
|
+
client_poll_ms: int = DEFAULT_CLIENT_POLL_MS,
|
|
115
|
+
) -> None:
|
|
116
|
+
self.cache_root = cache_root
|
|
117
|
+
self.clock = clock
|
|
118
|
+
self.lease_timeout = lease_timeout
|
|
119
|
+
self.client_poll_ms = client_poll_ms
|
|
120
|
+
self.started_at = clock()
|
|
121
|
+
self.resources: dict[str, Resource] = {}
|
|
122
|
+
self.subscriptions: dict[str, Subscription] = {}
|
|
123
|
+
self.scheduler = Scheduler()
|
|
124
|
+
self._runner = runner
|
|
125
|
+
self._clients: dict[str, GitLabClient] = {}
|
|
126
|
+
self._push_feeds = PushFeedCache()
|
|
127
|
+
self._inflight: dict[str, int] = {}
|
|
128
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
129
|
+
self._wake: asyncio.Event | None = None
|
|
130
|
+
self._idle_since: float | None = clock()
|
|
131
|
+
|
|
132
|
+
# ----------------------------------------------------------------------------------------------------------
|
|
133
|
+
# Client-facing operations
|
|
134
|
+
|
|
135
|
+
def client_for(self, host: str) -> GitLabClient:
|
|
136
|
+
client = self._clients.get(host)
|
|
137
|
+
if client is None:
|
|
138
|
+
client = GitLabClient(host, runner=self._runner, clock=self.clock)
|
|
139
|
+
self._clients[host] = client
|
|
140
|
+
return client
|
|
141
|
+
|
|
142
|
+
def subscribe(
|
|
143
|
+
self,
|
|
144
|
+
client_id: str,
|
|
145
|
+
ref: ResourceRef,
|
|
146
|
+
events: frozenset[str],
|
|
147
|
+
logs: LogsMode = "failed",
|
|
148
|
+
baseline: Any = None,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
if logs not in LOGS_MODES:
|
|
151
|
+
raise EngineError(f"unknown logs mode {logs!r}")
|
|
152
|
+
now = self.clock()
|
|
153
|
+
res = self._acquire(ref)
|
|
154
|
+
if baseline is not None and not res.polled:
|
|
155
|
+
res.poller.seed(baseline)
|
|
156
|
+
sub = Subscription(uuid.uuid4().hex, client_id, ref, events | frozenset(LIFECYCLE_KINDS), logs, now)
|
|
157
|
+
self.subscriptions[sub.id] = sub
|
|
158
|
+
res.subscriptions.add(sub.id)
|
|
159
|
+
self._idle_since = None
|
|
160
|
+
cursor = res.seq
|
|
161
|
+
if res.polled:
|
|
162
|
+
sub.private.append(self._started_event(res))
|
|
163
|
+
sub.started = True
|
|
164
|
+
if res.end_event is not None:
|
|
165
|
+
sub.private.append(res.end_event)
|
|
166
|
+
self._recompute_options(res)
|
|
167
|
+
log.info("subscribe %s client=%s sub=%s", ref.key, client_id[:8], sub.id[:8])
|
|
168
|
+
self._kick()
|
|
169
|
+
return {"subscription_id": sub.id, "cursor": cursor}
|
|
170
|
+
|
|
171
|
+
def poll(self, client_id: str, cursors: Mapping[str, int]) -> dict[str, Any]:
|
|
172
|
+
now = self.clock()
|
|
173
|
+
for owned in self.subscriptions.values():
|
|
174
|
+
if owned.client_id == client_id:
|
|
175
|
+
owned.renewed_at = now
|
|
176
|
+
events: dict[str, list[dict[str, Any]]] = {}
|
|
177
|
+
snapshots: dict[str, dict[str, Any]] = {}
|
|
178
|
+
unknown: list[str] = []
|
|
179
|
+
for sid, cursor in cursors.items():
|
|
180
|
+
sub = self.subscriptions.get(sid)
|
|
181
|
+
if sub is None or sub.client_id != client_id:
|
|
182
|
+
unknown.append(sid)
|
|
183
|
+
continue
|
|
184
|
+
res = self.resources.get(sub.ref.key)
|
|
185
|
+
if res is None:
|
|
186
|
+
unknown.append(sid)
|
|
187
|
+
continue
|
|
188
|
+
batch = list(sub.private)
|
|
189
|
+
sub.private.clear()
|
|
190
|
+
batch.extend(e for e in res.log if e.seq > cursor and e.kind in sub.events)
|
|
191
|
+
if batch:
|
|
192
|
+
events[sid] = [e.to_json() for e in batch]
|
|
193
|
+
snapshot_hash = res.poller.snapshot_hash()
|
|
194
|
+
if snapshot_hash is not None and snapshot_hash != sub.sent_snapshot_hash:
|
|
195
|
+
snapshots[sid] = {"hash": snapshot_hash, "data": res.poller.snapshot_json()}
|
|
196
|
+
sub.sent_snapshot_hash = snapshot_hash
|
|
197
|
+
return {"events": events, "snapshots": snapshots, "unknown": unknown, "next_poll_ms": self.client_poll_ms}
|
|
198
|
+
|
|
199
|
+
def unsubscribe(self, client_id: str, subscription_id: str) -> bool:
|
|
200
|
+
sub = self.subscriptions.get(subscription_id)
|
|
201
|
+
if sub is None or sub.client_id != client_id:
|
|
202
|
+
return False
|
|
203
|
+
self._drop_subscription(sub, "unsubscribed")
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
def idle_for(self, now: float) -> float:
|
|
207
|
+
"""Seconds without any subscription, 0 while there is one."""
|
|
208
|
+
if self.subscriptions or self._idle_since is None:
|
|
209
|
+
return 0.0
|
|
210
|
+
return now - self._idle_since
|
|
211
|
+
|
|
212
|
+
def reap(self, now: float) -> None:
|
|
213
|
+
expired = [s for s in self.subscriptions.values() if now - s.renewed_at > self.lease_timeout]
|
|
214
|
+
for sub in expired:
|
|
215
|
+
self._drop_subscription(sub, "lease expired")
|
|
216
|
+
|
|
217
|
+
def status(self) -> dict[str, Any]:
|
|
218
|
+
now = self.clock()
|
|
219
|
+
return {
|
|
220
|
+
"subscriptions": [
|
|
221
|
+
{
|
|
222
|
+
"id": s.id,
|
|
223
|
+
"client_id": s.client_id,
|
|
224
|
+
"resource": s.ref.key,
|
|
225
|
+
"idle_s": round(now - s.renewed_at, 1),
|
|
226
|
+
"logs": s.logs,
|
|
227
|
+
"events": sorted(s.events - frozenset(LIFECYCLE_KINDS)),
|
|
228
|
+
}
|
|
229
|
+
for s in self.subscriptions.values()
|
|
230
|
+
],
|
|
231
|
+
"resources": [
|
|
232
|
+
{
|
|
233
|
+
"resource": r.key,
|
|
234
|
+
"subscriptions": len(r.subscriptions),
|
|
235
|
+
"parents": sorted(r.parents),
|
|
236
|
+
"children": sorted(r.children),
|
|
237
|
+
"seq": r.seq,
|
|
238
|
+
"polls": r.polls,
|
|
239
|
+
"ended": r.ended,
|
|
240
|
+
"degraded": r.degraded,
|
|
241
|
+
"last_error": r.last_error,
|
|
242
|
+
"next_poll_in_s": round(d - now, 1) if (d := self.scheduler.due_at(r.key)) is not None else None,
|
|
243
|
+
}
|
|
244
|
+
for r in self.resources.values()
|
|
245
|
+
],
|
|
246
|
+
"hosts": {
|
|
247
|
+
host: {"budget": c.budget.describe(now), "requests": c.stats.describe()}
|
|
248
|
+
for host, c in sorted(self._clients.items())
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# ----------------------------------------------------------------------------------------------------------
|
|
253
|
+
# Resources
|
|
254
|
+
|
|
255
|
+
def _acquire(self, ref: ResourceRef) -> Resource:
|
|
256
|
+
res = self.resources.get(ref.key)
|
|
257
|
+
if res is None:
|
|
258
|
+
ctx = PollContext(self.client_for(ref.host), self.cache_root, self.clock, self._push_feeds)
|
|
259
|
+
poller = POLLERS[ref.kind](ref, ctx, PollerOptions(frozenset(), "none"))
|
|
260
|
+
res = Resource(ref, poller)
|
|
261
|
+
self.resources[ref.key] = res
|
|
262
|
+
self.scheduler.schedule(ref.key, self.clock(), urgent=True)
|
|
263
|
+
log.info("start polling %s", ref.key)
|
|
264
|
+
return res
|
|
265
|
+
|
|
266
|
+
def _release(self, res: Resource) -> None:
|
|
267
|
+
if res.held or res.key not in self.resources:
|
|
268
|
+
return
|
|
269
|
+
del self.resources[res.key]
|
|
270
|
+
self.scheduler.forget(res.key)
|
|
271
|
+
log.info("stop polling %s", res.key)
|
|
272
|
+
for child_key in list(res.children):
|
|
273
|
+
self._unlink_child(res, child_key)
|
|
274
|
+
|
|
275
|
+
def _unlink_child(self, parent: Resource, child_key: str) -> None:
|
|
276
|
+
parent.children.discard(child_key)
|
|
277
|
+
child = self.resources.get(child_key)
|
|
278
|
+
if child is None:
|
|
279
|
+
return
|
|
280
|
+
child.parents.discard(parent.key)
|
|
281
|
+
self._recompute_options(child)
|
|
282
|
+
self._release(child)
|
|
283
|
+
|
|
284
|
+
def _drop_subscription(self, sub: Subscription, why: str) -> None:
|
|
285
|
+
self.subscriptions.pop(sub.id, None)
|
|
286
|
+
log.info("drop subscription %s on %s: %s", sub.id[:8], sub.ref.key, why)
|
|
287
|
+
res = self.resources.get(sub.ref.key)
|
|
288
|
+
if res is not None:
|
|
289
|
+
res.subscriptions.discard(sub.id)
|
|
290
|
+
self._recompute_options(res)
|
|
291
|
+
self._release(res)
|
|
292
|
+
if not self.subscriptions:
|
|
293
|
+
self._idle_since = self.clock()
|
|
294
|
+
|
|
295
|
+
def _recompute_options(self, res: Resource) -> None:
|
|
296
|
+
events: set[str] = set()
|
|
297
|
+
modes: list[LogsMode] = []
|
|
298
|
+
for sid in res.subscriptions:
|
|
299
|
+
sub = self.subscriptions[sid]
|
|
300
|
+
events |= sub.events
|
|
301
|
+
modes.append(sub.logs)
|
|
302
|
+
for parent_key in res.parents:
|
|
303
|
+
parent = self.resources.get(parent_key)
|
|
304
|
+
options = parent.poller.child_options(res.ref) if parent is not None else None
|
|
305
|
+
if options is not None:
|
|
306
|
+
events |= options.events
|
|
307
|
+
modes.append(options.logs)
|
|
308
|
+
new = PollerOptions(frozenset(events), merge_logs_modes(modes))
|
|
309
|
+
if new == res.poller.options:
|
|
310
|
+
return
|
|
311
|
+
grew = not new.events <= res.poller.options.events
|
|
312
|
+
res.poller.set_options(new)
|
|
313
|
+
if grew and res.polled and not res.ended and not res.polling:
|
|
314
|
+
self.scheduler.schedule(res.key, self.clock(), urgent=True)
|
|
315
|
+
self._kick()
|
|
316
|
+
for child_key in list(res.children):
|
|
317
|
+
child = self.resources.get(child_key)
|
|
318
|
+
if child is not None:
|
|
319
|
+
self._recompute_options(child)
|
|
320
|
+
|
|
321
|
+
def _reconcile_children(self, res: Resource) -> None:
|
|
322
|
+
wanted = {ref.key: ref for ref in res.poller.children()} if not res.ended else {}
|
|
323
|
+
for child_key in list(res.children):
|
|
324
|
+
if child_key not in wanted:
|
|
325
|
+
self._unlink_child(res, child_key)
|
|
326
|
+
for key, ref in wanted.items():
|
|
327
|
+
if key in res.children:
|
|
328
|
+
continue
|
|
329
|
+
child = self._acquire(ref)
|
|
330
|
+
child.parents.add(res.key)
|
|
331
|
+
res.children.add(key)
|
|
332
|
+
self._recompute_options(child)
|
|
333
|
+
|
|
334
|
+
# ----------------------------------------------------------------------------------------------------------
|
|
335
|
+
# Events
|
|
336
|
+
|
|
337
|
+
def _started_event(self, res: Resource) -> Event:
|
|
338
|
+
data = {"summary": res.poller.summary(), "snapshot_hash": res.poller.snapshot_hash()}
|
|
339
|
+
return Event(WATCH_STARTED, res.key, res.seq, utc_now_iso(), data)
|
|
340
|
+
|
|
341
|
+
def _append(self, res: Resource, raw: RawEvent) -> None:
|
|
342
|
+
res.seq += 1
|
|
343
|
+
event = Event(raw.kind, res.key, res.seq, utc_now_iso(), dict(raw.data), raw.artifacts)
|
|
344
|
+
res.log.append(event)
|
|
345
|
+
if raw.kind in LIFECYCLE_KINDS:
|
|
346
|
+
return
|
|
347
|
+
for parent_key in res.parents:
|
|
348
|
+
parent = self.resources.get(parent_key)
|
|
349
|
+
if parent is not None and not parent.ended:
|
|
350
|
+
self._append(parent, raw)
|
|
351
|
+
|
|
352
|
+
def _end(self, res: Resource, reason: str, data: Mapping[str, Any]) -> None:
|
|
353
|
+
self._append(res, RawEvent(WATCH_ENDED, {"reason": reason, **data}))
|
|
354
|
+
res.ended = True
|
|
355
|
+
res.end_event = res.log[-1]
|
|
356
|
+
self.scheduler.remove(res.key)
|
|
357
|
+
for child_key in list(res.children):
|
|
358
|
+
self._unlink_child(res, child_key)
|
|
359
|
+
log.info("ended %s: %s", res.key, reason)
|
|
360
|
+
|
|
361
|
+
def _degrade(self, res: Resource, reason: str, message: str) -> None:
|
|
362
|
+
res.last_error = message
|
|
363
|
+
if res.degraded == reason:
|
|
364
|
+
return
|
|
365
|
+
res.degraded = reason
|
|
366
|
+
self._append(res, RawEvent(WATCH_DEGRADED, {"reason": reason, "message": message}))
|
|
367
|
+
log.warning("degraded %s: %s: %s", res.key, reason, message)
|
|
368
|
+
|
|
369
|
+
# ----------------------------------------------------------------------------------------------------------
|
|
370
|
+
# Polling
|
|
371
|
+
|
|
372
|
+
def _kick(self) -> None:
|
|
373
|
+
if self._wake is not None:
|
|
374
|
+
self._wake.set()
|
|
375
|
+
|
|
376
|
+
def _stretch(self, host: str, now: float) -> float:
|
|
377
|
+
client = self._clients[host]
|
|
378
|
+
demand = sum(
|
|
379
|
+
r.poller.expected_cost() / max(r.poller.next_delay(), 0.5)
|
|
380
|
+
for r in self.resources.values()
|
|
381
|
+
if r.ref.host == host and not r.ended
|
|
382
|
+
)
|
|
383
|
+
return client.budget.stretch_factor(demand, now)
|
|
384
|
+
|
|
385
|
+
async def _poll(self, res: Resource) -> None:
|
|
386
|
+
host = res.ref.host
|
|
387
|
+
client = self.client_for(host)
|
|
388
|
+
delay: float | None = None
|
|
389
|
+
try:
|
|
390
|
+
raws = await res.poller.poll()
|
|
391
|
+
except NotFound as exc:
|
|
392
|
+
if not res.polled:
|
|
393
|
+
self._end(res, "not_found", {"message": str(exc)})
|
|
394
|
+
else:
|
|
395
|
+
self._degrade(res, "not_found", str(exc))
|
|
396
|
+
delay = self._error_delay(res)
|
|
397
|
+
except RateLimited as exc:
|
|
398
|
+
self._degrade(res, "rate_limited", str(exc))
|
|
399
|
+
delay = max(self._error_delay(res), client.budget.blocked_until - self.clock())
|
|
400
|
+
except Unauthorized as exc:
|
|
401
|
+
self._degrade(res, "auth", str(exc))
|
|
402
|
+
delay = self._error_delay(res)
|
|
403
|
+
except (TransportError, ApiError, GlabNotFoundError) as exc:
|
|
404
|
+
self._degrade(res, "error", str(exc))
|
|
405
|
+
delay = self._error_delay(res)
|
|
406
|
+
except Exception as exc:
|
|
407
|
+
log.exception("poll of %s failed", res.key)
|
|
408
|
+
self._degrade(res, "error", f"{type(exc).__name__}: {exc}")
|
|
409
|
+
delay = self._error_delay(res)
|
|
410
|
+
else:
|
|
411
|
+
res.failures = 0
|
|
412
|
+
res.degraded = None
|
|
413
|
+
res.last_error = None
|
|
414
|
+
first = not res.polled
|
|
415
|
+
res.polled = True
|
|
416
|
+
for raw in raws:
|
|
417
|
+
self._append(res, raw)
|
|
418
|
+
if first:
|
|
419
|
+
for sid in res.subscriptions:
|
|
420
|
+
sub = self.subscriptions[sid]
|
|
421
|
+
if not sub.started:
|
|
422
|
+
sub.private.append(self._started_event(res))
|
|
423
|
+
sub.started = True
|
|
424
|
+
if res.poller.terminal:
|
|
425
|
+
self._end(res, res.poller.terminal_reason or "finished", res.poller.terminal_data)
|
|
426
|
+
else:
|
|
427
|
+
self._reconcile_children(res)
|
|
428
|
+
now = self.clock()
|
|
429
|
+
delay = res.poller.next_delay() * self._stretch(host, now)
|
|
430
|
+
finally:
|
|
431
|
+
res.polling = False
|
|
432
|
+
res.polls += 1
|
|
433
|
+
res.last_poll_at = self.clock()
|
|
434
|
+
self._inflight[host] = self._inflight.get(host, 1) - 1
|
|
435
|
+
if delay is not None and not res.ended and res.key in self.resources:
|
|
436
|
+
self.scheduler.schedule(res.key, self.clock() + delay)
|
|
437
|
+
self._kick()
|
|
438
|
+
|
|
439
|
+
def _error_delay(self, res: Resource) -> float:
|
|
440
|
+
res.failures += 1
|
|
441
|
+
return float(min(res.poller.next_delay() * (2 ** min(res.failures, 8)), MAX_ERROR_BACKOFF))
|
|
442
|
+
|
|
443
|
+
def _start_due(self, now: float) -> float:
|
|
444
|
+
"""Start every due poll the budgets allow. Return seconds until the loop should look again."""
|
|
445
|
+
wait = 1.0
|
|
446
|
+
for key in self.scheduler.ready(now):
|
|
447
|
+
res = self.resources.get(key)
|
|
448
|
+
if res is None:
|
|
449
|
+
self.scheduler.forget(key)
|
|
450
|
+
continue
|
|
451
|
+
host = res.ref.host
|
|
452
|
+
if self._inflight.get(host, 0) >= MAX_INFLIGHT_PER_HOST:
|
|
453
|
+
continue
|
|
454
|
+
budget = self.client_for(host).budget
|
|
455
|
+
cost = res.poller.expected_cost()
|
|
456
|
+
if not budget.can_spend(cost, now):
|
|
457
|
+
wait = min(wait, max(0.05, budget.wait_time(cost, now)))
|
|
458
|
+
continue
|
|
459
|
+
self.scheduler.mark_served(key, now)
|
|
460
|
+
res.polling = True
|
|
461
|
+
self._inflight[host] = self._inflight.get(host, 0) + 1
|
|
462
|
+
task = asyncio.create_task(self._poll(res))
|
|
463
|
+
self._tasks.add(task)
|
|
464
|
+
task.add_done_callback(self._tasks.discard)
|
|
465
|
+
next_due = self.scheduler.next_due()
|
|
466
|
+
if next_due is not None:
|
|
467
|
+
wait = min(wait, max(0.05, next_due - now))
|
|
468
|
+
return wait
|
|
469
|
+
|
|
470
|
+
async def poll_due_once(self) -> None:
|
|
471
|
+
"""Start every due poll and wait for all of them: one deterministic step, for tests and debugging."""
|
|
472
|
+
self.reap(self.clock())
|
|
473
|
+
self._start_due(self.clock())
|
|
474
|
+
while self._tasks:
|
|
475
|
+
await asyncio.gather(*list(self._tasks))
|
|
476
|
+
|
|
477
|
+
async def run(self, stop: asyncio.Event) -> None:
|
|
478
|
+
self._wake = asyncio.Event()
|
|
479
|
+
try:
|
|
480
|
+
while not stop.is_set():
|
|
481
|
+
now = self.clock()
|
|
482
|
+
self.reap(now)
|
|
483
|
+
wait = self._start_due(now)
|
|
484
|
+
self._wake.clear()
|
|
485
|
+
with contextlib.suppress(TimeoutError):
|
|
486
|
+
await asyncio.wait_for(self._wake.wait(), wait)
|
|
487
|
+
finally:
|
|
488
|
+
for task in list(self._tasks):
|
|
489
|
+
task.cancel()
|
|
490
|
+
if self._tasks:
|
|
491
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Daemon lifecycle: the daemon's own main loop, and the client side that finds or starts it.
|
|
2
|
+
|
|
3
|
+
There is one daemon per user and wire-protocol version. It holds an exclusive lock on `daemon-v<N>.lock` for its
|
|
4
|
+
whole life, and exits after `idle_timeout` seconds without subscriptions. Clients start it on demand; the spawn lock
|
|
5
|
+
makes concurrent clients start one daemon, not one each.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from glab_cli_extras import __version__
|
|
21
|
+
from glab_cli_extras.daemon.engine import DEFAULT_CLIENT_POLL_MS, DEFAULT_LEASE_TIMEOUT, Engine
|
|
22
|
+
from glab_cli_extras.daemon.locks import FileLock
|
|
23
|
+
from glab_cli_extras.daemon.server import handle
|
|
24
|
+
from glab_cli_extras.daemon.transport import DaemonFiles, DaemonUnavailable, request, start_server, use_tcp
|
|
25
|
+
from glab_cli_extras.paths import ensure_private_dir, user_cache_dir
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("glab_cli_extras.daemon")
|
|
28
|
+
|
|
29
|
+
DEFAULT_IDLE_TIMEOUT = 30.0
|
|
30
|
+
SPAWN_TIMEOUT = 10.0
|
|
31
|
+
MAX_LOG_BYTES = 5 * 1024 * 1024
|
|
32
|
+
IDLE_TIMEOUT_ENV = "GLAB_CLI_EXTRAS_IDLE_TIMEOUT"
|
|
33
|
+
LEASE_TIMEOUT_ENV = "GLAB_CLI_EXTRAS_LEASE_TIMEOUT"
|
|
34
|
+
CLIENT_POLL_MS_ENV = "GLAB_CLI_EXTRAS_CLIENT_POLL_MS"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _env_float(name: str, default: float) -> float:
|
|
38
|
+
try:
|
|
39
|
+
return float(os.environ.get(name, default))
|
|
40
|
+
except ValueError:
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def serve(files: DaemonFiles, engine: Engine, idle_timeout: float) -> None:
|
|
45
|
+
stop = asyncio.Event()
|
|
46
|
+
loop = asyncio.get_running_loop()
|
|
47
|
+
if os.name != "nt":
|
|
48
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
49
|
+
loop.add_signal_handler(sig, stop.set)
|
|
50
|
+
|
|
51
|
+
async def on_request(req: dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
return handle(engine, req, stop)
|
|
53
|
+
|
|
54
|
+
server, cleanup = await start_server(files, on_request)
|
|
55
|
+
log.info(
|
|
56
|
+
"daemon %s started, pid %d, protocol endpoint %s, idle timeout %.0f s",
|
|
57
|
+
__version__,
|
|
58
|
+
os.getpid(),
|
|
59
|
+
files.describe_endpoint(),
|
|
60
|
+
idle_timeout,
|
|
61
|
+
)
|
|
62
|
+
engine_task = asyncio.create_task(engine.run(stop))
|
|
63
|
+
try:
|
|
64
|
+
while not stop.is_set():
|
|
65
|
+
with contextlib.suppress(TimeoutError):
|
|
66
|
+
await asyncio.wait_for(stop.wait(), 1.0)
|
|
67
|
+
if engine.idle_for(engine.clock()) >= idle_timeout:
|
|
68
|
+
log.info("no subscriptions for %.0f s, exiting", idle_timeout)
|
|
69
|
+
stop.set()
|
|
70
|
+
finally:
|
|
71
|
+
# Remove the endpoint first, so a client that connects from now on starts a new daemon.
|
|
72
|
+
cleanup()
|
|
73
|
+
server.close()
|
|
74
|
+
await server.wait_closed()
|
|
75
|
+
stop.set()
|
|
76
|
+
await engine_task
|
|
77
|
+
log.info("daemon stopped")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_daemon() -> int:
|
|
81
|
+
"""The daemon process: take the lock or exit, then serve until idle or stopped."""
|
|
82
|
+
files = DaemonFiles.default()
|
|
83
|
+
ensure_private_dir(files.state_dir)
|
|
84
|
+
logging.basicConfig(
|
|
85
|
+
level=logging.DEBUG if os.environ.get("GLAB_CLI_EXTRAS_DEBUG") else logging.INFO,
|
|
86
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
87
|
+
stream=sys.stderr,
|
|
88
|
+
)
|
|
89
|
+
lock = FileLock(files.lock)
|
|
90
|
+
if not lock.try_acquire():
|
|
91
|
+
log.info("another daemon holds %s, exiting", files.lock)
|
|
92
|
+
return 0
|
|
93
|
+
try:
|
|
94
|
+
engine = Engine(
|
|
95
|
+
cache_root=user_cache_dir(),
|
|
96
|
+
lease_timeout=_env_float(LEASE_TIMEOUT_ENV, DEFAULT_LEASE_TIMEOUT),
|
|
97
|
+
client_poll_ms=int(_env_float(CLIENT_POLL_MS_ENV, DEFAULT_CLIENT_POLL_MS)),
|
|
98
|
+
)
|
|
99
|
+
asyncio.run(serve(files, engine, _env_float(IDLE_TIMEOUT_ENV, DEFAULT_IDLE_TIMEOUT)))
|
|
100
|
+
finally:
|
|
101
|
+
lock.release()
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ping(files: DaemonFiles, timeout: float = 2.0) -> dict[str, Any] | None:
|
|
106
|
+
try:
|
|
107
|
+
response = request(files, {"op": "hello"}, timeout=timeout)
|
|
108
|
+
except (DaemonUnavailable, ValueError):
|
|
109
|
+
return None
|
|
110
|
+
return response if response.get("ok") else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _rotate_log(files: DaemonFiles) -> None:
|
|
114
|
+
with contextlib.suppress(OSError):
|
|
115
|
+
if files.log.stat().st_size > MAX_LOG_BYTES:
|
|
116
|
+
os.replace(files.log, files.log.with_suffix(".log.1"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def spawn_daemon(files: DaemonFiles) -> subprocess.Popen[bytes]:
|
|
120
|
+
"""Start a detached daemon process with its output in the log file."""
|
|
121
|
+
ensure_private_dir(files.state_dir)
|
|
122
|
+
_rotate_log(files)
|
|
123
|
+
with open(files.log, "ab") as log_file:
|
|
124
|
+
kwargs: dict[str, Any] = {
|
|
125
|
+
"stdin": subprocess.DEVNULL,
|
|
126
|
+
"stdout": log_file,
|
|
127
|
+
"stderr": log_file,
|
|
128
|
+
"cwd": str(files.state_dir),
|
|
129
|
+
"close_fds": True,
|
|
130
|
+
}
|
|
131
|
+
if sys.platform == "win32":
|
|
132
|
+
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
133
|
+
else:
|
|
134
|
+
kwargs["start_new_session"] = True
|
|
135
|
+
return subprocess.Popen([sys.executable, "-m", "glab_cli_extras.daemon"], **kwargs)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ensure_daemon(files: DaemonFiles, timeout: float = SPAWN_TIMEOUT) -> dict[str, Any]:
|
|
139
|
+
"""Return the hello response of a running daemon, starting one first if needed."""
|
|
140
|
+
hello = ping(files)
|
|
141
|
+
if hello is not None:
|
|
142
|
+
return hello
|
|
143
|
+
with FileLock(files.spawn_lock) as spawn_lock:
|
|
144
|
+
spawn_lock.acquire(timeout)
|
|
145
|
+
deadline = time.monotonic() + timeout
|
|
146
|
+
spawned: subprocess.Popen[bytes] | None = None
|
|
147
|
+
while time.monotonic() < deadline:
|
|
148
|
+
hello = ping(files, timeout=1.0)
|
|
149
|
+
if hello is not None:
|
|
150
|
+
return hello
|
|
151
|
+
# A daemon that is exiting on idle releases its lock last; spawn again once it is gone.
|
|
152
|
+
if (spawned is None or spawned.poll() is not None) and not daemon_running(files):
|
|
153
|
+
# Nothing holds the daemon lock, so no daemon is alive: any endpoint file left is stale.
|
|
154
|
+
for stale in (files.socket, files.endpoint):
|
|
155
|
+
with contextlib.suppress(OSError):
|
|
156
|
+
stale.unlink()
|
|
157
|
+
spawned = spawn_daemon(files)
|
|
158
|
+
time.sleep(0.05)
|
|
159
|
+
raise DaemonUnavailable(f"the daemon did not start within {timeout:.0f} s; see {files.log}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def daemon_running(files: DaemonFiles) -> bool:
|
|
163
|
+
"""Whether a daemon process holds the lock, answering or not."""
|
|
164
|
+
probe = FileLock(files.lock)
|
|
165
|
+
if probe.try_acquire():
|
|
166
|
+
probe.release()
|
|
167
|
+
return False
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def stop_daemon(files: DaemonFiles, timeout: float = 10.0) -> bool:
|
|
172
|
+
"""Ask the daemon to stop and wait until it released its lock. Return whether one was running."""
|
|
173
|
+
if not daemon_running(files):
|
|
174
|
+
return False
|
|
175
|
+
with contextlib.suppress(DaemonUnavailable, ValueError):
|
|
176
|
+
request(files, {"op": "stop"}, timeout=2.0)
|
|
177
|
+
deadline = time.monotonic() + timeout
|
|
178
|
+
while time.monotonic() < deadline:
|
|
179
|
+
if not daemon_running(files):
|
|
180
|
+
return True
|
|
181
|
+
time.sleep(0.05)
|
|
182
|
+
raise DaemonUnavailable(f"the daemon did not stop within {timeout:.0f} s; see {files.log}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def transport_name() -> str:
|
|
186
|
+
return "tcp" if use_tcp() else "unix"
|