@smilintux/skmemory 0.5.0 → 0.7.2
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.
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +13 -6
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +101 -19
- package/CHANGELOG.md +153 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +419 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/src/index.ts +255 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +29 -9
- package/requirements.txt +10 -2
- package/seeds/cloud9-opus.seed.json +7 -7
- package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
- package/seeds/lumina-kingdom-founding.seed.json +47 -0
- package/seeds/lumina-pma-signed.seed.json +46 -0
- package/seeds/lumina-singular-achievement.seed.json +46 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +12 -1
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +40 -0
- package/skmemory/anchor.py +4 -2
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/file_backend.py +2 -1
- package/skmemory/backends/skgraph_backend.py +608 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
- package/skmemory/backends/sqlite_backend.py +122 -51
- package/skmemory/backends/vaulted_backend.py +286 -0
- package/skmemory/cli.py +1238 -29
- package/skmemory/config.py +173 -0
- package/skmemory/context_loader.py +335 -0
- package/skmemory/endpoint_selector.py +386 -0
- package/skmemory/fortress.py +685 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +351 -43
- package/skmemory/importers/telegram_api.py +488 -0
- package/skmemory/journal.py +4 -2
- package/skmemory/lovenote.py +4 -2
- package/skmemory/mcp_server.py +706 -0
- package/skmemory/models.py +41 -0
- package/skmemory/openclaw.py +8 -8
- package/skmemory/predictive.py +232 -0
- package/skmemory/promotion.py +524 -0
- package/skmemory/register.py +454 -0
- package/skmemory/register_mcp.py +197 -0
- package/skmemory/ritual.py +121 -47
- package/skmemory/seeds.py +257 -8
- package/skmemory/setup_wizard.py +920 -0
- package/skmemory/sharing.py +402 -0
- package/skmemory/soul.py +71 -20
- package/skmemory/steelman.py +250 -263
- package/skmemory/store.py +271 -60
- package/skmemory/vault.py +228 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +355 -0
- package/tests/integration/test_skgraph_live.py +424 -0
- package/tests/integration/test_skvector_live.py +369 -0
- package/tests/test_backup_rotation.py +327 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +801 -0
- package/tests/test_fortress.py +255 -0
- package/tests/test_fortress_hardening.py +444 -0
- package/tests/test_openclaw.py +5 -2
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +340 -0
- package/tests/test_ritual.py +4 -4
- package/tests/test_seeds.py +96 -0
- package/tests/test_setup.py +835 -0
- package/tests/test_sharing.py +250 -0
- package/tests/test_skgraph_backend.py +667 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_steelman.py +5 -5
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_vault.py +186 -0
- package/skmemory/backends/falkordb_backend.py +0 -310
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"""Tests for SKMemory EndpointSelector — HA routing engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from unittest.mock import MagicMock, patch
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from skmemory.endpoint_selector import (
|
|
16
|
+
Endpoint,
|
|
17
|
+
EndpointSelector,
|
|
18
|
+
RoutingConfig,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Endpoint model tests
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestEndpointModel:
|
|
28
|
+
"""Tests for Endpoint model serialization and defaults."""
|
|
29
|
+
|
|
30
|
+
def test_defaults(self) -> None:
|
|
31
|
+
"""Endpoint has sensible defaults."""
|
|
32
|
+
ep = Endpoint(url="http://localhost:6333")
|
|
33
|
+
assert ep.role == "primary"
|
|
34
|
+
assert ep.latency_ms == -1.0
|
|
35
|
+
assert ep.healthy is True
|
|
36
|
+
assert ep.fail_count == 0
|
|
37
|
+
assert ep.tailscale_ip == ""
|
|
38
|
+
|
|
39
|
+
def test_serialization_roundtrip(self) -> None:
|
|
40
|
+
"""Endpoint serializes and deserializes correctly."""
|
|
41
|
+
ep = Endpoint(url="http://host:6333", role="replica", latency_ms=12.5)
|
|
42
|
+
data = ep.model_dump()
|
|
43
|
+
restored = Endpoint(**data)
|
|
44
|
+
assert restored.url == ep.url
|
|
45
|
+
assert restored.role == ep.role
|
|
46
|
+
assert restored.latency_ms == ep.latency_ms
|
|
47
|
+
|
|
48
|
+
def test_unhealthy_state(self) -> None:
|
|
49
|
+
"""Endpoint transitions to unhealthy correctly."""
|
|
50
|
+
ep = Endpoint(url="http://host:6333", healthy=False, fail_count=5)
|
|
51
|
+
assert ep.healthy is False
|
|
52
|
+
assert ep.fail_count == 5
|
|
53
|
+
|
|
54
|
+
def test_last_checked_timestamp(self) -> None:
|
|
55
|
+
"""last_checked stores ISO timestamp."""
|
|
56
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
57
|
+
ep = Endpoint(url="http://host:6333", last_checked=now)
|
|
58
|
+
assert ep.last_checked == now
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestRoutingConfigModel:
|
|
62
|
+
"""Tests for RoutingConfig defaults."""
|
|
63
|
+
|
|
64
|
+
def test_defaults(self) -> None:
|
|
65
|
+
"""RoutingConfig has sensible defaults."""
|
|
66
|
+
rc = RoutingConfig()
|
|
67
|
+
assert rc.strategy == "failover"
|
|
68
|
+
assert rc.probe_interval_seconds == 30
|
|
69
|
+
assert rc.probe_timeout_seconds == 3
|
|
70
|
+
assert rc.max_fail_count == 3
|
|
71
|
+
assert rc.recovery_interval_seconds == 60
|
|
72
|
+
|
|
73
|
+
def test_custom_values(self) -> None:
|
|
74
|
+
"""RoutingConfig accepts custom values."""
|
|
75
|
+
rc = RoutingConfig(strategy="latency", probe_interval_seconds=10)
|
|
76
|
+
assert rc.strategy == "latency"
|
|
77
|
+
assert rc.probe_interval_seconds == 10
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Latency probing tests
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestLatencyProbing:
|
|
86
|
+
"""Tests for TCP latency probing (all networking mocked)."""
|
|
87
|
+
|
|
88
|
+
def test_probe_success(self) -> None:
|
|
89
|
+
"""Successful probe updates latency and marks healthy."""
|
|
90
|
+
ep = Endpoint(url="http://localhost:6333")
|
|
91
|
+
selector = EndpointSelector(skvector_endpoints=[ep])
|
|
92
|
+
|
|
93
|
+
mock_sock = MagicMock()
|
|
94
|
+
with patch("skmemory.endpoint_selector.socket.create_connection", return_value=mock_sock):
|
|
95
|
+
result = selector.probe_endpoint(ep)
|
|
96
|
+
|
|
97
|
+
assert result.healthy is True
|
|
98
|
+
assert result.latency_ms >= 0
|
|
99
|
+
assert result.fail_count == 0
|
|
100
|
+
assert result.last_checked != ""
|
|
101
|
+
mock_sock.close.assert_called_once()
|
|
102
|
+
|
|
103
|
+
def test_probe_timeout(self) -> None:
|
|
104
|
+
"""Timeout increments fail_count but keeps healthy until max."""
|
|
105
|
+
ep = Endpoint(url="http://localhost:6333")
|
|
106
|
+
selector = EndpointSelector(skvector_endpoints=[ep])
|
|
107
|
+
|
|
108
|
+
with patch(
|
|
109
|
+
"skmemory.endpoint_selector.socket.create_connection",
|
|
110
|
+
side_effect=socket.timeout("timed out"),
|
|
111
|
+
):
|
|
112
|
+
result = selector.probe_endpoint(ep)
|
|
113
|
+
|
|
114
|
+
assert result.fail_count == 1
|
|
115
|
+
assert result.healthy is True # Still healthy (max_fail_count=3)
|
|
116
|
+
assert result.latency_ms == -1.0
|
|
117
|
+
|
|
118
|
+
def test_probe_connection_refused(self) -> None:
|
|
119
|
+
"""Connection refused increments fail_count."""
|
|
120
|
+
ep = Endpoint(url="http://localhost:6333")
|
|
121
|
+
selector = EndpointSelector(skvector_endpoints=[ep])
|
|
122
|
+
|
|
123
|
+
with patch(
|
|
124
|
+
"skmemory.endpoint_selector.socket.create_connection",
|
|
125
|
+
side_effect=ConnectionRefusedError("refused"),
|
|
126
|
+
):
|
|
127
|
+
result = selector.probe_endpoint(ep)
|
|
128
|
+
|
|
129
|
+
assert result.fail_count == 1
|
|
130
|
+
assert result.latency_ms == -1.0
|
|
131
|
+
|
|
132
|
+
def test_probe_unhealthy_after_max_failures(self) -> None:
|
|
133
|
+
"""Endpoint marked unhealthy after max_fail_count failures."""
|
|
134
|
+
ep = Endpoint(url="http://localhost:6333", fail_count=2)
|
|
135
|
+
selector = EndpointSelector(
|
|
136
|
+
skvector_endpoints=[ep],
|
|
137
|
+
config=RoutingConfig(max_fail_count=3),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
with patch(
|
|
141
|
+
"skmemory.endpoint_selector.socket.create_connection",
|
|
142
|
+
side_effect=OSError("unreachable"),
|
|
143
|
+
):
|
|
144
|
+
result = selector.probe_endpoint(ep)
|
|
145
|
+
|
|
146
|
+
assert result.fail_count == 3
|
|
147
|
+
assert result.healthy is False
|
|
148
|
+
|
|
149
|
+
def test_probe_recovery(self) -> None:
|
|
150
|
+
"""Unhealthy endpoint recovers when probe succeeds."""
|
|
151
|
+
ep = Endpoint(url="http://localhost:6333", healthy=False, fail_count=5)
|
|
152
|
+
selector = EndpointSelector(skvector_endpoints=[ep])
|
|
153
|
+
|
|
154
|
+
mock_sock = MagicMock()
|
|
155
|
+
with patch("skmemory.endpoint_selector.socket.create_connection", return_value=mock_sock):
|
|
156
|
+
result = selector.probe_endpoint(ep)
|
|
157
|
+
|
|
158
|
+
assert result.healthy is True
|
|
159
|
+
assert result.fail_count == 0
|
|
160
|
+
assert result.latency_ms >= 0
|
|
161
|
+
|
|
162
|
+
def test_probe_all_updates_timestamp(self) -> None:
|
|
163
|
+
"""probe_all updates the last probe time."""
|
|
164
|
+
ep1 = Endpoint(url="http://host1:6333")
|
|
165
|
+
ep2 = Endpoint(url="redis://host2:6379")
|
|
166
|
+
selector = EndpointSelector(skvector_endpoints=[ep1], skgraph_endpoints=[ep2])
|
|
167
|
+
|
|
168
|
+
mock_sock = MagicMock()
|
|
169
|
+
with patch("skmemory.endpoint_selector.socket.create_connection", return_value=mock_sock):
|
|
170
|
+
results = selector.probe_all()
|
|
171
|
+
|
|
172
|
+
assert len(results["skvector"]) == 1
|
|
173
|
+
assert len(results["skgraph"]) == 1
|
|
174
|
+
assert selector._last_probe_time > 0
|
|
175
|
+
|
|
176
|
+
def test_probe_infers_redis_port(self) -> None:
|
|
177
|
+
"""Probe infers port 6379 for redis:// scheme."""
|
|
178
|
+
ep = Endpoint(url="redis://myhost")
|
|
179
|
+
selector = EndpointSelector(skgraph_endpoints=[ep])
|
|
180
|
+
|
|
181
|
+
mock_sock = MagicMock()
|
|
182
|
+
with patch("skmemory.endpoint_selector.socket.create_connection", return_value=mock_sock) as mock_connect:
|
|
183
|
+
selector.probe_endpoint(ep)
|
|
184
|
+
|
|
185
|
+
args = mock_connect.call_args[0]
|
|
186
|
+
assert args[0] == ("myhost", 6379)
|
|
187
|
+
|
|
188
|
+
def test_probe_infers_https_port(self) -> None:
|
|
189
|
+
"""Probe infers port 443 for https:// scheme."""
|
|
190
|
+
ep = Endpoint(url="https://secure.qdrant.io")
|
|
191
|
+
selector = EndpointSelector(skvector_endpoints=[ep])
|
|
192
|
+
|
|
193
|
+
mock_sock = MagicMock()
|
|
194
|
+
with patch("skmemory.endpoint_selector.socket.create_connection", return_value=mock_sock) as mock_connect:
|
|
195
|
+
selector.probe_endpoint(ep)
|
|
196
|
+
|
|
197
|
+
args = mock_connect.call_args[0]
|
|
198
|
+
assert args[0] == ("secure.qdrant.io", 443)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Routing strategy tests
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TestFailoverStrategy:
|
|
207
|
+
"""Tests for the failover routing strategy."""
|
|
208
|
+
|
|
209
|
+
def test_picks_first_healthy(self) -> None:
|
|
210
|
+
"""Failover returns the first healthy endpoint."""
|
|
211
|
+
ep1 = Endpoint(url="http://a:6333", latency_ms=50)
|
|
212
|
+
ep2 = Endpoint(url="http://b:6333", latency_ms=10)
|
|
213
|
+
selector = EndpointSelector(
|
|
214
|
+
skvector_endpoints=[ep1, ep2],
|
|
215
|
+
config=RoutingConfig(strategy="failover", probe_interval_seconds=9999),
|
|
216
|
+
)
|
|
217
|
+
selector._last_probe_time = time.monotonic()
|
|
218
|
+
|
|
219
|
+
result = selector.select_skvector()
|
|
220
|
+
assert result is not None
|
|
221
|
+
assert result.url == "http://a:6333"
|
|
222
|
+
|
|
223
|
+
def test_skips_unhealthy(self) -> None:
|
|
224
|
+
"""Failover skips unhealthy endpoints."""
|
|
225
|
+
ep1 = Endpoint(url="http://a:6333", healthy=False)
|
|
226
|
+
ep2 = Endpoint(url="http://b:6333", healthy=True)
|
|
227
|
+
selector = EndpointSelector(
|
|
228
|
+
skvector_endpoints=[ep1, ep2],
|
|
229
|
+
config=RoutingConfig(strategy="failover", probe_interval_seconds=9999),
|
|
230
|
+
)
|
|
231
|
+
selector._last_probe_time = time.monotonic()
|
|
232
|
+
|
|
233
|
+
result = selector.select_skvector()
|
|
234
|
+
assert result is not None
|
|
235
|
+
assert result.url == "http://b:6333"
|
|
236
|
+
|
|
237
|
+
def test_returns_none_when_all_unhealthy(self) -> None:
|
|
238
|
+
"""Failover returns None when all endpoints are unhealthy."""
|
|
239
|
+
ep1 = Endpoint(url="http://a:6333", healthy=False)
|
|
240
|
+
ep2 = Endpoint(url="http://b:6333", healthy=False)
|
|
241
|
+
selector = EndpointSelector(
|
|
242
|
+
skvector_endpoints=[ep1, ep2],
|
|
243
|
+
config=RoutingConfig(strategy="failover", probe_interval_seconds=9999),
|
|
244
|
+
)
|
|
245
|
+
selector._last_probe_time = time.monotonic()
|
|
246
|
+
|
|
247
|
+
assert selector.select_skvector() is None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class TestLatencyStrategy:
|
|
251
|
+
"""Tests for the latency routing strategy."""
|
|
252
|
+
|
|
253
|
+
def test_picks_lowest_latency(self) -> None:
|
|
254
|
+
"""Latency strategy picks the endpoint with lowest latency."""
|
|
255
|
+
ep1 = Endpoint(url="http://a:6333", latency_ms=50)
|
|
256
|
+
ep2 = Endpoint(url="http://b:6333", latency_ms=10)
|
|
257
|
+
ep3 = Endpoint(url="http://c:6333", latency_ms=30)
|
|
258
|
+
selector = EndpointSelector(
|
|
259
|
+
skvector_endpoints=[ep1, ep2, ep3],
|
|
260
|
+
config=RoutingConfig(strategy="latency", probe_interval_seconds=9999),
|
|
261
|
+
)
|
|
262
|
+
selector._last_probe_time = time.monotonic()
|
|
263
|
+
|
|
264
|
+
result = selector.select_skvector()
|
|
265
|
+
assert result is not None
|
|
266
|
+
assert result.url == "http://b:6333"
|
|
267
|
+
|
|
268
|
+
def test_falls_back_to_first_when_unprobed(self) -> None:
|
|
269
|
+
"""Latency falls back to first healthy if none probed."""
|
|
270
|
+
ep1 = Endpoint(url="http://a:6333") # latency_ms=-1
|
|
271
|
+
ep2 = Endpoint(url="http://b:6333")
|
|
272
|
+
selector = EndpointSelector(
|
|
273
|
+
skvector_endpoints=[ep1, ep2],
|
|
274
|
+
config=RoutingConfig(strategy="latency", probe_interval_seconds=9999),
|
|
275
|
+
)
|
|
276
|
+
selector._last_probe_time = time.monotonic()
|
|
277
|
+
|
|
278
|
+
result = selector.select_skvector()
|
|
279
|
+
assert result is not None
|
|
280
|
+
assert result.url == "http://a:6333"
|
|
281
|
+
|
|
282
|
+
def test_skips_unhealthy(self) -> None:
|
|
283
|
+
"""Latency skips unhealthy endpoints even with low latency."""
|
|
284
|
+
ep1 = Endpoint(url="http://a:6333", latency_ms=5, healthy=False)
|
|
285
|
+
ep2 = Endpoint(url="http://b:6333", latency_ms=50)
|
|
286
|
+
selector = EndpointSelector(
|
|
287
|
+
skvector_endpoints=[ep1, ep2],
|
|
288
|
+
config=RoutingConfig(strategy="latency", probe_interval_seconds=9999),
|
|
289
|
+
)
|
|
290
|
+
selector._last_probe_time = time.monotonic()
|
|
291
|
+
|
|
292
|
+
result = selector.select_skvector()
|
|
293
|
+
assert result is not None
|
|
294
|
+
assert result.url == "http://b:6333"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class TestLocalFirstStrategy:
|
|
298
|
+
"""Tests for the local-first routing strategy."""
|
|
299
|
+
|
|
300
|
+
def test_prefers_localhost(self) -> None:
|
|
301
|
+
"""Local-first picks localhost over lower-latency remote."""
|
|
302
|
+
ep1 = Endpoint(url="http://remote:6333", latency_ms=5)
|
|
303
|
+
ep2 = Endpoint(url="http://localhost:6333", latency_ms=15)
|
|
304
|
+
selector = EndpointSelector(
|
|
305
|
+
skvector_endpoints=[ep1, ep2],
|
|
306
|
+
config=RoutingConfig(strategy="local-first", probe_interval_seconds=9999),
|
|
307
|
+
)
|
|
308
|
+
selector._last_probe_time = time.monotonic()
|
|
309
|
+
|
|
310
|
+
result = selector.select_skvector()
|
|
311
|
+
assert result is not None
|
|
312
|
+
assert result.url == "http://localhost:6333"
|
|
313
|
+
|
|
314
|
+
def test_prefers_127_0_0_1(self) -> None:
|
|
315
|
+
"""Local-first recognizes 127.0.0.1 as local."""
|
|
316
|
+
ep1 = Endpoint(url="http://remote:6333", latency_ms=5)
|
|
317
|
+
ep2 = Endpoint(url="http://127.0.0.1:6333", latency_ms=15)
|
|
318
|
+
selector = EndpointSelector(
|
|
319
|
+
skvector_endpoints=[ep1, ep2],
|
|
320
|
+
config=RoutingConfig(strategy="local-first", probe_interval_seconds=9999),
|
|
321
|
+
)
|
|
322
|
+
selector._last_probe_time = time.monotonic()
|
|
323
|
+
|
|
324
|
+
result = selector.select_skvector()
|
|
325
|
+
assert result is not None
|
|
326
|
+
assert result.url == "http://127.0.0.1:6333"
|
|
327
|
+
|
|
328
|
+
def test_falls_back_to_latency_when_no_local(self) -> None:
|
|
329
|
+
"""Local-first falls back to lowest latency when no local endpoint."""
|
|
330
|
+
ep1 = Endpoint(url="http://remote-a:6333", latency_ms=50)
|
|
331
|
+
ep2 = Endpoint(url="http://remote-b:6333", latency_ms=10)
|
|
332
|
+
selector = EndpointSelector(
|
|
333
|
+
skvector_endpoints=[ep1, ep2],
|
|
334
|
+
config=RoutingConfig(strategy="local-first", probe_interval_seconds=9999),
|
|
335
|
+
)
|
|
336
|
+
selector._last_probe_time = time.monotonic()
|
|
337
|
+
|
|
338
|
+
result = selector.select_skvector()
|
|
339
|
+
assert result is not None
|
|
340
|
+
assert result.url == "http://remote-b:6333"
|
|
341
|
+
|
|
342
|
+
def test_skips_unhealthy_local(self) -> None:
|
|
343
|
+
"""Local-first skips unhealthy localhost."""
|
|
344
|
+
ep1 = Endpoint(url="http://localhost:6333", healthy=False)
|
|
345
|
+
ep2 = Endpoint(url="http://remote:6333", latency_ms=10)
|
|
346
|
+
selector = EndpointSelector(
|
|
347
|
+
skvector_endpoints=[ep1, ep2],
|
|
348
|
+
config=RoutingConfig(strategy="local-first", probe_interval_seconds=9999),
|
|
349
|
+
)
|
|
350
|
+
selector._last_probe_time = time.monotonic()
|
|
351
|
+
|
|
352
|
+
result = selector.select_skvector()
|
|
353
|
+
assert result is not None
|
|
354
|
+
assert result.url == "http://remote:6333"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class TestReadLocalWritePrimaryStrategy:
|
|
358
|
+
"""Tests for the read-local-write-primary strategy."""
|
|
359
|
+
|
|
360
|
+
def test_writes_go_to_primary_only(self) -> None:
|
|
361
|
+
"""Writes are routed only to primary endpoints."""
|
|
362
|
+
ep1 = Endpoint(url="http://localhost:6333", role="replica", latency_ms=2)
|
|
363
|
+
ep2 = Endpoint(url="http://primary:6333", role="primary", latency_ms=20)
|
|
364
|
+
selector = EndpointSelector(
|
|
365
|
+
skvector_endpoints=[ep1, ep2],
|
|
366
|
+
config=RoutingConfig(strategy="read-local-write-primary", probe_interval_seconds=9999),
|
|
367
|
+
)
|
|
368
|
+
selector._last_probe_time = time.monotonic()
|
|
369
|
+
|
|
370
|
+
result = selector.select_skvector(for_write=True)
|
|
371
|
+
assert result is not None
|
|
372
|
+
assert result.url == "http://primary:6333"
|
|
373
|
+
|
|
374
|
+
def test_reads_prefer_local(self) -> None:
|
|
375
|
+
"""Reads prefer localhost in read-local-write-primary."""
|
|
376
|
+
ep1 = Endpoint(url="http://primary:6333", role="primary", latency_ms=20)
|
|
377
|
+
ep2 = Endpoint(url="http://localhost:6333", role="replica", latency_ms=2)
|
|
378
|
+
selector = EndpointSelector(
|
|
379
|
+
skvector_endpoints=[ep1, ep2],
|
|
380
|
+
config=RoutingConfig(strategy="read-local-write-primary", probe_interval_seconds=9999),
|
|
381
|
+
)
|
|
382
|
+
selector._last_probe_time = time.monotonic()
|
|
383
|
+
|
|
384
|
+
result = selector.select_skvector(for_write=False)
|
|
385
|
+
assert result is not None
|
|
386
|
+
assert result.url == "http://localhost:6333"
|
|
387
|
+
|
|
388
|
+
def test_writes_return_none_when_no_primary(self) -> None:
|
|
389
|
+
"""Writes return None if no primary is healthy."""
|
|
390
|
+
ep1 = Endpoint(url="http://localhost:6333", role="replica")
|
|
391
|
+
ep2 = Endpoint(url="http://remote:6333", role="replica")
|
|
392
|
+
selector = EndpointSelector(
|
|
393
|
+
skvector_endpoints=[ep1, ep2],
|
|
394
|
+
config=RoutingConfig(strategy="read-local-write-primary", probe_interval_seconds=9999),
|
|
395
|
+
)
|
|
396
|
+
selector._last_probe_time = time.monotonic()
|
|
397
|
+
|
|
398
|
+
assert selector.select_skvector(for_write=True) is None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# Failover behavior tests
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class TestFailover:
|
|
407
|
+
"""Tests for failover transitions and recovery."""
|
|
408
|
+
|
|
409
|
+
def test_healthy_to_unhealthy_transition(self) -> None:
|
|
410
|
+
"""Endpoint transitions from healthy to unhealthy after max failures."""
|
|
411
|
+
ep = Endpoint(url="http://host:6333")
|
|
412
|
+
selector = EndpointSelector(
|
|
413
|
+
skvector_endpoints=[ep],
|
|
414
|
+
config=RoutingConfig(max_fail_count=2),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
with patch(
|
|
418
|
+
"skmemory.endpoint_selector.socket.create_connection",
|
|
419
|
+
side_effect=OSError("down"),
|
|
420
|
+
):
|
|
421
|
+
selector.probe_endpoint(ep)
|
|
422
|
+
assert ep.healthy is True # 1 failure, not yet max
|
|
423
|
+
|
|
424
|
+
selector.probe_endpoint(ep)
|
|
425
|
+
assert ep.healthy is False # 2 failures = max
|
|
426
|
+
|
|
427
|
+
def test_mark_unhealthy_by_url(self) -> None:
|
|
428
|
+
"""mark_unhealthy marks the correct endpoint."""
|
|
429
|
+
ep1 = Endpoint(url="http://a:6333")
|
|
430
|
+
ep2 = Endpoint(url="http://b:6333")
|
|
431
|
+
selector = EndpointSelector(skvector_endpoints=[ep1, ep2])
|
|
432
|
+
|
|
433
|
+
selector.mark_unhealthy("http://a:6333")
|
|
434
|
+
assert ep1.healthy is False
|
|
435
|
+
assert ep2.healthy is True
|
|
436
|
+
|
|
437
|
+
def test_mark_unhealthy_nonexistent_url(self) -> None:
|
|
438
|
+
"""mark_unhealthy does nothing for unknown URLs."""
|
|
439
|
+
ep = Endpoint(url="http://a:6333")
|
|
440
|
+
selector = EndpointSelector(skvector_endpoints=[ep])
|
|
441
|
+
|
|
442
|
+
selector.mark_unhealthy("http://unknown:6333")
|
|
443
|
+
assert ep.healthy is True
|
|
444
|
+
|
|
445
|
+
def test_recovery_after_failure(self) -> None:
|
|
446
|
+
"""Endpoint recovers when probing succeeds again."""
|
|
447
|
+
ep = Endpoint(url="http://host:6333", healthy=False, fail_count=5)
|
|
448
|
+
selector = EndpointSelector(skvector_endpoints=[ep])
|
|
449
|
+
|
|
450
|
+
mock_sock = MagicMock()
|
|
451
|
+
with patch("skmemory.endpoint_selector.socket.create_connection", return_value=mock_sock):
|
|
452
|
+
selector.probe_endpoint(ep)
|
|
453
|
+
|
|
454
|
+
assert ep.healthy is True
|
|
455
|
+
assert ep.fail_count == 0
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
# Heartbeat discovery tests
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class TestHeartbeatDiscovery:
|
|
464
|
+
"""Tests for discovering endpoints from heartbeat files."""
|
|
465
|
+
|
|
466
|
+
def test_discover_qdrant_from_heartbeat(self, tmp_path: Path) -> None:
|
|
467
|
+
"""Discovers Qdrant endpoint from heartbeat file."""
|
|
468
|
+
hb_dir = tmp_path / "heartbeats"
|
|
469
|
+
hb_dir.mkdir()
|
|
470
|
+
(hb_dir / "peer.json").write_text(json.dumps({
|
|
471
|
+
"agent_name": "peer",
|
|
472
|
+
"hostname": "peer-host",
|
|
473
|
+
"tailscale_ip": "100.64.0.5",
|
|
474
|
+
"services": [
|
|
475
|
+
{"name": "skvector", "port": 6333, "protocol": "http"},
|
|
476
|
+
],
|
|
477
|
+
}), encoding="utf-8")
|
|
478
|
+
|
|
479
|
+
selector = EndpointSelector()
|
|
480
|
+
selector.discover_from_heartbeats(hb_dir)
|
|
481
|
+
|
|
482
|
+
assert len(selector.skvector_endpoints) == 1
|
|
483
|
+
assert selector.skvector_endpoints[0].url == "http://100.64.0.5:6333"
|
|
484
|
+
assert selector.skvector_endpoints[0].role == "replica"
|
|
485
|
+
|
|
486
|
+
def test_discover_falkordb_from_heartbeat(self, tmp_path: Path) -> None:
|
|
487
|
+
"""Discovers FalkorDB endpoint from heartbeat file."""
|
|
488
|
+
hb_dir = tmp_path / "heartbeats"
|
|
489
|
+
hb_dir.mkdir()
|
|
490
|
+
(hb_dir / "peer.json").write_text(json.dumps({
|
|
491
|
+
"agent_name": "peer",
|
|
492
|
+
"hostname": "peer-host",
|
|
493
|
+
"services": [
|
|
494
|
+
{"name": "skgraph", "port": 6379, "protocol": "redis"},
|
|
495
|
+
],
|
|
496
|
+
}), encoding="utf-8")
|
|
497
|
+
|
|
498
|
+
selector = EndpointSelector()
|
|
499
|
+
selector.discover_from_heartbeats(hb_dir)
|
|
500
|
+
|
|
501
|
+
assert len(selector.skgraph_endpoints) == 1
|
|
502
|
+
assert selector.skgraph_endpoints[0].url == "redis://peer-host:6379"
|
|
503
|
+
|
|
504
|
+
def test_discover_uses_tailscale_ip_over_hostname(self, tmp_path: Path) -> None:
|
|
505
|
+
"""Tailscale IP is preferred over hostname for URL construction."""
|
|
506
|
+
hb_dir = tmp_path / "heartbeats"
|
|
507
|
+
hb_dir.mkdir()
|
|
508
|
+
(hb_dir / "peer.json").write_text(json.dumps({
|
|
509
|
+
"agent_name": "peer",
|
|
510
|
+
"hostname": "peer-host",
|
|
511
|
+
"tailscale_ip": "100.64.0.10",
|
|
512
|
+
"services": [
|
|
513
|
+
{"name": "skvector", "port": 6333, "protocol": "http"},
|
|
514
|
+
],
|
|
515
|
+
}), encoding="utf-8")
|
|
516
|
+
|
|
517
|
+
selector = EndpointSelector()
|
|
518
|
+
selector.discover_from_heartbeats(hb_dir)
|
|
519
|
+
|
|
520
|
+
assert selector.skvector_endpoints[0].url == "http://100.64.0.10:6333"
|
|
521
|
+
|
|
522
|
+
def test_discover_skips_existing_urls(self, tmp_path: Path) -> None:
|
|
523
|
+
"""Discovery doesn't duplicate existing config endpoints."""
|
|
524
|
+
hb_dir = tmp_path / "heartbeats"
|
|
525
|
+
hb_dir.mkdir()
|
|
526
|
+
(hb_dir / "peer.json").write_text(json.dumps({
|
|
527
|
+
"agent_name": "peer",
|
|
528
|
+
"hostname": "peer-host",
|
|
529
|
+
"tailscale_ip": "100.64.0.5",
|
|
530
|
+
"services": [
|
|
531
|
+
{"name": "skvector", "port": 6333, "protocol": "http"},
|
|
532
|
+
],
|
|
533
|
+
}), encoding="utf-8")
|
|
534
|
+
|
|
535
|
+
existing = Endpoint(url="http://100.64.0.5:6333")
|
|
536
|
+
selector = EndpointSelector(skvector_endpoints=[existing])
|
|
537
|
+
selector.discover_from_heartbeats(hb_dir)
|
|
538
|
+
|
|
539
|
+
assert len(selector.skvector_endpoints) == 1
|
|
540
|
+
|
|
541
|
+
def test_discover_empty_dir(self, tmp_path: Path) -> None:
|
|
542
|
+
"""Empty heartbeat dir adds no endpoints."""
|
|
543
|
+
hb_dir = tmp_path / "heartbeats"
|
|
544
|
+
hb_dir.mkdir()
|
|
545
|
+
|
|
546
|
+
selector = EndpointSelector()
|
|
547
|
+
selector.discover_from_heartbeats(hb_dir)
|
|
548
|
+
|
|
549
|
+
assert len(selector.skvector_endpoints) == 0
|
|
550
|
+
assert len(selector.skgraph_endpoints) == 0
|
|
551
|
+
|
|
552
|
+
def test_discover_missing_dir(self, tmp_path: Path) -> None:
|
|
553
|
+
"""Missing heartbeat dir doesn't raise."""
|
|
554
|
+
selector = EndpointSelector()
|
|
555
|
+
selector.discover_from_heartbeats(tmp_path / "nonexistent")
|
|
556
|
+
|
|
557
|
+
assert len(selector.skvector_endpoints) == 0
|
|
558
|
+
|
|
559
|
+
def test_discover_skips_invalid_json(self, tmp_path: Path) -> None:
|
|
560
|
+
"""Invalid JSON in heartbeat files is skipped gracefully."""
|
|
561
|
+
hb_dir = tmp_path / "heartbeats"
|
|
562
|
+
hb_dir.mkdir()
|
|
563
|
+
(hb_dir / "bad.json").write_text("not json", encoding="utf-8")
|
|
564
|
+
|
|
565
|
+
selector = EndpointSelector()
|
|
566
|
+
selector.discover_from_heartbeats(hb_dir)
|
|
567
|
+
|
|
568
|
+
assert len(selector.skvector_endpoints) == 0
|
|
569
|
+
|
|
570
|
+
def test_discover_skips_no_host(self, tmp_path: Path) -> None:
|
|
571
|
+
"""Heartbeat without hostname or tailscale_ip is skipped."""
|
|
572
|
+
hb_dir = tmp_path / "heartbeats"
|
|
573
|
+
hb_dir.mkdir()
|
|
574
|
+
(hb_dir / "peer.json").write_text(json.dumps({
|
|
575
|
+
"agent_name": "peer",
|
|
576
|
+
"services": [
|
|
577
|
+
{"name": "skvector", "port": 6333},
|
|
578
|
+
],
|
|
579
|
+
}), encoding="utf-8")
|
|
580
|
+
|
|
581
|
+
selector = EndpointSelector()
|
|
582
|
+
selector.discover_from_heartbeats(hb_dir)
|
|
583
|
+
|
|
584
|
+
assert len(selector.skvector_endpoints) == 0
|
|
585
|
+
|
|
586
|
+
def test_discover_multiple_services(self, tmp_path: Path) -> None:
|
|
587
|
+
"""Discovers both Qdrant and FalkorDB from one heartbeat."""
|
|
588
|
+
hb_dir = tmp_path / "heartbeats"
|
|
589
|
+
hb_dir.mkdir()
|
|
590
|
+
(hb_dir / "peer.json").write_text(json.dumps({
|
|
591
|
+
"agent_name": "peer",
|
|
592
|
+
"hostname": "peer-host",
|
|
593
|
+
"services": [
|
|
594
|
+
{"name": "skvector", "port": 6333, "protocol": "http"},
|
|
595
|
+
{"name": "skgraph", "port": 6379, "protocol": "redis"},
|
|
596
|
+
],
|
|
597
|
+
}), encoding="utf-8")
|
|
598
|
+
|
|
599
|
+
selector = EndpointSelector()
|
|
600
|
+
selector.discover_from_heartbeats(hb_dir)
|
|
601
|
+
|
|
602
|
+
assert len(selector.skvector_endpoints) == 1
|
|
603
|
+
assert len(selector.skgraph_endpoints) == 1
|
|
604
|
+
|
|
605
|
+
def test_discover_skips_tmp_files(self, tmp_path: Path) -> None:
|
|
606
|
+
"""Files ending in .tmp are skipped."""
|
|
607
|
+
hb_dir = tmp_path / "heartbeats"
|
|
608
|
+
hb_dir.mkdir()
|
|
609
|
+
(hb_dir / "peer.json.tmp").write_text(json.dumps({
|
|
610
|
+
"agent_name": "peer",
|
|
611
|
+
"hostname": "peer-host",
|
|
612
|
+
"services": [{"name": "skvector", "port": 6333}],
|
|
613
|
+
}), encoding="utf-8")
|
|
614
|
+
|
|
615
|
+
selector = EndpointSelector()
|
|
616
|
+
selector.discover_from_heartbeats(hb_dir)
|
|
617
|
+
|
|
618
|
+
assert len(selector.skvector_endpoints) == 0
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
# Backward compatibility tests
|
|
623
|
+
# ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
class TestBackwardCompatibility:
|
|
627
|
+
"""Tests that single-URL configs still work."""
|
|
628
|
+
|
|
629
|
+
def test_empty_endpoints(self) -> None:
|
|
630
|
+
"""Empty endpoint lists return None for selection."""
|
|
631
|
+
selector = EndpointSelector(
|
|
632
|
+
config=RoutingConfig(probe_interval_seconds=9999),
|
|
633
|
+
)
|
|
634
|
+
selector._last_probe_time = time.monotonic()
|
|
635
|
+
|
|
636
|
+
assert selector.select_skvector() is None
|
|
637
|
+
assert selector.select_skgraph() is None
|
|
638
|
+
|
|
639
|
+
def test_single_endpoint(self) -> None:
|
|
640
|
+
"""Single endpoint is selected regardless of strategy."""
|
|
641
|
+
ep = Endpoint(url="http://localhost:6333")
|
|
642
|
+
selector = EndpointSelector(
|
|
643
|
+
skvector_endpoints=[ep],
|
|
644
|
+
config=RoutingConfig(strategy="latency", probe_interval_seconds=9999),
|
|
645
|
+
)
|
|
646
|
+
selector._last_probe_time = time.monotonic()
|
|
647
|
+
|
|
648
|
+
result = selector.select_skvector()
|
|
649
|
+
assert result is not None
|
|
650
|
+
assert result.url == "http://localhost:6333"
|
|
651
|
+
|
|
652
|
+
def test_normalize_from_dict(self) -> None:
|
|
653
|
+
"""Endpoints can be passed as dicts."""
|
|
654
|
+
selector = EndpointSelector(
|
|
655
|
+
skvector_endpoints=[{"url": "http://host:6333", "role": "replica"}],
|
|
656
|
+
)
|
|
657
|
+
assert len(selector.skvector_endpoints) == 1
|
|
658
|
+
assert selector.skvector_endpoints[0].role == "replica"
|
|
659
|
+
|
|
660
|
+
def test_normalize_from_endpoint_config(self) -> None:
|
|
661
|
+
"""Endpoints can be passed as EndpointConfig-like objects."""
|
|
662
|
+
from skmemory.config import EndpointConfig
|
|
663
|
+
|
|
664
|
+
ec = EndpointConfig(url="http://host:6333", role="replica")
|
|
665
|
+
selector = EndpointSelector(skvector_endpoints=[ec])
|
|
666
|
+
|
|
667
|
+
assert len(selector.skvector_endpoints) == 1
|
|
668
|
+
assert selector.skvector_endpoints[0].url == "http://host:6333"
|
|
669
|
+
|
|
670
|
+
def test_falkordb_selection(self) -> None:
|
|
671
|
+
"""FalkorDB endpoints work the same as Qdrant."""
|
|
672
|
+
ep = Endpoint(url="redis://localhost:6379")
|
|
673
|
+
selector = EndpointSelector(
|
|
674
|
+
skgraph_endpoints=[ep],
|
|
675
|
+
config=RoutingConfig(probe_interval_seconds=9999),
|
|
676
|
+
)
|
|
677
|
+
selector._last_probe_time = time.monotonic()
|
|
678
|
+
|
|
679
|
+
result = selector.select_skgraph()
|
|
680
|
+
assert result is not None
|
|
681
|
+
assert result.url == "redis://localhost:6379"
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# ---------------------------------------------------------------------------
|
|
685
|
+
# Status reporting tests
|
|
686
|
+
# ---------------------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
class TestEndpointSelectorStatus:
|
|
690
|
+
"""Tests for status output format."""
|
|
691
|
+
|
|
692
|
+
def test_status_format(self) -> None:
|
|
693
|
+
"""Status returns expected keys and format."""
|
|
694
|
+
ep = Endpoint(url="http://host:6333", latency_ms=12.5)
|
|
695
|
+
selector = EndpointSelector(
|
|
696
|
+
skvector_endpoints=[ep],
|
|
697
|
+
config=RoutingConfig(strategy="latency"),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
info = selector.status()
|
|
701
|
+
assert info["strategy"] == "latency"
|
|
702
|
+
assert "probe_interval_seconds" in info
|
|
703
|
+
assert "last_probe_age_seconds" in info
|
|
704
|
+
assert "skvector_endpoints" in info
|
|
705
|
+
assert "skgraph_endpoints" in info
|
|
706
|
+
assert len(info["skvector_endpoints"]) == 1
|
|
707
|
+
assert info["skvector_endpoints"][0]["url"] == "http://host:6333"
|
|
708
|
+
|
|
709
|
+
def test_status_empty(self) -> None:
|
|
710
|
+
"""Status works with no endpoints."""
|
|
711
|
+
selector = EndpointSelector()
|
|
712
|
+
info = selector.status()
|
|
713
|
+
assert info["skvector_endpoints"] == []
|
|
714
|
+
assert info["skgraph_endpoints"] == []
|
|
715
|
+
|
|
716
|
+
def test_status_probe_age(self) -> None:
|
|
717
|
+
"""Status reflects probe age correctly."""
|
|
718
|
+
selector = EndpointSelector()
|
|
719
|
+
info = selector.status()
|
|
720
|
+
assert info["last_probe_age_seconds"] == -1 # Never probed
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# ---------------------------------------------------------------------------
|
|
724
|
+
# Maybe-probe (lazy probing) tests
|
|
725
|
+
# ---------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
class TestMaybeProbe:
|
|
729
|
+
"""Tests for lazy on-demand probing."""
|
|
730
|
+
|
|
731
|
+
def test_probes_on_first_select(self) -> None:
|
|
732
|
+
"""First select triggers a probe."""
|
|
733
|
+
ep = Endpoint(url="http://host:6333")
|
|
734
|
+
selector = EndpointSelector(
|
|
735
|
+
skvector_endpoints=[ep],
|
|
736
|
+
config=RoutingConfig(probe_interval_seconds=30),
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
mock_sock = MagicMock()
|
|
740
|
+
with patch("skmemory.endpoint_selector.socket.create_connection", return_value=mock_sock) as mock_conn:
|
|
741
|
+
selector.select_skvector()
|
|
742
|
+
|
|
743
|
+
mock_conn.assert_called_once()
|
|
744
|
+
|
|
745
|
+
def test_does_not_reprobe_if_fresh(self) -> None:
|
|
746
|
+
"""Second select within interval does not re-probe."""
|
|
747
|
+
ep = Endpoint(url="http://host:6333")
|
|
748
|
+
selector = EndpointSelector(
|
|
749
|
+
skvector_endpoints=[ep],
|
|
750
|
+
config=RoutingConfig(probe_interval_seconds=30),
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
mock_sock = MagicMock()
|
|
754
|
+
with patch("skmemory.endpoint_selector.socket.create_connection", return_value=mock_sock) as mock_conn:
|
|
755
|
+
selector.select_skvector()
|
|
756
|
+
selector.select_skvector()
|
|
757
|
+
|
|
758
|
+
# Should only probe once (first call)
|
|
759
|
+
assert mock_conn.call_count == 1
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
# ---------------------------------------------------------------------------
|
|
763
|
+
# Config integration tests
|
|
764
|
+
# ---------------------------------------------------------------------------
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
class TestConfigIntegration:
|
|
768
|
+
"""Tests for build_endpoint_list backward compat bridge."""
|
|
769
|
+
|
|
770
|
+
def test_single_url_becomes_endpoint(self) -> None:
|
|
771
|
+
"""Single URL is promoted to endpoint list."""
|
|
772
|
+
from skmemory.config import build_endpoint_list, EndpointConfig
|
|
773
|
+
|
|
774
|
+
result = build_endpoint_list("http://localhost:6333", [])
|
|
775
|
+
assert len(result) == 1
|
|
776
|
+
assert result[0].url == "http://localhost:6333"
|
|
777
|
+
assert result[0].role == "primary"
|
|
778
|
+
|
|
779
|
+
def test_endpoints_list_takes_precedence(self) -> None:
|
|
780
|
+
"""Endpoint list is used when present."""
|
|
781
|
+
from skmemory.config import build_endpoint_list, EndpointConfig
|
|
782
|
+
|
|
783
|
+
eps = [EndpointConfig(url="http://a:6333"), EndpointConfig(url="http://b:6333")]
|
|
784
|
+
result = build_endpoint_list("http://c:6333", eps)
|
|
785
|
+
assert len(result) == 3 # c prepended since not in list
|
|
786
|
+
assert result[0].url == "http://c:6333"
|
|
787
|
+
|
|
788
|
+
def test_no_duplicate_when_url_in_list(self) -> None:
|
|
789
|
+
"""Single URL not duplicated if already in endpoints list."""
|
|
790
|
+
from skmemory.config import build_endpoint_list, EndpointConfig
|
|
791
|
+
|
|
792
|
+
eps = [EndpointConfig(url="http://a:6333")]
|
|
793
|
+
result = build_endpoint_list("http://a:6333", eps)
|
|
794
|
+
assert len(result) == 1
|
|
795
|
+
|
|
796
|
+
def test_empty_returns_empty(self) -> None:
|
|
797
|
+
"""No URL and no endpoints returns empty list."""
|
|
798
|
+
from skmemory.config import build_endpoint_list
|
|
799
|
+
|
|
800
|
+
result = build_endpoint_list(None, [])
|
|
801
|
+
assert result == []
|