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
fleet/core/backend.py
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Optional, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
import redis.asyncio as aioredis
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RawItem:
|
|
15
|
+
"""Raw backend record. Plugins never see this — primitives wrap it."""
|
|
16
|
+
id: str
|
|
17
|
+
payload: bytes
|
|
18
|
+
tags: dict[str, Any]
|
|
19
|
+
expires_at: Optional[float]
|
|
20
|
+
in_use_until: Optional[float]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class Backend(Protocol):
|
|
25
|
+
async def kv_set(self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]) -> None: ...
|
|
26
|
+
async def kv_setnx(
|
|
27
|
+
self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]
|
|
28
|
+
) -> bool: ...
|
|
29
|
+
async def kv_get(self, ns: str, key: str) -> Optional[bytes]: ...
|
|
30
|
+
async def kv_del(self, ns: str, key: str) -> bool: ...
|
|
31
|
+
|
|
32
|
+
async def pool_put(
|
|
33
|
+
self,
|
|
34
|
+
name: str,
|
|
35
|
+
item_id: str,
|
|
36
|
+
payload: bytes,
|
|
37
|
+
tags: dict[str, Any],
|
|
38
|
+
ttl_seconds: Optional[int],
|
|
39
|
+
) -> None: ...
|
|
40
|
+
|
|
41
|
+
async def pool_list(self, name: str) -> list[RawItem]: ...
|
|
42
|
+
async def pool_remove(self, name: str, item_id: str) -> bool: ...
|
|
43
|
+
async def pool_release(self, name: str, item_id: str) -> bool: ...
|
|
44
|
+
|
|
45
|
+
async def pool_claim_any(
|
|
46
|
+
self,
|
|
47
|
+
name: str,
|
|
48
|
+
where: dict[str, Any],
|
|
49
|
+
hold_seconds: int,
|
|
50
|
+
) -> Optional[RawItem]: ...
|
|
51
|
+
|
|
52
|
+
async def lock_acquire(self, name: str, hold_seconds: int, wait_seconds: float) -> Optional[str]: ...
|
|
53
|
+
async def lock_release(self, name: str, token: str) -> bool: ...
|
|
54
|
+
|
|
55
|
+
async def counter_incr(self, name: str, by: int) -> int: ...
|
|
56
|
+
async def counter_get(self, name: str) -> int: ...
|
|
57
|
+
async def counter_set(self, name: str, value: int) -> None: ...
|
|
58
|
+
async def counter_del(self, name: str) -> None: ...
|
|
59
|
+
|
|
60
|
+
async def queue_push(self, name: str, body: bytes, *, priority: int = 0) -> None: ...
|
|
61
|
+
async def queue_size(self, name: str) -> int: ...
|
|
62
|
+
async def queue_peek(self, name: str, n: int) -> list[bytes]: ...
|
|
63
|
+
async def pool_size(self, name: str) -> int: ...
|
|
64
|
+
async def queue_reserve(
|
|
65
|
+
self, name: str, timeout_seconds: float, visibility_seconds: int
|
|
66
|
+
) -> Optional[bytes]: ...
|
|
67
|
+
async def queue_ack(self, name: str, task_id: str) -> bool: ...
|
|
68
|
+
async def queue_nack(
|
|
69
|
+
self, name: str, task_id: str, body: bytes, *, delay_seconds: float
|
|
70
|
+
) -> bool: ...
|
|
71
|
+
async def queue_sweep_expired(self, name: str, now: Optional[float] = None) -> int: ...
|
|
72
|
+
async def queue_dead(self, name: str, body: bytes) -> None: ...
|
|
73
|
+
async def dlq_peek(self, name: str, n: int) -> list[bytes]: ...
|
|
74
|
+
async def dlq_pop(self, name: str, n: int) -> list[bytes]: ...
|
|
75
|
+
async def dlq_length(self, name: str) -> int: ...
|
|
76
|
+
async def dlq_clear(self, name: str) -> int: ...
|
|
77
|
+
|
|
78
|
+
async def stream_push(
|
|
79
|
+
self,
|
|
80
|
+
name: str,
|
|
81
|
+
envelope: bytes,
|
|
82
|
+
*,
|
|
83
|
+
score: float,
|
|
84
|
+
max_len: int,
|
|
85
|
+
ttl_seconds: int,
|
|
86
|
+
) -> None: ...
|
|
87
|
+
async def stream_pop(self, name: str, n: int) -> list[bytes]: ...
|
|
88
|
+
async def stream_peek(self, name: str, n: int) -> list[bytes]: ...
|
|
89
|
+
async def stream_length(self, name: str) -> int: ...
|
|
90
|
+
async def stream_trim(self, name: str, *, max_len: int, ttl_seconds: int) -> None: ...
|
|
91
|
+
|
|
92
|
+
async def event_publish(self, topic: str, body: bytes) -> None: ...
|
|
93
|
+
|
|
94
|
+
async def worker_register(
|
|
95
|
+
self, automation_type: str, worker_id: str, hardware: dict[str, Any]
|
|
96
|
+
) -> None: ...
|
|
97
|
+
async def worker_get_config(
|
|
98
|
+
self, automation_type: str, worker_id: str
|
|
99
|
+
) -> tuple[dict[str, Any], int]: ...
|
|
100
|
+
async def worker_set_config(
|
|
101
|
+
self, automation_type: str, worker_id: str, config: dict[str, Any]
|
|
102
|
+
) -> tuple[dict[str, Any], int]: ...
|
|
103
|
+
async def worker_set_state(
|
|
104
|
+
self,
|
|
105
|
+
automation_type: str,
|
|
106
|
+
worker_id: str,
|
|
107
|
+
state: str,
|
|
108
|
+
last_error: Optional[str],
|
|
109
|
+
last_seen: float,
|
|
110
|
+
) -> None: ...
|
|
111
|
+
async def worker_set_stats(
|
|
112
|
+
self, automation_type: str, worker_id: str, stats: dict[str, Any]
|
|
113
|
+
) -> None: ...
|
|
114
|
+
async def worker_stats_push(
|
|
115
|
+
self,
|
|
116
|
+
automation_type: str,
|
|
117
|
+
worker_id: str,
|
|
118
|
+
ts: float,
|
|
119
|
+
frame: dict[str, Any],
|
|
120
|
+
*,
|
|
121
|
+
max_entries: int = 240,
|
|
122
|
+
) -> None: ...
|
|
123
|
+
async def worker_stats_history(
|
|
124
|
+
self,
|
|
125
|
+
automation_type: str,
|
|
126
|
+
worker_id: str,
|
|
127
|
+
*,
|
|
128
|
+
since: float = 0,
|
|
129
|
+
limit: int = 240,
|
|
130
|
+
) -> list[dict[str, Any]]: ...
|
|
131
|
+
async def worker_get(
|
|
132
|
+
self, automation_type: str, worker_id: str
|
|
133
|
+
) -> Optional[dict[str, Any]]: ...
|
|
134
|
+
async def worker_list(
|
|
135
|
+
self, automation_type: Optional[str] = None
|
|
136
|
+
) -> list[tuple[str, str]]: ...
|
|
137
|
+
async def worker_remove(self, automation_type: str, worker_id: str) -> bool: ...
|
|
138
|
+
|
|
139
|
+
async def catalog_set(self, automation_type: str, doc: bytes) -> None: ...
|
|
140
|
+
async def catalog_get(self, automation_type: str) -> Optional[bytes]: ...
|
|
141
|
+
async def catalog_all(self) -> dict[str, bytes]: ...
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class RedisBackend:
|
|
145
|
+
KV_KEY = "fleet:kv:{ns}:{key}"
|
|
146
|
+
POOL_HASH = "fleet:pool:{name}"
|
|
147
|
+
POOL_CLAIM = "fleet:pool:{name}:claim:{id}"
|
|
148
|
+
LOCK_KEY = "fleet:lock:{name}"
|
|
149
|
+
COUNTER_KEY = "fleet:counter:{name}"
|
|
150
|
+
QUEUE_KEY = "fleet:queue:{name}"
|
|
151
|
+
QUEUE_HIGH = "fleet:queue:{name}:high"
|
|
152
|
+
QUEUE_LOW = "fleet:queue:{name}:low"
|
|
153
|
+
QUEUE_PROCESSING = "fleet:queue:{name}:processing" # ZSET task_id -> deadline
|
|
154
|
+
QUEUE_PROCESSING_BODY = "fleet:queue:{name}:processing:body" # HASH task_id -> body
|
|
155
|
+
QUEUE_SCHEDULED = "fleet:queue:{name}:scheduled" # ZSET body -> ready_at
|
|
156
|
+
QUEUE_DEAD = "fleet:queue:{name}:dead"
|
|
157
|
+
STREAM_KEY = "stream:{name}" # unprefixed: docs + tests refer to this layout
|
|
158
|
+
EVENT_CHANNEL = "event:{topic}"
|
|
159
|
+
CATALOG_HASH = "fleet:catalog"
|
|
160
|
+
WORKER_FIELD = "wkr:{at}:{wid}:{field}"
|
|
161
|
+
WORKER_TYPE_INDEX = "wkrs:{at}"
|
|
162
|
+
WORKER_ALL_INDEX = "wkrs:all"
|
|
163
|
+
|
|
164
|
+
def __init__(self, url: str = "redis://localhost:6379/0") -> None:
|
|
165
|
+
self._r: aioredis.Redis = aioredis.from_url(url, decode_responses=False)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def r(self) -> aioredis.Redis:
|
|
169
|
+
return self._r
|
|
170
|
+
|
|
171
|
+
async def aclose(self) -> None:
|
|
172
|
+
try:
|
|
173
|
+
await self._r.aclose()
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
async def kv_set(self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]) -> None:
|
|
178
|
+
k = self.KV_KEY.format(ns=ns, key=key)
|
|
179
|
+
if ttl_seconds is not None and ttl_seconds > 0:
|
|
180
|
+
await self._r.set(k, value, ex=ttl_seconds)
|
|
181
|
+
else:
|
|
182
|
+
await self._r.set(k, value)
|
|
183
|
+
|
|
184
|
+
async def kv_setnx(
|
|
185
|
+
self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]
|
|
186
|
+
) -> bool:
|
|
187
|
+
k = self.KV_KEY.format(ns=ns, key=key)
|
|
188
|
+
if ttl_seconds is not None and ttl_seconds > 0:
|
|
189
|
+
res = await self._r.set(k, value, ex=ttl_seconds, nx=True)
|
|
190
|
+
else:
|
|
191
|
+
res = await self._r.set(k, value, nx=True)
|
|
192
|
+
return bool(res)
|
|
193
|
+
|
|
194
|
+
async def kv_get(self, ns: str, key: str) -> Optional[bytes]:
|
|
195
|
+
return await self._r.get(self.KV_KEY.format(ns=ns, key=key))
|
|
196
|
+
|
|
197
|
+
async def kv_del(self, ns: str, key: str) -> bool:
|
|
198
|
+
return bool(await self._r.delete(self.KV_KEY.format(ns=ns, key=key)))
|
|
199
|
+
|
|
200
|
+
# Pool = hash {item_id -> json blob}. Claims are separate keys with TTL
|
|
201
|
+
# so a crashed slot's claim auto-expires.
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _encode_item(payload: bytes, tags: dict[str, Any], expires_at: Optional[float]) -> bytes:
|
|
205
|
+
from base64 import b64encode
|
|
206
|
+
return json.dumps({
|
|
207
|
+
"p": b64encode(payload).decode("ascii"),
|
|
208
|
+
"t": tags,
|
|
209
|
+
"x": expires_at,
|
|
210
|
+
}).encode("utf-8")
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _decode_item(raw: bytes) -> tuple[bytes, dict[str, Any], Optional[float]]:
|
|
214
|
+
from base64 import b64decode
|
|
215
|
+
d = json.loads(raw.decode("utf-8"))
|
|
216
|
+
return b64decode(d["p"]), d["t"], d.get("x")
|
|
217
|
+
|
|
218
|
+
async def pool_put(
|
|
219
|
+
self,
|
|
220
|
+
name: str,
|
|
221
|
+
item_id: str,
|
|
222
|
+
payload: bytes,
|
|
223
|
+
tags: dict[str, Any],
|
|
224
|
+
ttl_seconds: Optional[int],
|
|
225
|
+
) -> None:
|
|
226
|
+
expires_at = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
|
|
227
|
+
encoded = self._encode_item(payload, tags, expires_at)
|
|
228
|
+
await self._r.hset(self.POOL_HASH.format(name=name), item_id, encoded)
|
|
229
|
+
# Per-item expiry is enforced on read; opportunistic prune on every put.
|
|
230
|
+
await self._prune_pool(name)
|
|
231
|
+
|
|
232
|
+
async def _prune_pool(self, name: str) -> None:
|
|
233
|
+
key = self.POOL_HASH.format(name=name)
|
|
234
|
+
all_items = await self._r.hgetall(key)
|
|
235
|
+
now = time.time()
|
|
236
|
+
to_del: list[bytes] = []
|
|
237
|
+
for item_id, raw in all_items.items():
|
|
238
|
+
try:
|
|
239
|
+
_, _, expires_at = self._decode_item(raw)
|
|
240
|
+
except Exception:
|
|
241
|
+
to_del.append(item_id)
|
|
242
|
+
continue
|
|
243
|
+
if expires_at is not None and expires_at < now:
|
|
244
|
+
to_del.append(item_id)
|
|
245
|
+
if to_del:
|
|
246
|
+
await self._r.hdel(key, *to_del)
|
|
247
|
+
|
|
248
|
+
async def pool_list(self, name: str) -> list[RawItem]:
|
|
249
|
+
key = self.POOL_HASH.format(name=name)
|
|
250
|
+
raw_items = await self._r.hgetall(key)
|
|
251
|
+
now = time.time()
|
|
252
|
+
out: list[RawItem] = []
|
|
253
|
+
for item_id_b, raw in raw_items.items():
|
|
254
|
+
try:
|
|
255
|
+
payload, tags, expires_at = self._decode_item(raw)
|
|
256
|
+
except Exception:
|
|
257
|
+
continue
|
|
258
|
+
if expires_at is not None and expires_at < now:
|
|
259
|
+
continue
|
|
260
|
+
item_id = item_id_b.decode("utf-8") if isinstance(item_id_b, bytes) else item_id_b
|
|
261
|
+
claim_key = self.POOL_CLAIM.format(name=name, id=item_id)
|
|
262
|
+
ttl = await self._r.pttl(claim_key)
|
|
263
|
+
in_use_until = (now + ttl / 1000.0) if ttl > 0 else None
|
|
264
|
+
out.append(RawItem(
|
|
265
|
+
id=item_id,
|
|
266
|
+
payload=payload,
|
|
267
|
+
tags=tags,
|
|
268
|
+
expires_at=expires_at,
|
|
269
|
+
in_use_until=in_use_until,
|
|
270
|
+
))
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
async def pool_remove(self, name: str, item_id: str) -> bool:
|
|
274
|
+
deleted = await self._r.hdel(self.POOL_HASH.format(name=name), item_id)
|
|
275
|
+
await self._r.delete(self.POOL_CLAIM.format(name=name, id=item_id))
|
|
276
|
+
return bool(deleted)
|
|
277
|
+
|
|
278
|
+
async def pool_release(self, name: str, item_id: str) -> bool:
|
|
279
|
+
return bool(await self._r.delete(self.POOL_CLAIM.format(name=name, id=item_id)))
|
|
280
|
+
|
|
281
|
+
async def pool_claim_any(
|
|
282
|
+
self,
|
|
283
|
+
name: str,
|
|
284
|
+
where: dict[str, Any],
|
|
285
|
+
hold_seconds: int,
|
|
286
|
+
) -> Optional[RawItem]:
|
|
287
|
+
# Atomic claim is SET NX EX per-item; on race we move on.
|
|
288
|
+
items = await self.pool_list(name)
|
|
289
|
+
items.sort(key=lambda x: (x.expires_at or 0), reverse=True)
|
|
290
|
+
for item in items:
|
|
291
|
+
if item.in_use_until is not None:
|
|
292
|
+
continue
|
|
293
|
+
if not all(item.tags.get(k) == v for k, v in where.items()):
|
|
294
|
+
continue
|
|
295
|
+
claim_key = self.POOL_CLAIM.format(name=name, id=item.id)
|
|
296
|
+
ok = await self._r.set(claim_key, "1", nx=True, ex=hold_seconds)
|
|
297
|
+
if ok:
|
|
298
|
+
return item
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
async def lock_acquire(self, name: str, hold_seconds: int, wait_seconds: float) -> Optional[str]:
|
|
302
|
+
key = self.LOCK_KEY.format(name=name)
|
|
303
|
+
token = secrets.token_hex(16)
|
|
304
|
+
deadline = time.monotonic() + max(0.0, wait_seconds)
|
|
305
|
+
backoff = 0.05
|
|
306
|
+
while True:
|
|
307
|
+
ok = await self._r.set(key, token, nx=True, ex=max(1, hold_seconds))
|
|
308
|
+
if ok:
|
|
309
|
+
return token
|
|
310
|
+
if time.monotonic() >= deadline:
|
|
311
|
+
return None
|
|
312
|
+
import asyncio
|
|
313
|
+
await asyncio.sleep(min(backoff, deadline - time.monotonic()))
|
|
314
|
+
backoff = min(backoff * 2, 0.5)
|
|
315
|
+
|
|
316
|
+
async def lock_release(self, name: str, token: str) -> bool:
|
|
317
|
+
# Token-checked DEL: prevents releasing a lock that expired and was re-acquired.
|
|
318
|
+
script = (
|
|
319
|
+
'if redis.call("GET", KEYS[1]) == ARGV[1] '
|
|
320
|
+
'then return redis.call("DEL", KEYS[1]) else return 0 end'
|
|
321
|
+
)
|
|
322
|
+
key = self.LOCK_KEY.format(name=name)
|
|
323
|
+
result = await self._r.eval(script, 1, key, token)
|
|
324
|
+
return bool(result)
|
|
325
|
+
|
|
326
|
+
async def counter_incr(self, name: str, by: int) -> int:
|
|
327
|
+
return int(await self._r.incrby(self.COUNTER_KEY.format(name=name), by))
|
|
328
|
+
|
|
329
|
+
async def counter_get(self, name: str) -> int:
|
|
330
|
+
raw = await self._r.get(self.COUNTER_KEY.format(name=name))
|
|
331
|
+
return int(raw) if raw is not None else 0
|
|
332
|
+
|
|
333
|
+
async def counter_set(self, name: str, value: int) -> None:
|
|
334
|
+
await self._r.set(self.COUNTER_KEY.format(name=name), value)
|
|
335
|
+
|
|
336
|
+
async def counter_del(self, name: str) -> None:
|
|
337
|
+
await self._r.delete(self.COUNTER_KEY.format(name=name))
|
|
338
|
+
|
|
339
|
+
def _queue_key_for_priority(self, name: str, priority: int) -> str:
|
|
340
|
+
if priority > 0:
|
|
341
|
+
return self.QUEUE_HIGH.format(name=name)
|
|
342
|
+
if priority < 0:
|
|
343
|
+
return self.QUEUE_LOW.format(name=name)
|
|
344
|
+
return self.QUEUE_KEY.format(name=name)
|
|
345
|
+
|
|
346
|
+
def _queue_keys_priority_order(self, name: str) -> list[str]:
|
|
347
|
+
return [
|
|
348
|
+
self.QUEUE_HIGH.format(name=name),
|
|
349
|
+
self.QUEUE_KEY.format(name=name),
|
|
350
|
+
self.QUEUE_LOW.format(name=name),
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
async def queue_push(self, name: str, body: bytes, *, priority: int = 0) -> None:
|
|
354
|
+
await self._r.rpush(self._queue_key_for_priority(name, priority), body)
|
|
355
|
+
|
|
356
|
+
async def queue_size(self, name: str) -> int:
|
|
357
|
+
# Includes high + normal + low so /metrics is honest.
|
|
358
|
+
total = 0
|
|
359
|
+
for k in self._queue_keys_priority_order(name):
|
|
360
|
+
total += int(await self._r.llen(k))
|
|
361
|
+
return total
|
|
362
|
+
|
|
363
|
+
async def queue_peek(self, name: str, n: int) -> list[bytes]:
|
|
364
|
+
if n <= 0:
|
|
365
|
+
return []
|
|
366
|
+
out: list[bytes] = []
|
|
367
|
+
for k in self._queue_keys_priority_order(name):
|
|
368
|
+
remaining = n - len(out)
|
|
369
|
+
if remaining <= 0:
|
|
370
|
+
break
|
|
371
|
+
chunk = await self._r.lrange(k, 0, remaining - 1)
|
|
372
|
+
out.extend(chunk)
|
|
373
|
+
return out
|
|
374
|
+
|
|
375
|
+
async def pool_size(self, name: str) -> int:
|
|
376
|
+
return int(await self._r.hlen(self.POOL_HASH.format(name=name)))
|
|
377
|
+
|
|
378
|
+
async def _drain_scheduled(self, name: str) -> None:
|
|
379
|
+
now = time.time()
|
|
380
|
+
sched = self.QUEUE_SCHEDULED.format(name=name)
|
|
381
|
+
due = await self._r.zrangebyscore(sched, 0, now)
|
|
382
|
+
if not due:
|
|
383
|
+
return
|
|
384
|
+
pipe = self._r.pipeline()
|
|
385
|
+
pipe.zremrangebyscore(sched, 0, now)
|
|
386
|
+
for body in due:
|
|
387
|
+
pipe.rpush(self.QUEUE_KEY.format(name=name), body)
|
|
388
|
+
await pipe.execute()
|
|
389
|
+
|
|
390
|
+
async def _wait_for_body(self, name: str, timeout_seconds: float) -> Optional[bytes]:
|
|
391
|
+
# BLPOP supports multi-key with priority order; first non-empty wins.
|
|
392
|
+
# 0 timeout means "forever", so sub-second falls back to a poll loop.
|
|
393
|
+
import asyncio
|
|
394
|
+
keys = self._queue_keys_priority_order(name)
|
|
395
|
+
if timeout_seconds >= 1.0:
|
|
396
|
+
res = await self._r.blpop(keys, timeout=int(timeout_seconds))
|
|
397
|
+
if res is None:
|
|
398
|
+
return None
|
|
399
|
+
_key, body = res
|
|
400
|
+
return body
|
|
401
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
402
|
+
while time.monotonic() < deadline:
|
|
403
|
+
for k in keys:
|
|
404
|
+
body = await self._r.lpop(k)
|
|
405
|
+
if body is not None:
|
|
406
|
+
return body
|
|
407
|
+
await asyncio.sleep(0.05)
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
async def queue_reserve(
|
|
411
|
+
self, name: str, timeout_seconds: float, visibility_seconds: int
|
|
412
|
+
) -> Optional[bytes]:
|
|
413
|
+
await self._drain_scheduled(name)
|
|
414
|
+
body = await self._wait_for_body(name, timeout_seconds)
|
|
415
|
+
if body is None:
|
|
416
|
+
return None
|
|
417
|
+
try:
|
|
418
|
+
envelope = json.loads(body.decode("utf-8"))
|
|
419
|
+
task_id = str(envelope.get("id", ""))
|
|
420
|
+
except Exception:
|
|
421
|
+
task_id = uuid.uuid4().hex
|
|
422
|
+
if not task_id:
|
|
423
|
+
return body
|
|
424
|
+
deadline = time.time() + max(1, visibility_seconds)
|
|
425
|
+
pipe = self._r.pipeline()
|
|
426
|
+
pipe.zadd(self.QUEUE_PROCESSING.format(name=name), {task_id: deadline})
|
|
427
|
+
pipe.hset(self.QUEUE_PROCESSING_BODY.format(name=name), task_id, body)
|
|
428
|
+
await pipe.execute()
|
|
429
|
+
return body
|
|
430
|
+
|
|
431
|
+
async def queue_ack(self, name: str, task_id: str) -> bool:
|
|
432
|
+
pipe = self._r.pipeline()
|
|
433
|
+
pipe.zrem(self.QUEUE_PROCESSING.format(name=name), task_id)
|
|
434
|
+
pipe.hdel(self.QUEUE_PROCESSING_BODY.format(name=name), task_id)
|
|
435
|
+
results = await pipe.execute()
|
|
436
|
+
return bool(results and results[0])
|
|
437
|
+
|
|
438
|
+
async def queue_nack(
|
|
439
|
+
self, name: str, task_id: str, body: bytes, *, delay_seconds: float
|
|
440
|
+
) -> bool:
|
|
441
|
+
# Remove the reservation; either re-enqueue immediately or schedule.
|
|
442
|
+
pipe = self._r.pipeline()
|
|
443
|
+
pipe.zrem(self.QUEUE_PROCESSING.format(name=name), task_id)
|
|
444
|
+
pipe.hdel(self.QUEUE_PROCESSING_BODY.format(name=name), task_id)
|
|
445
|
+
if delay_seconds > 0:
|
|
446
|
+
ready_at = time.time() + delay_seconds
|
|
447
|
+
pipe.zadd(self.QUEUE_SCHEDULED.format(name=name), {body: ready_at})
|
|
448
|
+
else:
|
|
449
|
+
pipe.rpush(self.QUEUE_KEY.format(name=name), body)
|
|
450
|
+
await pipe.execute()
|
|
451
|
+
return True
|
|
452
|
+
|
|
453
|
+
async def queue_sweep_expired(self, name: str, now: Optional[float] = None) -> int:
|
|
454
|
+
now = now if now is not None else time.time()
|
|
455
|
+
proc_key = self.QUEUE_PROCESSING.format(name=name)
|
|
456
|
+
body_key = self.QUEUE_PROCESSING_BODY.format(name=name)
|
|
457
|
+
expired_ids = await self._r.zrangebyscore(proc_key, 0, now)
|
|
458
|
+
if not expired_ids:
|
|
459
|
+
return 0
|
|
460
|
+
bodies = await self._r.hmget(body_key, expired_ids)
|
|
461
|
+
pipe = self._r.pipeline()
|
|
462
|
+
for tid, body in zip(expired_ids, bodies, strict=True):
|
|
463
|
+
if body is None:
|
|
464
|
+
continue
|
|
465
|
+
pipe.rpush(self.QUEUE_KEY.format(name=name), body)
|
|
466
|
+
pipe.zrem(proc_key, tid)
|
|
467
|
+
pipe.hdel(body_key, tid)
|
|
468
|
+
await pipe.execute()
|
|
469
|
+
return sum(1 for b in bodies if b is not None)
|
|
470
|
+
|
|
471
|
+
async def queue_dead(self, name: str, body: bytes) -> None:
|
|
472
|
+
await self._r.rpush(self.QUEUE_DEAD.format(name=name), body)
|
|
473
|
+
|
|
474
|
+
async def dlq_peek(self, name: str, n: int) -> list[bytes]:
|
|
475
|
+
return list(await self._r.lrange(self.QUEUE_DEAD.format(name=name), 0, max(0, n - 1)))
|
|
476
|
+
|
|
477
|
+
async def dlq_pop(self, name: str, n: int) -> list[bytes]:
|
|
478
|
+
key = self.QUEUE_DEAD.format(name=name)
|
|
479
|
+
out: list[bytes] = []
|
|
480
|
+
for _ in range(n):
|
|
481
|
+
b = await self._r.lpop(key)
|
|
482
|
+
if b is None:
|
|
483
|
+
break
|
|
484
|
+
out.append(b)
|
|
485
|
+
return out
|
|
486
|
+
|
|
487
|
+
async def dlq_length(self, name: str) -> int:
|
|
488
|
+
return int(await self._r.llen(self.QUEUE_DEAD.format(name=name)))
|
|
489
|
+
|
|
490
|
+
async def dlq_clear(self, name: str) -> int:
|
|
491
|
+
length = await self.dlq_length(name)
|
|
492
|
+
await self._r.delete(self.QUEUE_DEAD.format(name=name))
|
|
493
|
+
return length
|
|
494
|
+
|
|
495
|
+
async def stream_push(
|
|
496
|
+
self,
|
|
497
|
+
name: str,
|
|
498
|
+
envelope: bytes,
|
|
499
|
+
*,
|
|
500
|
+
score: float,
|
|
501
|
+
max_len: int,
|
|
502
|
+
ttl_seconds: int,
|
|
503
|
+
) -> None:
|
|
504
|
+
key = self.STREAM_KEY.format(name=name)
|
|
505
|
+
pipe = self._r.pipeline()
|
|
506
|
+
pipe.zadd(key, {envelope: score})
|
|
507
|
+
if ttl_seconds > 0:
|
|
508
|
+
pipe.zremrangebyscore(key, 0, score - ttl_seconds)
|
|
509
|
+
if max_len > 0:
|
|
510
|
+
pipe.zremrangebyrank(key, 0, -(max_len + 1))
|
|
511
|
+
pipe.publish(self.EVENT_CHANNEL.format(topic=f"{name}.emit"), str(score))
|
|
512
|
+
await pipe.execute()
|
|
513
|
+
|
|
514
|
+
async def stream_pop(self, name: str, n: int) -> list[bytes]:
|
|
515
|
+
raw = await self._r.zpopmin(self.STREAM_KEY.format(name=name), n)
|
|
516
|
+
return [m for m, _score in raw]
|
|
517
|
+
|
|
518
|
+
async def stream_peek(self, name: str, n: int) -> list[bytes]:
|
|
519
|
+
return list(await self._r.zrange(self.STREAM_KEY.format(name=name), 0, n - 1))
|
|
520
|
+
|
|
521
|
+
async def stream_length(self, name: str) -> int:
|
|
522
|
+
return int(await self._r.zcard(self.STREAM_KEY.format(name=name)))
|
|
523
|
+
|
|
524
|
+
async def stream_trim(self, name: str, *, max_len: int, ttl_seconds: int) -> None:
|
|
525
|
+
key = self.STREAM_KEY.format(name=name)
|
|
526
|
+
now = time.time()
|
|
527
|
+
pipe = self._r.pipeline()
|
|
528
|
+
if ttl_seconds > 0:
|
|
529
|
+
pipe.zremrangebyscore(key, 0, now - ttl_seconds)
|
|
530
|
+
if max_len > 0:
|
|
531
|
+
pipe.zremrangebyrank(key, 0, -(max_len + 1))
|
|
532
|
+
await pipe.execute()
|
|
533
|
+
|
|
534
|
+
async def event_publish(self, topic: str, body: bytes) -> None:
|
|
535
|
+
await self._r.publish(self.EVENT_CHANNEL.format(topic=topic), body)
|
|
536
|
+
|
|
537
|
+
def _wkey(self, at: str, wid: str, field: str) -> str:
|
|
538
|
+
return self.WORKER_FIELD.format(at=at, wid=wid, field=field)
|
|
539
|
+
|
|
540
|
+
async def worker_register(
|
|
541
|
+
self, automation_type: str, worker_id: str, hardware: dict[str, Any]
|
|
542
|
+
) -> None:
|
|
543
|
+
at, wid = automation_type, worker_id
|
|
544
|
+
await self._r.set(self._wkey(at, wid, "hardware"), json.dumps(hardware).encode("utf-8"))
|
|
545
|
+
await self._r.sadd(self.WORKER_TYPE_INDEX.format(at=at), wid)
|
|
546
|
+
await self._r.sadd(self.WORKER_ALL_INDEX, f"{at}|{wid}")
|
|
547
|
+
if not await self._r.exists(self._wkey(at, wid, "gen")):
|
|
548
|
+
await self._r.set(self._wkey(at, wid, "gen"), "0")
|
|
549
|
+
if not await self._r.exists(self._wkey(at, wid, "config")):
|
|
550
|
+
await self._r.set(self._wkey(at, wid, "config"), b"{}")
|
|
551
|
+
|
|
552
|
+
async def worker_get_config(
|
|
553
|
+
self, automation_type: str, worker_id: str
|
|
554
|
+
) -> tuple[dict[str, Any], int]:
|
|
555
|
+
raw_cfg = await self._r.get(self._wkey(automation_type, worker_id, "config"))
|
|
556
|
+
raw_gen = await self._r.get(self._wkey(automation_type, worker_id, "gen"))
|
|
557
|
+
cfg = json.loads(raw_cfg.decode("utf-8")) if raw_cfg else {}
|
|
558
|
+
gen = int(raw_gen.decode("utf-8")) if raw_gen else 0
|
|
559
|
+
return cfg, gen
|
|
560
|
+
|
|
561
|
+
async def worker_set_config(
|
|
562
|
+
self, automation_type: str, worker_id: str, config: dict[str, Any]
|
|
563
|
+
) -> tuple[dict[str, Any], int]:
|
|
564
|
+
at, wid = automation_type, worker_id
|
|
565
|
+
await self._r.set(self._wkey(at, wid, "config"), json.dumps(config).encode("utf-8"))
|
|
566
|
+
new_gen = await self._r.incr(self._wkey(at, wid, "gen"))
|
|
567
|
+
return config, int(new_gen)
|
|
568
|
+
|
|
569
|
+
async def worker_set_state(
|
|
570
|
+
self,
|
|
571
|
+
automation_type: str,
|
|
572
|
+
worker_id: str,
|
|
573
|
+
state: str,
|
|
574
|
+
last_error: Optional[str],
|
|
575
|
+
last_seen: float,
|
|
576
|
+
) -> None:
|
|
577
|
+
key = self._wkey(automation_type, worker_id, "state")
|
|
578
|
+
await self._r.hset(
|
|
579
|
+
key,
|
|
580
|
+
mapping={
|
|
581
|
+
"state": state,
|
|
582
|
+
"last_seen": str(last_seen),
|
|
583
|
+
"last_error": last_error or "",
|
|
584
|
+
},
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
async def worker_set_stats(
|
|
588
|
+
self, automation_type: str, worker_id: str, stats: dict[str, Any]
|
|
589
|
+
) -> None:
|
|
590
|
+
await self._r.set(
|
|
591
|
+
self._wkey(automation_type, worker_id, "stats"),
|
|
592
|
+
json.dumps(stats).encode("utf-8"),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
async def worker_stats_push(
|
|
596
|
+
self,
|
|
597
|
+
automation_type: str,
|
|
598
|
+
worker_id: str,
|
|
599
|
+
ts: float,
|
|
600
|
+
frame: dict[str, Any],
|
|
601
|
+
*,
|
|
602
|
+
max_entries: int = 240,
|
|
603
|
+
) -> None:
|
|
604
|
+
key = self._wkey(automation_type, worker_id, "stats:history")
|
|
605
|
+
body = json.dumps(frame, default=str).encode("utf-8")
|
|
606
|
+
pipe = self._r.pipeline()
|
|
607
|
+
pipe.zadd(key, {body: ts})
|
|
608
|
+
if max_entries > 0:
|
|
609
|
+
pipe.zremrangebyrank(key, 0, -(max_entries + 1))
|
|
610
|
+
await pipe.execute()
|
|
611
|
+
|
|
612
|
+
async def worker_stats_history(
|
|
613
|
+
self,
|
|
614
|
+
automation_type: str,
|
|
615
|
+
worker_id: str,
|
|
616
|
+
*,
|
|
617
|
+
since: float = 0,
|
|
618
|
+
limit: int = 240,
|
|
619
|
+
) -> list[dict[str, Any]]:
|
|
620
|
+
key = self._wkey(automation_type, worker_id, "stats:history")
|
|
621
|
+
raw = await self._r.zrangebyscore(key, since, "+inf", start=0, num=limit)
|
|
622
|
+
out: list[dict[str, Any]] = []
|
|
623
|
+
for entry in raw:
|
|
624
|
+
try:
|
|
625
|
+
out.append(json.loads(entry.decode("utf-8") if isinstance(entry, bytes) else entry))
|
|
626
|
+
except Exception:
|
|
627
|
+
continue
|
|
628
|
+
return out
|
|
629
|
+
|
|
630
|
+
async def worker_get(
|
|
631
|
+
self, automation_type: str, worker_id: str
|
|
632
|
+
) -> Optional[dict[str, Any]]:
|
|
633
|
+
at, wid = automation_type, worker_id
|
|
634
|
+
is_member = await self._r.sismember(self.WORKER_TYPE_INDEX.format(at=at), wid)
|
|
635
|
+
if not is_member:
|
|
636
|
+
return None
|
|
637
|
+
raw_hw = await self._r.get(self._wkey(at, wid, "hardware"))
|
|
638
|
+
raw_stats = await self._r.get(self._wkey(at, wid, "stats"))
|
|
639
|
+
cfg, gen = await self.worker_get_config(at, wid)
|
|
640
|
+
state_h = await self._r.hgetall(self._wkey(at, wid, "state"))
|
|
641
|
+
state_d = {
|
|
642
|
+
(k.decode("utf-8") if isinstance(k, bytes) else k):
|
|
643
|
+
(v.decode("utf-8") if isinstance(v, bytes) else v)
|
|
644
|
+
for k, v in state_h.items()
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
"hardware": json.loads(raw_hw.decode("utf-8")) if raw_hw else {},
|
|
648
|
+
"config": cfg,
|
|
649
|
+
"config_gen": gen,
|
|
650
|
+
"state": state_d.get("state", "IDLE"),
|
|
651
|
+
"last_seen": float(state_d.get("last_seen") or 0),
|
|
652
|
+
"last_error": state_d.get("last_error") or None,
|
|
653
|
+
"stats": json.loads(raw_stats.decode("utf-8")) if raw_stats else {},
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async def worker_list(
|
|
657
|
+
self, automation_type: Optional[str] = None
|
|
658
|
+
) -> list[tuple[str, str]]:
|
|
659
|
+
if automation_type is None:
|
|
660
|
+
raw = await self._r.smembers(self.WORKER_ALL_INDEX)
|
|
661
|
+
out: list[tuple[str, str]] = []
|
|
662
|
+
for entry in raw:
|
|
663
|
+
s = entry.decode("utf-8") if isinstance(entry, bytes) else entry
|
|
664
|
+
at, _, wid = s.partition("|")
|
|
665
|
+
if wid:
|
|
666
|
+
out.append((at, wid))
|
|
667
|
+
return out
|
|
668
|
+
raw = await self._r.smembers(self.WORKER_TYPE_INDEX.format(at=automation_type))
|
|
669
|
+
return [
|
|
670
|
+
(automation_type, e.decode("utf-8") if isinstance(e, bytes) else e)
|
|
671
|
+
for e in raw
|
|
672
|
+
]
|
|
673
|
+
|
|
674
|
+
async def worker_remove(self, automation_type: str, worker_id: str) -> bool:
|
|
675
|
+
at, wid = automation_type, worker_id
|
|
676
|
+
is_member = await self._r.sismember(self.WORKER_TYPE_INDEX.format(at=at), wid)
|
|
677
|
+
if not is_member:
|
|
678
|
+
return False
|
|
679
|
+
for field in ("hardware", "config", "gen", "state", "stats"):
|
|
680
|
+
await self._r.delete(self._wkey(at, wid, field))
|
|
681
|
+
await self._r.srem(self.WORKER_TYPE_INDEX.format(at=at), wid)
|
|
682
|
+
await self._r.srem(self.WORKER_ALL_INDEX, f"{at}|{wid}")
|
|
683
|
+
return True
|
|
684
|
+
|
|
685
|
+
async def catalog_set(self, automation_type: str, doc: bytes) -> None:
|
|
686
|
+
await self._r.hset(self.CATALOG_HASH, automation_type, doc)
|
|
687
|
+
|
|
688
|
+
async def catalog_get(self, automation_type: str) -> Optional[bytes]:
|
|
689
|
+
return await self._r.hget(self.CATALOG_HASH, automation_type)
|
|
690
|
+
|
|
691
|
+
async def catalog_all(self) -> dict[str, bytes]:
|
|
692
|
+
raw = await self._r.hgetall(self.CATALOG_HASH)
|
|
693
|
+
return {
|
|
694
|
+
(k.decode("utf-8") if isinstance(k, bytes) else k): v
|
|
695
|
+
for k, v in raw.items()
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def backend_from_url(url: str) -> Backend:
|
|
700
|
+
"""Construct a Backend from a URL by scheme.
|
|
701
|
+
|
|
702
|
+
Built-in schemes:
|
|
703
|
+
redis://... -> RedisBackend
|
|
704
|
+
rediss://... -> RedisBackend (TLS)
|
|
705
|
+
memory:// -> InMemoryBackend (single-process)
|
|
706
|
+
|
|
707
|
+
Third-party schemes can register via the `fleet.backends` entry-point
|
|
708
|
+
group. The entry-point name is the URL scheme; the loaded callable
|
|
709
|
+
receives the full URL and must return an object implementing Backend.
|
|
710
|
+
"""
|
|
711
|
+
scheme = url.split("://", 1)[0].lower() if "://" in url else url.lower()
|
|
712
|
+
if scheme in ("redis", "rediss"):
|
|
713
|
+
return RedisBackend(url)
|
|
714
|
+
if scheme in ("memory", "mem"):
|
|
715
|
+
from fleet.core.memory_backend import InMemoryBackend
|
|
716
|
+
return InMemoryBackend(url)
|
|
717
|
+
if scheme in ("sqlite", "file"):
|
|
718
|
+
from fleet.core.sqlite_backend import SqliteBackend
|
|
719
|
+
return SqliteBackend(url)
|
|
720
|
+
try:
|
|
721
|
+
from importlib.metadata import entry_points
|
|
722
|
+
eps = entry_points(group="fleet.backends")
|
|
723
|
+
except Exception:
|
|
724
|
+
eps = []
|
|
725
|
+
for ep in eps:
|
|
726
|
+
if ep.name.lower() == scheme:
|
|
727
|
+
factory = ep.load()
|
|
728
|
+
return factory(url)
|
|
729
|
+
raise ValueError(
|
|
730
|
+
f"no backend registered for scheme '{scheme}'. "
|
|
731
|
+
"built-in: redis://, rediss://, memory://. "
|
|
732
|
+
"third-party: install a package that exposes a 'fleet.backends' entry-point."
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
__all__ = ["Backend", "RawItem", "RedisBackend", "backend_from_url"]
|