Skip to content

Commit 0117490

Browse files
committed
feat(devbox): add devbox.on_evict eviction notifications (SSE)
Adds an object-oriented `Devbox.on_evict(callback)` (sync and async) backed by a per-client `EvictionMonitor`. The first registration opens a single account-wide `watch_evictions` SSE stream; incoming notifications are matched against the local interest set, the devbox is removed before its callback fires (at-most-once), and the stream is closed once the interest set empties. Depends on the Stainless-generated `devboxes.watch_evictions` method and `DevboxEvictionEvent` type, which land downstream once the server endpoint merges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0ebcf9d commit 0117490

6 files changed

Lines changed: 301 additions & 0 deletions

File tree

src/runloop_api_client/sdk/async_.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from .async_agent import AsyncAgent
4646
from .async_devbox import AsyncDevbox
4747
from .async_scorer import AsyncScorer
48+
from .async_eviction import shutdown_monitor_for as shutdown_eviction_monitor
4849
from .async_secret import AsyncSecret
4950
from .async_scenario import AsyncScenario
5051
from .async_snapshot import AsyncSnapshot
@@ -1373,6 +1374,7 @@ def __init__(
13731374

13741375
async def aclose(self) -> None:
13751376
"""Close the underlying HTTP client and release resources."""
1377+
await shutdown_eviction_monitor(self.api)
13761378
await self.api.close()
13771379

13781380
async def __aenter__(self) -> "AsyncRunloopSDK":

src/runloop_api_client/sdk/async_devbox.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from .._types import omit
3434
from .._client import AsyncRunloop
3535
from ._helpers import filter_params
36+
from .async_eviction import AsyncEvictionCallback, monitor_for as _eviction_monitor_for
3637
from .._streaming import AsyncStream
3738
from ..lib.polling import PollingConfig
3839
from ..types.devboxes import ExecutionUpdateChunk
@@ -216,6 +217,24 @@ async def await_suspended(self, *, polling_config: PollingConfig | None = None)
216217
"""
217218
return await self._client.devboxes.await_suspended(self._id, polling_config=polling_config)
218219

220+
async def on_evict(self, callback: AsyncEvictionCallback) -> None:
221+
"""Register a callback fired once if this devbox gets a pending infrastructure eviction.
222+
223+
The first ``on_evict`` across any devbox on this client opens a single
224+
account-wide notification stream; it closes automatically once every
225+
registered devbox has been notified. The callback runs at most once and
226+
receives the eviction event (with its ``eviction_deadline_ms``) — use it to
227+
run cleanup before the devbox is suspended. The callback may be sync or async.
228+
229+
:param callback: Callable invoked with the eviction event for this devbox.
230+
:type callback: AsyncEvictionCallback
231+
"""
232+
await _eviction_monitor_for(self._client).register(self._id, callback)
233+
234+
async def cancel_on_evict(self) -> None:
235+
"""Withdraw this devbox's eviction interest registered via :meth:`on_evict`."""
236+
await _eviction_monitor_for(self._client).unregister(self._id)
237+
219238
async def shutdown(
220239
self,
221240
**options: Unpack[LongRequestOptions],
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Async client-side eviction notification monitor.
2+
3+
Async counterpart of :mod:`runloop_api_client.sdk.eviction`; see that module for the
4+
delivery contract. Callbacks may be plain functions or coroutines.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import asyncio
10+
import inspect
11+
import logging
12+
from typing import TYPE_CHECKING, Dict, Union, Callable, Optional, Awaitable
13+
from weakref import WeakKeyDictionary
14+
15+
if TYPE_CHECKING:
16+
from .._client import AsyncRunloop
17+
from .._streaming import AsyncStream
18+
19+
# Assumed Stainless-generated SSE event type for ``watch_evictions``; carries
20+
# ``devbox_id`` and ``eviction_deadline_ms``. Reconcile the import once the
21+
# generated code lands.
22+
from ..types import DevboxEvictionEvent
23+
24+
AsyncEvictionCallback = Callable[["DevboxEvictionEvent"], Union[None, Awaitable[None]]]
25+
"""Sync or async callable invoked once with the eviction event for its devbox."""
26+
27+
_logger = logging.getLogger(__name__)
28+
29+
30+
class AsyncEvictionMonitor:
31+
"""Async fan-out of account-wide eviction notifications to per-devbox callbacks."""
32+
33+
def __init__(self, client: "AsyncRunloop") -> None:
34+
self._client = client
35+
self._lock = asyncio.Lock()
36+
self._callbacks: Dict[str, AsyncEvictionCallback] = {}
37+
self._task: Optional["asyncio.Task[None]"] = None
38+
self._stream: Optional["AsyncStream[DevboxEvictionEvent]"] = None
39+
40+
async def register(self, devbox_id: str, callback: AsyncEvictionCallback) -> None:
41+
"""Add ``devbox_id`` to the interest set, starting the stream task if idle."""
42+
async with self._lock:
43+
self._callbacks[devbox_id] = callback
44+
if self._task is None or self._task.done():
45+
self._task = asyncio.create_task(self._run(), name="runloop-eviction-monitor")
46+
47+
async def unregister(self, devbox_id: str) -> None:
48+
"""Drop ``devbox_id``; close the stream if it was the last interested devbox."""
49+
async with self._lock:
50+
self._callbacks.pop(devbox_id, None)
51+
empty = not self._callbacks
52+
if empty:
53+
await self._close_stream()
54+
55+
async def close(self) -> None:
56+
"""Clear all interest and tear down the stream."""
57+
async with self._lock:
58+
self._callbacks.clear()
59+
await self._close_stream()
60+
61+
async def _run(self) -> None:
62+
try:
63+
stream = await self._client.devboxes.watch_evictions()
64+
async with self._lock:
65+
self._stream = stream
66+
async with stream:
67+
async for event in stream:
68+
await self._dispatch(event)
69+
async with self._lock:
70+
if not self._callbacks:
71+
break
72+
except asyncio.CancelledError:
73+
raise
74+
except Exception:
75+
_logger.exception("async eviction monitor stream failed")
76+
finally:
77+
async with self._lock:
78+
self._stream = None
79+
self._task = None
80+
81+
async def _dispatch(self, event: "DevboxEvictionEvent") -> None:
82+
async with self._lock:
83+
callback = self._callbacks.pop(event.devbox_id, None)
84+
if callback is None:
85+
return
86+
try:
87+
result = callback(event)
88+
if inspect.isawaitable(result):
89+
await result
90+
except Exception:
91+
_logger.exception("error in eviction callback for devbox %s", event.devbox_id)
92+
93+
async def _close_stream(self) -> None:
94+
async with self._lock:
95+
stream = self._stream
96+
self._stream = None
97+
if stream is not None:
98+
try:
99+
await stream.close()
100+
except Exception:
101+
_logger.debug("error closing eviction stream", exc_info=True)
102+
103+
104+
# asyncio is single-threaded, so the registry needs no lock.
105+
_monitors: "WeakKeyDictionary[AsyncRunloop, AsyncEvictionMonitor]" = WeakKeyDictionary()
106+
107+
108+
def monitor_for(client: "AsyncRunloop") -> AsyncEvictionMonitor:
109+
"""Return the shared :class:`AsyncEvictionMonitor` for ``client``, creating it once."""
110+
monitor = _monitors.get(client)
111+
if monitor is None:
112+
monitor = AsyncEvictionMonitor(client)
113+
_monitors[client] = monitor
114+
return monitor
115+
116+
117+
async def shutdown_monitor_for(client: "AsyncRunloop") -> None:
118+
"""Tear down the shared monitor for ``client`` if one exists."""
119+
monitor = _monitors.pop(client, None)
120+
if monitor is not None:
121+
await monitor.close()

src/runloop_api_client/sdk/devbox.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
)
3434
from .._types import omit
3535
from .._client import Runloop
36+
from .eviction import EvictionCallback, monitor_for as _eviction_monitor_for
3637
from ._helpers import filter_params
3738
from .execution import Execution, _StreamingGroup
3839
from .._streaming import Stream
@@ -219,6 +220,24 @@ def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Dev
219220
"""
220221
return self._client.devboxes.await_suspended(self._id, polling_config=polling_config)
221222

223+
def on_evict(self, callback: EvictionCallback) -> None:
224+
"""Register a callback fired once if this devbox gets a pending infrastructure eviction.
225+
226+
The first ``on_evict`` across any devbox on this client opens a single
227+
account-wide notification stream; it closes automatically once every
228+
registered devbox has been notified. The callback runs at most once and
229+
receives the eviction event (with its ``eviction_deadline_ms``) — use it to
230+
run cleanup before the devbox is suspended.
231+
232+
:param callback: Callable invoked with the eviction event for this devbox.
233+
:type callback: EvictionCallback
234+
"""
235+
_eviction_monitor_for(self._client).register(self._id, callback)
236+
237+
def cancel_on_evict(self) -> None:
238+
"""Withdraw this devbox's eviction interest registered via :meth:`on_evict`."""
239+
_eviction_monitor_for(self._client).unregister(self._id)
240+
222241
def shutdown(
223242
self,
224243
**options: Unpack[LongRequestOptions],
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Client-side eviction notification monitor.
2+
3+
Backs :meth:`runloop_api_client.sdk.devbox.Devbox.on_evict`. A single account-wide
4+
SSE stream (the generated ``devboxes.watch_evictions`` endpoint) is opened lazily
5+
the moment the first devbox registers interest, and torn down as soon as the last
6+
interested devbox has been notified.
7+
8+
Delivery contract:
9+
10+
* The server replays every currently-pending eviction on connect, so a devbox that
11+
registers after its eviction was scheduled is still notified.
12+
* Notifications for devboxes not in the interest set are discarded.
13+
* A devbox is removed from the interest set *before* its callback runs, so the
14+
callback fires at most once even if the server repeats the notification.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import logging
20+
import threading
21+
from typing import TYPE_CHECKING, Dict, Callable, Optional
22+
from weakref import WeakKeyDictionary
23+
24+
if TYPE_CHECKING:
25+
from .._client import Runloop
26+
from .._streaming import Stream
27+
28+
# Assumed Stainless-generated SSE event type for ``watch_evictions``; carries
29+
# ``devbox_id`` and ``eviction_deadline_ms``. Reconcile the import once the
30+
# generated code lands.
31+
from ..types import DevboxEvictionEvent
32+
33+
EvictionCallback = Callable[["DevboxEvictionEvent"], None]
34+
"""Invoked once with the eviction event for the devbox it was registered for."""
35+
36+
_logger = logging.getLogger(__name__)
37+
38+
39+
class EvictionMonitor:
40+
"""Fans account-wide eviction notifications out to per-devbox callbacks.
41+
42+
One monitor is shared by every :class:`~runloop_api_client.sdk.devbox.Devbox`
43+
built from the same generated client (see :func:`monitor_for`), so all
44+
registered devboxes are served by a single SSE connection.
45+
"""
46+
47+
def __init__(self, client: "Runloop") -> None:
48+
self._client = client
49+
self._lock = threading.Lock()
50+
self._callbacks: Dict[str, EvictionCallback] = {}
51+
self._thread: Optional[threading.Thread] = None
52+
self._stream: Optional["Stream[DevboxEvictionEvent]"] = None
53+
54+
def register(self, devbox_id: str, callback: EvictionCallback) -> None:
55+
"""Add ``devbox_id`` to the interest set, starting the stream if idle."""
56+
with self._lock:
57+
self._callbacks[devbox_id] = callback
58+
if self._thread is None or not self._thread.is_alive():
59+
self._thread = threading.Thread(
60+
target=self._run,
61+
name="runloop-eviction-monitor",
62+
daemon=True,
63+
)
64+
self._thread.start()
65+
66+
def unregister(self, devbox_id: str) -> None:
67+
"""Drop ``devbox_id``; close the stream if it was the last interested devbox."""
68+
with self._lock:
69+
self._callbacks.pop(devbox_id, None)
70+
empty = not self._callbacks
71+
if empty:
72+
self._close_stream()
73+
74+
def close(self) -> None:
75+
"""Clear all interest and tear down the stream."""
76+
with self._lock:
77+
self._callbacks.clear()
78+
self._close_stream()
79+
80+
def _run(self) -> None:
81+
try:
82+
stream = self._client.devboxes.watch_evictions()
83+
with self._lock:
84+
self._stream = stream
85+
with stream:
86+
for event in stream:
87+
self._dispatch(event)
88+
with self._lock:
89+
if not self._callbacks:
90+
break
91+
except Exception:
92+
_logger.exception("eviction monitor stream failed")
93+
finally:
94+
with self._lock:
95+
self._stream = None
96+
self._thread = None
97+
98+
def _dispatch(self, event: "DevboxEvictionEvent") -> None:
99+
with self._lock:
100+
callback = self._callbacks.pop(event.devbox_id, None)
101+
if callback is None:
102+
return
103+
try:
104+
callback(event)
105+
except Exception:
106+
_logger.exception("error in eviction callback for devbox %s", event.devbox_id)
107+
108+
def _close_stream(self) -> None:
109+
with self._lock:
110+
stream = self._stream
111+
self._stream = None
112+
if stream is not None:
113+
try:
114+
stream.close()
115+
except Exception:
116+
_logger.debug("error closing eviction stream", exc_info=True)
117+
118+
119+
_monitors: "WeakKeyDictionary[Runloop, EvictionMonitor]" = WeakKeyDictionary()
120+
_monitors_lock = threading.Lock()
121+
122+
123+
def monitor_for(client: "Runloop") -> EvictionMonitor:
124+
"""Return the shared :class:`EvictionMonitor` for ``client``, creating it once."""
125+
with _monitors_lock:
126+
monitor = _monitors.get(client)
127+
if monitor is None:
128+
monitor = EvictionMonitor(client)
129+
_monitors[client] = monitor
130+
return monitor
131+
132+
133+
def shutdown_monitor_for(client: "Runloop") -> None:
134+
"""Tear down the shared monitor for ``client`` if one exists."""
135+
with _monitors_lock:
136+
monitor = _monitors.pop(client, None)
137+
if monitor is not None:
138+
monitor.close()

src/runloop_api_client/sdk/sync.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
)
4242
from .devbox import Devbox
4343
from .scorer import Scorer
44+
from .eviction import shutdown_monitor_for as shutdown_eviction_monitor
4445
from .secret import Secret
4546
from .._types import Timeout, NotGiven, not_given
4647
from .._client import DEFAULT_MAX_RETRIES, Runloop
@@ -1398,6 +1399,7 @@ def __init__(
13981399

13991400
def close(self) -> None:
14001401
"""Close the underlying HTTP client and release resources."""
1402+
shutdown_eviction_monitor(self.api)
14011403
self.api.close()
14021404

14031405
def __enter__(self) -> "RunloopSDK":

0 commit comments

Comments
 (0)