-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathasync_eviction.py
More file actions
155 lines (133 loc) · 6.84 KB
/
Copy pathasync_eviction.py
File metadata and controls
155 lines (133 loc) · 6.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
"""Async client-side eviction notification monitor.
Async counterpart of :mod:`runloop_api_client.sdk.eviction`; see that module for the
delivery contract. Callbacks may be plain functions or coroutines.
"""
from __future__ import annotations
import asyncio
import inspect
import logging
from typing import TYPE_CHECKING, Dict, Tuple, Union, Callable, Optional, Awaitable
from weakref import WeakKeyDictionary
if TYPE_CHECKING:
from ..types import DevboxEvictionEventView
from .._client import AsyncRunloop
from .._streaming import AsyncStream
from .async_devbox import AsyncDevbox
AsyncEvictionCallback = Callable[["AsyncDevbox", int], Union[None, Awaitable[None]]]
"""Sync or async callable invoked once with the devbox and its eviction deadline (ms)."""
_logger = logging.getLogger(__name__)
class AsyncEvictionMonitor:
"""Async fan-out of account-wide eviction notifications to per-devbox callbacks."""
def __init__(self, client: "AsyncRunloop") -> None:
self._client = client
self._lock = asyncio.Lock()
self._callbacks: Dict[str, Tuple["AsyncDevbox", AsyncEvictionCallback]] = {}
self._task: Optional["asyncio.Task[None]"] = None
self._stream: Optional["AsyncStream[DevboxEvictionEventView]"] = None
async def register(self, devbox: "AsyncDevbox", callback: AsyncEvictionCallback) -> None:
"""Add ``devbox`` to the interest set, starting the stream task if idle."""
async with self._lock:
self._callbacks[devbox.id] = (devbox, callback)
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._run(), name="runloop-eviction-monitor")
async def unregister(self, devbox_id: str) -> None:
"""Drop ``devbox_id``; close the stream if it was the last interested devbox."""
async with self._lock:
self._callbacks.pop(devbox_id, None)
empty = not self._callbacks
if empty:
await self._close_stream()
async def close(self) -> None:
"""Clear all interest and tear down the stream."""
async with self._lock:
self._callbacks.clear()
await self._close_stream()
# Reconnect backoff bounds (seconds). The server force-closes the stream on purpose — on a
# leader change (FAILED_PRECONDITION) or a slow consumer (RESOURCE_EXHAUSTED) — and expects the
# client to reconnect and re-read the snapshot, which re-delivers anything missed. So a single
# stream ending is normal, not terminal: reconnect until no devbox is still interested.
_RECONNECT_BACKOFF_INITIAL_S = 0.5
_RECONNECT_BACKOFF_MAX_S = 30.0
async def _run(self) -> None:
backoff = self._RECONNECT_BACKOFF_INITIAL_S
try:
while True:
async with self._lock:
if not self._callbacks:
return
try:
# Force the SSE Accept header: the endpoint only streams for
# text/event-stream; the generated client's default (application/json) gets an
# empty text/plain response, so the feed would silently deliver nothing.
stream = await self._client.devboxes.watch_evictions(extra_headers={"Accept": "text/event-stream"})
async with self._lock:
self._stream = stream
_logger.debug("async eviction monitor stream connected")
async with stream:
async for event in stream:
_logger.debug("async eviction monitor received event for %s", event.devbox_id)
await self._dispatch(event)
async with self._lock:
if not self._callbacks:
return
# Clean end (server closed the stream): reset backoff and reconnect if still
# interested. The reconnect's snapshot re-delivers still-pending evictions.
backoff = self._RECONNECT_BACKOFF_INITIAL_S
_logger.debug("async eviction monitor stream ended; reconnecting")
except asyncio.CancelledError:
raise
except Exception:
# An intentional teardown (close/unregister clears the interest set, then closes
# the stream) surfaces here as a read error — exit quietly in that case.
async with self._lock:
interested = bool(self._callbacks)
if not interested:
return
# Routine: the server force-closes on leader change / slow consumer, and a
# long-lived stream can drop (e.g. an HTTP/2 disconnect). Reconnecting recovers
# it, so keep this at debug to avoid log spam.
_logger.debug("async eviction monitor stream error; reconnecting", exc_info=True)
async with self._lock:
if not self._callbacks:
return
await asyncio.sleep(backoff)
backoff = min(backoff * 2, self._RECONNECT_BACKOFF_MAX_S)
finally:
async with self._lock:
self._stream = None
self._task = None
async def _dispatch(self, event: "DevboxEvictionEventView") -> None:
async with self._lock:
entry = self._callbacks.pop(event.devbox_id, None)
if entry is None:
return
devbox, callback = entry
try:
result = callback(devbox, event.eviction_deadline_ms)
if inspect.isawaitable(result):
await result
except Exception:
_logger.exception("error in eviction callback for devbox %s", event.devbox_id)
async def _close_stream(self) -> None:
async with self._lock:
stream = self._stream
self._stream = None
if stream is not None:
try:
await stream.close()
except Exception:
_logger.debug("error closing eviction stream", exc_info=True)
# asyncio is single-threaded, so the registry needs no lock.
_monitors: "WeakKeyDictionary[AsyncRunloop, AsyncEvictionMonitor]" = WeakKeyDictionary()
def monitor_for(client: "AsyncRunloop") -> AsyncEvictionMonitor:
"""Return the shared :class:`AsyncEvictionMonitor` for ``client``, creating it once."""
monitor = _monitors.get(client)
if monitor is None:
monitor = AsyncEvictionMonitor(client)
_monitors[client] = monitor
return monitor
async def shutdown_monitor_for(client: "AsyncRunloop") -> None:
"""Tear down the shared monitor for ``client`` if one exists."""
monitor = _monitors.pop(client, None)
if monitor is not None:
await monitor.close()