velune-cli 0.9.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.
Files changed (279) hide show
  1. velune/__init__.py +5 -0
  2. velune/__main__.py +6 -0
  3. velune/cli/__init__.py +5 -0
  4. velune/cli/app.py +208 -0
  5. velune/cli/autocomplete.py +80 -0
  6. velune/cli/banner.py +60 -0
  7. velune/cli/commands/__init__.py +32 -0
  8. velune/cli/commands/ask.py +175 -0
  9. velune/cli/commands/base.py +16 -0
  10. velune/cli/commands/chat.py +228 -0
  11. velune/cli/commands/config.py +224 -0
  12. velune/cli/commands/daemon.py +88 -0
  13. velune/cli/commands/doctor.py +721 -0
  14. velune/cli/commands/init.py +170 -0
  15. velune/cli/commands/mcp.py +82 -0
  16. velune/cli/commands/memory.py +293 -0
  17. velune/cli/commands/models.py +683 -0
  18. velune/cli/commands/preflight.py +95 -0
  19. velune/cli/commands/run.py +270 -0
  20. velune/cli/commands/setup.py +184 -0
  21. velune/cli/commands/workspace.py +249 -0
  22. velune/cli/context.py +36 -0
  23. velune/cli/councilmodel_ui.py +199 -0
  24. velune/cli/display/council_view.py +254 -0
  25. velune/cli/display/memory_view.py +126 -0
  26. velune/cli/display/panels.py +35 -0
  27. velune/cli/display/progress.py +25 -0
  28. velune/cli/display/themes.py +25 -0
  29. velune/cli/main.py +15 -0
  30. velune/cli/model_selector.py +51 -0
  31. velune/cli/modes.py +86 -0
  32. velune/cli/pull_ui.py +123 -0
  33. velune/cli/registry.py +80 -0
  34. velune/cli/rendering/__init__.py +5 -0
  35. velune/cli/rendering/error_panel.py +79 -0
  36. velune/cli/rendering/markdown.py +63 -0
  37. velune/cli/repl.py +1855 -0
  38. velune/cli/session_manager.py +71 -0
  39. velune/cli/slash_commands.py +37 -0
  40. velune/cli/theme.py +8 -0
  41. velune/cognition/__init__.py +23 -0
  42. velune/cognition/agents/__init__.py +7 -0
  43. velune/cognition/agents/coder.py +209 -0
  44. velune/cognition/agents/planner.py +156 -0
  45. velune/cognition/agents/reviewer.py +195 -0
  46. velune/cognition/arbitrator.py +220 -0
  47. velune/cognition/architecture.py +415 -0
  48. velune/cognition/budget.py +65 -0
  49. velune/cognition/council/__init__.py +47 -0
  50. velune/cognition/council/base.py +217 -0
  51. velune/cognition/council/challenger.py +74 -0
  52. velune/cognition/council/coder.py +79 -0
  53. velune/cognition/council/critic_agent.py +43 -0
  54. velune/cognition/council/critic_configs.py +111 -0
  55. velune/cognition/council/critics.py +41 -0
  56. velune/cognition/council/debate.py +46 -0
  57. velune/cognition/council/factory.py +140 -0
  58. velune/cognition/council/messages.py +56 -0
  59. velune/cognition/council/planner.py +124 -0
  60. velune/cognition/council/reviewer.py +74 -0
  61. velune/cognition/council/synthesizer.py +67 -0
  62. velune/cognition/council/tiers.py +188 -0
  63. velune/cognition/council_orchestrator.py +282 -0
  64. velune/cognition/firewall.py +354 -0
  65. velune/cognition/module.py +46 -0
  66. velune/cognition/orchestrator.py +1205 -0
  67. velune/cognition/personality.py +238 -0
  68. velune/cognition/state.py +104 -0
  69. velune/cognition/style_resolver.py +64 -0
  70. velune/cognition/verification.py +205 -0
  71. velune/context/__init__.py +28 -0
  72. velune/context/assembler.py +240 -0
  73. velune/context/budget.py +97 -0
  74. velune/context/extractive.py +95 -0
  75. velune/context/prompt_adaptation.py +480 -0
  76. velune/context/sections.py +99 -0
  77. velune/context/token_counter.py +134 -0
  78. velune/context/utilization.py +33 -0
  79. velune/context/window.py +63 -0
  80. velune/core/__init__.py +89 -0
  81. velune/core/background.py +5 -0
  82. velune/core/config/__init__.py +37 -0
  83. velune/core/errors/__init__.py +90 -0
  84. velune/core/errors/catalog.py +188 -0
  85. velune/core/errors/execution.py +31 -0
  86. velune/core/errors/memory.py +25 -0
  87. velune/core/errors/orchestration.py +31 -0
  88. velune/core/errors/provider.py +37 -0
  89. velune/core/event_loop.py +35 -0
  90. velune/core/logging.py +83 -0
  91. velune/core/paths.py +165 -0
  92. velune/core/runtime.py +113 -0
  93. velune/core/startup_profiler.py +56 -0
  94. velune/core/task_registry.py +117 -0
  95. velune/core/trace.py +83 -0
  96. velune/core/types/__init__.py +48 -0
  97. velune/core/types/agent.py +53 -0
  98. velune/core/types/context.py +42 -0
  99. velune/core/types/inference.py +38 -0
  100. velune/core/types/memory.py +42 -0
  101. velune/core/types/model.py +70 -0
  102. velune/core/types/provider.py +62 -0
  103. velune/core/types/repository.py +38 -0
  104. velune/core/types/task.py +61 -0
  105. velune/core/types/workspace.py +28 -0
  106. velune/daemon/client.py +13 -0
  107. velune/daemon/server.py +127 -0
  108. velune/daemon/transport.py +179 -0
  109. velune/events.py +204 -0
  110. velune/execution/__init__.py +22 -0
  111. velune/execution/benchmarker.py +315 -0
  112. velune/execution/cancellation.py +53 -0
  113. velune/execution/checkpointer.py +130 -0
  114. velune/execution/command_spec.py +165 -0
  115. velune/execution/diff_preview.py +197 -0
  116. velune/execution/executor.py +181 -0
  117. velune/execution/module.py +18 -0
  118. velune/execution/multi_diff.py +67 -0
  119. velune/execution/path_guard.py +74 -0
  120. velune/execution/planner.py +91 -0
  121. velune/execution/rollback.py +89 -0
  122. velune/execution/sandbox.py +268 -0
  123. velune/execution/validator.py +115 -0
  124. velune/hardware/__init__.py +1 -0
  125. velune/hardware/detector.py +192 -0
  126. velune/kernel/__init__.py +55 -0
  127. velune/kernel/bootstrap.py +125 -0
  128. velune/kernel/config.py +426 -0
  129. velune/kernel/entrypoint.py +78 -0
  130. velune/kernel/health.py +54 -0
  131. velune/kernel/lifecycle.py +143 -0
  132. velune/kernel/module.py +17 -0
  133. velune/kernel/modules.py +23 -0
  134. velune/kernel/registry.py +96 -0
  135. velune/kernel/schemas.py +28 -0
  136. velune/main.py +9 -0
  137. velune/mcp/__init__.py +9 -0
  138. velune/mcp/client.py +115 -0
  139. velune/mcp/config.py +19 -0
  140. velune/mcp/server.py +624 -0
  141. velune/memory/__init__.py +32 -0
  142. velune/memory/compaction.py +506 -0
  143. velune/memory/embedding_pipeline.py +241 -0
  144. velune/memory/lifecycle.py +680 -0
  145. velune/memory/module.py +218 -0
  146. velune/memory/prioritizer.py +67 -0
  147. velune/memory/storage/episodic_schema.sql +53 -0
  148. velune/memory/storage/lancedb_store.py +282 -0
  149. velune/memory/storage/sqlite_manager.py +369 -0
  150. velune/memory/storage/sqlite_pool.py +149 -0
  151. velune/memory/tiers/episodic.py +588 -0
  152. velune/memory/tiers/graph.py +378 -0
  153. velune/memory/tiers/lineage.py +416 -0
  154. velune/memory/tiers/semantic.py +475 -0
  155. velune/memory/tiers/working.py +168 -0
  156. velune/memory/vitality.py +132 -0
  157. velune/models/__init__.py +15 -0
  158. velune/models/family.py +76 -0
  159. velune/models/module.py +20 -0
  160. velune/models/probes.py +192 -0
  161. velune/models/profile_cache.py +84 -0
  162. velune/models/profiler.py +108 -0
  163. velune/models/registry.py +251 -0
  164. velune/models/scorer.py +233 -0
  165. velune/models/specializations.py +205 -0
  166. velune/orchestration/__init__.py +19 -0
  167. velune/orchestration/engine.py +239 -0
  168. velune/orchestration/module.py +15 -0
  169. velune/orchestration/role_assignments.py +82 -0
  170. velune/orchestration/schemas.py +98 -0
  171. velune/plugins/__init__.py +20 -0
  172. velune/plugins/hooks.py +50 -0
  173. velune/plugins/loader.py +161 -0
  174. velune/plugins/registry.py +56 -0
  175. velune/plugins/schemas.py +21 -0
  176. velune/providers/__init__.py +23 -0
  177. velune/providers/adapters/anthropic.py +257 -0
  178. velune/providers/adapters/fireworks.py +115 -0
  179. velune/providers/adapters/google.py +234 -0
  180. velune/providers/adapters/groq.py +151 -0
  181. velune/providers/adapters/huggingface.py +210 -0
  182. velune/providers/adapters/llamacpp.py +208 -0
  183. velune/providers/adapters/lmstudio.py +175 -0
  184. velune/providers/adapters/ollama.py +233 -0
  185. velune/providers/adapters/openai.py +213 -0
  186. velune/providers/adapters/openrouter.py +81 -0
  187. velune/providers/adapters/together.py +134 -0
  188. velune/providers/adapters/xai.py +60 -0
  189. velune/providers/base.py +86 -0
  190. velune/providers/benchmarker.py +138 -0
  191. velune/providers/discovery/__init__.py +33 -0
  192. velune/providers/discovery/anthropic.py +79 -0
  193. velune/providers/discovery/benchmarks.py +44 -0
  194. velune/providers/discovery/classifier.py +69 -0
  195. velune/providers/discovery/fireworks.py +95 -0
  196. velune/providers/discovery/gguf.py +88 -0
  197. velune/providers/discovery/google.py +95 -0
  198. velune/providers/discovery/gpu.py +117 -0
  199. velune/providers/discovery/groq.py +21 -0
  200. velune/providers/discovery/huggingface.py +67 -0
  201. velune/providers/discovery/lmstudio.py +80 -0
  202. velune/providers/discovery/ollama.py +162 -0
  203. velune/providers/discovery/openai.py +96 -0
  204. velune/providers/discovery/openrouter.py +113 -0
  205. velune/providers/discovery/scanner.py +115 -0
  206. velune/providers/discovery/together.py +114 -0
  207. velune/providers/discovery/xai.py +57 -0
  208. velune/providers/health.py +67 -0
  209. velune/providers/health_monitor.py +169 -0
  210. velune/providers/keystore.py +142 -0
  211. velune/providers/local_paths.py +49 -0
  212. velune/providers/local_resolver.py +229 -0
  213. velune/providers/module.py +51 -0
  214. velune/providers/ollama_manager.py +193 -0
  215. velune/providers/registry.py +220 -0
  216. velune/providers/router.py +255 -0
  217. velune/providers/task_classifier.py +288 -0
  218. velune/py.typed +0 -0
  219. velune/repository/__init__.py +33 -0
  220. velune/repository/analyzer.py +127 -0
  221. velune/repository/ast_parser.py +822 -0
  222. velune/repository/blast_radius.py +298 -0
  223. velune/repository/boundary_classifier.py +295 -0
  224. velune/repository/cognition.py +316 -0
  225. velune/repository/grapher.py +179 -0
  226. velune/repository/import_graph.py +263 -0
  227. velune/repository/incremental_indexer.py +275 -0
  228. velune/repository/index_state.py +96 -0
  229. velune/repository/indexer.py +243 -0
  230. velune/repository/module.py +17 -0
  231. velune/repository/parser.py +474 -0
  232. velune/repository/project_type.py +300 -0
  233. velune/repository/rename_journal.py +287 -0
  234. velune/repository/scanner.py +193 -0
  235. velune/repository/schemas.py +102 -0
  236. velune/repository/symbol_registry.py +365 -0
  237. velune/repository/tracker.py +252 -0
  238. velune/retrieval/__init__.py +27 -0
  239. velune/retrieval/cache.py +110 -0
  240. velune/retrieval/fast_path.py +391 -0
  241. velune/retrieval/graph.py +124 -0
  242. velune/retrieval/hybrid.py +271 -0
  243. velune/retrieval/keyword.py +131 -0
  244. velune/retrieval/module.py +26 -0
  245. velune/retrieval/pipeline.py +303 -0
  246. velune/retrieval/reranker.py +102 -0
  247. velune/retrieval/schemas.py +59 -0
  248. velune/retrieval/slow_path.py +364 -0
  249. velune/retrieval/vector.py +203 -0
  250. velune/telemetry/__init__.py +59 -0
  251. velune/telemetry/cognition.py +267 -0
  252. velune/telemetry/cost_estimator.py +92 -0
  253. velune/telemetry/debug.py +304 -0
  254. velune/telemetry/doctor.py +244 -0
  255. velune/telemetry/logging.py +286 -0
  256. velune/telemetry/spans.py +277 -0
  257. velune/telemetry/token_tracker.py +140 -0
  258. velune/telemetry/usage_tracker.py +340 -0
  259. velune/tools/__init__.py +41 -0
  260. velune/tools/base/registry.py +87 -0
  261. velune/tools/base/tool.py +63 -0
  262. velune/tools/code/navigate.py +116 -0
  263. velune/tools/code/search.py +123 -0
  264. velune/tools/filesystem/read.py +75 -0
  265. velune/tools/filesystem/search.py +136 -0
  266. velune/tools/filesystem/write.py +163 -0
  267. velune/tools/git/history.py +177 -0
  268. velune/tools/git/operations.py +122 -0
  269. velune/tools/git/state.py +121 -0
  270. velune/tools/module.py +81 -0
  271. velune/tools/terminal/execute.py +72 -0
  272. velune/tools/terminal/history.py +47 -0
  273. velune/tools/web/fetch.py +55 -0
  274. velune/tools/web/validator.py +122 -0
  275. velune_cli-0.9.0.dist-info/METADATA +518 -0
  276. velune_cli-0.9.0.dist-info/RECORD +279 -0
  277. velune_cli-0.9.0.dist-info/WHEEL +4 -0
  278. velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
  279. velune_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,369 @@
