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,1117 @@
|
|
|
1
|
+
"""Sqlite-backed Backend implementation.
|
|
2
|
+
|
|
3
|
+
Targets single-host deployments where running Redis is overkill — a CLI tool, a
|
|
4
|
+
laptop demo, a tiny VPS. One file on disk, WAL mode for concurrency, the same
|
|
5
|
+
Backend protocol as RedisBackend and InMemoryBackend.
|
|
6
|
+
|
|
7
|
+
The implementation uses one sqlite3 connection guarded by a threading.Lock.
|
|
8
|
+
Every public method dispatches the actual SQL to a worker thread via
|
|
9
|
+
asyncio.to_thread so the event loop never blocks on a slow disk.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import secrets
|
|
17
|
+
import sqlite3
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
from fleet.core.backend import RawItem
|
|
24
|
+
|
|
25
|
+
_SCHEMA = """
|
|
26
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
27
|
+
ns TEXT NOT NULL,
|
|
28
|
+
key TEXT NOT NULL,
|
|
29
|
+
value BLOB NOT NULL,
|
|
30
|
+
expires_at REAL,
|
|
31
|
+
PRIMARY KEY(ns, key)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS pool_items (
|
|
35
|
+
name TEXT NOT NULL,
|
|
36
|
+
item_id TEXT NOT NULL,
|
|
37
|
+
payload BLOB NOT NULL,
|
|
38
|
+
tags TEXT NOT NULL,
|
|
39
|
+
expires_at REAL,
|
|
40
|
+
PRIMARY KEY(name, item_id)
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS pool_claims (
|
|
44
|
+
name TEXT NOT NULL,
|
|
45
|
+
item_id TEXT NOT NULL,
|
|
46
|
+
until REAL NOT NULL,
|
|
47
|
+
PRIMARY KEY(name, item_id)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS locks (
|
|
51
|
+
name TEXT PRIMARY KEY,
|
|
52
|
+
token TEXT NOT NULL,
|
|
53
|
+
expires_at REAL NOT NULL
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS counters (
|
|
57
|
+
name TEXT PRIMARY KEY,
|
|
58
|
+
value INTEGER NOT NULL
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS queues (
|
|
62
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
+
name TEXT NOT NULL,
|
|
64
|
+
tier TEXT NOT NULL,
|
|
65
|
+
body BLOB NOT NULL
|
|
66
|
+
);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS queues_name_tier ON queues(name, tier, id);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS queue_processing (
|
|
70
|
+
name TEXT NOT NULL,
|
|
71
|
+
task_id TEXT NOT NULL,
|
|
72
|
+
body BLOB NOT NULL,
|
|
73
|
+
deadline REAL NOT NULL,
|
|
74
|
+
PRIMARY KEY(name, task_id)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS queue_scheduled (
|
|
78
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
79
|
+
name TEXT NOT NULL,
|
|
80
|
+
available_at REAL NOT NULL,
|
|
81
|
+
body BLOB NOT NULL
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS queue_scheduled_name_at ON queue_scheduled(name, available_at);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS queue_dead (
|
|
86
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
name TEXT NOT NULL,
|
|
88
|
+
body BLOB NOT NULL
|
|
89
|
+
);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS queue_dead_name_id ON queue_dead(name, id);
|
|
91
|
+
|
|
92
|
+
CREATE TABLE IF NOT EXISTS streams (
|
|
93
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
94
|
+
name TEXT NOT NULL,
|
|
95
|
+
score REAL NOT NULL,
|
|
96
|
+
body BLOB NOT NULL
|
|
97
|
+
);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS streams_name_score ON streams(name, score);
|
|
99
|
+
|
|
100
|
+
CREATE TABLE IF NOT EXISTS workers (
|
|
101
|
+
automation_type TEXT NOT NULL,
|
|
102
|
+
worker_id TEXT NOT NULL,
|
|
103
|
+
hardware TEXT NOT NULL,
|
|
104
|
+
config TEXT NOT NULL,
|
|
105
|
+
config_gen INTEGER NOT NULL DEFAULT 0,
|
|
106
|
+
state TEXT NOT NULL DEFAULT 'IDLE',
|
|
107
|
+
last_seen REAL NOT NULL DEFAULT 0,
|
|
108
|
+
last_error TEXT,
|
|
109
|
+
stats TEXT NOT NULL DEFAULT '{}',
|
|
110
|
+
PRIMARY KEY(automation_type, worker_id)
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
CREATE TABLE IF NOT EXISTS worker_stats_history (
|
|
114
|
+
automation_type TEXT NOT NULL,
|
|
115
|
+
worker_id TEXT NOT NULL,
|
|
116
|
+
ts REAL NOT NULL,
|
|
117
|
+
frame TEXT NOT NULL
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS wsh_at_wid_ts ON worker_stats_history(automation_type, worker_id, ts);
|
|
120
|
+
|
|
121
|
+
CREATE TABLE IF NOT EXISTS catalog (
|
|
122
|
+
automation_type TEXT PRIMARY KEY,
|
|
123
|
+
doc BLOB NOT NULL
|
|
124
|
+
);
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _tier_for_priority(priority: int) -> str:
|
|
129
|
+
if priority > 0:
|
|
130
|
+
return "high"
|
|
131
|
+
if priority < 0:
|
|
132
|
+
return "low"
|
|
133
|
+
return "normal"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_TIER_ORDER = ("high", "normal", "low")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SqliteBackend:
|
|
140
|
+
def __init__(self, url_or_path: str = "sqlite://:memory:") -> None:
|
|
141
|
+
path = _path_from_url(url_or_path)
|
|
142
|
+
if path != ":memory:":
|
|
143
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
self._conn = sqlite3.connect(
|
|
145
|
+
path,
|
|
146
|
+
isolation_level=None, # autocommit; we manage txns by hand
|
|
147
|
+
check_same_thread=False,
|
|
148
|
+
timeout=30.0,
|
|
149
|
+
)
|
|
150
|
+
self._lock = threading.Lock()
|
|
151
|
+
self._conn.execute("PRAGMA journal_mode=WAL;")
|
|
152
|
+
self._conn.execute("PRAGMA synchronous=NORMAL;")
|
|
153
|
+
self._conn.execute("PRAGMA busy_timeout=5000;")
|
|
154
|
+
self._conn.executescript(_SCHEMA)
|
|
155
|
+
|
|
156
|
+
async def aclose(self) -> None:
|
|
157
|
+
await asyncio.to_thread(self._close_sync)
|
|
158
|
+
|
|
159
|
+
def _close_sync(self) -> None:
|
|
160
|
+
with self._lock:
|
|
161
|
+
try:
|
|
162
|
+
self._conn.close()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
# ------- KV ----------
|
|
167
|
+
|
|
168
|
+
async def kv_set(self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]) -> None:
|
|
169
|
+
exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
|
|
170
|
+
await asyncio.to_thread(self._kv_set_sync, ns, key, value, exp)
|
|
171
|
+
|
|
172
|
+
def _kv_set_sync(self, ns: str, key: str, value: bytes, exp: Optional[float]) -> None:
|
|
173
|
+
with self._lock:
|
|
174
|
+
self._conn.execute(
|
|
175
|
+
"INSERT OR REPLACE INTO kv(ns, key, value, expires_at) VALUES(?,?,?,?)",
|
|
176
|
+
(ns, key, value, exp),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
async def kv_setnx(
|
|
180
|
+
self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]
|
|
181
|
+
) -> bool:
|
|
182
|
+
exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
|
|
183
|
+
return await asyncio.to_thread(self._kv_setnx_sync, ns, key, value, exp)
|
|
184
|
+
|
|
185
|
+
def _kv_setnx_sync(self, ns: str, key: str, value: bytes, exp: Optional[float]) -> bool:
|
|
186
|
+
now = time.time()
|
|
187
|
+
with self._lock:
|
|
188
|
+
self._conn.execute(
|
|
189
|
+
"DELETE FROM kv WHERE ns=? AND key=? AND expires_at IS NOT NULL AND expires_at < ?",
|
|
190
|
+
(ns, key, now),
|
|
191
|
+
)
|
|
192
|
+
try:
|
|
193
|
+
self._conn.execute(
|
|
194
|
+
"INSERT INTO kv(ns, key, value, expires_at) VALUES(?,?,?,?)",
|
|
195
|
+
(ns, key, value, exp),
|
|
196
|
+
)
|
|
197
|
+
return True
|
|
198
|
+
except sqlite3.IntegrityError:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
async def kv_get(self, ns: str, key: str) -> Optional[bytes]:
|
|
202
|
+
return await asyncio.to_thread(self._kv_get_sync, ns, key)
|
|
203
|
+
|
|
204
|
+
def _kv_get_sync(self, ns: str, key: str) -> Optional[bytes]:
|
|
205
|
+
now = time.time()
|
|
206
|
+
with self._lock:
|
|
207
|
+
row = self._conn.execute(
|
|
208
|
+
"SELECT value, expires_at FROM kv WHERE ns=? AND key=?",
|
|
209
|
+
(ns, key),
|
|
210
|
+
).fetchone()
|
|
211
|
+
if row is None:
|
|
212
|
+
return None
|
|
213
|
+
value, exp = row
|
|
214
|
+
if exp is not None and exp < now:
|
|
215
|
+
# Lazy expiry: delete and pretend it wasn't there.
|
|
216
|
+
with self._lock:
|
|
217
|
+
self._conn.execute("DELETE FROM kv WHERE ns=? AND key=?", (ns, key))
|
|
218
|
+
return None
|
|
219
|
+
return value
|
|
220
|
+
|
|
221
|
+
async def kv_del(self, ns: str, key: str) -> bool:
|
|
222
|
+
return await asyncio.to_thread(self._kv_del_sync, ns, key)
|
|
223
|
+
|
|
224
|
+
def _kv_del_sync(self, ns: str, key: str) -> bool:
|
|
225
|
+
with self._lock:
|
|
226
|
+
cur = self._conn.execute("DELETE FROM kv WHERE ns=? AND key=?", (ns, key))
|
|
227
|
+
return cur.rowcount > 0
|
|
228
|
+
|
|
229
|
+
# ------- Pool ----------
|
|
230
|
+
|
|
231
|
+
async def pool_put(
|
|
232
|
+
self,
|
|
233
|
+
name: str,
|
|
234
|
+
item_id: str,
|
|
235
|
+
payload: bytes,
|
|
236
|
+
tags: dict[str, Any],
|
|
237
|
+
ttl_seconds: Optional[int],
|
|
238
|
+
) -> None:
|
|
239
|
+
exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
|
|
240
|
+
await asyncio.to_thread(self._pool_put_sync, name, item_id, payload, tags, exp)
|
|
241
|
+
|
|
242
|
+
def _pool_put_sync(
|
|
243
|
+
self, name: str, item_id: str, payload: bytes, tags: dict[str, Any], exp: Optional[float]
|
|
244
|
+
) -> None:
|
|
245
|
+
with self._lock:
|
|
246
|
+
self._conn.execute(
|
|
247
|
+
"INSERT OR REPLACE INTO pool_items(name, item_id, payload, tags, expires_at) "
|
|
248
|
+
"VALUES(?,?,?,?,?)",
|
|
249
|
+
(name, item_id, payload, json.dumps(tags), exp),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def pool_list(self, name: str) -> list[RawItem]:
|
|
253
|
+
return await asyncio.to_thread(self._pool_list_sync, name)
|
|
254
|
+
|
|
255
|
+
def _pool_list_sync(self, name: str) -> list[RawItem]:
|
|
256
|
+
now = time.time()
|
|
257
|
+
with self._lock:
|
|
258
|
+
self._conn.execute(
|
|
259
|
+
"DELETE FROM pool_items WHERE name=? AND expires_at IS NOT NULL AND expires_at < ?",
|
|
260
|
+
(name, now),
|
|
261
|
+
)
|
|
262
|
+
self._conn.execute(
|
|
263
|
+
"DELETE FROM pool_claims WHERE name=? AND until < ?",
|
|
264
|
+
(name, now),
|
|
265
|
+
)
|
|
266
|
+
rows = self._conn.execute(
|
|
267
|
+
"SELECT item_id, payload, tags, expires_at FROM pool_items WHERE name=?",
|
|
268
|
+
(name,),
|
|
269
|
+
).fetchall()
|
|
270
|
+
claims = dict(
|
|
271
|
+
self._conn.execute(
|
|
272
|
+
"SELECT item_id, until FROM pool_claims WHERE name=?", (name,)
|
|
273
|
+
).fetchall()
|
|
274
|
+
)
|
|
275
|
+
out: list[RawItem] = []
|
|
276
|
+
for iid, payload, tags_json, exp in rows:
|
|
277
|
+
in_use = claims.get(iid)
|
|
278
|
+
out.append(RawItem(
|
|
279
|
+
id=iid,
|
|
280
|
+
payload=payload,
|
|
281
|
+
tags=json.loads(tags_json),
|
|
282
|
+
expires_at=exp,
|
|
283
|
+
in_use_until=in_use if (in_use is not None and in_use > now) else None,
|
|
284
|
+
))
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
async def pool_size(self, name: str) -> int:
|
|
288
|
+
return await asyncio.to_thread(self._pool_size_sync, name)
|
|
289
|
+
|
|
290
|
+
def _pool_size_sync(self, name: str) -> int:
|
|
291
|
+
now = time.time()
|
|
292
|
+
with self._lock:
|
|
293
|
+
row = self._conn.execute(
|
|
294
|
+
"SELECT COUNT(*) FROM pool_items WHERE name=? AND "
|
|
295
|
+
"(expires_at IS NULL OR expires_at >= ?)",
|
|
296
|
+
(name, now),
|
|
297
|
+
).fetchone()
|
|
298
|
+
return int(row[0])
|
|
299
|
+
|
|
300
|
+
async def pool_remove(self, name: str, item_id: str) -> bool:
|
|
301
|
+
return await asyncio.to_thread(self._pool_remove_sync, name, item_id)
|
|
302
|
+
|
|
303
|
+
def _pool_remove_sync(self, name: str, item_id: str) -> bool:
|
|
304
|
+
with self._lock:
|
|
305
|
+
self._conn.execute(
|
|
306
|
+
"DELETE FROM pool_claims WHERE name=? AND item_id=?",
|
|
307
|
+
(name, item_id),
|
|
308
|
+
)
|
|
309
|
+
cur = self._conn.execute(
|
|
310
|
+
"DELETE FROM pool_items WHERE name=? AND item_id=?",
|
|
311
|
+
(name, item_id),
|
|
312
|
+
)
|
|
313
|
+
return cur.rowcount > 0
|
|
314
|
+
|
|
315
|
+
async def pool_release(self, name: str, item_id: str) -> bool:
|
|
316
|
+
return await asyncio.to_thread(self._pool_release_sync, name, item_id)
|
|
317
|
+
|
|
318
|
+
def _pool_release_sync(self, name: str, item_id: str) -> bool:
|
|
319
|
+
with self._lock:
|
|
320
|
+
cur = self._conn.execute(
|
|
321
|
+
"DELETE FROM pool_claims WHERE name=? AND item_id=?",
|
|
322
|
+
(name, item_id),
|
|
323
|
+
)
|
|
324
|
+
return cur.rowcount > 0
|
|
325
|
+
|
|
326
|
+
async def pool_claim_any(
|
|
327
|
+
self,
|
|
328
|
+
name: str,
|
|
329
|
+
where: dict[str, Any],
|
|
330
|
+
hold_seconds: int,
|
|
331
|
+
) -> Optional[RawItem]:
|
|
332
|
+
return await asyncio.to_thread(self._pool_claim_any_sync, name, where, hold_seconds)
|
|
333
|
+
|
|
334
|
+
def _pool_claim_any_sync(
|
|
335
|
+
self, name: str, where: dict[str, Any], hold_seconds: int
|
|
336
|
+
) -> Optional[RawItem]:
|
|
337
|
+
now = time.time()
|
|
338
|
+
until = now + max(1, hold_seconds)
|
|
339
|
+
with self._lock:
|
|
340
|
+
# purge expired items + claims first
|
|
341
|
+
self._conn.execute(
|
|
342
|
+
"DELETE FROM pool_items WHERE name=? AND expires_at IS NOT NULL AND expires_at < ?",
|
|
343
|
+
(name, now),
|
|
344
|
+
)
|
|
345
|
+
self._conn.execute(
|
|
346
|
+
"DELETE FROM pool_claims WHERE name=? AND until < ?",
|
|
347
|
+
(name, now),
|
|
348
|
+
)
|
|
349
|
+
# newest items first (largest expires_at; NULL last).
|
|
350
|
+
rows = self._conn.execute(
|
|
351
|
+
"SELECT i.item_id, i.payload, i.tags, i.expires_at "
|
|
352
|
+
"FROM pool_items i "
|
|
353
|
+
"LEFT JOIN pool_claims c ON c.name=i.name AND c.item_id=i.item_id "
|
|
354
|
+
"WHERE i.name=? AND c.item_id IS NULL "
|
|
355
|
+
"ORDER BY (i.expires_at IS NULL), i.expires_at DESC",
|
|
356
|
+
(name,),
|
|
357
|
+
).fetchall()
|
|
358
|
+
for iid, payload, tags_json, exp in rows:
|
|
359
|
+
tags = json.loads(tags_json)
|
|
360
|
+
if not all(tags.get(k) == v for k, v in where.items()):
|
|
361
|
+
continue
|
|
362
|
+
try:
|
|
363
|
+
self._conn.execute(
|
|
364
|
+
"INSERT INTO pool_claims(name, item_id, until) VALUES(?,?,?)",
|
|
365
|
+
(name, iid, until),
|
|
366
|
+
)
|
|
367
|
+
except sqlite3.IntegrityError:
|
|
368
|
+
continue
|
|
369
|
+
return RawItem(
|
|
370
|
+
id=iid,
|
|
371
|
+
payload=payload,
|
|
372
|
+
tags=tags,
|
|
373
|
+
expires_at=exp,
|
|
374
|
+
in_use_until=until,
|
|
375
|
+
)
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
# ------- Locks ----------
|
|
379
|
+
|
|
380
|
+
async def lock_acquire(
|
|
381
|
+
self, name: str, hold_seconds: int, wait_seconds: float
|
|
382
|
+
) -> Optional[str]:
|
|
383
|
+
deadline = time.monotonic() + max(0.0, wait_seconds)
|
|
384
|
+
backoff = 0.05
|
|
385
|
+
while True:
|
|
386
|
+
tok = await asyncio.to_thread(self._lock_try_acquire_sync, name, hold_seconds)
|
|
387
|
+
if tok is not None:
|
|
388
|
+
return tok
|
|
389
|
+
if time.monotonic() >= deadline:
|
|
390
|
+
return None
|
|
391
|
+
await asyncio.sleep(min(backoff, max(0.0, deadline - time.monotonic())))
|
|
392
|
+
backoff = min(backoff * 2, 0.5)
|
|
393
|
+
|
|
394
|
+
def _lock_try_acquire_sync(self, name: str, hold_seconds: int) -> Optional[str]:
|
|
395
|
+
now = time.time()
|
|
396
|
+
until = now + max(1, hold_seconds)
|
|
397
|
+
with self._lock:
|
|
398
|
+
self._conn.execute(
|
|
399
|
+
"DELETE FROM locks WHERE name=? AND expires_at < ?", (name, now)
|
|
400
|
+
)
|
|
401
|
+
token = secrets.token_hex(16)
|
|
402
|
+
try:
|
|
403
|
+
self._conn.execute(
|
|
404
|
+
"INSERT INTO locks(name, token, expires_at) VALUES(?,?,?)",
|
|
405
|
+
(name, token, until),
|
|
406
|
+
)
|
|
407
|
+
return token
|
|
408
|
+
except sqlite3.IntegrityError:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
async def lock_release(self, name: str, token: str) -> bool:
|
|
412
|
+
return await asyncio.to_thread(self._lock_release_sync, name, token)
|
|
413
|
+
|
|
414
|
+
def _lock_release_sync(self, name: str, token: str) -> bool:
|
|
415
|
+
with self._lock:
|
|
416
|
+
cur = self._conn.execute(
|
|
417
|
+
"DELETE FROM locks WHERE name=? AND token=?", (name, token)
|
|
418
|
+
)
|
|
419
|
+
return cur.rowcount > 0
|
|
420
|
+
|
|
421
|
+
# ------- Counters ----------
|
|
422
|
+
|
|
423
|
+
async def counter_incr(self, name: str, by: int) -> int:
|
|
424
|
+
return await asyncio.to_thread(self._counter_incr_sync, name, by)
|
|
425
|
+
|
|
426
|
+
def _counter_incr_sync(self, name: str, by: int) -> int:
|
|
427
|
+
with self._lock:
|
|
428
|
+
self._conn.execute(
|
|
429
|
+
"INSERT INTO counters(name, value) VALUES(?,0) "
|
|
430
|
+
"ON CONFLICT(name) DO NOTHING",
|
|
431
|
+
(name,),
|
|
432
|
+
)
|
|
433
|
+
self._conn.execute(
|
|
434
|
+
"UPDATE counters SET value = value + ? WHERE name=?",
|
|
435
|
+
(by, name),
|
|
436
|
+
)
|
|
437
|
+
row = self._conn.execute(
|
|
438
|
+
"SELECT value FROM counters WHERE name=?", (name,)
|
|
439
|
+
).fetchone()
|
|
440
|
+
return int(row[0])
|
|
441
|
+
|
|
442
|
+
async def counter_get(self, name: str) -> int:
|
|
443
|
+
return await asyncio.to_thread(self._counter_get_sync, name)
|
|
444
|
+
|
|
445
|
+
def _counter_get_sync(self, name: str) -> int:
|
|
446
|
+
with self._lock:
|
|
447
|
+
row = self._conn.execute(
|
|
448
|
+
"SELECT value FROM counters WHERE name=?", (name,)
|
|
449
|
+
).fetchone()
|
|
450
|
+
return int(row[0]) if row else 0
|
|
451
|
+
|
|
452
|
+
async def counter_set(self, name: str, value: int) -> None:
|
|
453
|
+
await asyncio.to_thread(self._counter_set_sync, name, value)
|
|
454
|
+
|
|
455
|
+
def _counter_set_sync(self, name: str, value: int) -> None:
|
|
456
|
+
with self._lock:
|
|
457
|
+
self._conn.execute(
|
|
458
|
+
"INSERT OR REPLACE INTO counters(name, value) VALUES(?,?)",
|
|
459
|
+
(name, value),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
async def counter_del(self, name: str) -> None:
|
|
463
|
+
await asyncio.to_thread(self._counter_del_sync, name)
|
|
464
|
+
|
|
465
|
+
def _counter_del_sync(self, name: str) -> None:
|
|
466
|
+
with self._lock:
|
|
467
|
+
self._conn.execute("DELETE FROM counters WHERE name=?", (name,))
|
|
468
|
+
|
|
469
|
+
# ------- Queues ----------
|
|
470
|
+
|
|
471
|
+
async def queue_push(self, name: str, body: bytes, *, priority: int = 0) -> None:
|
|
472
|
+
await asyncio.to_thread(self._queue_push_sync, name, body, priority)
|
|
473
|
+
|
|
474
|
+
def _queue_push_sync(self, name: str, body: bytes, priority: int) -> None:
|
|
475
|
+
tier = _tier_for_priority(priority)
|
|
476
|
+
with self._lock:
|
|
477
|
+
self._conn.execute(
|
|
478
|
+
"INSERT INTO queues(name, tier, body) VALUES(?,?,?)",
|
|
479
|
+
(name, tier, body),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
async def queue_size(self, name: str) -> int:
|
|
483
|
+
return await asyncio.to_thread(self._queue_size_sync, name)
|
|
484
|
+
|
|
485
|
+
def _queue_size_sync(self, name: str) -> int:
|
|
486
|
+
with self._lock:
|
|
487
|
+
row = self._conn.execute(
|
|
488
|
+
"SELECT COUNT(*) FROM queues WHERE name=?", (name,)
|
|
489
|
+
).fetchone()
|
|
490
|
+
return int(row[0])
|
|
491
|
+
|
|
492
|
+
async def queue_peek(self, name: str, n: int) -> list[bytes]:
|
|
493
|
+
if n <= 0:
|
|
494
|
+
return []
|
|
495
|
+
return await asyncio.to_thread(self._queue_peek_sync, name, n)
|
|
496
|
+
|
|
497
|
+
def _queue_peek_sync(self, name: str, n: int) -> list[bytes]:
|
|
498
|
+
out: list[bytes] = []
|
|
499
|
+
with self._lock:
|
|
500
|
+
for tier in _TIER_ORDER:
|
|
501
|
+
remaining = n - len(out)
|
|
502
|
+
if remaining <= 0:
|
|
503
|
+
break
|
|
504
|
+
rows = self._conn.execute(
|
|
505
|
+
"SELECT body FROM queues WHERE name=? AND tier=? "
|
|
506
|
+
"ORDER BY id ASC LIMIT ?",
|
|
507
|
+
(name, tier, remaining),
|
|
508
|
+
).fetchall()
|
|
509
|
+
out.extend(row[0] for row in rows)
|
|
510
|
+
return out
|
|
511
|
+
|
|
512
|
+
async def queue_reserve(
|
|
513
|
+
self, name: str, timeout_seconds: float, visibility_seconds: int
|
|
514
|
+
) -> Optional[bytes]:
|
|
515
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
516
|
+
backoff = 0.05
|
|
517
|
+
while True:
|
|
518
|
+
body = await asyncio.to_thread(self._queue_reserve_sync, name, visibility_seconds)
|
|
519
|
+
if body is not None:
|
|
520
|
+
return body
|
|
521
|
+
if time.monotonic() >= deadline:
|
|
522
|
+
return None
|
|
523
|
+
await asyncio.sleep(min(backoff, max(0.0, deadline - time.monotonic())))
|
|
524
|
+
backoff = min(backoff * 2, 0.5)
|
|
525
|
+
|
|
526
|
+
def _queue_reserve_sync(
|
|
527
|
+
self, name: str, visibility_seconds: int
|
|
528
|
+
) -> Optional[bytes]:
|
|
529
|
+
now = time.time()
|
|
530
|
+
with self._lock:
|
|
531
|
+
# 1. Move due-scheduled into the queue (normal tier).
|
|
532
|
+
due = self._conn.execute(
|
|
533
|
+
"SELECT id, body FROM queue_scheduled WHERE name=? AND available_at<=?",
|
|
534
|
+
(name, now),
|
|
535
|
+
).fetchall()
|
|
536
|
+
if due:
|
|
537
|
+
ids = [row[0] for row in due]
|
|
538
|
+
placeholders = ",".join("?" * len(ids))
|
|
539
|
+
for _id, body in due:
|
|
540
|
+
self._conn.execute(
|
|
541
|
+
"INSERT INTO queues(name, tier, body) VALUES(?,?,?)",
|
|
542
|
+
(name, "normal", body),
|
|
543
|
+
)
|
|
544
|
+
self._conn.execute(
|
|
545
|
+
f"DELETE FROM queue_scheduled WHERE id IN ({placeholders})",
|
|
546
|
+
ids,
|
|
547
|
+
)
|
|
548
|
+
# 2. Pop one body in priority order.
|
|
549
|
+
body: Optional[bytes] = None
|
|
550
|
+
for tier in _TIER_ORDER:
|
|
551
|
+
row = self._conn.execute(
|
|
552
|
+
"SELECT id, body FROM queues WHERE name=? AND tier=? "
|
|
553
|
+
"ORDER BY id ASC LIMIT 1",
|
|
554
|
+
(name, tier),
|
|
555
|
+
).fetchone()
|
|
556
|
+
if row is not None:
|
|
557
|
+
self._conn.execute("DELETE FROM queues WHERE id=?", (row[0],))
|
|
558
|
+
body = row[1]
|
|
559
|
+
break
|
|
560
|
+
if body is None:
|
|
561
|
+
return None
|
|
562
|
+
# 3. Add to processing table.
|
|
563
|
+
try:
|
|
564
|
+
envelope = json.loads(body.decode("utf-8"))
|
|
565
|
+
task_id = str(envelope.get("id", ""))
|
|
566
|
+
except Exception:
|
|
567
|
+
task_id = ""
|
|
568
|
+
if task_id:
|
|
569
|
+
self._conn.execute(
|
|
570
|
+
"INSERT OR REPLACE INTO queue_processing(name, task_id, body, deadline) "
|
|
571
|
+
"VALUES(?,?,?,?)",
|
|
572
|
+
(name, task_id, body, now + max(1, visibility_seconds)),
|
|
573
|
+
)
|
|
574
|
+
return body
|
|
575
|
+
|
|
576
|
+
async def queue_ack(self, name: str, task_id: str) -> bool:
|
|
577
|
+
return await asyncio.to_thread(self._queue_ack_sync, name, task_id)
|
|
578
|
+
|
|
579
|
+
def _queue_ack_sync(self, name: str, task_id: str) -> bool:
|
|
580
|
+
with self._lock:
|
|
581
|
+
cur = self._conn.execute(
|
|
582
|
+
"DELETE FROM queue_processing WHERE name=? AND task_id=?",
|
|
583
|
+
(name, task_id),
|
|
584
|
+
)
|
|
585
|
+
return cur.rowcount > 0
|
|
586
|
+
|
|
587
|
+
async def queue_nack(
|
|
588
|
+
self, name: str, task_id: str, body: bytes, *, delay_seconds: float
|
|
589
|
+
) -> bool:
|
|
590
|
+
return await asyncio.to_thread(
|
|
591
|
+
self._queue_nack_sync, name, task_id, body, delay_seconds,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
def _queue_nack_sync(
|
|
595
|
+
self, name: str, task_id: str, body: bytes, delay_seconds: float
|
|
596
|
+
) -> bool:
|
|
597
|
+
with self._lock:
|
|
598
|
+
cur = self._conn.execute(
|
|
599
|
+
"DELETE FROM queue_processing WHERE name=? AND task_id=?",
|
|
600
|
+
(name, task_id),
|
|
601
|
+
)
|
|
602
|
+
had = cur.rowcount > 0
|
|
603
|
+
if delay_seconds > 0:
|
|
604
|
+
self._conn.execute(
|
|
605
|
+
"INSERT INTO queue_scheduled(name, available_at, body) VALUES(?,?,?)",
|
|
606
|
+
(name, time.time() + delay_seconds, body),
|
|
607
|
+
)
|
|
608
|
+
else:
|
|
609
|
+
self._conn.execute(
|
|
610
|
+
"INSERT INTO queues(name, tier, body) VALUES(?,?,?)",
|
|
611
|
+
(name, "normal", body),
|
|
612
|
+
)
|
|
613
|
+
return had
|
|
614
|
+
|
|
615
|
+
async def queue_sweep_expired(self, name: str, now: Optional[float] = None) -> int:
|
|
616
|
+
return await asyncio.to_thread(self._queue_sweep_sync, name, now)
|
|
617
|
+
|
|
618
|
+
def _queue_sweep_sync(self, name: str, now: Optional[float]) -> int:
|
|
619
|
+
n = now if now is not None else time.time()
|
|
620
|
+
with self._lock:
|
|
621
|
+
rows = self._conn.execute(
|
|
622
|
+
"SELECT task_id, body FROM queue_processing WHERE name=? AND deadline<=?",
|
|
623
|
+
(name, n),
|
|
624
|
+
).fetchall()
|
|
625
|
+
for task_id, body in rows:
|
|
626
|
+
self._conn.execute(
|
|
627
|
+
"INSERT INTO queues(name, tier, body) VALUES(?,?,?)",
|
|
628
|
+
(name, "normal", body),
|
|
629
|
+
)
|
|
630
|
+
self._conn.execute(
|
|
631
|
+
"DELETE FROM queue_processing WHERE name=? AND task_id=?",
|
|
632
|
+
(name, task_id),
|
|
633
|
+
)
|
|
634
|
+
return len(rows)
|
|
635
|
+
|
|
636
|
+
async def queue_dead(self, name: str, body: bytes) -> None:
|
|
637
|
+
await asyncio.to_thread(self._queue_dead_sync, name, body)
|
|
638
|
+
|
|
639
|
+
def _queue_dead_sync(self, name: str, body: bytes) -> None:
|
|
640
|
+
with self._lock:
|
|
641
|
+
self._conn.execute(
|
|
642
|
+
"INSERT INTO queue_dead(name, body) VALUES(?,?)", (name, body),
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
async def dlq_peek(self, name: str, n: int) -> list[bytes]:
|
|
646
|
+
return await asyncio.to_thread(self._dlq_peek_sync, name, n)
|
|
647
|
+
|
|
648
|
+
def _dlq_peek_sync(self, name: str, n: int) -> list[bytes]:
|
|
649
|
+
with self._lock:
|
|
650
|
+
rows = self._conn.execute(
|
|
651
|
+
"SELECT body FROM queue_dead WHERE name=? ORDER BY id ASC LIMIT ?",
|
|
652
|
+
(name, n),
|
|
653
|
+
).fetchall()
|
|
654
|
+
return [r[0] for r in rows]
|
|
655
|
+
|
|
656
|
+
async def dlq_pop(self, name: str, n: int) -> list[bytes]:
|
|
657
|
+
return await asyncio.to_thread(self._dlq_pop_sync, name, n)
|
|
658
|
+
|
|
659
|
+
def _dlq_pop_sync(self, name: str, n: int) -> list[bytes]:
|
|
660
|
+
with self._lock:
|
|
661
|
+
rows = self._conn.execute(
|
|
662
|
+
"SELECT id, body FROM queue_dead WHERE name=? ORDER BY id ASC LIMIT ?",
|
|
663
|
+
(name, n),
|
|
664
|
+
).fetchall()
|
|
665
|
+
ids = [r[0] for r in rows]
|
|
666
|
+
if ids:
|
|
667
|
+
placeholders = ",".join("?" * len(ids))
|
|
668
|
+
self._conn.execute(
|
|
669
|
+
f"DELETE FROM queue_dead WHERE id IN ({placeholders})", ids,
|
|
670
|
+
)
|
|
671
|
+
return [r[1] for r in rows]
|
|
672
|
+
|
|
673
|
+
async def dlq_length(self, name: str) -> int:
|
|
674
|
+
return await asyncio.to_thread(self._dlq_length_sync, name)
|
|
675
|
+
|
|
676
|
+
def _dlq_length_sync(self, name: str) -> int:
|
|
677
|
+
with self._lock:
|
|
678
|
+
row = self._conn.execute(
|
|
679
|
+
"SELECT COUNT(*) FROM queue_dead WHERE name=?", (name,)
|
|
680
|
+
).fetchone()
|
|
681
|
+
return int(row[0])
|
|
682
|
+
|
|
683
|
+
async def dlq_clear(self, name: str) -> int:
|
|
684
|
+
return await asyncio.to_thread(self._dlq_clear_sync, name)
|
|
685
|
+
|
|
686
|
+
def _dlq_clear_sync(self, name: str) -> int:
|
|
687
|
+
with self._lock:
|
|
688
|
+
cur = self._conn.execute("DELETE FROM queue_dead WHERE name=?", (name,))
|
|
689
|
+
return cur.rowcount
|
|
690
|
+
|
|
691
|
+
# ------- Streams ----------
|
|
692
|
+
|
|
693
|
+
async def stream_push(
|
|
694
|
+
self,
|
|
695
|
+
name: str,
|
|
696
|
+
envelope: bytes,
|
|
697
|
+
*,
|
|
698
|
+
score: float,
|
|
699
|
+
max_len: int,
|
|
700
|
+
ttl_seconds: int,
|
|
701
|
+
) -> None:
|
|
702
|
+
await asyncio.to_thread(
|
|
703
|
+
self._stream_push_sync, name, envelope, score, max_len, ttl_seconds,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def _stream_push_sync(
|
|
707
|
+
self, name: str, envelope: bytes, score: float, max_len: int, ttl_seconds: int,
|
|
708
|
+
) -> None:
|
|
709
|
+
with self._lock:
|
|
710
|
+
self._conn.execute(
|
|
711
|
+
"INSERT INTO streams(name, score, body) VALUES(?,?,?)",
|
|
712
|
+
(name, score, envelope),
|
|
713
|
+
)
|
|
714
|
+
if ttl_seconds > 0:
|
|
715
|
+
cutoff = score - ttl_seconds
|
|
716
|
+
self._conn.execute(
|
|
717
|
+
"DELETE FROM streams WHERE name=? AND score<?", (name, cutoff),
|
|
718
|
+
)
|
|
719
|
+
if max_len > 0:
|
|
720
|
+
row = self._conn.execute(
|
|
721
|
+
"SELECT COUNT(*) FROM streams WHERE name=?", (name,)
|
|
722
|
+
).fetchone()
|
|
723
|
+
count = int(row[0])
|
|
724
|
+
if count > max_len:
|
|
725
|
+
extra = count - max_len
|
|
726
|
+
self._conn.execute(
|
|
727
|
+
"DELETE FROM streams WHERE id IN ("
|
|
728
|
+
"SELECT id FROM streams WHERE name=? ORDER BY score ASC LIMIT ?)",
|
|
729
|
+
(name, extra),
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
async def stream_pop(self, name: str, n: int) -> list[bytes]:
|
|
733
|
+
return await asyncio.to_thread(self._stream_pop_sync, name, n)
|
|
734
|
+
|
|
735
|
+
def _stream_pop_sync(self, name: str, n: int) -> list[bytes]:
|
|
736
|
+
if n <= 0:
|
|
737
|
+
return []
|
|
738
|
+
with self._lock:
|
|
739
|
+
rows = self._conn.execute(
|
|
740
|
+
"SELECT id, body FROM streams WHERE name=? ORDER BY score ASC LIMIT ?",
|
|
741
|
+
(name, n),
|
|
742
|
+
).fetchall()
|
|
743
|
+
ids = [r[0] for r in rows]
|
|
744
|
+
if ids:
|
|
745
|
+
placeholders = ",".join("?" * len(ids))
|
|
746
|
+
self._conn.execute(
|
|
747
|
+
f"DELETE FROM streams WHERE id IN ({placeholders})", ids,
|
|
748
|
+
)
|
|
749
|
+
return [r[1] for r in rows]
|
|
750
|
+
|
|
751
|
+
async def stream_peek(self, name: str, n: int) -> list[bytes]:
|
|
752
|
+
return await asyncio.to_thread(self._stream_peek_sync, name, n)
|
|
753
|
+
|
|
754
|
+
def _stream_peek_sync(self, name: str, n: int) -> list[bytes]:
|
|
755
|
+
with self._lock:
|
|
756
|
+
rows = self._conn.execute(
|
|
757
|
+
"SELECT body FROM streams WHERE name=? ORDER BY score ASC LIMIT ?",
|
|
758
|
+
(name, n),
|
|
759
|
+
).fetchall()
|
|
760
|
+
return [r[0] for r in rows]
|
|
761
|
+
|
|
762
|
+
async def stream_length(self, name: str) -> int:
|
|
763
|
+
return await asyncio.to_thread(self._stream_length_sync, name)
|
|
764
|
+
|
|
765
|
+
def _stream_length_sync(self, name: str) -> int:
|
|
766
|
+
with self._lock:
|
|
767
|
+
row = self._conn.execute(
|
|
768
|
+
"SELECT COUNT(*) FROM streams WHERE name=?", (name,)
|
|
769
|
+
).fetchone()
|
|
770
|
+
return int(row[0])
|
|
771
|
+
|
|
772
|
+
async def stream_trim(self, name: str, *, max_len: int, ttl_seconds: int) -> None:
|
|
773
|
+
await asyncio.to_thread(self._stream_trim_sync, name, max_len, ttl_seconds)
|
|
774
|
+
|
|
775
|
+
def _stream_trim_sync(self, name: str, max_len: int, ttl_seconds: int) -> None:
|
|
776
|
+
with self._lock:
|
|
777
|
+
if ttl_seconds > 0:
|
|
778
|
+
cutoff = time.time() - ttl_seconds
|
|
779
|
+
self._conn.execute(
|
|
780
|
+
"DELETE FROM streams WHERE name=? AND score<?", (name, cutoff),
|
|
781
|
+
)
|
|
782
|
+
if max_len > 0:
|
|
783
|
+
row = self._conn.execute(
|
|
784
|
+
"SELECT COUNT(*) FROM streams WHERE name=?", (name,)
|
|
785
|
+
).fetchone()
|
|
786
|
+
count = int(row[0])
|
|
787
|
+
if count > max_len:
|
|
788
|
+
extra = count - max_len
|
|
789
|
+
self._conn.execute(
|
|
790
|
+
"DELETE FROM streams WHERE id IN ("
|
|
791
|
+
"SELECT id FROM streams WHERE name=? ORDER BY score ASC LIMIT ?)",
|
|
792
|
+
(name, extra),
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
async def event_publish(self, topic: str, body: bytes) -> None:
|
|
796
|
+
# No subscribers in a single-process sqlite deploy. Same caveat as
|
|
797
|
+
# InMemoryBackend: plugins that need pub/sub must use a real broker.
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
# ------- Workers ----------
|
|
801
|
+
|
|
802
|
+
async def worker_register(
|
|
803
|
+
self, automation_type: str, worker_id: str, hardware: dict[str, Any]
|
|
804
|
+
) -> None:
|
|
805
|
+
await asyncio.to_thread(self._worker_register_sync, automation_type, worker_id, hardware)
|
|
806
|
+
|
|
807
|
+
def _worker_register_sync(
|
|
808
|
+
self, automation_type: str, worker_id: str, hardware: dict[str, Any]
|
|
809
|
+
) -> None:
|
|
810
|
+
hw_json = json.dumps(hardware)
|
|
811
|
+
with self._lock:
|
|
812
|
+
row = self._conn.execute(
|
|
813
|
+
"SELECT 1 FROM workers WHERE automation_type=? AND worker_id=?",
|
|
814
|
+
(automation_type, worker_id),
|
|
815
|
+
).fetchone()
|
|
816
|
+
if row is None:
|
|
817
|
+
self._conn.execute(
|
|
818
|
+
"INSERT INTO workers(automation_type, worker_id, hardware, config, "
|
|
819
|
+
"config_gen, state, last_seen, last_error, stats) "
|
|
820
|
+
"VALUES(?,?,?,?,0,'IDLE',0,NULL,'{}')",
|
|
821
|
+
(automation_type, worker_id, hw_json, "{}"),
|
|
822
|
+
)
|
|
823
|
+
else:
|
|
824
|
+
self._conn.execute(
|
|
825
|
+
"UPDATE workers SET hardware=? WHERE automation_type=? AND worker_id=?",
|
|
826
|
+
(hw_json, automation_type, worker_id),
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
async def worker_get_config(
|
|
830
|
+
self, automation_type: str, worker_id: str
|
|
831
|
+
) -> tuple[dict[str, Any], int]:
|
|
832
|
+
return await asyncio.to_thread(self._worker_get_config_sync, automation_type, worker_id)
|
|
833
|
+
|
|
834
|
+
def _worker_get_config_sync(
|
|
835
|
+
self, automation_type: str, worker_id: str
|
|
836
|
+
) -> tuple[dict[str, Any], int]:
|
|
837
|
+
with self._lock:
|
|
838
|
+
row = self._conn.execute(
|
|
839
|
+
"SELECT config, config_gen FROM workers WHERE automation_type=? AND worker_id=?",
|
|
840
|
+
(automation_type, worker_id),
|
|
841
|
+
).fetchone()
|
|
842
|
+
if row is None:
|
|
843
|
+
return {}, 0
|
|
844
|
+
return json.loads(row[0]), int(row[1])
|
|
845
|
+
|
|
846
|
+
async def worker_set_config(
|
|
847
|
+
self, automation_type: str, worker_id: str, config: dict[str, Any]
|
|
848
|
+
) -> tuple[dict[str, Any], int]:
|
|
849
|
+
return await asyncio.to_thread(self._worker_set_config_sync, automation_type, worker_id, config)
|
|
850
|
+
|
|
851
|
+
def _worker_set_config_sync(
|
|
852
|
+
self, automation_type: str, worker_id: str, config: dict[str, Any]
|
|
853
|
+
) -> tuple[dict[str, Any], int]:
|
|
854
|
+
cfg_json = json.dumps(config)
|
|
855
|
+
with self._lock:
|
|
856
|
+
row = self._conn.execute(
|
|
857
|
+
"SELECT config_gen FROM workers WHERE automation_type=? AND worker_id=?",
|
|
858
|
+
(automation_type, worker_id),
|
|
859
|
+
).fetchone()
|
|
860
|
+
if row is None:
|
|
861
|
+
self._conn.execute(
|
|
862
|
+
"INSERT INTO workers(automation_type, worker_id, hardware, config, "
|
|
863
|
+
"config_gen, state, last_seen, last_error, stats) "
|
|
864
|
+
"VALUES(?,?,'{}',?,1,'IDLE',0,NULL,'{}')",
|
|
865
|
+
(automation_type, worker_id, cfg_json),
|
|
866
|
+
)
|
|
867
|
+
new_gen = 1
|
|
868
|
+
else:
|
|
869
|
+
new_gen = int(row[0]) + 1
|
|
870
|
+
self._conn.execute(
|
|
871
|
+
"UPDATE workers SET config=?, config_gen=? WHERE automation_type=? AND worker_id=?",
|
|
872
|
+
(cfg_json, new_gen, automation_type, worker_id),
|
|
873
|
+
)
|
|
874
|
+
return json.loads(cfg_json), new_gen
|
|
875
|
+
|
|
876
|
+
async def worker_set_state(
|
|
877
|
+
self,
|
|
878
|
+
automation_type: str,
|
|
879
|
+
worker_id: str,
|
|
880
|
+
state: str,
|
|
881
|
+
last_error: Optional[str],
|
|
882
|
+
last_seen: float,
|
|
883
|
+
) -> None:
|
|
884
|
+
await asyncio.to_thread(
|
|
885
|
+
self._worker_set_state_sync, automation_type, worker_id, state, last_error, last_seen,
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
def _worker_set_state_sync(
|
|
889
|
+
self,
|
|
890
|
+
automation_type: str,
|
|
891
|
+
worker_id: str,
|
|
892
|
+
state: str,
|
|
893
|
+
last_error: Optional[str],
|
|
894
|
+
last_seen: float,
|
|
895
|
+
) -> None:
|
|
896
|
+
with self._lock:
|
|
897
|
+
row = self._conn.execute(
|
|
898
|
+
"SELECT 1 FROM workers WHERE automation_type=? AND worker_id=?",
|
|
899
|
+
(automation_type, worker_id),
|
|
900
|
+
).fetchone()
|
|
901
|
+
if row is None:
|
|
902
|
+
self._conn.execute(
|
|
903
|
+
"INSERT INTO workers(automation_type, worker_id, hardware, config, "
|
|
904
|
+
"config_gen, state, last_seen, last_error, stats) "
|
|
905
|
+
"VALUES(?,?,'{}','{}',0,?,?,?,'{}')",
|
|
906
|
+
(automation_type, worker_id, state, last_seen, last_error),
|
|
907
|
+
)
|
|
908
|
+
else:
|
|
909
|
+
self._conn.execute(
|
|
910
|
+
"UPDATE workers SET state=?, last_seen=?, last_error=? "
|
|
911
|
+
"WHERE automation_type=? AND worker_id=?",
|
|
912
|
+
(state, last_seen, last_error, automation_type, worker_id),
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
async def worker_set_stats(
|
|
916
|
+
self, automation_type: str, worker_id: str, stats: dict[str, Any]
|
|
917
|
+
) -> None:
|
|
918
|
+
await asyncio.to_thread(self._worker_set_stats_sync, automation_type, worker_id, stats)
|
|
919
|
+
|
|
920
|
+
def _worker_set_stats_sync(
|
|
921
|
+
self, automation_type: str, worker_id: str, stats: dict[str, Any]
|
|
922
|
+
) -> None:
|
|
923
|
+
stats_json = json.dumps(stats)
|
|
924
|
+
with self._lock:
|
|
925
|
+
row = self._conn.execute(
|
|
926
|
+
"SELECT 1 FROM workers WHERE automation_type=? AND worker_id=?",
|
|
927
|
+
(automation_type, worker_id),
|
|
928
|
+
).fetchone()
|
|
929
|
+
if row is None:
|
|
930
|
+
self._conn.execute(
|
|
931
|
+
"INSERT INTO workers(automation_type, worker_id, hardware, config, "
|
|
932
|
+
"config_gen, state, last_seen, last_error, stats) "
|
|
933
|
+
"VALUES(?,?,'{}','{}',0,'IDLE',0,NULL,?)",
|
|
934
|
+
(automation_type, worker_id, stats_json),
|
|
935
|
+
)
|
|
936
|
+
else:
|
|
937
|
+
self._conn.execute(
|
|
938
|
+
"UPDATE workers SET stats=? WHERE automation_type=? AND worker_id=?",
|
|
939
|
+
(stats_json, automation_type, worker_id),
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
async def worker_stats_push(
|
|
943
|
+
self,
|
|
944
|
+
automation_type: str,
|
|
945
|
+
worker_id: str,
|
|
946
|
+
ts: float,
|
|
947
|
+
frame: dict[str, Any],
|
|
948
|
+
*,
|
|
949
|
+
max_entries: int = 240,
|
|
950
|
+
) -> None:
|
|
951
|
+
await asyncio.to_thread(
|
|
952
|
+
self._worker_stats_push_sync, automation_type, worker_id, ts, frame, max_entries,
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
def _worker_stats_push_sync(
|
|
956
|
+
self,
|
|
957
|
+
automation_type: str,
|
|
958
|
+
worker_id: str,
|
|
959
|
+
ts: float,
|
|
960
|
+
frame: dict[str, Any],
|
|
961
|
+
max_entries: int,
|
|
962
|
+
) -> None:
|
|
963
|
+
frame_json = json.dumps(frame, default=str)
|
|
964
|
+
with self._lock:
|
|
965
|
+
self._conn.execute(
|
|
966
|
+
"INSERT INTO worker_stats_history(automation_type, worker_id, ts, frame) "
|
|
967
|
+
"VALUES(?,?,?,?)",
|
|
968
|
+
(automation_type, worker_id, ts, frame_json),
|
|
969
|
+
)
|
|
970
|
+
if max_entries > 0:
|
|
971
|
+
row = self._conn.execute(
|
|
972
|
+
"SELECT COUNT(*) FROM worker_stats_history "
|
|
973
|
+
"WHERE automation_type=? AND worker_id=?",
|
|
974
|
+
(automation_type, worker_id),
|
|
975
|
+
).fetchone()
|
|
976
|
+
count = int(row[0])
|
|
977
|
+
if count > max_entries:
|
|
978
|
+
extra = count - max_entries
|
|
979
|
+
self._conn.execute(
|
|
980
|
+
"DELETE FROM worker_stats_history WHERE rowid IN ("
|
|
981
|
+
"SELECT rowid FROM worker_stats_history "
|
|
982
|
+
"WHERE automation_type=? AND worker_id=? "
|
|
983
|
+
"ORDER BY ts ASC LIMIT ?)",
|
|
984
|
+
(automation_type, worker_id, extra),
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
async def worker_stats_history(
|
|
988
|
+
self,
|
|
989
|
+
automation_type: str,
|
|
990
|
+
worker_id: str,
|
|
991
|
+
*,
|
|
992
|
+
since: float = 0,
|
|
993
|
+
limit: int = 240,
|
|
994
|
+
) -> list[dict[str, Any]]:
|
|
995
|
+
return await asyncio.to_thread(
|
|
996
|
+
self._worker_stats_history_sync, automation_type, worker_id, since, limit,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
def _worker_stats_history_sync(
|
|
1000
|
+
self,
|
|
1001
|
+
automation_type: str,
|
|
1002
|
+
worker_id: str,
|
|
1003
|
+
since: float,
|
|
1004
|
+
limit: int,
|
|
1005
|
+
) -> list[dict[str, Any]]:
|
|
1006
|
+
with self._lock:
|
|
1007
|
+
rows = self._conn.execute(
|
|
1008
|
+
"SELECT frame FROM worker_stats_history "
|
|
1009
|
+
"WHERE automation_type=? AND worker_id=? AND ts>=? "
|
|
1010
|
+
"ORDER BY ts ASC LIMIT ?",
|
|
1011
|
+
(automation_type, worker_id, since, limit),
|
|
1012
|
+
).fetchall()
|
|
1013
|
+
return [json.loads(r[0]) for r in rows]
|
|
1014
|
+
|
|
1015
|
+
async def worker_get(
|
|
1016
|
+
self, automation_type: str, worker_id: str
|
|
1017
|
+
) -> Optional[dict[str, Any]]:
|
|
1018
|
+
return await asyncio.to_thread(self._worker_get_sync, automation_type, worker_id)
|
|
1019
|
+
|
|
1020
|
+
def _worker_get_sync(
|
|
1021
|
+
self, automation_type: str, worker_id: str
|
|
1022
|
+
) -> Optional[dict[str, Any]]:
|
|
1023
|
+
with self._lock:
|
|
1024
|
+
row = self._conn.execute(
|
|
1025
|
+
"SELECT hardware, config, config_gen, state, last_seen, last_error, stats "
|
|
1026
|
+
"FROM workers WHERE automation_type=? AND worker_id=?",
|
|
1027
|
+
(automation_type, worker_id),
|
|
1028
|
+
).fetchone()
|
|
1029
|
+
if row is None:
|
|
1030
|
+
return None
|
|
1031
|
+
return {
|
|
1032
|
+
"hardware": json.loads(row[0]),
|
|
1033
|
+
"config": json.loads(row[1]),
|
|
1034
|
+
"config_gen": int(row[2]),
|
|
1035
|
+
"state": row[3],
|
|
1036
|
+
"last_seen": float(row[4]),
|
|
1037
|
+
"last_error": row[5],
|
|
1038
|
+
"stats": json.loads(row[6]),
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async def worker_list(
|
|
1042
|
+
self, automation_type: Optional[str] = None
|
|
1043
|
+
) -> list[tuple[str, str]]:
|
|
1044
|
+
return await asyncio.to_thread(self._worker_list_sync, automation_type)
|
|
1045
|
+
|
|
1046
|
+
def _worker_list_sync(self, automation_type: Optional[str]) -> list[tuple[str, str]]:
|
|
1047
|
+
with self._lock:
|
|
1048
|
+
if automation_type is None:
|
|
1049
|
+
rows = self._conn.execute(
|
|
1050
|
+
"SELECT automation_type, worker_id FROM workers"
|
|
1051
|
+
).fetchall()
|
|
1052
|
+
else:
|
|
1053
|
+
rows = self._conn.execute(
|
|
1054
|
+
"SELECT automation_type, worker_id FROM workers WHERE automation_type=?",
|
|
1055
|
+
(automation_type,),
|
|
1056
|
+
).fetchall()
|
|
1057
|
+
return [(r[0], r[1]) for r in rows]
|
|
1058
|
+
|
|
1059
|
+
async def worker_remove(self, automation_type: str, worker_id: str) -> bool:
|
|
1060
|
+
return await asyncio.to_thread(self._worker_remove_sync, automation_type, worker_id)
|
|
1061
|
+
|
|
1062
|
+
def _worker_remove_sync(self, automation_type: str, worker_id: str) -> bool:
|
|
1063
|
+
with self._lock:
|
|
1064
|
+
cur = self._conn.execute(
|
|
1065
|
+
"DELETE FROM workers WHERE automation_type=? AND worker_id=?",
|
|
1066
|
+
(automation_type, worker_id),
|
|
1067
|
+
)
|
|
1068
|
+
self._conn.execute(
|
|
1069
|
+
"DELETE FROM worker_stats_history WHERE automation_type=? AND worker_id=?",
|
|
1070
|
+
(automation_type, worker_id),
|
|
1071
|
+
)
|
|
1072
|
+
return cur.rowcount > 0
|
|
1073
|
+
|
|
1074
|
+
async def catalog_set(self, automation_type: str, doc: bytes) -> None:
|
|
1075
|
+
await asyncio.to_thread(self._catalog_set_sync, automation_type, doc)
|
|
1076
|
+
|
|
1077
|
+
def _catalog_set_sync(self, automation_type: str, doc: bytes) -> None:
|
|
1078
|
+
with self._lock:
|
|
1079
|
+
self._conn.execute(
|
|
1080
|
+
"INSERT OR REPLACE INTO catalog(automation_type, doc) VALUES(?,?)",
|
|
1081
|
+
(automation_type, doc),
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
async def catalog_get(self, automation_type: str) -> Optional[bytes]:
|
|
1085
|
+
return await asyncio.to_thread(self._catalog_get_sync, automation_type)
|
|
1086
|
+
|
|
1087
|
+
def _catalog_get_sync(self, automation_type: str) -> Optional[bytes]:
|
|
1088
|
+
with self._lock:
|
|
1089
|
+
row = self._conn.execute(
|
|
1090
|
+
"SELECT doc FROM catalog WHERE automation_type=?", (automation_type,),
|
|
1091
|
+
).fetchone()
|
|
1092
|
+
return row[0] if row else None
|
|
1093
|
+
|
|
1094
|
+
async def catalog_all(self) -> dict[str, bytes]:
|
|
1095
|
+
return await asyncio.to_thread(self._catalog_all_sync)
|
|
1096
|
+
|
|
1097
|
+
def _catalog_all_sync(self) -> dict[str, bytes]:
|
|
1098
|
+
with self._lock:
|
|
1099
|
+
rows = self._conn.execute("SELECT automation_type, doc FROM catalog").fetchall()
|
|
1100
|
+
return {r[0]: r[1] for r in rows}
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _path_from_url(url: str) -> str:
|
|
1104
|
+
if "://" not in url:
|
|
1105
|
+
return url
|
|
1106
|
+
scheme, rest = url.split("://", 1)
|
|
1107
|
+
if scheme.lower() not in ("sqlite", "file"):
|
|
1108
|
+
raise ValueError(f"SqliteBackend cannot handle scheme: {scheme}")
|
|
1109
|
+
if rest in ("", ":memory:"):
|
|
1110
|
+
return ":memory:"
|
|
1111
|
+
if rest.startswith("/"):
|
|
1112
|
+
# sqlite:///abs/path → /abs/path; sqlite:////abs/path also works
|
|
1113
|
+
return rest
|
|
1114
|
+
return rest
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
__all__ = ["SqliteBackend"]
|