-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathtest_disconnect_registry.py
More file actions
179 lines (126 loc) · 6.01 KB
/
Copy pathtest_disconnect_registry.py
File metadata and controls
179 lines (126 loc) · 6.01 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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
"""Disconnect propagation through the shared DisconnectRegistry actor.
Ray Serve is mocked out; the registry actor is stood in for by a fake whose
``.remote()`` mimics Ray actor-method dispatch (fire-and-forget for set/clear,
awaitable for is_set) so the keying contract can be exercised without a cluster.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from ray.exceptions import RayActorError
from modelship.infer.infer_config import RawRequestProxy, RequestWatcher, _DisconnectStore
@pytest.fixture(autouse=True)
def neutralize_request_watcher():
"""Override the conftest stub: this module exercises the real RequestWatcher
watch loop and DisconnectRegistry interaction directly."""
yield
class _FakeRegistry:
"""Stand-in for the DisconnectRegistry actor handle."""
def __init__(self):
self.disconnected: set[str] = set()
self.set = self._Method(self._set)
self.is_set = self._Method(self._is_set)
self.is_set_many = self._Method(self._is_set_many)
self.clear = self._Method(self._clear)
async def _set(self, rid):
self.disconnected.add(rid)
async def _is_set(self, rid):
return rid in self.disconnected
async def _is_set_many(self, rids):
return [rid for rid in rids if rid in self.disconnected]
async def _clear(self, rid):
self.disconnected.discard(rid)
class _Method:
def __init__(self, fn):
self._fn = fn
def remote(self, *args):
return asyncio.ensure_future(self._fn(*args))
class _DeadRegistry:
"""A registry handle whose every method raises RayActorError on await, as a
dead Ray actor's methods do."""
def __init__(self):
self.set = self._Method()
self.is_set = self._Method()
self.is_set_many = self._Method()
self.clear = self._Method()
class _Method:
def remote(self, *args):
fut: asyncio.Future = asyncio.Future()
fut.set_exception(RayActorError())
return fut
@pytest.mark.asyncio
async def test_proxies_keyed_independently_on_one_registry():
reg = _FakeRegistry()
p1 = RawRequestProxy(reg, {}, "req-1")
p2 = RawRequestProxy(reg, {}, "req-2")
await reg.set.remote("req-1")
assert await p1.is_disconnected() is True
assert await p2.is_disconnected() is False
await reg.clear.remote("req-1")
assert await p1.is_disconnected() is False
@pytest.mark.asyncio
async def test_watcher_sets_on_disconnect_and_stop_leaves_entry_for_ttl():
"""stop() must NOT clear the entry: clearing it raced the model deployment's
cross-process poll and dropped the signal before it was read. The entry is
left for the registry to TTL-evict; stop() only cancels the watch task."""
reg = _FakeRegistry()
raw_request = MagicMock()
raw_request.is_disconnected = AsyncMock(return_value=True)
with patch("modelship.infer.infer_config.get_disconnect_registry", return_value=reg):
watcher = RequestWatcher(raw_request, "req-9", model="m", endpoint="e")
await watcher._task # watch loop records the disconnect, then breaks
assert "req-9" in reg.disconnected
watcher.stop()
await asyncio.sleep(0) # nothing fires, but give any stray task a tick
assert "req-9" in reg.disconnected # survives stop() — deployment can still read it
def test_disconnect_store_evicts_after_ttl():
clock = {"t": 1000.0}
store = _DisconnectStore(ttl_seconds=300.0, now=lambda: clock["t"])
store.set("req-1")
assert store.is_set("req-1") is True
clock["t"] += 299.0 # just inside the window
assert store.is_set("req-1") is True
clock["t"] += 2.0 # now past the 300s deadline
assert store.is_set("req-1") is False
def test_disconnect_store_set_sweeps_expired_entries():
clock = {"t": 0.0}
store = _DisconnectStore(ttl_seconds=10.0, now=lambda: clock["t"])
store.set("stale")
clock["t"] += 11.0 # "stale" is now expired
store.set("fresh") # set() sweeps expired entries
assert "stale" not in store._deadlines
assert store.is_set("fresh") is True
def test_disconnect_store_clear_removes_entry():
store = _DisconnectStore(ttl_seconds=300.0)
store.set("req-1")
store.clear("req-1")
assert store.is_set("req-1") is False
store.clear("never-set") # clearing an absent id is a no-op
def test_disconnect_store_is_set_many_filters_to_disconnected_subset():
clock = {"t": 0.0}
store = _DisconnectStore(ttl_seconds=10.0, now=lambda: clock["t"])
store.set("a")
store.set("b")
assert store.is_set_many(["a", "b", "never-set"]) == ["a", "b"]
clock["t"] += 11.0 # past both deadlines
assert store.is_set_many(["a", "b", "never-set"]) == []
@pytest.mark.asyncio
async def test_is_disconnected_degrades_and_reresolves_on_actor_death():
"""A dead registry actor must not fail a healthy in-flight request: the proxy
degrades to 'still connected' and re-resolves the recreated actor for later polls."""
healthy = _FakeRegistry()
proxy = RawRequestProxy(_DeadRegistry(), {}, "req-1")
with patch("modelship.infer.infer_config.get_disconnect_registry", return_value=healthy):
assert await proxy.is_disconnected() is False # degraded, not raised
assert proxy._registry is healthy # re-resolved to the live actor for later polls
@pytest.mark.asyncio
async def test_watch_reresolves_and_retries_set_on_actor_death():
"""When the registry actor dies, the watcher re-resolves and retries the set so
the disconnect still lands on the recreated actor."""
healthy = _FakeRegistry()
raw_request = MagicMock()
raw_request.is_disconnected = AsyncMock(return_value=True)
# First resolve (in __init__) hands back the dead actor; the retry re-resolves to a live one.
with patch("modelship.infer.infer_config.get_disconnect_registry", side_effect=[_DeadRegistry(), healthy]):
watcher = RequestWatcher(raw_request, "req-2", model="m", endpoint="e")
await watcher._task
assert "req-2" in healthy.disconnected