1
+ """Thread-safe SQLite manager with WAL mode and connection pooling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import queue
7
+ import sqlite3
8
+ import threading
9
+ import time
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger("velune.memory.storage.sqlite_manager")
15
+
16
+
17
+ class SQLiteManager:
18
+ """Thread-safe SQLite manager with WAL mode and connection pooling.
19
+
20
+ All memory tiers should use this manager instead of direct sqlite3.connect() calls.
21
+ WAL (Write-Ahead Logging) mode allows concurrent readers with one writer.
22
+ """
23
+
24
+ def __init__(self, db_path: Path) -> None:
25
+ self.db_path = db_path
26
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
27
+ self._write_queue: queue.Queue = queue.Queue()
28
+ self._is_running = True
29
+
30
+ # Write thread health tracking and lock
31
+ self._thread_lock = threading.Lock()
32
+ self._thread_healthy = threading.Event()
33
+
34
+ # Read connection pool
35
+ self._local = threading.local()
36
+
37
+ # Start the writer thread
38
+ self._write_thread = threading.Thread(target=self._process_writes, daemon=True)
39
+ self._write_thread.start()
40
+ self._initialize_wal()
41
+
42
+ def _initialize_wal(self) -> None:
43
+ """Enable WAL mode for better concurrent read performance."""
44
+ with self._read_connection() as conn:
45
+ conn.execute("PRAGMA journal_mode=WAL")
46
+ conn.execute("PRAGMA synchronous=NORMAL")
47
+ conn.execute("PRAGMA busy_timeout=5000")
48
+
49
+ @contextmanager
50
+ def _read_connection(self):
51
+ """Get a read connection. Multiple read connections are safe with WAL mode."""
52
+ conn = sqlite3.connect(str(self.db_path), timeout=30.0)
53
+ conn.row_factory = sqlite3.Row
54
+ try:
55
+ yield conn
56
+ finally:
57
+ conn.close()
58
+
59
+ def _get_read_connection(self):
60
+ """Get or create a thread-local read connection.
61
+
62
+ If the stored connection was closed externally (e.g. by :meth:`close`
63
+ on a previous call in the same thread), a ``sqlite3.ProgrammingError``
64
+ is raised on the next use. We catch that case here and transparently
65
+ recreate the connection so callers never see the error.
66
+ """
67
+ if not hasattr(self._local, "conn") or self._local.conn is None:
68
+ self._local.conn = self._new_read_connection()
69
+ else:
70
+ # Probe for a closed/stale handle without querying the DB
71
+ try:
72
+ self._local.conn.execute("SELECT 1")
73
+ except Exception:
74
+ # Connection was closed or is otherwise broken — recreate it
75
+ try:
76
+ self._local.conn.close()
77
+ except Exception:
78
+ pass
79
+ self._local.conn = self._new_read_connection()
80
+ return self._local.conn
81
+
82
+ def _new_read_connection(self) -> sqlite3.Connection:
83
+ """Create and configure a new read-mode SQLite connection."""
84
+ conn = sqlite3.connect(str(self.db_path), timeout=30.0)
85
+ conn.row_factory = sqlite3.Row
86
+ conn.execute("PRAGMA journal_mode=WAL")
87
+ conn.execute("PRAGMA busy_timeout=5000")
88
+ return conn
89
+
90
+ def _restart_write_thread(self) -> None:
91
+ """Create a new write thread if the current one is dead."""
92
+ with self._thread_lock:
93
+ if not self._is_running:
94
+ return
95
+ if self._write_thread is None or not self._write_thread.is_alive():
96
+ logger.info("SQLiteManager restarting dead write thread.")
97
+ self._write_thread = threading.Thread(target=self._process_writes, daemon=True)
98
+ self._write_thread.start()
99
+
100
+ def _safe_put(self, item: tuple) -> None:
101
+ """Restart the write thread if necessary, log queue warning if saturated, and queue write."""
102
+ self._restart_write_thread()
103
+ qsize = self._write_queue.qsize()
104
+ if qsize > 50:
105
+ logger.warning(
106
+ "SQLiteManager write queue depth exceeds 50 (current depth: %d). "
107
+ "Possible performance bottleneck or write queue saturation.",
108
+ qsize,
109
+ )
110
+ self._write_queue.put(item)
111
+
112
+ def execute_write(self, query: str, params: tuple = ()) -> None:
113
+ """Queue a write operation. Fire and forget."""
114
+ self._safe_put((query, params, None))
115
+
116
+ def execute_write_sync(self, query: str, params: tuple = (), timeout: float = 15.0) -> None:
117
+ """Queue a write operation and wait for completion.
118
+
119
+ Raises:
120
+ TimeoutError: If write doesn't complete within timeout.
121
+ This indicates write thread death or severe queue backup.
122
+ RuntimeError: If the write fails with a database error.
123
+ """
124
+ done = threading.Event()
125
+ error_holder: list[Exception] = []
126
+ self._safe_put((query, params, done, error_holder))
127
+
128
+ # Log warning if taking longer than expected (5s)
129
+ completed = done.wait(timeout=min(5.0, timeout))
130
+ if not completed:
131
+ if timeout > 5.0:
132
+ logger.warning(
133
+ "SQLite write sync taking longer than expected (5s elapsed). Queue depth: %d",
134
+ self._write_queue.qsize(),
135
+ )
136
+ completed = done.wait(timeout=timeout - 5.0)
137
+
138
+ if not completed:
139
+ alive = self._write_thread.is_alive() if self._write_thread else False
140
+ logger.critical(
141
+ "SQLite write sync TIMEOUT exceeded (%ds). "
142
+ "Queue depth: %d. "
143
+ "Write thread alive: %s. "
144
+ "Query: %s",
145
+ timeout,
146
+ self._write_queue.qsize(),
147
+ alive,
148
+ query[:100],
149
+ )
150
+ raise TimeoutError(
151
+ f"SQLite write queue timeout after {timeout}s. "
152
+ f"Queue depth: {self._write_queue.qsize()}. "
153
+ f"Write thread alive: {alive}. "
154
+ f"Query: {query[:100]}"
155
+ )
156
+ if error_holder:
157
+ raise error_holder[0]
158
+
159
+ def execute_write_many(self, queries: list[tuple[str, tuple]], timeout: float = 15.0) -> None:
160
+ """Batch multiple writes as a single atomic transaction.
161
+
162
+ Raises:
163
+ TimeoutError: If batch write doesn't complete within timeout.
164
+ RuntimeError: If the batch write fails.
165
+ """
166
+ done = threading.Event()
167
+ error_holder: list[Exception] = []
168
+ self._safe_put(("__BATCH__", queries, done, error_holder))
169
+
170
+ # Log warning if taking longer than expected (5s)
171
+ completed = done.wait(timeout=min(5.0, timeout))
172
+ if not completed:
173
+ if timeout > 5.0:
174
+ logger.warning(
175
+ "SQLite batch write taking longer than expected (5s elapsed). Queue depth: %d",
176
+ self._write_queue.qsize(),
177
+ )
178
+ completed = done.wait(timeout=timeout - 5.0)
179
+
180
+ if not completed:
181
+ alive = self._write_thread.is_alive() if self._write_thread else False
182
+ logger.critical(
183
+ "SQLite batch write TIMEOUT exceeded (%ds). "
184
+ "Batch size: %d. "
185
+ "Queue depth: %d. "
186
+ "Write thread alive: %s.",
187
+ timeout,
188
+ len(queries),
189
+ self._write_queue.qsize(),
190
+ alive,
191
+ )
192
+ raise TimeoutError(
193
+ f"SQLite batch write timeout after {timeout}s. "
194
+ f"Batch size: {len(queries)}. "
195
+ f"Queue depth: {self._write_queue.qsize()}."
196
+ )
197
+ if error_holder:
198
+ raise error_holder[0]
199
+
200
+ def execute_read(self, query: str, params: tuple = ()) -> list[sqlite3.Row]:
201
+ """Execute a read query. Thread-safe with WAL mode."""
202
+ conn = self._get_read_connection()
203
+ cursor = conn.execute(query, params)
204
+ return cursor.fetchall()
205
+
206
+ def execute_script(self, script: str, timeout: float = 10.0) -> None:
207
+ """Execute a DDL script (CREATE TABLE, CREATE INDEX, etc.).
208
+
209
+ Raises:
210
+ TimeoutError: If script doesn't complete within timeout.
211
+ RuntimeError: If the script fails.
212
+ """
213
+ done = threading.Event()
214
+ error_holder: list[Exception] = []
215
+ self._safe_put((script, None, done, error_holder))
216
+
217
+ completed = done.wait(timeout=min(5.0, timeout))
218
+ if not completed:
219
+ if timeout > 5.0:
220
+ logger.warning(
221
+ "SQLite script taking longer than expected (5s elapsed). Queue depth: %d",
222
+ self._write_queue.qsize(),
223
+ )
224
+ completed = done.wait(timeout=timeout - 5.0)
225
+
226
+ if not completed:
227
+ raise TimeoutError(f"SQLite script timeout after {timeout}s.")
228
+ if error_holder:
229
+ raise error_holder[0]
230
+
231
+ def close(self) -> None:
232
+ """Synchronously close the calling thread's read connection.
233
+
234
+ Call this from the **same thread** that used :meth:`execute_read` to
235
+ ensure the handle is released promptly. On Windows this is critical:
236
+ a lingering open handle prevents ``os.remove()`` from succeeding and
237
+ causes ``PermissionError`` in tests.
238
+
239
+ Safe to call multiple times — subsequent calls are no-ops.
240
+ """
241
+ conn = getattr(self._local, "conn", None)
242
+ if conn is not None:
243
+ try:
244
+ conn.close()
245
+ except Exception:
246
+ pass
247
+ finally:
248
+ self._local.conn = None
249
+
250
+ def is_healthy(self) -> bool:
251
+ """Returns True if the write thread is alive and queue depth is under 100."""
252
+ return (
253
+ self._write_thread is not None
254
+ and self._write_thread.is_alive()
255
+ and self._write_queue.qsize() < 100
256
+ )
257
+
258
+ def _process_writes(self) -> None:
259
+ self._thread_healthy.set()
260
+ try:
261
+ while self._is_running:
262
+ try:
263
+ item = self._write_queue.get(timeout=0.5)
264
+ if item is None:
265
+ self._write_queue.task_done()
266
+ break
267
+
268
+ # Support both 3-tuple (fire-and-forget) and 4-tuple (with error capture)
269
+ if len(item) == 4:
270
+ query, params, done_event, error_holder = item
271
+ else:
272
+ query, params, done_event = item
273
+ error_holder = None
274
+
275
+ try:
276
+ self._do_write(query, params)
277
+ except Exception as write_error:
278
+ logger.error(
279
+ "SQLite write error (query: %s): %s", str(query)[:80], write_error
280
+ )
281
+ if error_holder is not None:
282
+ error_holder.append(RuntimeError(f"SQLite write failed: {write_error}"))
283
+ finally:
284
+ if done_event:
285
+ done_event.set()
286
+
287
+ self._write_queue.task_done()
288
+
289
+ except queue.Empty:
290
+ continue
291
+ except Exception as e:
292
+ logger.error("SQLite write thread loop error: %s", e)
293
+ except Exception as e:
294
+ logger.error("SQLite write thread unhandled exception: %s", e)
295
+ finally:
296
+ self._thread_healthy.clear()
297
+
298
+ def _do_write(self, query: str, params: Any | None) -> None:
299
+ last_error: Exception | None = None
300
+ backoffs = [0.05, 0.1, 0.2, 0.4, 0.8]
301
+ for attempt in range(5):
302
+ try:
303
+ conn = sqlite3.connect(str(self.db_path), timeout=30.0)
304
+ conn.execute("PRAGMA busy_timeout=5000")
305
+ if query == "__BATCH__":
306
+ # params is actually a list of (query, params) tuples
307
+ for q, p in params:
308
+ conn.execute(q, p)
309
+ elif params is None:
310
+ conn.executescript(query)
311
+ else:
312
+ conn.execute(query, params)
313
+ conn.commit()
314
+ conn.close()
315
+ return
316
+ except sqlite3.OperationalError as e:
317
+ last_error = e
318
+ if "locked" in str(e) and attempt < 4:
319
+ backoff = backoffs[attempt]
320
+ logger.debug(
321
+ "SQLite lock contention in _do_write. "
322
+ "Attempt %d/5 failed with error: %s. Retrying in %ss...",
323
+ attempt + 1,
324
+ e,
325
+ backoff,
326
+ )
327
+ time.sleep(backoff)
328
+ continue
329
+ logger.error("Write failed after %d attempts: %s", attempt + 1, e)
330
+ break
331
+ if last_error is not None:
332
+ raise last_error
333
+
334
+ async def initialize(self) -> None:
335
+ """Lifecycle start, no-op since thread starts in __init__."""
336
+ pass
337
+
338
+ async def shutdown(self) -> None:
339
+ """Gracefully shut down the write processing thread.
340
+
341
+ This is non-blocking with a timeout to prevent deadlocks if the write thread is dead.
342
+ """
343
+ self._is_running = False
344
+ # Drain with timeout — do not block forever
345
+ try:
346
+
347
+ def drain():
348
+ try:
349
+ self._write_queue.join()
350
+ except Exception:
351
+ pass
352
+
353
+ if self._write_thread.is_alive():
354
+ t = threading.Thread(target=drain, daemon=True)
355
+ t.start()
356
+ t.join(timeout=2.0)
357
+ except Exception:
358
+ pass
359
+ # Signal thread to exit
360
+ self._write_queue.put(None) # sentinel
361
+ self._write_thread.join(timeout=5.0)
362
+
363
+ # Clean up thread local connections
364
+ if hasattr(self._local, "conn") and self._local.conn is not None:
365
+ try:
366
+ self._local.conn.close()
367
+ except Exception:
368
+ pass
369
+ self._local.conn = None
@@ -0,0 +1,149 @@
1
+ """Async single-writer SQLite connection pool.
2
+
3
+ Eliminates WAL write contention by serialising all writes through one
4
+ persistent connection guarded by an ``asyncio.Lock``. Reads open a
5
+ short-lived second connection per call; WAL mode lets concurrent readers
6
+ coexist with the single writer without blocking.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import sqlite3
14
+ from collections.abc import AsyncIterator
15
+ from contextlib import asynccontextmanager
16
+ from pathlib import Path
17
+
18
+ import aiosqlite
19
+
20
+ logger = logging.getLogger("velune.memory.storage.sqlite_pool")
21
+
22
+ _WRITE_PRAGMAS = (
23
+ "PRAGMA journal_mode=WAL",
24
+ "PRAGMA synchronous=NORMAL",
25
+ "PRAGMA cache_size=-64000", # 64 MB page cache
26
+ )
27
+
28
+
29
+ class SQLiteConnectionPool:
30
+ """Async single-writer SQLite connection pool.
31
+
32
+ One persistent write connection is held open for the lifetime of the pool,
33
+ protected by an ``asyncio.Lock`` so only one coroutine writes at a time.
34
+ Read connections are short-lived, opened and closed per query; WAL mode
35
+ ensures they never block behind the writer.
36
+
37
+ Lifecycle
38
+ ---------
39
+ Call ``await pool.startup()`` before any read/write operations.
40
+ Call ``await pool.shutdown()`` during graceful teardown (commits any
41
+ pending work and closes the connection).
42
+
43
+ Usage
44
+ -----
45
+ Writes::
46
+
47
+ async with pool.write() as conn:
48
+ await conn.execute("INSERT INTO t VALUES (?)", (val,))
49
+ # commit happens automatically on exit
50
+
51
+ Reads::
52
+
53
+ async with pool.read() as conn:
54
+ cursor = await conn.execute("SELECT * FROM t")
55
+ rows = await cursor.fetchall()
56
+ """
57
+
58
+ def __init__(self, db_path: str | Path) -> None:
59
+ self._db_path = Path(db_path)
60
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
61
+ self._write_conn: aiosqlite.Connection | None = None
62
+ self._write_lock: asyncio.Lock = asyncio.Lock()
63
+
64
+ # ------------------------------------------------------------------
65
+ # Lifecycle
66
+ # ------------------------------------------------------------------
67
+
68
+ async def startup(self) -> None:
69
+ """Open the write connection and configure SQLite PRAGMAs."""
70
+ self._write_conn = await aiosqlite.connect(str(self._db_path))
71
+ self._write_conn.row_factory = sqlite3.Row
72
+ for pragma in _WRITE_PRAGMAS:
73
+ await self._write_conn.execute(pragma)
74
+ await self._write_conn.commit()
75
+ logger.info("SQLiteConnectionPool started at %s", self._db_path)
76
+
77
+ async def shutdown(self) -> None:
78
+ """Commit any pending work and close the write connection."""
79
+ if self._write_conn is not None:
80
+ try:
81
+ await self._write_conn.commit()
82
+ await self._write_conn.close()
83
+ except Exception as exc:
84
+ logger.error("Error closing write connection: %s", exc)
85
+ finally:
86
+ self._write_conn = None
87
+ logger.info("SQLiteConnectionPool shut down.")
88
+
89
+ # Lifecycle protocol aliases (used by LifecycleCoordinator)
90
+ async def initialize(self) -> None:
91
+ await self.startup()
92
+
93
+ # ------------------------------------------------------------------
94
+ # Context managers
95
+ # ------------------------------------------------------------------
96
+
97
+ @asynccontextmanager
98
+ async def write(self) -> AsyncIterator[aiosqlite.Connection]:
99
+ """Acquire the single write connection.
100
+
101
+ Commits on clean exit; rolls back on exception. The asyncio.Lock
102
+ guarantees at most one writer at a time — the root fix for WAL
103
+ write contention.
104
+ """
105
+ if self._write_conn is None:
106
+ raise RuntimeError("SQLiteConnectionPool has not been started — call startup() first.")
107
+ async with self._write_lock:
108
+ try:
109
+ yield self._write_conn
110
+ await self._write_conn.commit()
111
+ except Exception:
112
+ try:
113
+ await self._write_conn.rollback()
114
+ except Exception:
115
+ pass
116
+ raise
117
+
118
+ @asynccontextmanager
119
+ async def read(self) -> AsyncIterator[aiosqlite.Connection]:
120
+ """Open a short-lived read connection and close it on exit.
121
+
122
+ WAL mode allows any number of concurrent readers alongside the
123
+ single writer, so there is no lock here.
124
+ """
125
+ conn = await aiosqlite.connect(str(self._db_path))
126
+ conn.row_factory = sqlite3.Row
127
+ await conn.execute("PRAGMA journal_mode=WAL")
128
+ try:
129
+ yield conn
130
+ finally:
131
+ await conn.close()
132
+
133
+ # ------------------------------------------------------------------
134
+ # Health
135
+ # ------------------------------------------------------------------
136
+
137
+ @property
138
+ def is_healthy(self) -> bool:
139
+ """``True`` if a write connection is open and the pool is ready."""
140
+ return self._write_conn is not None
141
+
142
+ def health_check(self) -> dict:
143
+ """Return a health detail dict suitable for SubsystemHealthMonitor hooks."""
144
+ return {
145
+ "healthy": self.is_healthy,
146
+ "db_path": str(self._db_path),
147
+ "write_conn_open": self._write_conn is not None,
148
+ "write_lock_locked": self._write_lock.locked(),
149
+ }