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/router.py ADDED
@@ -0,0 +1,1049 @@
1
+ """Placement, fallback, circuit breaking and reconciliation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ import uuid
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from stackbox import ledger as L
13
+ from stackbox.backends import (
14
+ GONE_STATES,
15
+ GW_ID_KEY,
16
+ MANAGED_KEY,
17
+ PIN_KEY,
18
+ ROUTER_KEYS,
19
+ Backend,
20
+ BackendError,
21
+ Created,
22
+ CreateFailure,
23
+ ReconcileError,
24
+ gateway_instance_id,
25
+ )
26
+ from stackbox.config import RouterConfig
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # Providers that delete asynchronously keep listing a sandbox briefly after a delete.
32
+ DELETE_GRACE_S = 60.0
33
+
34
+ # Quota discovery. When a backend refuses a create for quota or capacity (429-class)
35
+ # while holding N sandboxes, its limit becomes N: creates beyond it go elsewhere (or
36
+ # wait) without paying for another refusal (Daytona takes ~3.4 s to refuse). The
37
+ # limit then grows by one every QUOTA_PROBE_S without a refusal, so it follows the
38
+ # quota back up. A refusal while holding nothing (an account-wide limit, or sandboxes
39
+ # still being torn down) instead skips the backend for 1 s, doubling per consecutive
40
+ # refusal, up to SATURATION_MAX_BACKOFF_S.
41
+ SATURATION_MAX_BACKOFF_S = 8.0
42
+ QUOTA_PROBE_S = 15.0
43
+
44
+ _SATURATED: Any = object() # _attempt result: the backend refused for capacity
45
+
46
+
47
+ class PlacementError(Exception):
48
+ def __init__(
49
+ self,
50
+ status: int,
51
+ code: str,
52
+ message: str,
53
+ details: Any = None,
54
+ hint: str | None = None,
55
+ ):
56
+ super().__init__(message)
57
+ self.status = status
58
+ self.code = code
59
+ self.details = details
60
+ self.hint = hint
61
+
62
+
63
+ @dataclass
64
+ class Breaker:
65
+ consecutive_failures: int = 0
66
+ open_until: float = 0.0
67
+
68
+ def is_open(self) -> bool:
69
+ return time.monotonic() < self.open_until
70
+
71
+
72
+ @dataclass
73
+ class BackendStats:
74
+ create_latencies_ms: list[float] = field(default_factory=list)
75
+ placements: int = 0
76
+ cold_starts: int = 0
77
+ saturations: int = 0
78
+
79
+
80
+ class StartupModel:
81
+ """Expected seconds from create request to a usable sandbox, per (backend, image).
82
+
83
+ An exponentially weighted moving average of observed creates. Cold starts (the
84
+ create included an image build) are tracked separately so one slow first boot
85
+ does not make a backend look slow once the image is prepared.
86
+ """
87
+
88
+ def __init__(self, alpha: float):
89
+ self.alpha = alpha
90
+ self.warm: dict[tuple[str, str | None], float] = {}
91
+ self.cold: dict[tuple[str, str | None], float] = {}
92
+ self.samples: dict[tuple[str, str | None], int] = {}
93
+
94
+ def observe(
95
+ self, backend: str, image: str | None, seconds: float, cold: bool
96
+ ) -> None:
97
+ table = self.cold if cold else self.warm
98
+ key = (backend, image)
99
+ prev = table.get(key)
100
+ table[key] = (
101
+ seconds if prev is None else self.alpha * seconds + (1 - self.alpha) * prev
102
+ )
103
+ if not cold:
104
+ self.samples[key] = self.samples.get(key, 0) + 1
105
+
106
+ def expected(self, backend: Backend, image: str | None, support: str) -> float:
107
+ key = (backend.name, image)
108
+ if support == "cold":
109
+ return self.cold.get(key, backend.cfg.cold_start_prior_s)
110
+ if key in self.warm:
111
+ return self.warm[key]
112
+ return backend.cfg.startup_prior_s
113
+
114
+
115
+ def parse_cpu(v: Any) -> float | None:
116
+ if v is None:
117
+ return None
118
+ s = str(v).strip()
119
+ try:
120
+ return float(s[:-1]) / 1000 if s.endswith("m") else float(s)
121
+ except ValueError:
122
+ return None
123
+
124
+
125
+ _MEM_UNITS = {
126
+ "Ki": 2**-20,
127
+ "Mi": 2**-10,
128
+ "Gi": 1,
129
+ "Ti": 2**10,
130
+ "K": 1e3 / 2**30,
131
+ "M": 1e6 / 2**30,
132
+ "G": 1e9 / 2**30,
133
+ }
134
+
135
+
136
+ def parse_memory_gib(v: Any) -> float | None:
137
+ if v is None:
138
+ return None
139
+ s = str(v).strip()
140
+ for unit, factor in sorted(_MEM_UNITS.items(), key=lambda kv: -len(kv[0])):
141
+ if s.endswith(unit):
142
+ try:
143
+ return float(s[: -len(unit)]) * factor
144
+ except ValueError:
145
+ return None
146
+ try:
147
+ return float(s) / 2**30
148
+ except ValueError:
149
+ return None
150
+
151
+
152
+ STRATEGIES = ("priority", "cost", "latency", "balanced")
153
+
154
+
155
+ @dataclass
156
+ class Request:
157
+ """What placement needs to know about a create, including per-request routing
158
+ preferences (OpenRouter-style: strategy, an ordered allow-list, a price ceiling)."""
159
+
160
+ image: str | None
161
+ cpu: float | None
162
+ memory_gib: float | None
163
+ strategy: str | None = None
164
+ providers: list[str] | None = None
165
+ max_price: float | None = None
166
+
167
+
168
+ def parse_preferences(
169
+ headers: dict[str, str], metadata: dict[str, str]
170
+ ) -> tuple[str | None, list[str] | None, float | None]:
171
+ """X-Stackbox-* headers, else stackbox.dev/* metadata. Raises PlacementError(400)."""
172
+ from stackbox.backends.base import MAX_PRICE_KEY, PROVIDERS_KEY, STRATEGY_KEY
173
+
174
+ def pick(header: str, key: str) -> str | None:
175
+ v = headers.get(header) or metadata.get(key)
176
+ return v.strip() if v else None
177
+
178
+ strategy = pick("x-stackbox-strategy", STRATEGY_KEY)
179
+ if strategy is not None and strategy not in STRATEGIES:
180
+ raise PlacementError(
181
+ 400,
182
+ "STACKBOX::BAD_PREFERENCE",
183
+ f"unknown routing strategy '{strategy}'",
184
+ hint=f"use one of: {', '.join(STRATEGIES)}",
185
+ )
186
+ raw = pick("x-stackbox-providers", PROVIDERS_KEY)
187
+ providers = (
188
+ [p for p in raw.replace(".", ",").split(",") if p.strip()] if raw else None
189
+ )
190
+ providers = [p.strip() for p in providers] if providers else None
191
+ price = pick("x-stackbox-max-price", MAX_PRICE_KEY)
192
+ try:
193
+ max_price = float(price) if price else None
194
+ except ValueError as exc:
195
+ raise PlacementError(
196
+ 400,
197
+ "STACKBOX::BAD_PREFERENCE",
198
+ f"max price '{price}' is not a number",
199
+ hint="give USD per sandbox-hour, e.g. X-Stackbox-Max-Price: 0.10",
200
+ ) from exc
201
+ return strategy, providers, max_price
202
+
203
+
204
+ @dataclass
205
+ class Choice:
206
+ backend: Backend
207
+ support: str # "ready" | "cold"
208
+ price_per_hour: float
209
+ expected_startup_s: float
210
+ score: float = 0.0
211
+ exploring: bool = False # ranked optimistically until it has enough samples
212
+
213
+
214
+ class Router:
215
+ def __init__(
216
+ self,
217
+ cfg: RouterConfig,
218
+ ledger: L.Ledger,
219
+ backends: dict[str, Backend],
220
+ ):
221
+ self.cfg = cfg
222
+ self.ledger = ledger
223
+ self.backends = backends
224
+ self.breakers = {name: Breaker() for name in backends}
225
+ self.saturated_until: dict[str, float] = {name: 0.0 for name in backends}
226
+ self.saturation_level: dict[str, int] = {name: 0 for name in backends}
227
+ self.learned_cap: dict[str, tuple[int, float] | None] = {
228
+ name: None for name in backends
229
+ }
230
+ self.stats = {name: BackendStats() for name in backends}
231
+ self.startup = StartupModel(cfg.routing.ewma_alpha)
232
+ self.instance_id = gateway_instance_id(ledger)
233
+ for b in backends.values():
234
+ b.instance_id = self.instance_id
235
+ for backend, image, seconds in ledger.startup_samples():
236
+ if backend in backends:
237
+ self.startup.observe(backend, image, seconds, cold=False)
238
+
239
+ # ------------------------------------------------------------------ create
240
+ def rank(self, req: Request, pinned: str | None = None) -> list[Choice]:
241
+ """Backends that can serve the request, best first under the routing strategy.
242
+
243
+ Ignores capacity and breakers; _candidates applies those.
244
+ """
245
+ known = ", ".join(self.backends)
246
+ if pinned is not None:
247
+ if pinned not in self.backends:
248
+ raise PlacementError(
249
+ 400,
250
+ "STACKBOX::UNKNOWN_BACKEND",
251
+ f"unknown backend '{pinned}'",
252
+ hint=f"configured backends: {known}",
253
+ )
254
+ pool = [self.backends[pinned]]
255
+ elif req.providers:
256
+ unknown = [p for p in req.providers if p not in self.backends]
257
+ if unknown:
258
+ raise PlacementError(
259
+ 400,
260
+ "STACKBOX::UNKNOWN_BACKEND",
261
+ f"unknown provider(s) in preferences: {', '.join(unknown)}",
262
+ hint=f"configured backends: {known}",
263
+ )
264
+ pool = [self.backends[p] for p in req.providers]
265
+ else:
266
+ pool = list(self.backends.values())
267
+ choices: list[Choice] = []
268
+ for b in pool:
269
+ support = b.image_support(req.image, req.cpu, req.memory_gib)
270
+ if support is None:
271
+ continue
272
+ price = b.price_per_hour(req.image, req.cpu, req.memory_gib)
273
+ if req.max_price is not None and price > req.max_price + 1e-12:
274
+ continue
275
+ choices.append(
276
+ Choice(
277
+ backend=b,
278
+ support=support,
279
+ price_per_hour=price,
280
+ expected_startup_s=self.startup.expected(b, req.image, support),
281
+ )
282
+ )
283
+ strategy = req.strategy or self.cfg.routing.strategy
284
+ if req.providers and req.strategy is None:
285
+ # An allow-list without a strategy is an order: try them as listed.
286
+ order = {p: i for i, p in enumerate(req.providers)}
287
+ choices.sort(key=lambda c: order[c.backend.name])
288
+ return choices
289
+ if strategy in ("latency", "balanced") and self.cfg.routing.explore_samples:
290
+ need = self.cfg.routing.explore_samples
291
+ unmeasured = [
292
+ c
293
+ for c in choices
294
+ if c.support == "ready"
295
+ and self.startup.samples.get((c.backend.name, req.image), 0) < need
296
+ ]
297
+ measured = [
298
+ c for c in choices if c not in unmeasured and c.support == "ready"
299
+ ]
300
+ if unmeasured and measured:
301
+ best = min(c.expected_startup_s for c in measured)
302
+ for c in unmeasured:
303
+ c.expected_startup_s = max(0.0, best - 0.01)
304
+ c.exploring = True
305
+ if req.providers: # with a strategy, list position breaks ties
306
+ order = {p: i for i, p in enumerate(req.providers)}
307
+ tie = lambda c: (order[c.backend.name], c.backend.name)
308
+ else:
309
+ tie = lambda c: (c.backend.cfg.priority, c.backend.name)
310
+ if strategy == "priority":
311
+ choices.sort(key=tie)
312
+ elif strategy == "cost":
313
+ choices.sort(
314
+ key=lambda c: (
315
+ round(c.price_per_hour, 6),
316
+ c.expected_startup_s,
317
+ *tie(c),
318
+ )
319
+ )
320
+ elif strategy == "latency":
321
+ choices.sort(
322
+ key=lambda c: (
323
+ round(c.expected_startup_s, 3),
324
+ c.price_per_hour,
325
+ *tie(c),
326
+ )
327
+ )
328
+ else: # balanced: each term relative to the best candidate on that axis
329
+ if choices:
330
+ min_price = min(c.price_per_hour for c in choices)
331
+ min_start = min(c.expected_startup_s for c in choices)
332
+ for c in choices:
333
+ c.score = self.cfg.routing.cost_weight * (
334
+ (c.price_per_hour + 1e-3) / (min_price + 1e-3)
335
+ ) + self.cfg.routing.latency_weight * (
336
+ (c.expected_startup_s + 0.1) / (min_start + 0.1)
337
+ )
338
+ choices.sort(key=lambda c: (round(c.score, 6), *tie(c)))
339
+ return choices
340
+
341
+ def explain(self, req: Request, pinned: str | None = None) -> list[dict[str, Any]]:
342
+ """Every backend, in routing order, with why it would or would not get this sandbox."""
343
+ try:
344
+ ranked = self.rank(req, pinned)
345
+ except PlacementError:
346
+ ranked = []
347
+ order = {c.backend.name: (i, c) for i, c in enumerate(ranked)}
348
+ rows: list[dict[str, Any]] = []
349
+ placed = False
350
+ for name, b in sorted(
351
+ self.backends.items(),
352
+ key=lambda kv: order.get(kv[0], (len(order), None))[0],
353
+ ):
354
+ row: dict[str, Any] = {"backend": name, "kind": b.kind, "rank": None}
355
+ if name in order:
356
+ i, c = order[name]
357
+ row.update(
358
+ rank=i + 1,
359
+ price_per_hour=round(c.price_per_hour, 6),
360
+ expected_startup_s=round(c.expected_startup_s, 2),
361
+ support=c.support,
362
+ exploring=c.exploring,
363
+ )
364
+ held, limit = self.ledger.held_count(name), self.limit(b)
365
+ if self.breakers[name].is_open():
366
+ status = "circuit breaker open after repeated failures (tried last)"
367
+ elif self._is_saturated(name):
368
+ left = self.saturated_until[name] - time.monotonic()
369
+ status = f"at provider quota; retrying in {left:.0f} s"
370
+ elif held >= limit:
371
+ quota = (
372
+ " (learned from provider refusals)"
373
+ if limit < b.cfg.max_active
374
+ else ""
375
+ )
376
+ status = f"full: {held}/{limit}{quota}"
377
+ else:
378
+ status = "available" if placed else "next placement goes here"
379
+ placed = True
380
+ if c.support == "cold":
381
+ status += "; first start includes an image build"
382
+ if c.exploring:
383
+ status += "; still measuring startup"
384
+ row["status"] = status
385
+ else:
386
+ row["status"] = self._why_excluded(b, req, pinned)
387
+ rows.append(row)
388
+ return rows
389
+
390
+ def _why_excluded(self, b: Backend, req: Request, pinned: str | None) -> str:
391
+ if pinned is not None and b.name != pinned:
392
+ return f"not used: request is pinned to {pinned}"
393
+ if req.providers and b.name not in req.providers:
394
+ return "not used: not in the request's provider list"
395
+ if b.image_support(req.image, req.cpu, req.memory_gib) is not None:
396
+ price = b.price_per_hour(req.image, req.cpu, req.memory_gib)
397
+ return f"too expensive: ${price:.3f}/h > max ${req.max_price:.3f}/h"
398
+ rec = (
399
+ b.image_record(req.image)
400
+ if req.image and hasattr(b, "image_record")
401
+ else None
402
+ )
403
+ if rec is not None and rec.status == L.IMAGE_BUILDING:
404
+ return f"preparing {req.image} (started {time.time() - rec.updated_at:.0f} s ago)"
405
+ if rec is not None and rec.status == L.IMAGE_FAILED:
406
+ return f"preparing {req.image} failed: {rec.error}"
407
+ if (
408
+ rec is not None
409
+ and rec.status == L.IMAGE_READY
410
+ and rec.detail
411
+ and "cpu" in rec.detail
412
+ ):
413
+ return (
414
+ f"too small: {req.image} is prepared at {rec.detail['cpu']:g} vCPU / "
415
+ f"{rec.detail['memory_gib']:g} GiB"
416
+ )
417
+ return f"{req.image} not prepared here; run `stackbox prepare {req.image}`"
418
+
419
+ def _candidates(self, req: Request, pinned: str | None) -> list[Choice]:
420
+ ordered = self.rank(req, pinned)
421
+ if pinned is not None and not ordered:
422
+ raise PlacementError(
423
+ 503,
424
+ "STACKBOX::IMAGE_NOT_READY",
425
+ f"backend '{pinned}' cannot serve image {req.image} yet",
426
+ )
427
+ # An open breaker demotes a backend instead of excluding it: it is tried
428
+ # only after every healthy backend is full. Excluding it outright idles its
429
+ # capacity, which made creates queue behind a backend that still succeeds
430
+ # most of the time (see bench/RESULTS.md). Each create tries a backend at
431
+ # most once, so a dead backend costs one fast failure per waiting create.
432
+ healthy = [c for c in ordered if not self.breakers[c.backend.name].is_open()]
433
+ demoted = [c for c in ordered if self.breakers[c.backend.name].is_open()]
434
+ return healthy + demoted
435
+
436
+ def _record_failure(self, name: str) -> None:
437
+ br = self.breakers[name]
438
+ br.consecutive_failures += 1
439
+ if br.consecutive_failures >= self.cfg.gateway.breaker_failures:
440
+ br.open_until = time.monotonic() + self.cfg.gateway.breaker_cooldown_s
441
+ logger.warning("circuit breaker opened for backend %s", name)
442
+
443
+ def _is_saturated(self, name: str) -> bool:
444
+ return time.monotonic() < self.saturated_until[name]
445
+
446
+ def _record_saturation(self, name: str) -> None:
447
+ self.stats[name].saturations += 1
448
+ # Running sandboxes only: creates still in flight have not been counted by the
449
+ # provider yet (counting them first set Daytona's limit to 7, not its real 5).
450
+ held = self.ledger.active_count(name)
451
+ if held > 0:
452
+ self.learned_cap[name] = (held, time.monotonic())
453
+ logger.info(
454
+ "backend %s refused at %d held; limiting it to %d", name, held, held
455
+ )
456
+ return
457
+ level = self.saturation_level[name]
458
+ backoff = min(SATURATION_MAX_BACKOFF_S, 2.0**level)
459
+ self.saturated_until[name] = time.monotonic() + backoff
460
+ self.saturation_level[name] = level + 1
461
+ logger.info(
462
+ "backend %s at provider capacity; skipping it for %.0fs", name, backoff
463
+ )
464
+
465
+ def limit(self, backend: Backend) -> int:
466
+ """Sandboxes this backend may hold now: configured max_active, or less if the
467
+ provider's quota was found to be lower."""
468
+ learned = self.learned_cap[backend.name]
469
+ if learned is None:
470
+ return backend.cfg.max_active
471
+ cap, since = learned
472
+ grown = cap + int((time.monotonic() - since) / QUOTA_PROBE_S)
473
+ if grown >= backend.cfg.max_active:
474
+ self.learned_cap[backend.name] = None
475
+ return backend.cfg.max_active
476
+ return grown
477
+
478
+ def _record_success(self, name: str, latency_ms: float) -> None:
479
+ self.breakers[name] = Breaker()
480
+ self.saturation_level[name] = 0
481
+ self.stats[name].create_latencies_ms.append(latency_ms)
482
+ self.stats[name].placements += 1
483
+
484
+ async def create(
485
+ self,
486
+ body: dict[str, Any],
487
+ pin_header: str | None = None,
488
+ headers: dict[str, str] | None = None,
489
+ ) -> dict[str, Any]:
490
+ gw_id = str(uuid.uuid4())
491
+ client_metadata: dict[str, str] = dict(body.get("metadata") or {})
492
+ pinned = pin_header or client_metadata.get(PIN_KEY)
493
+ limits = body.get("resourceLimits") or {}
494
+ img = body.get("image")
495
+ strategy, providers, max_price = parse_preferences(
496
+ {k.lower(): v for k, v in (headers or {}).items()}, client_metadata
497
+ )
498
+ req = Request(
499
+ image=img.get("uri") if isinstance(img, dict) else img,
500
+ cpu=parse_cpu(limits.get("cpu")),
501
+ memory_gib=parse_memory_gib(limits.get("memory")),
502
+ strategy=strategy,
503
+ providers=providers,
504
+ max_price=max_price,
505
+ )
506
+ self.ledger.create_intent(
507
+ gw_id,
508
+ cpu=req.cpu,
509
+ memory_gib=req.memory_gib,
510
+ pinned_backend=pinned,
511
+ client_metadata=client_metadata,
512
+ image=req.image,
513
+ )
514
+ upstream_body = {
515
+ **body,
516
+ "metadata": {
517
+ **client_metadata,
518
+ GW_ID_KEY: gw_id,
519
+ MANAGED_KEY: self.instance_id,
520
+ },
521
+ }
522
+ try:
523
+ return await self._place(gw_id, upstream_body, req, pinned)
524
+ except PlacementError as exc:
525
+ rec = self.ledger.get(gw_id)
526
+ if rec is not None and rec.state == L.PROVISIONING:
527
+ self.ledger.set_state(gw_id, L.FAILED, str(exc))
528
+ raise
529
+
530
+ async def _place(
531
+ self, gw_id: str, body: dict[str, Any], req: Request, pinned: str | None
532
+ ) -> dict[str, Any]:
533
+ deadline = time.monotonic() + self.cfg.gateway.queue_timeout_s
534
+ max_attempts = self.cfg.gateway.max_create_attempts
535
+ failed: dict[str, str] = {} # last error per backend for this create
536
+ attempts = 0
537
+ while True:
538
+ if attempts >= max_attempts:
539
+ raise PlacementError(
540
+ 503,
541
+ "STACKBOX::ALL_BACKENDS_FAILED",
542
+ f"provisioning failed {attempts} times: "
543
+ + "; ".join(f"{k}: {v}" for k, v in failed.items()),
544
+ {"failed": failed, "backends": self.explain(req, pinned)},
545
+ hint="check the provider errors above; `stackbox doctor` "
546
+ "tests each provider end to end",
547
+ )
548
+ # Backends this create has not failed on come first; ones it already
549
+ # failed on (cleanly, so nothing exists there) are retried only after
550
+ # those are full. Otherwise one random failure strands the create in
551
+ # the queue of a full backend while the failed one has free slots.
552
+ ordered = self._candidates(req, pinned)
553
+ candidates = [c for c in ordered if c.backend.name not in failed] + [
554
+ c for c in ordered if c.backend.name in failed
555
+ ]
556
+ if not candidates:
557
+ if time.monotonic() >= deadline:
558
+ why = self.explain(req, pinned)
559
+ raise PlacementError(
560
+ 503,
561
+ "STACKBOX::NO_BACKEND_FOR_IMAGE",
562
+ f"no backend can run {req.image} under these constraints: "
563
+ + "; ".join(f"{r['backend']}: {r['status']}" for r in why),
564
+ {"backends": why},
565
+ hint=f"run `stackbox prepare {req.image}`, or relax "
566
+ "X-Stackbox-Providers / X-Stackbox-Max-Price",
567
+ )
568
+ await asyncio.sleep(0.5) # e.g. waiting for a background prepare
569
+ continue
570
+ for choice in candidates:
571
+ backend = choice.backend
572
+ if self._is_saturated(backend.name):
573
+ continue # provider said it is at capacity; treat as full
574
+ if not self.ledger.claim_backend(
575
+ gw_id, backend.name, self.limit(backend)
576
+ ):
577
+ continue # full; try the next one
578
+ attempts += 1
579
+ result = await self._attempt(gw_id, backend, body, req)
580
+ if result is _SATURATED:
581
+ attempts -= 1 # a quota refusal is not a failed attempt
582
+ continue # spill to the next backend now
583
+ if result is not None:
584
+ return result
585
+ failed[backend.name] = self.ledger.get(gw_id).last_error or "failed" # type: ignore[union-attr]
586
+ break # re-evaluate candidates (breakers may have changed)
587
+ else:
588
+ # Every candidate was full. Wait for capacity.
589
+ if time.monotonic() >= deadline:
590
+ why = self.explain(req, pinned)
591
+ raise PlacementError(
592
+ 503,
593
+ "STACKBOX::NO_CAPACITY",
594
+ f"every eligible backend stayed full for {self.cfg.gateway.queue_timeout_s:.0f} s: "
595
+ + "; ".join(f"{r['backend']}: {r['status']}" for r in why),
596
+ {"backends": why},
597
+ hint="raise max_active where your plan allows, add a provider, "
598
+ "or raise gateway.queue_timeout_s",
599
+ )
600
+ await asyncio.sleep(0.2)
601
+
602
+ async def _attempt(
603
+ self, gw_id: str, backend: Backend, body: dict[str, Any], req: Request
604
+ ) -> dict[str, Any] | None:
605
+ """Returns the client response on success, None if provisioning clearly failed here."""
606
+ attempt = self.ledger.start_attempt(gw_id, backend.name)
607
+ t0 = time.monotonic()
608
+ try:
609
+ created = await backend.create(body, gw_id=gw_id)
610
+ except CreateFailure as exc:
611
+ if exc.kind == "client":
612
+ self.ledger.finish_attempt(
613
+ attempt, L.CLIENT_ERROR, exc.status, str(exc)
614
+ )
615
+ self.ledger.set_state(gw_id, L.FAILED, str(exc))
616
+ raise PlacementError(
617
+ exc.status or 400, "STACKBOX::UPSTREAM_REJECTED", str(exc), exc.body
618
+ )
619
+ if exc.kind == "clear" and exc.status == 429:
620
+ self.ledger.finish_attempt(attempt, L.SATURATED, 429, str(exc))
621
+ self.ledger.release_backend(gw_id, f"{backend.name}: {exc}")
622
+ self._record_saturation(backend.name)
623
+ return _SATURATED
624
+ if exc.kind == "clear":
625
+ self.ledger.finish_attempt(
626
+ attempt, L.FAILED_CLEAR, exc.status, str(exc)
627
+ )
628
+ self.ledger.release_backend(gw_id, f"{backend.name}: {exc}")
629
+ self._record_failure(backend.name)
630
+ return None
631
+ # Ambiguous: never retry elsewhere until we know nothing was created here.
632
+ self.ledger.finish_attempt(attempt, L.AMBIGUOUS, exc.status, str(exc))
633
+ self._record_failure(backend.name)
634
+ try:
635
+ found = await backend.find_by_gw_id(gw_id)
636
+ except ReconcileError as rexc:
637
+ self.ledger.set_state(
638
+ gw_id, L.UNKNOWN, f"{backend.name}: {exc}; reconcile failed: {rexc}"
639
+ )
640
+ raise PlacementError(
641
+ 503,
642
+ "STACKBOX::OUTCOME_UNKNOWN",
643
+ "create outcome is unknown; the gateway will reconcile and clean up",
644
+ ) from rexc
645
+ if found is not None and not backend.adopt_ambiguous:
646
+ # It exists but its setup may be incomplete: delete it, then retry.
647
+ try:
648
+ resp = await backend.delete(found["id"])
649
+ deleted = resp.status in (200, 202, 204, 404)
650
+ except BackendError:
651
+ deleted = False
652
+ if not deleted:
653
+ self.ledger.set_state(
654
+ gw_id, L.UNKNOWN, f"{backend.name}: {exc}; cleanup failed"
655
+ )
656
+ raise PlacementError(
657
+ 503,
658
+ "STACKBOX::OUTCOME_UNKNOWN",
659
+ "create outcome is unknown; the gateway will reconcile and clean up",
660
+ )
661
+ self.ledger.resolve_ambiguous_attempt(gw_id, backend.name, L.DISCARDED)
662
+ self.ledger.release_backend(
663
+ gw_id, f"{backend.name}: {exc} (found and deleted)"
664
+ )
665
+ return None
666
+ if found is None:
667
+ self.ledger.resolve_ambiguous_attempt(gw_id, backend.name, L.ABSENT)
668
+ self.ledger.release_backend(
669
+ gw_id, f"{backend.name}: {exc} (confirmed absent)"
670
+ )
671
+ return None
672
+ self.ledger.resolve_ambiguous_attempt(gw_id, backend.name, L.ADOPTED)
673
+ created = Created(sandbox=found)
674
+ else:
675
+ self.ledger.finish_attempt(attempt, L.OK, 200)
676
+ self.startup.observe(
677
+ backend.name, req.image, time.monotonic() - t0, cold=created.cold
678
+ )
679
+ if created.cold:
680
+ self.stats[backend.name].cold_starts += 1
681
+ latency_ms = (time.monotonic() - t0) * 1000
682
+ self.ledger.mark_active(
683
+ gw_id,
684
+ created.sandbox["id"],
685
+ cpu=created.cpu,
686
+ memory_gib=created.memory_gib,
687
+ backend_data=created.backend_data,
688
+ )
689
+ self._record_success(backend.name, latency_ms)
690
+ return self.to_client(created.sandbox, gw_id)
691
+
692
+ # ---------------------------------------------------------------- helpers
693
+ def to_client(self, sandbox: dict[str, Any], gw_id: str) -> dict[str, Any]:
694
+ out = dict(sandbox)
695
+ out["id"] = gw_id
696
+ rec = self.ledger.get(gw_id)
697
+ meta = {**(rec.client_metadata if rec else {}), **(out.get("metadata") or {})}
698
+ for k in ROUTER_KEYS:
699
+ meta.pop(k, None)
700
+ out["metadata"] = meta
701
+ return out
702
+
703
+ def resolve(self, gw_id: str) -> tuple[L.SandboxRecord, Backend]:
704
+ rec = self.ledger.get(gw_id)
705
+ if (
706
+ rec is None
707
+ or rec.state not in (L.ACTIVE,)
708
+ or rec.backend is None
709
+ or rec.upstream_id is None
710
+ ):
711
+ raise PlacementError(
712
+ 404, "SANDBOX::NOT_FOUND", f"sandbox {gw_id} not found"
713
+ )
714
+ return rec, self.backends[rec.backend]
715
+
716
+ async def delete(self, gw_id: str) -> int:
717
+ rec, backend = self.resolve(gw_id)
718
+ resp = await backend.delete(rec.upstream_id) # type: ignore[arg-type]
719
+ if resp.status in (200, 202, 204, 404):
720
+ self.ledger.set_state(gw_id, L.DELETED)
721
+ return resp.status
722
+
723
+ # ------------------------------------------------------------- reconcile
724
+ async def reconcile_once(self) -> dict[str, int]:
725
+ """Resolve unknown creates, expire vanished sandboxes, delete leaked ones."""
726
+ counts = {
727
+ "adopted_and_deleted": 0,
728
+ "confirmed_absent": 0,
729
+ "expired": 0,
730
+ "leaks_deleted": 0,
731
+ }
732
+ for rec in self.ledger.in_states(L.UNKNOWN):
733
+ if rec.backend is None:
734
+ continue
735
+ backend = self.backends[rec.backend]
736
+ try:
737
+ found = await backend.find_by_gw_id(rec.gw_id)
738
+ except ReconcileError:
739
+ continue
740
+ if found is None:
741
+ self.ledger.resolve_ambiguous_attempt(rec.gw_id, backend.name, L.ABSENT)
742
+ self.ledger.set_state(
743
+ rec.gw_id, L.FAILED, "ambiguous create confirmed absent"
744
+ )
745
+ counts["confirmed_absent"] += 1
746
+ else:
747
+ # The client already received an error, so nobody owns this sandbox.
748
+ self.ledger.resolve_ambiguous_attempt(
749
+ rec.gw_id, backend.name, L.ADOPTED
750
+ )
751
+ try:
752
+ await backend.delete(found["id"])
753
+ except BackendError:
754
+ continue
755
+ self.ledger.set_state(
756
+ rec.gw_id, L.DELETED, "orphan from ambiguous create; deleted"
757
+ )
758
+ counts["adopted_and_deleted"] += 1
759
+
760
+ for backend in self.backends.values():
761
+ listed_at = time.time()
762
+ try:
763
+ managed = await backend.list_sandboxes({MANAGED_KEY: self.instance_id})
764
+ except BackendError:
765
+ continue
766
+ live = {
767
+ s["id"]: s
768
+ for s in managed
769
+ if (s.get("status") or {}).get("state") not in GONE_STATES
770
+ }
771
+ for rec in self.ledger.in_states(L.ACTIVE):
772
+ if rec.backend != backend.name or rec.upstream_id in live:
773
+ continue
774
+ if rec.ready_at is not None and rec.ready_at < listed_at - 5:
775
+ self.ledger.set_state(
776
+ rec.gw_id, L.EXPIRED, "no longer present upstream"
777
+ )
778
+ counts["expired"] += 1
779
+ for upstream_id, s in live.items():
780
+ gw_id = (s.get("metadata") or {}).get(GW_ID_KEY)
781
+ rec = self.ledger.get(gw_id) if gw_id else None
782
+ if (
783
+ rec is not None
784
+ and rec.state in L.HOLDING_STATES
785
+ and rec.backend == backend.name
786
+ and (rec.state != L.ACTIVE or rec.upstream_id == upstream_id)
787
+ ):
788
+ continue # the sandbox this record owns (or an in-flight create)
789
+ if (
790
+ rec is not None
791
+ and rec.state == L.DELETED
792
+ and rec.upstream_id == upstream_id
793
+ and rec.ended_at is not None
794
+ and listed_at - rec.ended_at < DELETE_GRACE_S
795
+ ):
796
+ continue # deleted moments ago; the provider is still tearing it down
797
+ created_at = s.get("createdAt")
798
+ logger.warning(
799
+ "deleting leaked sandbox %s on %s (gw_id=%s, created %s)",
800
+ upstream_id,
801
+ backend.name,
802
+ gw_id,
803
+ created_at,
804
+ )
805
+ try:
806
+ await backend.delete(upstream_id)
807
+ except BackendError:
808
+ continue
809
+ counts["leaks_deleted"] += 1
810
+ return counts
811
+
812
+ async def reconcile_forever(self) -> None:
813
+ while True:
814
+ try:
815
+ counts = await self.reconcile_once()
816
+ if any(counts.values()):
817
+ logger.info("reconcile: %s", counts)
818
+ except Exception: # keep the loop alive
819
+ logger.exception("reconcile failed")
820
+ await asyncio.sleep(self.cfg.gateway.reconcile_interval_s)
821
+
822
+ # ----------------------------------------------------- introspection API
823
+ def startup_estimates(self, backend: str | None = None) -> list[dict[str, Any]]:
824
+ keys = set(self.startup.warm) | set(self.startup.cold)
825
+ rows = []
826
+ for b, image in sorted(keys, key=str):
827
+ if backend is not None and b != backend:
828
+ continue
829
+ warm, cold = (
830
+ self.startup.warm.get((b, image)),
831
+ self.startup.cold.get((b, image)),
832
+ )
833
+ rows.append(
834
+ {
835
+ "image": image,
836
+ "warm_s": None if warm is None else round(warm, 2),
837
+ "cold_s": None if cold is None else round(cold, 2),
838
+ "samples": self.startup.samples.get((b, image), 0),
839
+ }
840
+ )
841
+ return rows
842
+
843
+ def _cost(
844
+ self, backend: str | None, vcpu: float, gib: float, seconds: float
845
+ ) -> float:
846
+ b = self.backends.get(backend or "")
847
+ if b is None:
848
+ return 0.0
849
+ per_hour = (
850
+ vcpu * b.cfg.cost_per_vcpu_hour * b.cfg.cpu_billed_fraction
851
+ + gib * b.cfg.cost_per_gib_hour
852
+ )
853
+ return per_hour * seconds / 3600
854
+
855
+ def provider_view(self, name: str) -> dict[str, Any]:
856
+ """GET /v1/providers/{name}: configuration, live capacity and health, learned
857
+ startup, prepared images, and usage so far."""
858
+ from stackbox.catalog import PROVIDERS
859
+
860
+ b = self.backends[name]
861
+ cat = PROVIDERS.get(b.kind)
862
+ lat = sorted(self.stats[name].create_latencies_ms)
863
+ held, limit = self.ledger.held_count(name), self.limit(b)
864
+ if self.breakers[name].is_open():
865
+ status = "breaker_open"
866
+ elif self._is_saturated(name):
867
+ status = "at_quota"
868
+ elif held >= limit:
869
+ status = "full"
870
+ else:
871
+ status = "ok"
872
+ usage = self.usage(group_by="provider")["providers"].get(name, {})
873
+ return {
874
+ "name": name,
875
+ "kind": b.kind,
876
+ "label": cat.label if cat else b.kind,
877
+ "status": status,
878
+ "priority": b.cfg.priority,
879
+ "pricing": {
880
+ "cost_per_vcpu_hour": b.cfg.cost_per_vcpu_hour,
881
+ "cost_per_gib_hour": b.cfg.cost_per_gib_hour,
882
+ "cpu_billed_fraction": b.cfg.cpu_billed_fraction,
883
+ "usd_per_hour_1vcpu_2gib": round(b.price_per_hour(None, 1, 2), 6),
884
+ },
885
+ "capacity": {
886
+ "held": held,
887
+ "limit": limit,
888
+ "max_active": b.cfg.max_active,
889
+ "limit_learned_from_refusals": limit < b.cfg.max_active,
890
+ },
891
+ "quota_refusals": self.stats[name].saturations,
892
+ "placements": self.stats[name].placements,
893
+ "cold_starts": self.stats[name].cold_starts,
894
+ "create_s": {
895
+ "p50": round(lat[len(lat) // 2] / 1000, 3) if lat else None,
896
+ "p95": round(lat[min(len(lat) - 1, int(len(lat) * 0.95))] / 1000, 3)
897
+ if lat
898
+ else None,
899
+ },
900
+ "startup": self.startup_estimates(name),
901
+ "images": [
902
+ {k: i[k] for k in ("image", "status", "ref", "error")}
903
+ for i in self.ledger.images()
904
+ if i["backend"] == name
905
+ ],
906
+ "usage": usage,
907
+ }
908
+
909
+ def placement(self, gw_id: str) -> dict[str, Any] | None:
910
+ """GET /v1/placements/{id}: where a sandbox went, every attempt, and its cost."""
911
+ rec = self.ledger.get(gw_id)
912
+ if rec is None:
913
+ return None
914
+ atts = [a for a in self.ledger.attempts() if a["gw_id"] == gw_id]
915
+ ok = next((a for a in atts if a["outcome"] == L.OK), None)
916
+ row = self._usage_row(rec, ok["started_at"] if ok else None)
917
+ return {
918
+ **row,
919
+ "queued_s": round(atts[0]["started_at"] - rec.requested_at, 3)
920
+ if atts
921
+ else None,
922
+ "create_s": round(ok["finished_at"] - ok["started_at"], 3) if ok else None,
923
+ "last_error": rec.last_error,
924
+ "attempts": [
925
+ {
926
+ "provider": a["backend"],
927
+ "outcome": a["outcome"],
928
+ "http_status": a["http_status"],
929
+ "error": a["error"],
930
+ "started_at": _iso(a["started_at"]),
931
+ "duration_s": None
932
+ if a["finished_at"] is None
933
+ else round(a["finished_at"] - a["started_at"], 3),
934
+ }
935
+ for a in atts
936
+ ],
937
+ }
938
+
939
+ def _usage_row(
940
+ self,
941
+ rec: L.SandboxRecord,
942
+ started: float | None,
943
+ since: float | None = None,
944
+ until: float | None = None,
945
+ ) -> dict[str, Any]:
946
+ now = time.time()
947
+ start = started or rec.ready_at
948
+ end = rec.ended_at or (now if rec.state in L.HOLDING_STATES else rec.ended_at)
949
+ seconds = 0.0
950
+ if start is not None and end is not None:
951
+ lo, hi = max(start, since or start), min(end, until or end)
952
+ seconds = max(0.0, hi - lo)
953
+ vcpu, gib = rec.cpu or 0.0, rec.memory_gib or 0.0
954
+ return {
955
+ "id": rec.gw_id,
956
+ "provider": rec.backend,
957
+ "image": rec.image,
958
+ "state": rec.state,
959
+ "requested_at": _iso(rec.requested_at),
960
+ "ready_at": _iso(rec.ready_at),
961
+ "ended_at": _iso(rec.ended_at),
962
+ "vcpu": vcpu,
963
+ "memory_gib": gib,
964
+ "seconds": round(seconds, 1),
965
+ "est_cost_usd": round(self._cost(rec.backend, vcpu, gib, seconds), 6),
966
+ }
967
+
968
+ def usage(
969
+ self,
970
+ since: float | None = None,
971
+ until: float | None = None,
972
+ group_by: str = "provider",
973
+ ) -> dict[str, Any]:
974
+ """GET /v1/usage: sandbox time and estimated cost (list prices), billed from the
975
+ start of the successful create to the delete, clipped to [since, until]."""
976
+ started = {
977
+ a["gw_id"]: a["started_at"]
978
+ for a in self.ledger.attempts()
979
+ if a["outcome"] == L.OK
980
+ }
981
+ rows = []
982
+ for rec in self.ledger.all():
983
+ if rec.ready_at is None and rec.state not in L.HOLDING_STATES:
984
+ continue # never ran
985
+ row = self._usage_row(rec, started.get(rec.gw_id), since, until)
986
+ if (
987
+ since is not None
988
+ and row["seconds"] == 0
989
+ and (rec.ended_at or time.time()) < since
990
+ ):
991
+ continue
992
+ rows.append(row)
993
+ totals = {
994
+ "sandboxes": len(rows),
995
+ "sandbox_hours": round(sum(r["seconds"] for r in rows) / 3600, 4),
996
+ "vcpu_hours": round(sum(r["seconds"] * r["vcpu"] for r in rows) / 3600, 4),
997
+ "gib_hours": round(
998
+ sum(r["seconds"] * r["memory_gib"] for r in rows) / 3600, 4
999
+ ),
1000
+ "est_cost_usd": round(sum(r["est_cost_usd"] for r in rows), 6),
1001
+ }
1002
+ out: dict[str, Any] = {
1003
+ "since": _iso(since),
1004
+ "until": _iso(until),
1005
+ "totals": totals,
1006
+ "note": "estimates from configured list prices, not invoices",
1007
+ }
1008
+ if group_by == "sandbox":
1009
+ out["sandboxes"] = rows
1010
+ else:
1011
+ per: dict[str, dict[str, Any]] = {}
1012
+ for r in rows:
1013
+ if not r["provider"]:
1014
+ continue
1015
+ agg = per.setdefault(
1016
+ r["provider"],
1017
+ {
1018
+ "sandboxes": 0,
1019
+ "sandbox_hours": 0.0,
1020
+ "vcpu_hours": 0.0,
1021
+ "gib_hours": 0.0,
1022
+ "est_cost_usd": 0.0,
1023
+ },
1024
+ )
1025
+ agg["sandboxes"] += 1
1026
+ agg["sandbox_hours"] += r["seconds"] / 3600
1027
+ agg["vcpu_hours"] += r["seconds"] * r["vcpu"] / 3600
1028
+ agg["gib_hours"] += r["seconds"] * r["memory_gib"] / 3600
1029
+ agg["est_cost_usd"] += r["est_cost_usd"]
1030
+ out["providers"] = {
1031
+ k: {
1032
+ kk: round(vv, 6) if isinstance(vv, float) else vv
1033
+ for kk, vv in v.items()
1034
+ }
1035
+ for k, v in per.items()
1036
+ }
1037
+ return out
1038
+
1039
+
1040
+ def _iso(ts: float | None) -> str | None:
1041
+ if ts is None:
1042
+ return None
1043
+ import datetime as dt
1044
+
1045
+ return (
1046
+ dt.datetime.fromtimestamp(ts, dt.UTC)
1047
+ .isoformat(timespec="seconds")
1048
+ .replace("+00:00", "Z")
1049
+ )