superlocalmemory 2.4.2 → 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.
@@ -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}>"