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.

Files changed (39) hide show
  1. spatial_memory/__init__.py +97 -97
  2. spatial_memory/__main__.py +241 -2
  3. spatial_memory/adapters/lancedb_repository.py +74 -5
  4. spatial_memory/config.py +115 -2
  5. spatial_memory/core/__init__.py +35 -0
  6. spatial_memory/core/cache.py +317 -0
  7. spatial_memory/core/circuit_breaker.py +297 -0
  8. spatial_memory/core/connection_pool.py +41 -3
  9. spatial_memory/core/consolidation_strategies.py +402 -0
  10. spatial_memory/core/database.py +791 -769
  11. spatial_memory/core/db_idempotency.py +242 -0
  12. spatial_memory/core/db_indexes.py +575 -0
  13. spatial_memory/core/db_migrations.py +584 -0
  14. spatial_memory/core/db_search.py +509 -0
  15. spatial_memory/core/db_versioning.py +177 -0
  16. spatial_memory/core/embeddings.py +156 -19
  17. spatial_memory/core/errors.py +75 -3
  18. spatial_memory/core/filesystem.py +178 -0
  19. spatial_memory/core/logging.py +194 -103
  20. spatial_memory/core/models.py +4 -0
  21. spatial_memory/core/rate_limiter.py +326 -105
  22. spatial_memory/core/response_types.py +497 -0
  23. spatial_memory/core/tracing.py +300 -0
  24. spatial_memory/core/validation.py +403 -319
  25. spatial_memory/factory.py +407 -0
  26. spatial_memory/migrations/__init__.py +40 -0
  27. spatial_memory/ports/repositories.py +52 -2
  28. spatial_memory/server.py +329 -188
  29. spatial_memory/services/export_import.py +61 -43
  30. spatial_memory/services/lifecycle.py +397 -122
  31. spatial_memory/services/memory.py +81 -4
  32. spatial_memory/services/spatial.py +129 -46
  33. spatial_memory/tools/definitions.py +695 -671
  34. {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/METADATA +83 -3
  35. spatial_memory_mcp-1.6.0.dist-info/RECORD +54 -0
  36. spatial_memory_mcp-1.0.3.dist-info/RECORD +0 -41
  37. {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/WHEEL +0 -0
  38. {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/entry_points.txt +0 -0
  39. {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
- # Move to end (most recently used)
81
- self._connections.move_to_end(uri)
82
- return self._connections[uri]
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: