fleet-framework 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.
- fleet/__init__.py +1 -0
- fleet/cli.py +290 -0
- fleet/core/__init__.py +69 -0
- fleet/core/automation.py +125 -0
- fleet/core/backend.py +736 -0
- fleet/core/config.py +38 -0
- fleet/core/context.py +102 -0
- fleet/core/contract.py +87 -0
- fleet/core/country_presets.py +50 -0
- fleet/core/events.py +55 -0
- fleet/core/logging.py +97 -0
- fleet/core/memory_backend.py +492 -0
- fleet/core/metrics.py +61 -0
- fleet/core/otel.py +97 -0
- fleet/core/primitives.py +310 -0
- fleet/core/protocol.py +171 -0
- fleet/core/proxy.py +166 -0
- fleet/core/reconcile.py +75 -0
- fleet/core/sqlite_backend.py +1117 -0
- fleet/core/store.py +104 -0
- fleet/master/__init__.py +3 -0
- fleet/master/api.py +324 -0
- fleet/master/app.py +105 -0
- fleet/master/auth.py +132 -0
- fleet/master/broadcaster.py +37 -0
- fleet/master/dashboard/__init__.py +4 -0
- fleet/master/dashboard/router.py +36 -0
- fleet/master/dashboard/static/style.css +97 -0
- fleet/master/dashboard/templates/index.html +372 -0
- fleet/master/metrics_route.py +141 -0
- fleet/master/ratelimit.py +55 -0
- fleet/master/ws_router.py +142 -0
- fleet/worker/__init__.py +3 -0
- fleet/worker/agent.py +173 -0
- fleet/worker/reconcile_loop.py +246 -0
- fleet/worker/slot_runner.py +256 -0
- fleet/worker/ws_client.py +164 -0
- fleet_browser/__init__.py +21 -0
- fleet_browser/browser.py +277 -0
- fleet_browser/cert.py +68 -0
- fleet_browser/fingerprint.py +327 -0
- fleet_browser/humanizer.py +157 -0
- fleet_browser/pool.py +241 -0
- fleet_browser/proxy_extension.py +122 -0
- fleet_browser/solver.py +51 -0
- fleet_browser/stealth.py +80 -0
- fleet_cloudflare/__init__.py +22 -0
- fleet_cloudflare/bypasser.py +168 -0
- fleet_cloudflare/harvest.py +266 -0
- fleet_cloudflare/replay.py +82 -0
- fleet_cloudflare/solver.py +28 -0
- fleet_content/__init__.py +24 -0
- fleet_content/automation.py +43 -0
- fleet_content/contracts.py +76 -0
- fleet_detect/__init__.py +26 -0
- fleet_detect/contracts.py +67 -0
- fleet_detect/detect.py +126 -0
- fleet_framework-0.1.0.dist-info/METADATA +160 -0
- fleet_framework-0.1.0.dist-info/RECORD +85 -0
- fleet_framework-0.1.0.dist-info/WHEEL +5 -0
- fleet_framework-0.1.0.dist-info/entry_points.txt +9 -0
- fleet_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
- fleet_framework-0.1.0.dist-info/top_level.txt +14 -0
- fleet_headers/__init__.py +28 -0
- fleet_headers/profiles.py +131 -0
- fleet_jobs/__init__.py +28 -0
- fleet_jobs/automation.py +34 -0
- fleet_jobs/contracts.py +143 -0
- fleet_marketplace/__init__.py +33 -0
- fleet_marketplace/automation.py +32 -0
- fleet_marketplace/contracts.py +151 -0
- fleet_news/__init__.py +21 -0
- fleet_news/automation.py +51 -0
- fleet_news/contracts.py +59 -0
- fleet_place/__init__.py +33 -0
- fleet_place/automation.py +37 -0
- fleet_place/contracts.py +156 -0
- fleet_provider_dataimpulse/__init__.py +82 -0
- fleet_provider_evomi/__init__.py +76 -0
- fleet_serp/__init__.py +30 -0
- fleet_serp/automation.py +47 -0
- fleet_serp/contracts.py +100 -0
- fleet_social/__init__.py +34 -0
- fleet_social/automation.py +44 -0
- fleet_social/contracts.py +172 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""In-process Backend implementation. Drop-in for tests + single-host dev.
|
|
2
|
+
|
|
3
|
+
No external dependencies. Same semantics as RedisBackend within a single
|
|
4
|
+
event loop. Not safe across processes or threads — use the Redis backend
|
|
5
|
+
for any deployment that has more than one process touching the same state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from fleet.core.backend import RawItem
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class _PoolEntry:
|
|
23
|
+
payload: bytes
|
|
24
|
+
tags: dict[str, Any]
|
|
25
|
+
expires_at: Optional[float]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InMemoryBackend:
|
|
29
|
+
def __init__(self, url: str = "memory://") -> None:
|
|
30
|
+
self._url = url
|
|
31
|
+
self._kv: dict[tuple[str, str], tuple[bytes, Optional[float]]] = {}
|
|
32
|
+
self._pools: dict[str, dict[str, _PoolEntry]] = defaultdict(dict)
|
|
33
|
+
self._claims: dict[tuple[str, str], float] = {}
|
|
34
|
+
self._locks: dict[str, tuple[str, float]] = {}
|
|
35
|
+
self._counters: dict[str, int] = defaultdict(int)
|
|
36
|
+
# Three priority tiers per queue: high, normal, low. Reservation
|
|
37
|
+
# drains them in that order.
|
|
38
|
+
self._queues: dict[tuple[str, str], asyncio.Queue[bytes]] = defaultdict(asyncio.Queue)
|
|
39
|
+
# task_id -> (queue_name, body, deadline). Keyed by (name, tid) elsewhere.
|
|
40
|
+
self._processing: dict[tuple[str, str], tuple[bytes, float]] = {}
|
|
41
|
+
# name -> list[(ready_at, body)] sorted by ready_at
|
|
42
|
+
self._scheduled: dict[str, list[tuple[float, bytes]]] = defaultdict(list)
|
|
43
|
+
self._dead: dict[str, list[bytes]] = defaultdict(list)
|
|
44
|
+
self._streams: dict[str, list[tuple[float, bytes]]] = defaultdict(list)
|
|
45
|
+
self._workers: dict[tuple[str, str], dict[str, Any]] = {}
|
|
46
|
+
self._catalog: dict[str, bytes] = {}
|
|
47
|
+
# list[(ts, frame_dict)] sorted by ts, capped at max_entries on push.
|
|
48
|
+
self._stats_history: dict[tuple[str, str], list[tuple[float, dict[str, Any]]]] = defaultdict(list)
|
|
49
|
+
|
|
50
|
+
async def aclose(self) -> None:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def _expired_kv(self, key: tuple[str, str]) -> bool:
|
|
54
|
+
rec = self._kv.get(key)
|
|
55
|
+
return rec is not None and rec[1] is not None and rec[1] < time.time()
|
|
56
|
+
|
|
57
|
+
async def kv_set(self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]) -> None:
|
|
58
|
+
exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
|
|
59
|
+
self._kv[(ns, key)] = (value, exp)
|
|
60
|
+
|
|
61
|
+
async def kv_setnx(
|
|
62
|
+
self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]
|
|
63
|
+
) -> bool:
|
|
64
|
+
k = (ns, key)
|
|
65
|
+
if self._expired_kv(k):
|
|
66
|
+
self._kv.pop(k, None)
|
|
67
|
+
if k in self._kv:
|
|
68
|
+
return False
|
|
69
|
+
exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
|
|
70
|
+
self._kv[k] = (value, exp)
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
async def kv_get(self, ns: str, key: str) -> Optional[bytes]:
|
|
74
|
+
k = (ns, key)
|
|
75
|
+
if self._expired_kv(k):
|
|
76
|
+
self._kv.pop(k, None)
|
|
77
|
+
return None
|
|
78
|
+
rec = self._kv.get(k)
|
|
79
|
+
return rec[0] if rec else None
|
|
80
|
+
|
|
81
|
+
async def kv_del(self, ns: str, key: str) -> bool:
|
|
82
|
+
return self._kv.pop((ns, key), None) is not None
|
|
83
|
+
|
|
84
|
+
def _prune_pool(self, name: str) -> None:
|
|
85
|
+
now = time.time()
|
|
86
|
+
bucket = self._pools[name]
|
|
87
|
+
to_del = [iid for iid, e in bucket.items() if e.expires_at is not None and e.expires_at < now]
|
|
88
|
+
for iid in to_del:
|
|
89
|
+
bucket.pop(iid, None)
|
|
90
|
+
for (n, iid) in list(self._claims):
|
|
91
|
+
if n == name and self._claims[(n, iid)] < now:
|
|
92
|
+
self._claims.pop((n, iid), None)
|
|
93
|
+
|
|
94
|
+
async def pool_put(
|
|
95
|
+
self,
|
|
96
|
+
name: str,
|
|
97
|
+
item_id: str,
|
|
98
|
+
payload: bytes,
|
|
99
|
+
tags: dict[str, Any],
|
|
100
|
+
ttl_seconds: Optional[int],
|
|
101
|
+
) -> None:
|
|
102
|
+
exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
|
|
103
|
+
self._pools[name][item_id] = _PoolEntry(payload=payload, tags=dict(tags), expires_at=exp)
|
|
104
|
+
self._prune_pool(name)
|
|
105
|
+
|
|
106
|
+
async def pool_list(self, name: str) -> list[RawItem]:
|
|
107
|
+
self._prune_pool(name)
|
|
108
|
+
now = time.time()
|
|
109
|
+
out: list[RawItem] = []
|
|
110
|
+
for iid, e in self._pools[name].items():
|
|
111
|
+
claim_exp = self._claims.get((name, iid))
|
|
112
|
+
in_use = claim_exp if (claim_exp is not None and claim_exp > now) else None
|
|
113
|
+
out.append(RawItem(
|
|
114
|
+
id=iid,
|
|
115
|
+
payload=e.payload,
|
|
116
|
+
tags=e.tags,
|
|
117
|
+
expires_at=e.expires_at,
|
|
118
|
+
in_use_until=in_use,
|
|
119
|
+
))
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
async def pool_remove(self, name: str, item_id: str) -> bool:
|
|
123
|
+
removed = self._pools[name].pop(item_id, None) is not None
|
|
124
|
+
self._claims.pop((name, item_id), None)
|
|
125
|
+
return removed
|
|
126
|
+
|
|
127
|
+
async def pool_release(self, name: str, item_id: str) -> bool:
|
|
128
|
+
return self._claims.pop((name, item_id), None) is not None
|
|
129
|
+
|
|
130
|
+
async def pool_claim_any(
|
|
131
|
+
self,
|
|
132
|
+
name: str,
|
|
133
|
+
where: dict[str, Any],
|
|
134
|
+
hold_seconds: int,
|
|
135
|
+
) -> Optional[RawItem]:
|
|
136
|
+
self._prune_pool(name)
|
|
137
|
+
now = time.time()
|
|
138
|
+
# newest items first, like RedisBackend
|
|
139
|
+
candidates = sorted(
|
|
140
|
+
self._pools[name].items(),
|
|
141
|
+
key=lambda kv: (kv[1].expires_at or 0),
|
|
142
|
+
reverse=True,
|
|
143
|
+
)
|
|
144
|
+
for iid, entry in candidates:
|
|
145
|
+
claim_exp = self._claims.get((name, iid))
|
|
146
|
+
if claim_exp is not None and claim_exp > now:
|
|
147
|
+
continue
|
|
148
|
+
if not all(entry.tags.get(k) == v for k, v in where.items()):
|
|
149
|
+
continue
|
|
150
|
+
self._claims[(name, iid)] = now + hold_seconds
|
|
151
|
+
return RawItem(
|
|
152
|
+
id=iid,
|
|
153
|
+
payload=entry.payload,
|
|
154
|
+
tags=entry.tags,
|
|
155
|
+
expires_at=entry.expires_at,
|
|
156
|
+
in_use_until=now + hold_seconds,
|
|
157
|
+
)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
async def lock_acquire(self, name: str, hold_seconds: int, wait_seconds: float) -> Optional[str]:
|
|
161
|
+
deadline = time.monotonic() + max(0.0, wait_seconds)
|
|
162
|
+
backoff = 0.05
|
|
163
|
+
while True:
|
|
164
|
+
now = time.time()
|
|
165
|
+
current = self._locks.get(name)
|
|
166
|
+
if current is None or current[1] < now:
|
|
167
|
+
token = secrets.token_hex(16)
|
|
168
|
+
self._locks[name] = (token, now + max(1, hold_seconds))
|
|
169
|
+
return token
|
|
170
|
+
if time.monotonic() >= deadline:
|
|
171
|
+
return None
|
|
172
|
+
await asyncio.sleep(min(backoff, deadline - time.monotonic()))
|
|
173
|
+
backoff = min(backoff * 2, 0.5)
|
|
174
|
+
|
|
175
|
+
async def lock_release(self, name: str, token: str) -> bool:
|
|
176
|
+
current = self._locks.get(name)
|
|
177
|
+
if current is None:
|
|
178
|
+
return False
|
|
179
|
+
if current[0] != token:
|
|
180
|
+
return False
|
|
181
|
+
self._locks.pop(name, None)
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
async def counter_incr(self, name: str, by: int) -> int:
|
|
185
|
+
self._counters[name] += by
|
|
186
|
+
return self._counters[name]
|
|
187
|
+
|
|
188
|
+
async def counter_get(self, name: str) -> int:
|
|
189
|
+
return self._counters.get(name, 0)
|
|
190
|
+
|
|
191
|
+
async def counter_set(self, name: str, value: int) -> None:
|
|
192
|
+
self._counters[name] = value
|
|
193
|
+
|
|
194
|
+
async def counter_del(self, name: str) -> None:
|
|
195
|
+
self._counters.pop(name, None)
|
|
196
|
+
|
|
197
|
+
def _tier(self, priority: int) -> str:
|
|
198
|
+
if priority > 0:
|
|
199
|
+
return "high"
|
|
200
|
+
if priority < 0:
|
|
201
|
+
return "low"
|
|
202
|
+
return "normal"
|
|
203
|
+
|
|
204
|
+
def _tiers_in_order(self) -> list[str]:
|
|
205
|
+
return ["high", "normal", "low"]
|
|
206
|
+
|
|
207
|
+
async def queue_push(self, name: str, body: bytes, *, priority: int = 0) -> None:
|
|
208
|
+
await self._queues[(name, self._tier(priority))].put(body)
|
|
209
|
+
|
|
210
|
+
async def queue_size(self, name: str) -> int:
|
|
211
|
+
return sum(
|
|
212
|
+
self._queues[(name, t)].qsize()
|
|
213
|
+
for t in self._tiers_in_order()
|
|
214
|
+
if (name, t) in self._queues
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def queue_peek(self, name: str, n: int) -> list[bytes]:
|
|
218
|
+
if n <= 0:
|
|
219
|
+
return []
|
|
220
|
+
out: list[bytes] = []
|
|
221
|
+
for t in self._tiers_in_order():
|
|
222
|
+
key = (name, t)
|
|
223
|
+
if key not in self._queues:
|
|
224
|
+
continue
|
|
225
|
+
q = self._queues[key]
|
|
226
|
+
for body in list(q._queue): # type: ignore[attr-defined]
|
|
227
|
+
if len(out) >= n:
|
|
228
|
+
return out
|
|
229
|
+
out.append(body)
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
async def pool_size(self, name: str) -> int:
|
|
233
|
+
return len(self._pools.get(name, {}))
|
|
234
|
+
|
|
235
|
+
def _move_due_scheduled(self, name: str) -> None:
|
|
236
|
+
now = time.time()
|
|
237
|
+
due = [b for (rt, b) in self._scheduled[name] if rt <= now]
|
|
238
|
+
if not due:
|
|
239
|
+
return
|
|
240
|
+
self._scheduled[name] = [(rt, b) for (rt, b) in self._scheduled[name] if rt > now]
|
|
241
|
+
# Re-pushed scheduled tasks go to the normal tier.
|
|
242
|
+
q = self._queues[(name, "normal")]
|
|
243
|
+
for body in due:
|
|
244
|
+
q.put_nowait(body)
|
|
245
|
+
|
|
246
|
+
async def queue_reserve(
|
|
247
|
+
self, name: str, timeout_seconds: float, visibility_seconds: int
|
|
248
|
+
) -> Optional[bytes]:
|
|
249
|
+
self._move_due_scheduled(name)
|
|
250
|
+
body: Optional[bytes] = None
|
|
251
|
+
# Try each tier non-blockingly, then poll if nothing immediately available.
|
|
252
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
253
|
+
while True:
|
|
254
|
+
for tier in self._tiers_in_order():
|
|
255
|
+
q = self._queues[(name, tier)]
|
|
256
|
+
if not q.empty():
|
|
257
|
+
body = q.get_nowait()
|
|
258
|
+
break
|
|
259
|
+
if body is not None or time.monotonic() >= deadline:
|
|
260
|
+
break
|
|
261
|
+
await asyncio.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
|
262
|
+
if body is None:
|
|
263
|
+
return None
|
|
264
|
+
try:
|
|
265
|
+
envelope = json.loads(body.decode("utf-8"))
|
|
266
|
+
tid = str(envelope.get("id", ""))
|
|
267
|
+
except Exception:
|
|
268
|
+
tid = ""
|
|
269
|
+
if tid:
|
|
270
|
+
d = time.time() + max(1, visibility_seconds)
|
|
271
|
+
self._processing[(name, tid)] = (body, d)
|
|
272
|
+
return body
|
|
273
|
+
|
|
274
|
+
async def queue_ack(self, name: str, task_id: str) -> bool:
|
|
275
|
+
return self._processing.pop((name, task_id), None) is not None
|
|
276
|
+
|
|
277
|
+
async def queue_nack(
|
|
278
|
+
self, name: str, task_id: str, body: bytes, *, delay_seconds: float
|
|
279
|
+
) -> bool:
|
|
280
|
+
had = self._processing.pop((name, task_id), None) is not None
|
|
281
|
+
if delay_seconds > 0:
|
|
282
|
+
self._scheduled[name].append((time.time() + delay_seconds, body))
|
|
283
|
+
self._scheduled[name].sort(key=lambda x: x[0])
|
|
284
|
+
else:
|
|
285
|
+
await self._queues[(name, "normal")].put(body)
|
|
286
|
+
return had
|
|
287
|
+
|
|
288
|
+
async def queue_sweep_expired(self, name: str, now: Optional[float] = None) -> int:
|
|
289
|
+
now = now if now is not None else time.time()
|
|
290
|
+
moved = 0
|
|
291
|
+
for key in list(self._processing.keys()):
|
|
292
|
+
if key[0] != name:
|
|
293
|
+
continue
|
|
294
|
+
body, deadline = self._processing[key]
|
|
295
|
+
if deadline <= now:
|
|
296
|
+
self._processing.pop(key, None)
|
|
297
|
+
await self._queues[(name, "normal")].put(body)
|
|
298
|
+
moved += 1
|
|
299
|
+
return moved
|
|
300
|
+
|
|
301
|
+
async def queue_dead(self, name: str, body: bytes) -> None:
|
|
302
|
+
self._dead[name].append(body)
|
|
303
|
+
|
|
304
|
+
async def dlq_peek(self, name: str, n: int) -> list[bytes]:
|
|
305
|
+
return list(self._dead.get(name, [])[:n])
|
|
306
|
+
|
|
307
|
+
async def dlq_pop(self, name: str, n: int) -> list[bytes]:
|
|
308
|
+
bucket = self._dead.get(name, [])
|
|
309
|
+
taken = bucket[:n]
|
|
310
|
+
self._dead[name] = bucket[n:]
|
|
311
|
+
return taken
|
|
312
|
+
|
|
313
|
+
async def dlq_length(self, name: str) -> int:
|
|
314
|
+
return len(self._dead.get(name, []))
|
|
315
|
+
|
|
316
|
+
async def dlq_clear(self, name: str) -> int:
|
|
317
|
+
n = len(self._dead.get(name, []))
|
|
318
|
+
self._dead[name] = []
|
|
319
|
+
return n
|
|
320
|
+
|
|
321
|
+
async def stream_push(
|
|
322
|
+
self,
|
|
323
|
+
name: str,
|
|
324
|
+
envelope: bytes,
|
|
325
|
+
*,
|
|
326
|
+
score: float,
|
|
327
|
+
max_len: int,
|
|
328
|
+
ttl_seconds: int,
|
|
329
|
+
) -> None:
|
|
330
|
+
bucket = self._streams[name]
|
|
331
|
+
bucket.append((score, envelope))
|
|
332
|
+
bucket.sort(key=lambda x: x[0])
|
|
333
|
+
if ttl_seconds > 0:
|
|
334
|
+
cutoff = score - ttl_seconds
|
|
335
|
+
self._streams[name] = [(s, m) for (s, m) in bucket if s >= cutoff]
|
|
336
|
+
if max_len > 0 and len(self._streams[name]) > max_len:
|
|
337
|
+
self._streams[name] = self._streams[name][-max_len:]
|
|
338
|
+
|
|
339
|
+
async def stream_pop(self, name: str, n: int) -> list[bytes]:
|
|
340
|
+
bucket = self._streams[name]
|
|
341
|
+
if n <= 0:
|
|
342
|
+
return []
|
|
343
|
+
taken = bucket[:n]
|
|
344
|
+
self._streams[name] = bucket[n:]
|
|
345
|
+
return [m for _s, m in taken]
|
|
346
|
+
|
|
347
|
+
async def stream_peek(self, name: str, n: int) -> list[bytes]:
|
|
348
|
+
return [m for _s, m in self._streams[name][:n]]
|
|
349
|
+
|
|
350
|
+
async def stream_length(self, name: str) -> int:
|
|
351
|
+
return len(self._streams.get(name, []))
|
|
352
|
+
|
|
353
|
+
async def stream_trim(self, name: str, *, max_len: int, ttl_seconds: int) -> None:
|
|
354
|
+
if name not in self._streams:
|
|
355
|
+
return
|
|
356
|
+
bucket = self._streams[name]
|
|
357
|
+
if ttl_seconds > 0:
|
|
358
|
+
cutoff = time.time() - ttl_seconds
|
|
359
|
+
bucket = [(s, m) for (s, m) in bucket if s >= cutoff]
|
|
360
|
+
if max_len > 0 and len(bucket) > max_len:
|
|
361
|
+
bucket = bucket[-max_len:]
|
|
362
|
+
self._streams[name] = bucket
|
|
363
|
+
|
|
364
|
+
async def event_publish(self, topic: str, body: bytes) -> None:
|
|
365
|
+
# No subscribers in-memory. Plugins that use ctx.events.subscribe must
|
|
366
|
+
# use a real broker; the memory backend is for tests + single-process.
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
async def worker_register(
|
|
370
|
+
self, automation_type: str, worker_id: str, hardware: dict[str, Any]
|
|
371
|
+
) -> None:
|
|
372
|
+
key = (automation_type, worker_id)
|
|
373
|
+
existing = self._workers.get(key, {})
|
|
374
|
+
existing["hardware"] = dict(hardware)
|
|
375
|
+
existing.setdefault("config", {})
|
|
376
|
+
existing.setdefault("config_gen", 0)
|
|
377
|
+
existing.setdefault("state", "IDLE")
|
|
378
|
+
existing.setdefault("last_seen", 0.0)
|
|
379
|
+
existing.setdefault("last_error", None)
|
|
380
|
+
existing.setdefault("stats", {})
|
|
381
|
+
self._workers[key] = existing
|
|
382
|
+
|
|
383
|
+
async def worker_get_config(
|
|
384
|
+
self, automation_type: str, worker_id: str
|
|
385
|
+
) -> tuple[dict[str, Any], int]:
|
|
386
|
+
rec = self._workers.get((automation_type, worker_id))
|
|
387
|
+
if rec is None:
|
|
388
|
+
return {}, 0
|
|
389
|
+
return dict(rec["config"]), rec["config_gen"]
|
|
390
|
+
|
|
391
|
+
async def worker_set_config(
|
|
392
|
+
self, automation_type: str, worker_id: str, config: dict[str, Any]
|
|
393
|
+
) -> tuple[dict[str, Any], int]:
|
|
394
|
+
key = (automation_type, worker_id)
|
|
395
|
+
rec = self._workers.setdefault(key, {
|
|
396
|
+
"hardware": {}, "config": {}, "config_gen": 0,
|
|
397
|
+
"state": "IDLE", "last_seen": 0.0, "last_error": None, "stats": {},
|
|
398
|
+
})
|
|
399
|
+
rec["config"] = dict(config)
|
|
400
|
+
rec["config_gen"] = int(rec.get("config_gen", 0)) + 1
|
|
401
|
+
return rec["config"], rec["config_gen"]
|
|
402
|
+
|
|
403
|
+
async def worker_set_state(
|
|
404
|
+
self,
|
|
405
|
+
automation_type: str,
|
|
406
|
+
worker_id: str,
|
|
407
|
+
state: str,
|
|
408
|
+
last_error: Optional[str],
|
|
409
|
+
last_seen: float,
|
|
410
|
+
) -> None:
|
|
411
|
+
key = (automation_type, worker_id)
|
|
412
|
+
rec = self._workers.setdefault(key, {
|
|
413
|
+
"hardware": {}, "config": {}, "config_gen": 0,
|
|
414
|
+
"state": "IDLE", "last_seen": 0.0, "last_error": None, "stats": {},
|
|
415
|
+
})
|
|
416
|
+
rec["state"] = state
|
|
417
|
+
rec["last_seen"] = last_seen
|
|
418
|
+
rec["last_error"] = last_error
|
|
419
|
+
|
|
420
|
+
async def worker_set_stats(
|
|
421
|
+
self, automation_type: str, worker_id: str, stats: dict[str, Any]
|
|
422
|
+
) -> None:
|
|
423
|
+
key = (automation_type, worker_id)
|
|
424
|
+
rec = self._workers.setdefault(key, {
|
|
425
|
+
"hardware": {}, "config": {}, "config_gen": 0,
|
|
426
|
+
"state": "IDLE", "last_seen": 0.0, "last_error": None, "stats": {},
|
|
427
|
+
})
|
|
428
|
+
rec["stats"] = dict(stats)
|
|
429
|
+
|
|
430
|
+
async def worker_stats_push(
|
|
431
|
+
self,
|
|
432
|
+
automation_type: str,
|
|
433
|
+
worker_id: str,
|
|
434
|
+
ts: float,
|
|
435
|
+
frame: dict[str, Any],
|
|
436
|
+
*,
|
|
437
|
+
max_entries: int = 240,
|
|
438
|
+
) -> None:
|
|
439
|
+
bucket = self._stats_history[(automation_type, worker_id)]
|
|
440
|
+
bucket.append((ts, dict(frame)))
|
|
441
|
+
bucket.sort(key=lambda x: x[0])
|
|
442
|
+
if max_entries > 0 and len(bucket) > max_entries:
|
|
443
|
+
del bucket[: len(bucket) - max_entries]
|
|
444
|
+
|
|
445
|
+
async def worker_stats_history(
|
|
446
|
+
self,
|
|
447
|
+
automation_type: str,
|
|
448
|
+
worker_id: str,
|
|
449
|
+
*,
|
|
450
|
+
since: float = 0,
|
|
451
|
+
limit: int = 240,
|
|
452
|
+
) -> list[dict[str, Any]]:
|
|
453
|
+
bucket = self._stats_history.get((automation_type, worker_id), [])
|
|
454
|
+
return [dict(f) for (t, f) in bucket if t >= since][:limit]
|
|
455
|
+
|
|
456
|
+
async def worker_get(
|
|
457
|
+
self, automation_type: str, worker_id: str
|
|
458
|
+
) -> Optional[dict[str, Any]]:
|
|
459
|
+
rec = self._workers.get((automation_type, worker_id))
|
|
460
|
+
if rec is None:
|
|
461
|
+
return None
|
|
462
|
+
return {
|
|
463
|
+
"hardware": dict(rec["hardware"]),
|
|
464
|
+
"config": dict(rec["config"]),
|
|
465
|
+
"config_gen": rec["config_gen"],
|
|
466
|
+
"state": rec["state"],
|
|
467
|
+
"last_seen": rec["last_seen"],
|
|
468
|
+
"last_error": rec["last_error"],
|
|
469
|
+
"stats": dict(rec["stats"]),
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async def worker_list(
|
|
473
|
+
self, automation_type: Optional[str] = None
|
|
474
|
+
) -> list[tuple[str, str]]:
|
|
475
|
+
if automation_type is None:
|
|
476
|
+
return list(self._workers.keys())
|
|
477
|
+
return [(at, wid) for (at, wid) in self._workers if at == automation_type]
|
|
478
|
+
|
|
479
|
+
async def worker_remove(self, automation_type: str, worker_id: str) -> bool:
|
|
480
|
+
return self._workers.pop((automation_type, worker_id), None) is not None
|
|
481
|
+
|
|
482
|
+
async def catalog_set(self, automation_type: str, doc: bytes) -> None:
|
|
483
|
+
self._catalog[automation_type] = doc
|
|
484
|
+
|
|
485
|
+
async def catalog_get(self, automation_type: str) -> Optional[bytes]:
|
|
486
|
+
return self._catalog.get(automation_type)
|
|
487
|
+
|
|
488
|
+
async def catalog_all(self) -> dict[str, bytes]:
|
|
489
|
+
return dict(self._catalog)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
__all__ = ["InMemoryBackend"]
|
fleet/core/metrics.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from threading import Lock
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class _Counter:
|
|
12
|
+
n: int = 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class _Gauge:
|
|
17
|
+
v: float = 0.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class _Histogram:
|
|
22
|
+
samples: deque = field(default_factory=lambda: deque(maxlen=512))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SlotMetrics:
|
|
26
|
+
# per-slot counters. dumped via WS Stats frames and surfaced on the dashboard.
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._lock = Lock()
|
|
29
|
+
self._counters: dict[str, _Counter] = defaultdict(_Counter)
|
|
30
|
+
self._gauges: dict[str, _Gauge] = defaultdict(_Gauge)
|
|
31
|
+
self._hists: dict[str, _Histogram] = defaultdict(_Histogram)
|
|
32
|
+
self._created_at = time.monotonic()
|
|
33
|
+
|
|
34
|
+
def incr(self, name: str, by: int = 1) -> None:
|
|
35
|
+
with self._lock:
|
|
36
|
+
self._counters[name].n += by
|
|
37
|
+
|
|
38
|
+
def set(self, name: str, value: float) -> None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
self._gauges[name].v = value
|
|
41
|
+
|
|
42
|
+
def observe(self, name: str, value: float) -> None:
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._hists[name].samples.append(value)
|
|
45
|
+
|
|
46
|
+
def snapshot(self) -> dict[str, Any]:
|
|
47
|
+
with self._lock:
|
|
48
|
+
counters = {k: v.n for k, v in self._counters.items()}
|
|
49
|
+
gauges = {k: v.v for k, v in self._gauges.items()}
|
|
50
|
+
hists = {}
|
|
51
|
+
for k, h in self._hists.items():
|
|
52
|
+
if not h.samples:
|
|
53
|
+
continue
|
|
54
|
+
s = sorted(h.samples)
|
|
55
|
+
hists[k] = {
|
|
56
|
+
"n": len(s),
|
|
57
|
+
"p50": s[len(s) // 2],
|
|
58
|
+
"p95": s[int(len(s) * 0.95)],
|
|
59
|
+
"p99": s[int(len(s) * 0.99)],
|
|
60
|
+
}
|
|
61
|
+
return {"counters": counters, "gauges": gauges, "histograms": hists}
|
fleet/core/otel.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Optional OpenTelemetry integration.
|
|
2
|
+
|
|
3
|
+
Importing this module never fails: if `opentelemetry-api` is not installed
|
|
4
|
+
or `OTEL_EXPORTER_OTLP_ENDPOINT` (or `FLEET_OTEL_ENABLED`) is not set, the
|
|
5
|
+
tracer falls back to no-op context managers so wrapping a hot path costs
|
|
6
|
+
roughly one Python function call.
|
|
7
|
+
|
|
8
|
+
Activation:
|
|
9
|
+
|
|
10
|
+
pip install fleet-core[otel]
|
|
11
|
+
export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
|
|
12
|
+
export OTEL_SERVICE_NAME=fleet-worker
|
|
13
|
+
|
|
14
|
+
Then spans appear in your collector for `slot.run_continuous`,
|
|
15
|
+
`slot.run_one`, `slot.reserve`, and `api.submit_task`. All spans set
|
|
16
|
+
`fleet.automation_type`, `fleet.worker_id`, `fleet.slot_id`, and (where
|
|
17
|
+
relevant) `fleet.task_id` attributes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from contextlib import contextmanager, nullcontext
|
|
25
|
+
from typing import Any, Iterator
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_tracer: Any = None
|
|
30
|
+
_disabled = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_tracer() -> Any:
|
|
34
|
+
global _tracer, _disabled
|
|
35
|
+
if _disabled:
|
|
36
|
+
return None
|
|
37
|
+
if _tracer is not None:
|
|
38
|
+
return _tracer
|
|
39
|
+
if not _otel_requested():
|
|
40
|
+
_disabled = True
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
from opentelemetry import trace
|
|
44
|
+
except ImportError:
|
|
45
|
+
logger.info("opentelemetry-api not installed; OTel hooks disabled")
|
|
46
|
+
_disabled = True
|
|
47
|
+
return None
|
|
48
|
+
_tracer = trace.get_tracer("fleet-core")
|
|
49
|
+
return _tracer
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _otel_requested() -> bool:
|
|
53
|
+
if os.environ.get("FLEET_OTEL_ENABLED", "").lower() in ("1", "true", "yes"):
|
|
54
|
+
return True
|
|
55
|
+
return any(k.startswith("OTEL_EXPORTER_") for k in os.environ)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def span(name: str, **attrs: Any) -> Iterator[Any]:
|
|
60
|
+
"""Start an OTel span if OTel is configured, otherwise a no-op.
|
|
61
|
+
|
|
62
|
+
Attributes are passed in as kwargs and are added to the span. Any
|
|
63
|
+
None-valued attributes are skipped (OTel rejects them)."""
|
|
64
|
+
tracer = _build_tracer()
|
|
65
|
+
if tracer is None:
|
|
66
|
+
with nullcontext() as ctx:
|
|
67
|
+
yield ctx
|
|
68
|
+
return
|
|
69
|
+
cleaned = {f"fleet.{k}": v for k, v in attrs.items() if v is not None}
|
|
70
|
+
with tracer.start_as_current_span(name, attributes=cleaned) as s:
|
|
71
|
+
yield s
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def record_exception(exc: BaseException) -> None:
|
|
75
|
+
"""Attach an exception to the active span if one exists."""
|
|
76
|
+
tracer = _build_tracer()
|
|
77
|
+
if tracer is None:
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
from opentelemetry import trace
|
|
81
|
+
current = trace.get_current_span()
|
|
82
|
+
if current is not None:
|
|
83
|
+
current.record_exception(exc)
|
|
84
|
+
except Exception:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def reset_for_tests() -> None:
|
|
89
|
+
"""Test helper. Clears cached tracer so monkeypatched env reloads."""
|
|
90
|
+
global _tracer, _disabled
|
|
91
|
+
_tracer = None
|
|
92
|
+
_disabled = False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_active() -> bool:
|
|
96
|
+
"""True if OTel is wired and ready to emit spans."""
|
|
97
|
+
return _build_tracer() is not None
|