spatial-memory-mcp 1.0.3__py3-none-any.whl → 1.6.0__py3-none-any.whl
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.
Potentially problematic release.
This version of spatial-memory-mcp might be problematic. Click here for more details.
- spatial_memory/__init__.py +97 -97
- spatial_memory/__main__.py +241 -2
- spatial_memory/adapters/lancedb_repository.py +74 -5
- spatial_memory/config.py +115 -2
- spatial_memory/core/__init__.py +35 -0
- spatial_memory/core/cache.py +317 -0
- spatial_memory/core/circuit_breaker.py +297 -0
- spatial_memory/core/connection_pool.py +41 -3
- spatial_memory/core/consolidation_strategies.py +402 -0
- spatial_memory/core/database.py +791 -769
- spatial_memory/core/db_idempotency.py +242 -0
- spatial_memory/core/db_indexes.py +575 -0
- spatial_memory/core/db_migrations.py +584 -0
- spatial_memory/core/db_search.py +509 -0
- spatial_memory/core/db_versioning.py +177 -0
- spatial_memory/core/embeddings.py +156 -19
- spatial_memory/core/errors.py +75 -3
- spatial_memory/core/filesystem.py +178 -0
- spatial_memory/core/logging.py +194 -103
- spatial_memory/core/models.py +4 -0
- spatial_memory/core/rate_limiter.py +326 -105
- spatial_memory/core/response_types.py +497 -0
- spatial_memory/core/tracing.py +300 -0
- spatial_memory/core/validation.py +403 -319
- spatial_memory/factory.py +407 -0
- spatial_memory/migrations/__init__.py +40 -0
- spatial_memory/ports/repositories.py +52 -2
- spatial_memory/server.py +329 -188
- spatial_memory/services/export_import.py +61 -43
- spatial_memory/services/lifecycle.py +397 -122
- spatial_memory/services/memory.py +81 -4
- spatial_memory/services/spatial.py +129 -46
- spatial_memory/tools/definitions.py +695 -671
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/METADATA +83 -3
- spatial_memory_mcp-1.6.0.dist-info/RECORD +54 -0
- spatial_memory_mcp-1.0.3.dist-info/RECORD +0 -41
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/WHEEL +0 -0
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/entry_points.txt +0 -0
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Circuit breaker pattern implementation for fault tolerance.
|
|
2
|
+
|
|
3
|
+
The circuit breaker prevents cascading failures by fast-failing requests
|
|
4
|
+
when a service is unhealthy, allowing time for recovery.
|
|
5
|
+
|
|
6
|
+
State transitions:
|
|
7
|
+
CLOSED (normal) -> OPEN (failures >= threshold)
|
|
8
|
+
OPEN -> HALF_OPEN (after reset_timeout)
|
|
9
|
+
HALF_OPEN -> CLOSED (probe succeeds)
|
|
10
|
+
HALF_OPEN -> OPEN (probe fails)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from typing import TypeVar
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CircuitState(Enum):
|
|
28
|
+
"""Circuit breaker states.
|
|
29
|
+
|
|
30
|
+
CLOSED: Normal operation, requests pass through.
|
|
31
|
+
OPEN: Circuit is tripped, requests are rejected immediately.
|
|
32
|
+
HALF_OPEN: Testing recovery, limited requests allowed.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
CLOSED = "closed"
|
|
36
|
+
OPEN = "open"
|
|
37
|
+
HALF_OPEN = "half_open"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CircuitOpenError(Exception):
|
|
41
|
+
"""Raised when circuit is open and call is rejected.
|
|
42
|
+
|
|
43
|
+
This exception indicates that the circuit breaker is preventing
|
|
44
|
+
requests from reaching a failing service.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
message: str = "Circuit breaker is open",
|
|
50
|
+
time_until_retry: float | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Initialize CircuitOpenError.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
message: Error description.
|
|
56
|
+
time_until_retry: Seconds until the circuit will transition to HALF_OPEN.
|
|
57
|
+
"""
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.time_until_retry = time_until_retry
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CircuitBreaker:
|
|
63
|
+
"""Circuit breaker for protecting external service calls.
|
|
64
|
+
|
|
65
|
+
Monitors failures and opens the circuit when failures exceed a threshold,
|
|
66
|
+
preventing further calls until a reset timeout has elapsed.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
breaker = CircuitBreaker(failure_threshold=5, reset_timeout=60.0)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = breaker.call(my_api_call, arg1, arg2)
|
|
73
|
+
except CircuitOpenError:
|
|
74
|
+
# Service is unhealthy, use fallback
|
|
75
|
+
result = fallback_value
|
|
76
|
+
|
|
77
|
+
Thread Safety:
|
|
78
|
+
This class is thread-safe. All state transitions and counters
|
|
79
|
+
are protected by a lock.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
failure_threshold: int = 5,
|
|
85
|
+
reset_timeout: float = 60.0,
|
|
86
|
+
half_open_max_calls: int = 1,
|
|
87
|
+
name: str | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Initialize the circuit breaker.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
failure_threshold: Number of consecutive failures before opening circuit.
|
|
93
|
+
reset_timeout: Seconds to wait before transitioning from OPEN to HALF_OPEN.
|
|
94
|
+
half_open_max_calls: Maximum concurrent calls allowed in HALF_OPEN state.
|
|
95
|
+
name: Optional name for logging purposes.
|
|
96
|
+
"""
|
|
97
|
+
if failure_threshold < 1:
|
|
98
|
+
raise ValueError("failure_threshold must be at least 1")
|
|
99
|
+
if reset_timeout <= 0:
|
|
100
|
+
raise ValueError("reset_timeout must be positive")
|
|
101
|
+
if half_open_max_calls < 1:
|
|
102
|
+
raise ValueError("half_open_max_calls must be at least 1")
|
|
103
|
+
|
|
104
|
+
self._failure_threshold = failure_threshold
|
|
105
|
+
self._reset_timeout = reset_timeout
|
|
106
|
+
self._half_open_max_calls = half_open_max_calls
|
|
107
|
+
self._name = name or "circuit_breaker"
|
|
108
|
+
|
|
109
|
+
self._state = CircuitState.CLOSED
|
|
110
|
+
self._failure_count = 0
|
|
111
|
+
self._last_failure_time: float | None = None
|
|
112
|
+
self._half_open_calls = 0
|
|
113
|
+
self._lock = threading.Lock()
|
|
114
|
+
|
|
115
|
+
# Statistics
|
|
116
|
+
self._total_calls = 0
|
|
117
|
+
self._total_failures = 0
|
|
118
|
+
self._total_rejections = 0
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def state(self) -> CircuitState:
|
|
122
|
+
"""Get current circuit state.
|
|
123
|
+
|
|
124
|
+
This property also handles automatic transition from OPEN to HALF_OPEN
|
|
125
|
+
when the reset timeout has elapsed.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Current circuit state.
|
|
129
|
+
"""
|
|
130
|
+
with self._lock:
|
|
131
|
+
return self._get_state_unlocked()
|
|
132
|
+
|
|
133
|
+
def _get_state_unlocked(self) -> CircuitState:
|
|
134
|
+
"""Get state without acquiring lock (must be called with lock held)."""
|
|
135
|
+
if self._state == CircuitState.OPEN:
|
|
136
|
+
if self._should_transition_to_half_open():
|
|
137
|
+
self._transition_to_half_open()
|
|
138
|
+
return self._state
|
|
139
|
+
|
|
140
|
+
def _should_transition_to_half_open(self) -> bool:
|
|
141
|
+
"""Check if reset timeout has elapsed (must be called with lock held)."""
|
|
142
|
+
if self._last_failure_time is None:
|
|
143
|
+
return False
|
|
144
|
+
elapsed = time.monotonic() - self._last_failure_time
|
|
145
|
+
return elapsed >= self._reset_timeout
|
|
146
|
+
|
|
147
|
+
def _transition_to_half_open(self) -> None:
|
|
148
|
+
"""Transition to HALF_OPEN state (must be called with lock held)."""
|
|
149
|
+
logger.info(f"[{self._name}] Circuit transitioning from OPEN to HALF_OPEN")
|
|
150
|
+
self._state = CircuitState.HALF_OPEN
|
|
151
|
+
self._half_open_calls = 0
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def failure_count(self) -> int:
|
|
155
|
+
"""Get current failure count."""
|
|
156
|
+
with self._lock:
|
|
157
|
+
return self._failure_count
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def stats(self) -> dict[str, int | str | float | None]:
|
|
161
|
+
"""Get circuit breaker statistics.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Dictionary with state, counters, and timing information.
|
|
165
|
+
"""
|
|
166
|
+
with self._lock:
|
|
167
|
+
state = self._get_state_unlocked()
|
|
168
|
+
time_until_retry = None
|
|
169
|
+
if state == CircuitState.OPEN and self._last_failure_time is not None:
|
|
170
|
+
elapsed = time.monotonic() - self._last_failure_time
|
|
171
|
+
time_until_retry = max(0.0, self._reset_timeout - elapsed)
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"state": state.value,
|
|
175
|
+
"failure_count": self._failure_count,
|
|
176
|
+
"failure_threshold": self._failure_threshold,
|
|
177
|
+
"total_calls": self._total_calls,
|
|
178
|
+
"total_failures": self._total_failures,
|
|
179
|
+
"total_rejections": self._total_rejections,
|
|
180
|
+
"reset_timeout": self._reset_timeout,
|
|
181
|
+
"time_until_retry": time_until_retry,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
def call(self, func: Callable[..., T], *args: object, **kwargs: object) -> T:
|
|
185
|
+
"""Execute function with circuit breaker protection.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
func: Function to execute.
|
|
189
|
+
*args: Positional arguments for the function.
|
|
190
|
+
**kwargs: Keyword arguments for the function.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Return value of the function.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
CircuitOpenError: If circuit is OPEN and not ready for retry.
|
|
197
|
+
Exception: Any exception raised by the function.
|
|
198
|
+
"""
|
|
199
|
+
with self._lock:
|
|
200
|
+
self._total_calls += 1
|
|
201
|
+
current_state = self._get_state_unlocked()
|
|
202
|
+
|
|
203
|
+
if current_state == CircuitState.OPEN:
|
|
204
|
+
self._total_rejections += 1
|
|
205
|
+
time_until_retry = None
|
|
206
|
+
if self._last_failure_time is not None:
|
|
207
|
+
elapsed = time.monotonic() - self._last_failure_time
|
|
208
|
+
time_until_retry = max(0.0, self._reset_timeout - elapsed)
|
|
209
|
+
raise CircuitOpenError(
|
|
210
|
+
f"[{self._name}] Circuit is OPEN, rejecting call",
|
|
211
|
+
time_until_retry=time_until_retry,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if current_state == CircuitState.HALF_OPEN:
|
|
215
|
+
if self._half_open_calls >= self._half_open_max_calls:
|
|
216
|
+
self._total_rejections += 1
|
|
217
|
+
raise CircuitOpenError(
|
|
218
|
+
f"[{self._name}] Circuit is HALF_OPEN, max probe calls reached",
|
|
219
|
+
time_until_retry=0.0,
|
|
220
|
+
)
|
|
221
|
+
self._half_open_calls += 1
|
|
222
|
+
|
|
223
|
+
# Execute function outside the lock
|
|
224
|
+
try:
|
|
225
|
+
result = func(*args, **kwargs)
|
|
226
|
+
self._on_success()
|
|
227
|
+
return result
|
|
228
|
+
except Exception as e:
|
|
229
|
+
self._on_failure(e)
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
def _on_success(self) -> None:
|
|
233
|
+
"""Handle successful call.
|
|
234
|
+
|
|
235
|
+
In CLOSED state: Reset failure count.
|
|
236
|
+
In HALF_OPEN state: Transition to CLOSED.
|
|
237
|
+
"""
|
|
238
|
+
with self._lock:
|
|
239
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
240
|
+
logger.info(
|
|
241
|
+
f"[{self._name}] Probe succeeded, circuit transitioning to CLOSED"
|
|
242
|
+
)
|
|
243
|
+
self._state = CircuitState.CLOSED
|
|
244
|
+
self._failure_count = 0
|
|
245
|
+
self._half_open_calls = 0
|
|
246
|
+
elif self._state == CircuitState.CLOSED:
|
|
247
|
+
self._failure_count = 0
|
|
248
|
+
|
|
249
|
+
def _on_failure(self, error: Exception) -> None:
|
|
250
|
+
"""Handle failed call.
|
|
251
|
+
|
|
252
|
+
In CLOSED state: Increment failure count, open circuit if threshold reached.
|
|
253
|
+
In HALF_OPEN state: Transition back to OPEN.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
error: The exception that was raised.
|
|
257
|
+
"""
|
|
258
|
+
with self._lock:
|
|
259
|
+
self._total_failures += 1
|
|
260
|
+
self._failure_count += 1
|
|
261
|
+
self._last_failure_time = time.monotonic()
|
|
262
|
+
|
|
263
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
264
|
+
logger.warning(
|
|
265
|
+
f"[{self._name}] Probe failed ({error!r}), "
|
|
266
|
+
f"circuit transitioning back to OPEN"
|
|
267
|
+
)
|
|
268
|
+
self._state = CircuitState.OPEN
|
|
269
|
+
self._half_open_calls = 0
|
|
270
|
+
elif self._state == CircuitState.CLOSED:
|
|
271
|
+
if self._failure_count >= self._failure_threshold:
|
|
272
|
+
logger.warning(
|
|
273
|
+
f"[{self._name}] Failure threshold reached "
|
|
274
|
+
f"({self._failure_count}/{self._failure_threshold}), "
|
|
275
|
+
f"circuit transitioning to OPEN"
|
|
276
|
+
)
|
|
277
|
+
self._state = CircuitState.OPEN
|
|
278
|
+
|
|
279
|
+
def reset(self) -> None:
|
|
280
|
+
"""Manually reset circuit to CLOSED state.
|
|
281
|
+
|
|
282
|
+
This clears all failure counters and transitions the circuit
|
|
283
|
+
to CLOSED state regardless of current state.
|
|
284
|
+
"""
|
|
285
|
+
with self._lock:
|
|
286
|
+
logger.info(f"[{self._name}] Circuit manually reset to CLOSED")
|
|
287
|
+
self._state = CircuitState.CLOSED
|
|
288
|
+
self._failure_count = 0
|
|
289
|
+
self._half_open_calls = 0
|
|
290
|
+
self._last_failure_time = None
|
|
291
|
+
|
|
292
|
+
def __repr__(self) -> str:
|
|
293
|
+
"""Return string representation."""
|
|
294
|
+
return (
|
|
295
|
+
f"CircuitBreaker(name={self._name!r}, state={self.state.value}, "
|
|
296
|
+
f"failures={self.failure_count}/{self._failure_threshold})"
|
|
297
|
+
)
|
|
@@ -62,6 +62,7 @@ class ConnectionPool:
|
|
|
62
62
|
self,
|
|
63
63
|
uri: str,
|
|
64
64
|
read_consistency_interval_ms: int = 0,
|
|
65
|
+
validate_health: bool = True,
|
|
65
66
|
**kwargs: Any,
|
|
66
67
|
) -> DBConnection:
|
|
67
68
|
"""Get existing connection or create new one.
|
|
@@ -69,6 +70,7 @@ class ConnectionPool:
|
|
|
69
70
|
Args:
|
|
70
71
|
uri: Database URI/path.
|
|
71
72
|
read_consistency_interval_ms: Read consistency interval.
|
|
73
|
+
validate_health: Whether to validate cached connection health (default: True).
|
|
72
74
|
**kwargs: Additional args for lancedb.connect().
|
|
73
75
|
|
|
74
76
|
Returns:
|
|
@@ -77,9 +79,21 @@ class ConnectionPool:
|
|
|
77
79
|
with self._lock:
|
|
78
80
|
# Check if exists
|
|
79
81
|
if uri in self._connections:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
conn = self._connections[uri]
|
|
83
|
+
|
|
84
|
+
# Optionally validate health of cached connection
|
|
85
|
+
if validate_health and not self._validate_connection(conn, uri):
|
|
86
|
+
# Connection is stale, remove and create new
|
|
87
|
+
logger.info(f"Stale connection detected for {uri}, recreating")
|
|
88
|
+
self._connections.pop(uri)
|
|
89
|
+
try:
|
|
90
|
+
conn.close()
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
else:
|
|
94
|
+
# Connection is healthy, move to end (most recently used)
|
|
95
|
+
self._connections.move_to_end(uri)
|
|
96
|
+
return conn
|
|
83
97
|
|
|
84
98
|
# Evict oldest if at capacity
|
|
85
99
|
while len(self._connections) >= self._max_size:
|
|
@@ -98,6 +112,30 @@ class ConnectionPool:
|
|
|
98
112
|
logger.debug(f"Created new connection for {uri} (pool size: {len(self._connections)})")
|
|
99
113
|
return conn
|
|
100
114
|
|
|
115
|
+
def _validate_connection(self, conn: DBConnection, uri: str) -> bool:
|
|
116
|
+
"""Validate that a cached connection is still healthy.
|
|
117
|
+
|
|
118
|
+
Performs a lightweight operation to verify the connection
|
|
119
|
+
is still usable.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
conn: The connection to validate.
|
|
123
|
+
uri: URI for logging purposes.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if connection is healthy, False if stale.
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
# List tables is a lightweight operation that validates connection
|
|
130
|
+
conn.table_names()
|
|
131
|
+
return True
|
|
132
|
+
except Exception as e:
|
|
133
|
+
if self.is_stale_connection_error(e):
|
|
134
|
+
logger.debug(f"Connection health check failed for {uri}: {e}")
|
|
135
|
+
return False
|
|
136
|
+
# Other errors might be transient, consider healthy
|
|
137
|
+
return True
|
|
138
|
+
|
|
101
139
|
def _evict_oldest(self) -> None:
|
|
102
140
|
"""Evict the oldest (least recently used) connection."""
|
|
103
141
|
if self._connections:
|