superlocalmemory 2.4.1 → 2.5.0
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/CHANGELOG.md +56 -0
- package/README.md +21 -0
- package/bin/slm +2 -2
- package/docs/ARCHITECTURE-V2.5.md +190 -0
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/CLI-COMMANDS-REFERENCE.md +1 -1
- package/docs/MCP-MANUAL-SETUP.md +1 -1
- package/docs/MCP-TROUBLESHOOTING.md +1 -1
- package/docs/UNIVERSAL-INTEGRATION.md +3 -3
- package/mcp_server.py +115 -14
- package/package.json +1 -1
- package/src/agent_registry.py +385 -0
- package/src/db_connection_manager.py +532 -0
- package/src/event_bus.py +555 -0
- package/src/memory_store_v2.py +626 -471
- package/src/provenance_tracker.py +322 -0
- package/src/subscription_manager.py +399 -0
- package/src/trust_scorer.py +456 -0
- package/src/webhook_dispatcher.py +229 -0
- package/ui/app.js +425 -0
- package/ui/index.html +147 -1
- package/ui/js/agents.js +192 -0
- package/ui/js/clusters.js +80 -0
- package/ui/js/core.js +230 -0
- package/ui/js/events.js +178 -0
- package/ui/js/graph.js +32 -0
- package/ui/js/init.js +31 -0
- package/ui/js/memories.js +149 -0
- package/ui/js/modal.js +139 -0
- package/ui/js/patterns.js +93 -0
- package/ui/js/profiles.js +202 -0
- package/ui/js/search.js +59 -0
- package/ui/js/settings.js +167 -0
- package/ui/js/timeline.js +32 -0
- package/ui_server.py +69 -1657
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SuperLocalMemory V2 - Database Connection Manager
|
|
4
|
+
Copyright (c) 2026 Varun Pratap Bhardwaj
|
|
5
|
+
Licensed under MIT License
|
|
6
|
+
|
|
7
|
+
Repository: https://github.com/varun369/SuperLocalMemoryV2
|
|
8
|
+
Author: Varun Pratap Bhardwaj (Solution Architect)
|
|
9
|
+
|
|
10
|
+
NOTICE: This software is protected by MIT License.
|
|
11
|
+
Attribution must be preserved in all copies or derivatives.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
DbConnectionManager — Thread-safe SQLite connection management with WAL mode.
|
|
16
|
+
|
|
17
|
+
Solves the "database is locked" bug when multiple agents (CLI + MCP from Claude +
|
|
18
|
+
MCP from Cursor + API) try to write or recall simultaneously.
|
|
19
|
+
|
|
20
|
+
Architecture:
|
|
21
|
+
- WAL mode (Write-Ahead Logging): Concurrent reads OK during writes
|
|
22
|
+
- Busy timeout (5s): Connections wait instead of failing immediately
|
|
23
|
+
- Connection pool: Reusable read connections via thread-local storage
|
|
24
|
+
- Write queue: Single dedicated writer thread serializes all writes
|
|
25
|
+
- Singleton: One manager per database path per process
|
|
26
|
+
|
|
27
|
+
This is a PREREQUISITE for the Event Bus (v2.5). The write queue guarantees:
|
|
28
|
+
1. Every write succeeds (queued, not dropped)
|
|
29
|
+
2. Events fire after commit, not before (consistency)
|
|
30
|
+
3. Events arrive in correct order (queue preserves order)
|
|
31
|
+
4. Multiple agents can write simultaneously without conflict
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
from db_connection_manager import DbConnectionManager
|
|
35
|
+
|
|
36
|
+
mgr = DbConnectionManager.get_instance(db_path)
|
|
37
|
+
|
|
38
|
+
# Reads — concurrent, non-blocking
|
|
39
|
+
conn = mgr.get_read_connection()
|
|
40
|
+
try:
|
|
41
|
+
cursor = conn.cursor()
|
|
42
|
+
cursor.execute("SELECT ...")
|
|
43
|
+
rows = cursor.fetchall()
|
|
44
|
+
finally:
|
|
45
|
+
mgr.release_read_connection(conn)
|
|
46
|
+
|
|
47
|
+
# Writes — serialized through queue
|
|
48
|
+
def do_insert(conn):
|
|
49
|
+
cursor = conn.cursor()
|
|
50
|
+
cursor.execute("INSERT INTO ...", (...))
|
|
51
|
+
conn.commit()
|
|
52
|
+
return cursor.lastrowid
|
|
53
|
+
|
|
54
|
+
result = mgr.execute_write(do_insert)
|
|
55
|
+
|
|
56
|
+
# Context manager for reads (preferred)
|
|
57
|
+
with mgr.read_connection() as conn:
|
|
58
|
+
cursor = conn.cursor()
|
|
59
|
+
cursor.execute("SELECT ...")
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
import sqlite3
|
|
63
|
+
import threading
|
|
64
|
+
import logging
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
from typing import Optional, Callable, Any, Dict
|
|
67
|
+
from contextlib import contextmanager
|
|
68
|
+
from queue import Queue
|
|
69
|
+
|
|
70
|
+
logger = logging.getLogger("superlocalmemory.db")
|
|
71
|
+
|
|
72
|
+
# Default configuration
|
|
73
|
+
DEFAULT_BUSY_TIMEOUT_MS = 5000
|
|
74
|
+
DEFAULT_READ_POOL_SIZE = 4
|
|
75
|
+
WRITE_QUEUE_SENTINEL = None # Signals the writer thread to stop
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DbConnectionManager:
|
|
79
|
+
"""
|
|
80
|
+
Thread-safe SQLite connection manager with WAL mode and serialized writes.
|
|
81
|
+
|
|
82
|
+
Singleton per database path — all callers in the same process share one manager.
|
|
83
|
+
This prevents the "database is locked" errors that occur when multiple agents
|
|
84
|
+
(CLI, MCP, API, Dashboard) write simultaneously.
|
|
85
|
+
|
|
86
|
+
Key features:
|
|
87
|
+
- WAL mode: Multiple readers + one writer concurrently
|
|
88
|
+
- Busy timeout: Wait up to 5s instead of failing immediately
|
|
89
|
+
- Read pool: Thread-local read connections, reused across calls
|
|
90
|
+
- Write queue: All writes serialized through a single thread
|
|
91
|
+
- Post-write hooks: Callback after each successful commit (for Event Bus)
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
# Singleton registry: db_path -> instance
|
|
95
|
+
_instances: Dict[str, "DbConnectionManager"] = {}
|
|
96
|
+
_instances_lock = threading.Lock()
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def get_instance(cls, db_path: Optional[Path] = None) -> "DbConnectionManager":
|
|
100
|
+
"""
|
|
101
|
+
Get or create the singleton DbConnectionManager for a database path.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
db_path: Path to SQLite database. Defaults to ~/.claude-memory/memory.db
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Shared DbConnectionManager instance for this db_path
|
|
108
|
+
"""
|
|
109
|
+
if db_path is None:
|
|
110
|
+
db_path = Path.home() / ".claude-memory" / "memory.db"
|
|
111
|
+
|
|
112
|
+
key = str(db_path)
|
|
113
|
+
|
|
114
|
+
with cls._instances_lock:
|
|
115
|
+
if key not in cls._instances:
|
|
116
|
+
instance = cls(db_path)
|
|
117
|
+
cls._instances[key] = instance
|
|
118
|
+
return cls._instances[key]
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def reset_instance(cls, db_path: Optional[Path] = None):
|
|
122
|
+
"""
|
|
123
|
+
Remove and close a singleton instance. Used for testing and cleanup.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
db_path: Path to the database. If None, resets all instances.
|
|
127
|
+
"""
|
|
128
|
+
with cls._instances_lock:
|
|
129
|
+
if db_path is None:
|
|
130
|
+
# Reset all
|
|
131
|
+
for instance in cls._instances.values():
|
|
132
|
+
instance.close()
|
|
133
|
+
cls._instances.clear()
|
|
134
|
+
else:
|
|
135
|
+
key = str(db_path)
|
|
136
|
+
if key in cls._instances:
|
|
137
|
+
cls._instances[key].close()
|
|
138
|
+
del cls._instances[key]
|
|
139
|
+
|
|
140
|
+
def __init__(self, db_path: Path):
|
|
141
|
+
"""
|
|
142
|
+
Initialize the connection manager. Do NOT call directly — use get_instance().
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
db_path: Path to SQLite database file
|
|
146
|
+
"""
|
|
147
|
+
self.db_path = Path(db_path)
|
|
148
|
+
self._closed = False
|
|
149
|
+
|
|
150
|
+
# Thread-local storage for read connections
|
|
151
|
+
self._local = threading.local()
|
|
152
|
+
|
|
153
|
+
# Read pool tracking (for cleanup)
|
|
154
|
+
self._read_connections: list = []
|
|
155
|
+
self._read_connections_lock = threading.Lock()
|
|
156
|
+
|
|
157
|
+
# Write queue and dedicated writer thread
|
|
158
|
+
self._write_queue: Queue = Queue()
|
|
159
|
+
self._writer_thread = threading.Thread(
|
|
160
|
+
target=self._writer_loop,
|
|
161
|
+
name="slm-db-writer",
|
|
162
|
+
daemon=True # Dies when main process exits
|
|
163
|
+
)
|
|
164
|
+
self._write_connection: Optional[sqlite3.Connection] = None
|
|
165
|
+
|
|
166
|
+
# Post-write hooks (for Event Bus integration)
|
|
167
|
+
self._post_write_hooks: list = []
|
|
168
|
+
self._post_write_hooks_lock = threading.Lock()
|
|
169
|
+
|
|
170
|
+
# Initialize WAL mode and start writer
|
|
171
|
+
self._init_wal_mode()
|
|
172
|
+
self._writer_thread.start()
|
|
173
|
+
|
|
174
|
+
logger.info(
|
|
175
|
+
"DbConnectionManager initialized: db=%s, WAL=enabled, busy_timeout=%dms",
|
|
176
|
+
self.db_path, DEFAULT_BUSY_TIMEOUT_MS
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _init_wal_mode(self):
|
|
180
|
+
"""
|
|
181
|
+
Enable WAL mode and set pragmas on a temporary connection.
|
|
182
|
+
|
|
183
|
+
WAL (Write-Ahead Logging) allows concurrent reads during writes.
|
|
184
|
+
This is the single most impactful fix for the "database is locked" bug.
|
|
185
|
+
Once set, WAL mode persists across all connections to this database.
|
|
186
|
+
"""
|
|
187
|
+
conn = sqlite3.connect(str(self.db_path))
|
|
188
|
+
try:
|
|
189
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
190
|
+
conn.execute(f"PRAGMA busy_timeout={DEFAULT_BUSY_TIMEOUT_MS}")
|
|
191
|
+
# Sync mode NORMAL is safe with WAL and faster than FULL
|
|
192
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
193
|
+
conn.close()
|
|
194
|
+
except Exception:
|
|
195
|
+
conn.close()
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
def _create_connection(self, readonly: bool = False) -> sqlite3.Connection:
|
|
199
|
+
"""
|
|
200
|
+
Create a new SQLite connection with proper pragmas.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
readonly: If True, opens in read-only mode (SQLite URI)
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Configured sqlite3.Connection
|
|
207
|
+
"""
|
|
208
|
+
if readonly:
|
|
209
|
+
# URI mode for read-only — prevents accidental writes from read connections
|
|
210
|
+
uri = f"file:{self.db_path}?mode=ro"
|
|
211
|
+
conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
|
212
|
+
else:
|
|
213
|
+
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
|
214
|
+
|
|
215
|
+
# Apply pragmas to every connection
|
|
216
|
+
conn.execute(f"PRAGMA busy_timeout={DEFAULT_BUSY_TIMEOUT_MS}")
|
|
217
|
+
# WAL mode is database-level (persists), but set it here for safety
|
|
218
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
219
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
220
|
+
|
|
221
|
+
return conn
|
|
222
|
+
|
|
223
|
+
# =========================================================================
|
|
224
|
+
# Read connections — thread-local pool, concurrent access OK
|
|
225
|
+
# =========================================================================
|
|
226
|
+
|
|
227
|
+
def get_read_connection(self) -> sqlite3.Connection:
|
|
228
|
+
"""
|
|
229
|
+
Get a read connection for the current thread.
|
|
230
|
+
|
|
231
|
+
Uses thread-local storage so each thread reuses its own connection.
|
|
232
|
+
Read connections are safe to use concurrently with WAL mode.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
sqlite3.Connection configured for reading
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
RuntimeError: If the manager has been closed
|
|
239
|
+
"""
|
|
240
|
+
if self._closed:
|
|
241
|
+
raise RuntimeError("DbConnectionManager is closed")
|
|
242
|
+
|
|
243
|
+
# Check thread-local for existing connection
|
|
244
|
+
conn = getattr(self._local, 'read_conn', None)
|
|
245
|
+
if conn is not None:
|
|
246
|
+
try:
|
|
247
|
+
# Verify connection is still alive
|
|
248
|
+
conn.execute("SELECT 1")
|
|
249
|
+
return conn
|
|
250
|
+
except sqlite3.Error:
|
|
251
|
+
# Connection is dead, create a new one
|
|
252
|
+
self._remove_from_pool(conn)
|
|
253
|
+
conn = None
|
|
254
|
+
|
|
255
|
+
# Create new read connection for this thread
|
|
256
|
+
conn = self._create_connection(readonly=True)
|
|
257
|
+
self._local.read_conn = conn
|
|
258
|
+
|
|
259
|
+
with self._read_connections_lock:
|
|
260
|
+
self._read_connections.append(conn)
|
|
261
|
+
|
|
262
|
+
return conn
|
|
263
|
+
|
|
264
|
+
def release_read_connection(self, conn: sqlite3.Connection):
|
|
265
|
+
"""
|
|
266
|
+
Release a read connection back to the pool.
|
|
267
|
+
|
|
268
|
+
With thread-local storage, this is a no-op — the connection stays
|
|
269
|
+
assigned to the thread. Call this for API compatibility and future
|
|
270
|
+
pool expansion.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
conn: The connection to release
|
|
274
|
+
"""
|
|
275
|
+
# No-op with thread-local strategy — connection stays with thread.
|
|
276
|
+
# Explicit close happens in close() or when thread dies.
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
@contextmanager
|
|
280
|
+
def read_connection(self):
|
|
281
|
+
"""
|
|
282
|
+
Context manager for read connections. Preferred API.
|
|
283
|
+
|
|
284
|
+
Usage:
|
|
285
|
+
with mgr.read_connection() as conn:
|
|
286
|
+
cursor = conn.cursor()
|
|
287
|
+
cursor.execute("SELECT ...")
|
|
288
|
+
rows = cursor.fetchall()
|
|
289
|
+
"""
|
|
290
|
+
conn = self.get_read_connection()
|
|
291
|
+
try:
|
|
292
|
+
yield conn
|
|
293
|
+
finally:
|
|
294
|
+
self.release_read_connection(conn)
|
|
295
|
+
|
|
296
|
+
def _remove_from_pool(self, conn: sqlite3.Connection):
|
|
297
|
+
"""Remove a dead connection from the tracking list."""
|
|
298
|
+
with self._read_connections_lock:
|
|
299
|
+
try:
|
|
300
|
+
self._read_connections.remove(conn)
|
|
301
|
+
except ValueError:
|
|
302
|
+
pass
|
|
303
|
+
try:
|
|
304
|
+
conn.close()
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
# =========================================================================
|
|
309
|
+
# Write connection — single writer thread, serialized queue
|
|
310
|
+
# =========================================================================
|
|
311
|
+
|
|
312
|
+
def execute_write(self, callback: Callable[[sqlite3.Connection], Any]) -> Any:
|
|
313
|
+
"""
|
|
314
|
+
Execute a write operation through the serialized write queue.
|
|
315
|
+
|
|
316
|
+
The callback receives a sqlite3.Connection and should perform its
|
|
317
|
+
write operations (INSERT/UPDATE/DELETE) and call conn.commit().
|
|
318
|
+
The callback runs on the dedicated writer thread — never on the caller's thread.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
callback: Function that takes a sqlite3.Connection and returns a result.
|
|
322
|
+
The callback MUST call conn.commit() for changes to persist.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Whatever the callback returns
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
RuntimeError: If the manager is closed
|
|
329
|
+
Exception: Re-raises any exception from the callback
|
|
330
|
+
"""
|
|
331
|
+
if self._closed:
|
|
332
|
+
raise RuntimeError("DbConnectionManager is closed")
|
|
333
|
+
|
|
334
|
+
# Create a future-like result holder
|
|
335
|
+
result_event = threading.Event()
|
|
336
|
+
result_holder = {"value": None, "error": None}
|
|
337
|
+
|
|
338
|
+
def wrapped_callback(conn):
|
|
339
|
+
try:
|
|
340
|
+
result_holder["value"] = callback(conn)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
result_holder["error"] = e
|
|
343
|
+
|
|
344
|
+
result_event.set()
|
|
345
|
+
|
|
346
|
+
# Enqueue the work
|
|
347
|
+
self._write_queue.put(wrapped_callback)
|
|
348
|
+
|
|
349
|
+
# Wait for completion
|
|
350
|
+
result_event.wait()
|
|
351
|
+
|
|
352
|
+
# Re-raise if callback failed
|
|
353
|
+
if result_holder["error"] is not None:
|
|
354
|
+
raise result_holder["error"]
|
|
355
|
+
|
|
356
|
+
return result_holder["value"]
|
|
357
|
+
|
|
358
|
+
def _writer_loop(self):
|
|
359
|
+
"""
|
|
360
|
+
Writer thread main loop. Processes write callbacks sequentially.
|
|
361
|
+
|
|
362
|
+
This is the heart of the concurrency fix. All writes go through this
|
|
363
|
+
single thread, eliminating write-write collisions entirely.
|
|
364
|
+
"""
|
|
365
|
+
# Create the dedicated write connection
|
|
366
|
+
self._write_connection = self._create_connection(readonly=False)
|
|
367
|
+
|
|
368
|
+
while True:
|
|
369
|
+
callback = self._write_queue.get()
|
|
370
|
+
|
|
371
|
+
# Sentinel value means shutdown
|
|
372
|
+
if callback is WRITE_QUEUE_SENTINEL:
|
|
373
|
+
self._write_queue.task_done()
|
|
374
|
+
break
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
callback(self._write_connection)
|
|
378
|
+
|
|
379
|
+
# Fire post-write hooks (for Event Bus)
|
|
380
|
+
self._fire_post_write_hooks()
|
|
381
|
+
|
|
382
|
+
except Exception as e:
|
|
383
|
+
# Log but don't crash the writer thread
|
|
384
|
+
logger.error("Write callback failed: %s", e)
|
|
385
|
+
|
|
386
|
+
self._write_queue.task_done()
|
|
387
|
+
|
|
388
|
+
# Cleanup
|
|
389
|
+
if self._write_connection:
|
|
390
|
+
try:
|
|
391
|
+
self._write_connection.close()
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
self._write_connection = None
|
|
395
|
+
|
|
396
|
+
# =========================================================================
|
|
397
|
+
# Post-write hooks (Event Bus integration point)
|
|
398
|
+
# =========================================================================
|
|
399
|
+
|
|
400
|
+
def register_post_write_hook(self, hook: Callable[[], None]):
|
|
401
|
+
"""
|
|
402
|
+
Register a callback that fires after every successful write commit.
|
|
403
|
+
|
|
404
|
+
This is the integration point for the Event Bus (Phase A).
|
|
405
|
+
Hooks run on the writer thread, so they should be fast and non-blocking.
|
|
406
|
+
For heavy work, hooks should enqueue to their own async queue.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
hook: Zero-argument callable invoked after each write
|
|
410
|
+
"""
|
|
411
|
+
with self._post_write_hooks_lock:
|
|
412
|
+
self._post_write_hooks.append(hook)
|
|
413
|
+
|
|
414
|
+
def unregister_post_write_hook(self, hook: Callable[[], None]):
|
|
415
|
+
"""
|
|
416
|
+
Remove a previously registered post-write hook.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
hook: The hook to remove
|
|
420
|
+
"""
|
|
421
|
+
with self._post_write_hooks_lock:
|
|
422
|
+
try:
|
|
423
|
+
self._post_write_hooks.remove(hook)
|
|
424
|
+
except ValueError:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
def _fire_post_write_hooks(self):
|
|
428
|
+
"""Fire all registered post-write hooks. Errors are logged, not raised."""
|
|
429
|
+
with self._post_write_hooks_lock:
|
|
430
|
+
hooks = list(self._post_write_hooks)
|
|
431
|
+
|
|
432
|
+
for hook in hooks:
|
|
433
|
+
try:
|
|
434
|
+
hook()
|
|
435
|
+
except Exception as e:
|
|
436
|
+
logger.error("Post-write hook failed: %s", e)
|
|
437
|
+
|
|
438
|
+
# =========================================================================
|
|
439
|
+
# Direct write connection access (for DDL / schema init)
|
|
440
|
+
# =========================================================================
|
|
441
|
+
|
|
442
|
+
def execute_ddl(self, callback: Callable[[sqlite3.Connection], Any]) -> Any:
|
|
443
|
+
"""
|
|
444
|
+
Execute DDL (CREATE TABLE, ALTER TABLE, etc.) through the write queue.
|
|
445
|
+
|
|
446
|
+
Identical to execute_write() but named separately for clarity.
|
|
447
|
+
DDL operations like schema initialization must go through the writer
|
|
448
|
+
to prevent "database is locked" during table creation.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
callback: Function that takes a sqlite3.Connection and performs DDL
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
Whatever the callback returns
|
|
455
|
+
"""
|
|
456
|
+
return self.execute_write(callback)
|
|
457
|
+
|
|
458
|
+
# =========================================================================
|
|
459
|
+
# Lifecycle management
|
|
460
|
+
# =========================================================================
|
|
461
|
+
|
|
462
|
+
def close(self):
|
|
463
|
+
"""
|
|
464
|
+
Shut down the connection manager. Drains the write queue and closes
|
|
465
|
+
all connections.
|
|
466
|
+
|
|
467
|
+
Safe to call multiple times. After close(), all operations raise RuntimeError.
|
|
468
|
+
"""
|
|
469
|
+
if self._closed:
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
self._closed = True
|
|
473
|
+
|
|
474
|
+
# Signal writer thread to stop
|
|
475
|
+
self._write_queue.put(WRITE_QUEUE_SENTINEL)
|
|
476
|
+
|
|
477
|
+
# Wait for writer to finish (with timeout to prevent hanging)
|
|
478
|
+
if self._writer_thread.is_alive():
|
|
479
|
+
self._writer_thread.join(timeout=10)
|
|
480
|
+
|
|
481
|
+
# Close all read connections
|
|
482
|
+
with self._read_connections_lock:
|
|
483
|
+
for conn in self._read_connections:
|
|
484
|
+
try:
|
|
485
|
+
conn.close()
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
self._read_connections.clear()
|
|
489
|
+
|
|
490
|
+
logger.info("DbConnectionManager closed: db=%s", self.db_path)
|
|
491
|
+
|
|
492
|
+
@property
|
|
493
|
+
def is_closed(self) -> bool:
|
|
494
|
+
"""Check if the manager has been shut down."""
|
|
495
|
+
return self._closed
|
|
496
|
+
|
|
497
|
+
@property
|
|
498
|
+
def write_queue_size(self) -> int:
|
|
499
|
+
"""Current number of pending write operations. Useful for monitoring."""
|
|
500
|
+
return self._write_queue.qsize()
|
|
501
|
+
|
|
502
|
+
def get_diagnostics(self) -> dict:
|
|
503
|
+
"""
|
|
504
|
+
Get diagnostic information about the connection manager state.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Dictionary with connection pool stats, queue depth, WAL status
|
|
508
|
+
"""
|
|
509
|
+
diagnostics = {
|
|
510
|
+
"db_path": str(self.db_path),
|
|
511
|
+
"closed": self._closed,
|
|
512
|
+
"write_queue_depth": self._write_queue.qsize(),
|
|
513
|
+
"writer_thread_alive": self._writer_thread.is_alive(),
|
|
514
|
+
"read_connections_count": len(self._read_connections),
|
|
515
|
+
"post_write_hooks_count": len(self._post_write_hooks),
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
# Check WAL mode
|
|
519
|
+
try:
|
|
520
|
+
with self.read_connection() as conn:
|
|
521
|
+
cursor = conn.execute("PRAGMA journal_mode")
|
|
522
|
+
diagnostics["journal_mode"] = cursor.fetchone()[0]
|
|
523
|
+
cursor = conn.execute("PRAGMA busy_timeout")
|
|
524
|
+
diagnostics["busy_timeout_ms"] = cursor.fetchone()[0]
|
|
525
|
+
except Exception as e:
|
|
526
|
+
diagnostics["pragma_check_error"] = str(e)
|
|
527
|
+
|
|
528
|
+
return diagnostics
|
|
529
|
+
|
|
530
|
+
def __repr__(self) -> str:
|
|
531
|
+
status = "closed" if self._closed else "active"
|
|
532
|
+
return f"<DbConnectionManager db={self.db_path} status={status}>"
|