stackbox 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.
stackbox/ledger.py ADDED
@@ -0,0 +1,387 @@
1
+ """Durable record of every sandbox the gateway placed and every provisioning attempt.
2
+
3
+ Intent is written *before* an upstream create is sent, so a crash or an ambiguous
4
+ timeout never leaves a billable sandbox the gateway has no record of.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ # Sandbox states
18
+ PROVISIONING = "provisioning" # create sent (or about to be); outcome not yet known
19
+ UNKNOWN = (
20
+ "unknown" # create outcome ambiguous; must be reconciled before reuse of the slot
21
+ )
22
+ ACTIVE = "active"
23
+ FAILED = "failed"
24
+ DELETED = "deleted"
25
+ EXPIRED = "expired" # upstream no longer has it (timeout or external deletion)
26
+
27
+ HOLDING_STATES = (PROVISIONING, UNKNOWN, ACTIVE)
28
+
29
+ # Attempt outcomes
30
+ OK = "ok"
31
+ FAILED_CLEAR = "failed" # upstream definitely did not create a sandbox
32
+ AMBIGUOUS = "ambiguous" # may or may not exist upstream
33
+ ADOPTED = "adopted" # ambiguous attempt later found to exist upstream
34
+ ABSENT = "absent" # ambiguous attempt later confirmed absent
35
+ SATURATED = (
36
+ "saturated" # backend refused for quota/capacity; nothing created, not a fault
37
+ )
38
+ DISCARDED = (
39
+ "discarded" # ambiguous attempt found upstream but deleted instead of adopted
40
+ )
41
+ CLIENT_ERROR = "client_error" # request rejected as invalid; not retried elsewhere
42
+
43
+ _SCHEMA = """
44
+ CREATE TABLE IF NOT EXISTS sandboxes (
45
+ gw_id TEXT PRIMARY KEY,
46
+ backend TEXT,
47
+ upstream_id TEXT,
48
+ state TEXT NOT NULL,
49
+ requested_at REAL NOT NULL,
50
+ ready_at REAL,
51
+ ended_at REAL,
52
+ attempts INTEGER NOT NULL DEFAULT 0,
53
+ last_error TEXT,
54
+ cpu REAL,
55
+ memory_gib REAL,
56
+ pinned_backend TEXT,
57
+ client_metadata TEXT,
58
+ image TEXT,
59
+ backend_data TEXT
60
+ );
61
+ CREATE INDEX IF NOT EXISTS sandboxes_backend_state ON sandboxes(backend, state);
62
+ CREATE INDEX IF NOT EXISTS sandboxes_upstream ON sandboxes(backend, upstream_id);
63
+ CREATE TABLE IF NOT EXISTS attempts (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ gw_id TEXT NOT NULL,
66
+ backend TEXT NOT NULL,
67
+ started_at REAL NOT NULL,
68
+ finished_at REAL,
69
+ outcome TEXT,
70
+ http_status INTEGER,
71
+ error TEXT
72
+ );
73
+ CREATE INDEX IF NOT EXISTS attempts_gw ON attempts(gw_id);
74
+ CREATE TABLE IF NOT EXISTS images (
75
+ backend TEXT NOT NULL,
76
+ image TEXT NOT NULL,
77
+ status TEXT NOT NULL,
78
+ ref TEXT,
79
+ error TEXT,
80
+ detail TEXT,
81
+ updated_at REAL NOT NULL,
82
+ PRIMARY KEY (backend, image)
83
+ );
84
+ CREATE TABLE IF NOT EXISTS kv (
85
+ key TEXT PRIMARY KEY,
86
+ value TEXT NOT NULL
87
+ );
88
+ """
89
+
90
+ # Columns added after the first release; applied to existing databases on open.
91
+ _MIGRATIONS = [
92
+ "ALTER TABLE sandboxes ADD COLUMN image TEXT",
93
+ "ALTER TABLE sandboxes ADD COLUMN backend_data TEXT",
94
+ ]
95
+
96
+ # Image preparation states
97
+ IMAGE_BUILDING = "building"
98
+ IMAGE_READY = "ready"
99
+ IMAGE_FAILED = "failed"
100
+
101
+
102
+ @dataclass
103
+ class SandboxRecord:
104
+ gw_id: str
105
+ backend: str | None
106
+ upstream_id: str | None
107
+ state: str
108
+ requested_at: float
109
+ ready_at: float | None
110
+ ended_at: float | None
111
+ attempts: int
112
+ last_error: str | None
113
+ cpu: float | None
114
+ memory_gib: float | None
115
+ pinned_backend: str | None
116
+ client_metadata: dict[str, str]
117
+ image: str | None = None
118
+ backend_data: dict[str, Any] | None = None
119
+
120
+ @classmethod
121
+ def from_row(cls, row: sqlite3.Row) -> SandboxRecord:
122
+ d = dict(row)
123
+ d["client_metadata"] = json.loads(d["client_metadata"] or "{}")
124
+ d["backend_data"] = (
125
+ json.loads(d["backend_data"]) if d.get("backend_data") else None
126
+ )
127
+ return cls(**d)
128
+
129
+
130
+ @dataclass
131
+ class ImageRecord:
132
+ backend: str
133
+ image: str
134
+ status: str
135
+ ref: str | None
136
+ error: str | None
137
+ detail: dict[str, Any] | None
138
+ updated_at: float
139
+
140
+
141
+ class Ledger:
142
+ def __init__(self, path: str):
143
+ if path != ":memory:":
144
+ Path(path).expanduser().parent.mkdir(parents=True, exist_ok=True)
145
+ path = str(Path(path).expanduser())
146
+ self._conn = sqlite3.connect(
147
+ path, check_same_thread=False, isolation_level=None
148
+ )
149
+ self._conn.row_factory = sqlite3.Row
150
+ self._conn.execute("PRAGMA journal_mode=WAL")
151
+ self._conn.executescript(_SCHEMA)
152
+ for stmt in _MIGRATIONS:
153
+ try:
154
+ self._conn.execute(stmt)
155
+ except sqlite3.OperationalError:
156
+ pass # column already exists
157
+ self._lock = threading.Lock()
158
+
159
+ def _exec(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
160
+ with self._lock:
161
+ return self._conn.execute(sql, params)
162
+
163
+ # --- sandboxes -------------------------------------------------------
164
+ def create_intent(
165
+ self,
166
+ gw_id: str,
167
+ *,
168
+ cpu: float | None,
169
+ memory_gib: float | None,
170
+ pinned_backend: str | None,
171
+ client_metadata: dict[str, str],
172
+ image: str | None = None,
173
+ ) -> None:
174
+ self._exec(
175
+ "INSERT INTO sandboxes (gw_id, state, requested_at, cpu, memory_gib, pinned_backend,"
176
+ " client_metadata, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
177
+ (
178
+ gw_id,
179
+ PROVISIONING,
180
+ time.time(),
181
+ cpu,
182
+ memory_gib,
183
+ pinned_backend,
184
+ json.dumps(client_metadata),
185
+ image,
186
+ ),
187
+ )
188
+
189
+ def claim_backend(self, gw_id: str, backend: str, max_active: int) -> bool:
190
+ """Atomically assign gw_id to backend if the backend has a free slot."""
191
+ with self._lock:
192
+ self._conn.execute("BEGIN IMMEDIATE")
193
+ try:
194
+ (held,) = self._conn.execute(
195
+ f"SELECT COUNT(*) FROM sandboxes WHERE backend = ? AND state IN ({','.join('?' * len(HOLDING_STATES))})",
196
+ (backend, *HOLDING_STATES),
197
+ ).fetchone()
198
+ if held >= max_active:
199
+ self._conn.execute("ROLLBACK")
200
+ return False
201
+ self._conn.execute(
202
+ "UPDATE sandboxes SET backend = ?, state = ?, attempts = attempts + 1 WHERE gw_id = ?",
203
+ (backend, PROVISIONING, gw_id),
204
+ )
205
+ self._conn.execute("COMMIT")
206
+ return True
207
+ except BaseException:
208
+ self._conn.execute("ROLLBACK")
209
+ raise
210
+
211
+ def release_backend(self, gw_id: str, error: str | None) -> None:
212
+ """Provisioning on the current backend definitely failed; free the slot for retry elsewhere."""
213
+ self._exec(
214
+ "UPDATE sandboxes SET backend = NULL, state = ?, last_error = ? WHERE gw_id = ?",
215
+ (PROVISIONING, error, gw_id),
216
+ )
217
+
218
+ def mark_active(
219
+ self,
220
+ gw_id: str,
221
+ upstream_id: str,
222
+ *,
223
+ cpu: float | None = None,
224
+ memory_gib: float | None = None,
225
+ backend_data: dict[str, Any] | None = None,
226
+ ) -> None:
227
+ """Record the sandbox as running. cpu/memory, when given, are what the backend
228
+ actually allocated (they replace the requested values for cost accounting)."""
229
+ self._exec(
230
+ "UPDATE sandboxes SET upstream_id = ?, state = ?, ready_at = ?,"
231
+ " cpu = COALESCE(?, cpu), memory_gib = COALESCE(?, memory_gib),"
232
+ " backend_data = COALESCE(?, backend_data) WHERE gw_id = ?",
233
+ (
234
+ upstream_id,
235
+ ACTIVE,
236
+ time.time(),
237
+ cpu,
238
+ memory_gib,
239
+ json.dumps(backend_data) if backend_data is not None else None,
240
+ gw_id,
241
+ ),
242
+ )
243
+
244
+ def set_state(self, gw_id: str, state: str, error: str | None = None) -> None:
245
+ ended = time.time() if state in (FAILED, DELETED, EXPIRED) else None
246
+ self._exec(
247
+ "UPDATE sandboxes SET state = ?, last_error = COALESCE(?, last_error),"
248
+ " ended_at = COALESCE(?, ended_at) WHERE gw_id = ?",
249
+ (state, error, ended, gw_id),
250
+ )
251
+
252
+ def get(self, gw_id: str) -> SandboxRecord | None:
253
+ row = self._exec("SELECT * FROM sandboxes WHERE gw_id = ?", (gw_id,)).fetchone()
254
+ return SandboxRecord.from_row(row) if row else None
255
+
256
+ def by_upstream(self, backend: str, upstream_id: str) -> SandboxRecord | None:
257
+ row = self._exec(
258
+ "SELECT * FROM sandboxes WHERE backend = ? AND upstream_id = ?",
259
+ (backend, upstream_id),
260
+ ).fetchone()
261
+ return SandboxRecord.from_row(row) if row else None
262
+
263
+ def in_states(self, *states: str) -> list[SandboxRecord]:
264
+ rows = self._exec(
265
+ f"SELECT * FROM sandboxes WHERE state IN ({','.join('?' * len(states))})",
266
+ states,
267
+ ).fetchall()
268
+ return [SandboxRecord.from_row(r) for r in rows]
269
+
270
+ def held_count(self, backend: str) -> int:
271
+ (n,) = self._exec(
272
+ f"SELECT COUNT(*) FROM sandboxes WHERE backend = ? AND state IN ({','.join('?' * len(HOLDING_STATES))})",
273
+ (backend, *HOLDING_STATES),
274
+ ).fetchone()
275
+ return n
276
+
277
+ def active_count(self, backend: str) -> int:
278
+ (n,) = self._exec(
279
+ "SELECT COUNT(*) FROM sandboxes WHERE backend = ? AND state = ?",
280
+ (backend, ACTIVE),
281
+ ).fetchone()
282
+ return n
283
+
284
+ def all(self) -> list[SandboxRecord]:
285
+ return [
286
+ SandboxRecord.from_row(r)
287
+ for r in self._exec("SELECT * FROM sandboxes").fetchall()
288
+ ]
289
+
290
+ # --- attempts --------------------------------------------------------
291
+ def start_attempt(self, gw_id: str, backend: str) -> int:
292
+ cur = self._exec(
293
+ "INSERT INTO attempts (gw_id, backend, started_at) VALUES (?, ?, ?)",
294
+ (gw_id, backend, time.time()),
295
+ )
296
+ return int(cur.lastrowid or 0)
297
+
298
+ def finish_attempt(
299
+ self,
300
+ attempt_id: int,
301
+ outcome: str,
302
+ http_status: int | None = None,
303
+ error: str | None = None,
304
+ ) -> None:
305
+ self._exec(
306
+ "UPDATE attempts SET finished_at = ?, outcome = ?, http_status = ?, error = ? WHERE id = ?",
307
+ (time.time(), outcome, http_status, error, attempt_id),
308
+ )
309
+
310
+ def resolve_ambiguous_attempt(self, gw_id: str, backend: str, outcome: str) -> None:
311
+ self._exec(
312
+ "UPDATE attempts SET outcome = ? WHERE gw_id = ? AND backend = ? AND outcome = ?",
313
+ (outcome, gw_id, backend, AMBIGUOUS),
314
+ )
315
+
316
+ def attempts(self) -> list[dict[str, Any]]:
317
+ return [
318
+ dict(r) for r in self._exec("SELECT * FROM attempts ORDER BY id").fetchall()
319
+ ]
320
+
321
+ def startup_samples(self) -> list[tuple[str, str | None, float]]:
322
+ """(backend, image, seconds) for every successful create, oldest first."""
323
+ rows = self._exec(
324
+ "SELECT a.backend, s.image, a.finished_at - a.started_at FROM attempts a"
325
+ " JOIN sandboxes s ON s.gw_id = a.gw_id"
326
+ " WHERE a.outcome = ? AND a.finished_at IS NOT NULL ORDER BY a.finished_at",
327
+ (OK,),
328
+ ).fetchall()
329
+ return [(r[0], r[1], float(r[2])) for r in rows]
330
+
331
+ # --- prepared images ---------------------------------------------------
332
+ def set_image(
333
+ self,
334
+ backend: str,
335
+ image: str,
336
+ status: str,
337
+ *,
338
+ ref: str | None = None,
339
+ error: str | None = None,
340
+ detail: dict[str, Any] | None = None,
341
+ ) -> None:
342
+ self._exec(
343
+ "INSERT INTO images (backend, image, status, ref, error, detail, updated_at)"
344
+ " VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (backend, image) DO UPDATE SET"
345
+ " status = excluded.status, ref = COALESCE(excluded.ref, images.ref),"
346
+ " error = excluded.error, detail = COALESCE(excluded.detail, images.detail),"
347
+ " updated_at = excluded.updated_at",
348
+ (
349
+ backend,
350
+ image,
351
+ status,
352
+ ref,
353
+ error,
354
+ json.dumps(detail) if detail is not None else None,
355
+ time.time(),
356
+ ),
357
+ )
358
+
359
+ def get_image(self, backend: str, image: str) -> ImageRecord | None:
360
+ row = self._exec(
361
+ "SELECT * FROM images WHERE backend = ? AND image = ?", (backend, image)
362
+ ).fetchone()
363
+ if row is None:
364
+ return None
365
+ d = dict(row)
366
+ d["detail"] = json.loads(d["detail"]) if d["detail"] else None
367
+ return ImageRecord(**d)
368
+
369
+ def images(self) -> list[dict[str, Any]]:
370
+ return [dict(r) for r in self._exec("SELECT * FROM images").fetchall()]
371
+
372
+ # --- key/value ---------------------------------------------------------
373
+ def get_or_create_secret(self, key: str, factory: Any) -> str:
374
+ with self._lock:
375
+ row = self._conn.execute(
376
+ "SELECT value FROM kv WHERE key = ?", (key,)
377
+ ).fetchone()
378
+ if row:
379
+ return str(row[0])
380
+ value = factory()
381
+ self._conn.execute(
382
+ "INSERT OR IGNORE INTO kv (key, value) VALUES (?, ?)", (key, value)
383
+ )
384
+ (value,) = self._conn.execute(
385
+ "SELECT value FROM kv WHERE key = ?", (key,)
386
+ ).fetchone()
387
+ return str(value)