spatial-memory-mcp 1.6.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.
Potentially problematic release.
This version of spatial-memory-mcp might be problematic. Click here for more details.
- spatial_memory/__init__.py +97 -0
- spatial_memory/__main__.py +270 -0
- spatial_memory/adapters/__init__.py +7 -0
- spatial_memory/adapters/lancedb_repository.py +878 -0
- spatial_memory/config.py +728 -0
- spatial_memory/core/__init__.py +118 -0
- spatial_memory/core/cache.py +317 -0
- spatial_memory/core/circuit_breaker.py +297 -0
- spatial_memory/core/connection_pool.py +220 -0
- spatial_memory/core/consolidation_strategies.py +402 -0
- spatial_memory/core/database.py +3069 -0
- 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 +557 -0
- spatial_memory/core/errors.py +317 -0
- spatial_memory/core/file_security.py +702 -0
- spatial_memory/core/filesystem.py +178 -0
- spatial_memory/core/health.py +289 -0
- spatial_memory/core/helpers.py +79 -0
- spatial_memory/core/import_security.py +432 -0
- spatial_memory/core/lifecycle_ops.py +1067 -0
- spatial_memory/core/logging.py +194 -0
- spatial_memory/core/metrics.py +192 -0
- spatial_memory/core/models.py +628 -0
- spatial_memory/core/rate_limiter.py +326 -0
- spatial_memory/core/response_types.py +497 -0
- spatial_memory/core/security.py +588 -0
- spatial_memory/core/spatial_ops.py +426 -0
- spatial_memory/core/tracing.py +300 -0
- spatial_memory/core/utils.py +110 -0
- spatial_memory/core/validation.py +403 -0
- spatial_memory/factory.py +407 -0
- spatial_memory/migrations/__init__.py +40 -0
- spatial_memory/ports/__init__.py +11 -0
- spatial_memory/ports/repositories.py +631 -0
- spatial_memory/py.typed +0 -0
- spatial_memory/server.py +1141 -0
- spatial_memory/services/__init__.py +70 -0
- spatial_memory/services/export_import.py +1023 -0
- spatial_memory/services/lifecycle.py +1120 -0
- spatial_memory/services/memory.py +412 -0
- spatial_memory/services/spatial.py +1147 -0
- spatial_memory/services/utility.py +409 -0
- spatial_memory/tools/__init__.py +5 -0
- spatial_memory/tools/definitions.py +695 -0
- spatial_memory/verify.py +140 -0
- spatial_memory_mcp-1.6.1.dist-info/METADATA +499 -0
- spatial_memory_mcp-1.6.1.dist-info/RECORD +54 -0
- spatial_memory_mcp-1.6.1.dist-info/WHEEL +4 -0
- spatial_memory_mcp-1.6.1.dist-info/entry_points.txt +2 -0
- spatial_memory_mcp-1.6.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)
|