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/__init__.py +9 -0
- stackbox/__main__.py +3 -0
- stackbox/app.py +405 -0
- stackbox/backends/__init__.py +107 -0
- stackbox/backends/base.py +181 -0
- stackbox/backends/cloudflare.py +241 -0
- stackbox/backends/daytona.py +306 -0
- stackbox/backends/e2b.py +241 -0
- stackbox/backends/hosted.py +536 -0
- stackbox/backends/modal.py +190 -0
- stackbox/backends/opensandbox.py +148 -0
- stackbox/backends/vercel.py +309 -0
- stackbox/catalog.py +156 -0
- stackbox/cli.py +873 -0
- stackbox/config.py +294 -0
- stackbox/execd.py +118 -0
- stackbox/ledger.py +387 -0
- stackbox/router.py +1049 -0
- stackbox/vcr.py +113 -0
- stackbox-0.1.0.dist-info/METADATA +288 -0
- stackbox-0.1.0.dist-info/RECORD +23 -0
- stackbox-0.1.0.dist-info/WHEEL +4 -0
- stackbox-0.1.0.dist-info/entry_points.txt +3 -0
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
|
+
)
|