spatial-memory-mcp 1.9.1__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.
Files changed (55) hide show
  1. spatial_memory/__init__.py +97 -0
  2. spatial_memory/__main__.py +271 -0
  3. spatial_memory/adapters/__init__.py +7 -0
  4. spatial_memory/adapters/lancedb_repository.py +880 -0
  5. spatial_memory/config.py +769 -0
  6. spatial_memory/core/__init__.py +118 -0
  7. spatial_memory/core/cache.py +317 -0
  8. spatial_memory/core/circuit_breaker.py +297 -0
  9. spatial_memory/core/connection_pool.py +220 -0
  10. spatial_memory/core/consolidation_strategies.py +401 -0
  11. spatial_memory/core/database.py +3072 -0
  12. spatial_memory/core/db_idempotency.py +242 -0
  13. spatial_memory/core/db_indexes.py +576 -0
  14. spatial_memory/core/db_migrations.py +588 -0
  15. spatial_memory/core/db_search.py +512 -0
  16. spatial_memory/core/db_versioning.py +178 -0
  17. spatial_memory/core/embeddings.py +558 -0
  18. spatial_memory/core/errors.py +317 -0
  19. spatial_memory/core/file_security.py +701 -0
  20. spatial_memory/core/filesystem.py +178 -0
  21. spatial_memory/core/health.py +289 -0
  22. spatial_memory/core/helpers.py +79 -0
  23. spatial_memory/core/import_security.py +433 -0
  24. spatial_memory/core/lifecycle_ops.py +1067 -0
  25. spatial_memory/core/logging.py +194 -0
  26. spatial_memory/core/metrics.py +192 -0
  27. spatial_memory/core/models.py +660 -0
  28. spatial_memory/core/rate_limiter.py +326 -0
  29. spatial_memory/core/response_types.py +500 -0
  30. spatial_memory/core/security.py +588 -0
  31. spatial_memory/core/spatial_ops.py +430 -0
  32. spatial_memory/core/tracing.py +300 -0
  33. spatial_memory/core/utils.py +110 -0
  34. spatial_memory/core/validation.py +406 -0
  35. spatial_memory/factory.py +444 -0
  36. spatial_memory/migrations/__init__.py +40 -0
  37. spatial_memory/ports/__init__.py +11 -0
  38. spatial_memory/ports/repositories.py +630 -0
  39. spatial_memory/py.typed +0 -0
  40. spatial_memory/server.py +1214 -0
  41. spatial_memory/services/__init__.py +70 -0
  42. spatial_memory/services/decay_manager.py +411 -0
  43. spatial_memory/services/export_import.py +1031 -0
  44. spatial_memory/services/lifecycle.py +1139 -0
  45. spatial_memory/services/memory.py +412 -0
  46. spatial_memory/services/spatial.py +1152 -0
  47. spatial_memory/services/utility.py +429 -0
  48. spatial_memory/tools/__init__.py +5 -0
  49. spatial_memory/tools/definitions.py +695 -0
  50. spatial_memory/verify.py +140 -0
  51. spatial_memory_mcp-1.9.1.dist-info/METADATA +509 -0
  52. spatial_memory_mcp-1.9.1.dist-info/RECORD +55 -0
  53. spatial_memory_mcp-1.9.1.dist-info/WHEEL +4 -0
  54. spatial_memory_mcp-1.9.1.dist-info/entry_points.txt +2 -0
  55. spatial_memory_mcp-1.9.1.dist-info/licenses/LICENSE +21 -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
