Skip to content

Commit c5e22e4

Browse files
committed
feat(api): warm up sandbox after create
enqueue a warmup hook on successful sandbox creation so runtime startup can begin without delaying API response completion. skip warmup when returning an idempotency cache hit, and add unit tests covering warmup scheduling and idempotent create paths.
1 parent bbf03ec commit c5e22e4

File tree

2 files changed

+158
-2
lines changed

2 files changed

+158
-2
lines changed

pkgs/bay/app/api/v1/sandboxes.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@
99
from datetime import datetime
1010

1111
import structlog
12-
from fastapi import APIRouter, Header, Query
12+
from fastapi import APIRouter, BackgroundTasks, Header, Query
1313
from fastapi.responses import JSONResponse
1414
from pydantic import BaseModel
1515

1616
from app.adapters.base import BaseAdapter
1717
from app.adapters.gull import GullAdapter
1818
from app.adapters.ship import ShipAdapter
19-
from app.api.dependencies import AuthDep, IdempotencyServiceDep, SandboxManagerDep
19+
from app.api.dependencies import AuthDep, IdempotencyServiceDep, SandboxManagerDep, get_driver
2020
from app.config import get_settings
21+
from app.db.session import get_async_session
22+
from app.managers.sandbox import SandboxManager
2123
from app.models.sandbox import Sandbox, SandboxStatus
2224
from app.models.session import Session
2325
from app.router.capability.adapter_pool import default_adapter_pool
@@ -276,9 +278,50 @@ async def _query_containers_status(
276278
# Endpoints
277279

278280

281+
async def _warmup_sandbox_runtime_impl(*, sandbox_id: str, owner: str) -> None:
282+
"""Perform sandbox warmup in an isolated DB session."""
283+
try:
284+
async with get_async_session() as db:
285+
manager = SandboxManager(driver=get_driver(), db_session=db)
286+
sandbox = await manager.get(sandbox_id, owner)
287+
await manager.ensure_running(sandbox)
288+
except Exception as exc:
289+
_log.warning(
290+
"sandbox.warmup_failed",
291+
sandbox_id=sandbox_id,
292+
owner=owner,
293+
error=str(exc),
294+
)
295+
296+
297+
async def _warmup_sandbox_runtime(*, sandbox_id: str, owner: str) -> None:
298+
"""Schedule sandbox warmup in a detached task and return immediately.
299+
300+
This keeps request completion fast even if invoked via BackgroundTasks.
301+
"""
302+
task = asyncio.create_task(
303+
_warmup_sandbox_runtime_impl(sandbox_id=sandbox_id, owner=owner),
304+
name=f"warmup-{sandbox_id}",
305+
)
306+
307+
def _on_warmup_done(t: asyncio.Task[None]) -> None:
308+
try:
309+
t.result()
310+
except Exception as exc: # pragma: no cover
311+
_log.warning(
312+
"sandbox.warmup_task_failed",
313+
sandbox_id=sandbox_id,
314+
owner=owner,
315+
error=str(exc),
316+
)
317+
318+
task.add_done_callback(_on_warmup_done)
319+
320+
279321
@router.post("", response_model=SandboxResponse, status_code=201)
280322
async def create_sandbox(
281323
request: CreateSandboxRequest,
324+
background_tasks: BackgroundTasks,
282325
sandbox_mgr: SandboxManagerDep,
283326
idempotency_svc: IdempotencyServiceDep,
284327
owner: AuthDep,
@@ -331,6 +374,14 @@ async def create_sandbox(
331374
status_code=201,
332375
)
333376

377+
# 4. Enqueue warmup hook. The hook itself detaches actual warmup work,
378+
# so this does not block request completion on keep-alive connections.
379+
background_tasks.add_task(
380+
_warmup_sandbox_runtime,
381+
sandbox_id=sandbox.id,
382+
owner=owner,
383+
)
384+
334385
return response
335386

336387

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Unit tests for sandbox create endpoint warmup behavior."""
2+
3+
from __future__ import annotations
4+
5+
from types import SimpleNamespace
6+
from unittest.mock import AsyncMock
7+
8+
import pytest
9+
from fastapi import BackgroundTasks
10+
from fastapi.responses import JSONResponse
11+
12+
from app.api.v1.sandboxes import CreateSandboxRequest, create_sandbox
13+
from app.models.sandbox import Sandbox
14+
15+
16+
@pytest.mark.asyncio
17+
async def test_create_sandbox_schedules_background_warmup_when_created():
18+
"""Fresh create should enqueue exactly one warmup background task."""
19+
request = CreateSandboxRequest(profile="python-default", ttl=300)
20+
background_tasks = BackgroundTasks()
21+
22+
sandbox_mgr = AsyncMock()
23+
sandbox_mgr.create.return_value = Sandbox(
24+
id="sandbox-abc123",
25+
owner="user-1",
26+
profile_id="python-default",
27+
cargo_id="cargo-1",
28+
)
29+
30+
idempotency_svc = AsyncMock()
31+
idempotency_svc.check.return_value = None
32+
33+
resp = await create_sandbox(
34+
request=request,
35+
background_tasks=background_tasks,
36+
sandbox_mgr=sandbox_mgr,
37+
idempotency_svc=idempotency_svc,
38+
owner="user-1",
39+
idempotency_key=None,
40+
)
41+
42+
assert resp.id == "sandbox-abc123"
43+
assert len(background_tasks.tasks) == 1
44+
task = background_tasks.tasks[0]
45+
assert task.func.__name__ == "_warmup_sandbox_runtime"
46+
assert task.kwargs == {"sandbox_id": "sandbox-abc123", "owner": "user-1"}
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_create_sandbox_idempotency_cache_does_not_enqueue_warmup():
51+
"""Idempotency cache hit should return cached JSONResponse without warmup task."""
52+
request = CreateSandboxRequest(profile="python-default", ttl=300)
53+
background_tasks = BackgroundTasks()
54+
55+
sandbox_mgr = AsyncMock()
56+
57+
idempotency_svc = AsyncMock()
58+
idempotency_svc.check.return_value = SimpleNamespace(
59+
response={"id": "sandbox-cached", "status": "idle"},
60+
status_code=201,
61+
)
62+
63+
resp = await create_sandbox(
64+
request=request,
65+
background_tasks=background_tasks,
66+
sandbox_mgr=sandbox_mgr,
67+
idempotency_svc=idempotency_svc,
68+
owner="user-1",
69+
idempotency_key="idem-key-1",
70+
)
71+
72+
assert isinstance(resp, JSONResponse)
73+
assert len(background_tasks.tasks) == 0
74+
sandbox_mgr.create.assert_not_awaited()
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_create_sandbox_with_idempotency_save_and_enqueue_warmup():
79+
"""Non-cached idempotent create should save key and enqueue warmup task."""
80+
request = CreateSandboxRequest(profile="python-default", ttl=600)
81+
background_tasks = BackgroundTasks()
82+
83+
sandbox_mgr = AsyncMock()
84+
sandbox_mgr.create.return_value = Sandbox(
85+
id="sandbox-new-idem",
86+
owner="user-1",
87+
profile_id="python-default",
88+
cargo_id="cargo-2",
89+
)
90+
91+
idempotency_svc = AsyncMock()
92+
idempotency_svc.check.return_value = None
93+
94+
resp = await create_sandbox(
95+
request=request,
96+
background_tasks=background_tasks,
97+
sandbox_mgr=sandbox_mgr,
98+
idempotency_svc=idempotency_svc,
99+
owner="user-1",
100+
idempotency_key="idem-key-2",
101+
)
102+
103+
assert resp.id == "sandbox-new-idem"
104+
idempotency_svc.save.assert_awaited_once()
105+
assert len(background_tasks.tasks) == 1

0 commit comments

Comments
 (0)