+ )
@@ -0,0 +1,220 @@
1
+ """Thread-safe LRU connection pool for LanceDB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from collections import OrderedDict
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import lancedb
11
+
12
+ if TYPE_CHECKING:
13
+ from lancedb import DBConnection
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ConnectionPool:
19
+ """Thread-safe LRU connection pool for LanceDB connections.
20
+
21
+ This class encapsulates connection management with:
22
+ - LRU eviction when at max capacity
23
+ - Thread-safe access with locking
24
+ - Connection reuse for same URIs
25
+
26
+ Example:
27
+ pool = ConnectionPool(max_size=10)
28
+ conn = pool.get_or_create("/path/to/db")
29
+ # ... use connection
30
+ pool.close_all() # cleanup
31
+ """
32
+
33
+ def __init__(self, max_size: int = 10) -> None:
34
+ """Initialize the connection pool.
35
+
36
+ Args:
37
+ max_size: Maximum number of connections to cache.
38
+ """
39
+ if max_size < 1:
40
+ raise ValueError("max_size must be >= 1")
41
+ self._connections: OrderedDict[str, DBConnection] = OrderedDict()
42
+ self._lock = threading.Lock()
43
+ self._max_size = max_size
44
+
45
+ @property
46
+ def max_size(self) -> int:
47
+ """Get the maximum pool size."""
48
+ return self._max_size
49
+
50
+ @max_size.setter
51
+ def max_size(self, value: int) -> None:
52
+ """Set the maximum pool size."""
53
+ if value < 1:
54
+ raise ValueError("max_size must be >= 1")
55
+ with self._lock:
56
+ self._max_size = value
57
+ # Evict if now over capacity
58
+ while len(self._connections) > self._max_size:
59
+ self._evict_oldest()
60
+
61
+ def get_or_create(
62
+ self,
63
+ uri: str,
64
+ read_consistency_interval_ms: int = 0,
65
+ validate_health: bool = True,
66
+ **kwargs: Any,
67
+ ) -> DBConnection:
68
+ """Get existing connection or create new one.
69
+
70
+ Args:
71
+ uri: Database URI/path.
72
+ read_consistency_interval_ms: Read consistency interval.
73
+ validate_health: Whether to validate cached connection health (default: True).
74
+ **kwargs: Additional args for lancedb.connect().
75
+
76
+ Returns:
77
+ Database connection.
78
+ """
79
+ with self._lock:
80
+ # Check if exists
81
+ if uri in self._connections:
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
97
+
98
+ # Evict oldest if at capacity
99
+ while len(self._connections) >= self._max_size:
100
+ self._evict_oldest()
101
+
102
+ # Create new connection
103
+ from datetime import timedelta
104
+ conn_kwargs = dict(kwargs)
105
+ if read_consistency_interval_ms > 0:
106
+ conn_kwargs["read_consistency_interval"] = timedelta(
107
+ milliseconds=read_consistency_interval_ms
108
+ )
109
+
110
+ conn = lancedb.connect(uri, **conn_kwargs)
111
+ self._connections[uri] = conn
112
+ logger.debug(f"Created new connection for {uri} (pool size: {len(self._connections)})")
113
+ return conn
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
+
139
+ def _evict_oldest(self) -> None:
140
+ """Evict the oldest (least recently used) connection."""
141
+ if self._connections:
142
+ uri, conn = self._connections.popitem(last=False)
143
+ try:
144
+ conn.close()
145
+ except Exception as e:
146
+ logger.debug(f"Error closing evicted connection {uri}: {e}")
147
+ logger.debug(f"Evicted connection for {uri}")
148
+
149
+ def close_all(self) -> None:
150
+ """Close all connections in the pool."""
151
+ with self._lock:
152
+ for uri, conn in list(self._connections.items()):
153
+ try:
154
+ conn.close()
155
+ except Exception as e:
156
+ logger.debug(f"Error closing connection {uri}: {e}")
157
+ self._connections.clear()
158
+ logger.info("Closed all pooled connections")
159
+
160
+ def stats(self) -> dict[str, Any]:
161
+ """Get pool statistics."""
162
+ with self._lock:
163
+ return {
164
+ "size": len(self._connections),
165
+ "max_size": self._max_size,
166
+ "uris": list(self._connections.keys()),
167
+ }
168
+
169
+ def __len__(self) -> int:
170
+ """Return number of connections in pool."""
171
+ with self._lock:
172
+ return len(self._connections)
173
+
174
+ def invalidate(self, uri: str) -> bool:
175
+ """Invalidate and remove a specific connection from the pool.
176
+
177
+ Use this when a connection is detected as stale (e.g., database
178
+ was deleted/recreated, or metadata references missing files).
179
+
180
+ Args:
181
+ uri: Database URI/path to invalidate.
182
+
183
+ Returns:
184
+ True if connection was found and removed, False otherwise.
185
+ """
186
+ with self._lock:
187
+ if uri in self._connections:
188
+ conn = self._connections.pop(uri)
189
+ try:
190
+ conn.close()
191
+ except Exception as e:
192
+ logger.debug(f"Error closing invalidated connection {uri}: {e}")
193
+ logger.info(f"Invalidated stale connection for {uri}")
194
+ return True
195
+ return False
196
+
197
+ @staticmethod
198
+ def is_stale_connection_error(error: Exception) -> bool:
199
+ """Check if an error indicates a stale/corrupted connection.
200
+
201
+ These errors occur when:
202
+ - Database was deleted and recreated while connection cached
203
+ - Database metadata references files that no longer exist
204
+ - Version mismatch from external modifications
205
+
206
+ Args:
207
+ error: The exception to check.
208
+
209
+ Returns:
210
+ True if the error indicates a stale connection.
211
+ """
212
+ error_str = str(error).lower()
213
+ stale_patterns = (
214
+ "_deletions/", # Missing deletion files
215
+ "not found:", # General missing file errors
216
+ "no such file", # File system errors
217
+ "version mismatch", # Stale version references
218
+ "manifest", # Corrupted manifest references
219
+ )
220
+ return any(pattern in error_str for pattern in stale_patterns)