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.
- velune/__init__.py +5 -0
- velune/__main__.py +6 -0
- velune/cli/__init__.py +5 -0
- velune/cli/app.py +208 -0
- velune/cli/autocomplete.py +80 -0
- velune/cli/banner.py +60 -0
- velune/cli/commands/__init__.py +32 -0
- velune/cli/commands/ask.py +175 -0
- velune/cli/commands/base.py +16 -0
- velune/cli/commands/chat.py +228 -0
- velune/cli/commands/config.py +224 -0
- velune/cli/commands/daemon.py +88 -0
- velune/cli/commands/doctor.py +721 -0
- velune/cli/commands/init.py +170 -0
- velune/cli/commands/mcp.py +82 -0
- velune/cli/commands/memory.py +293 -0
- velune/cli/commands/models.py +683 -0
- velune/cli/commands/preflight.py +95 -0
- velune/cli/commands/run.py +270 -0
- velune/cli/commands/setup.py +184 -0
- velune/cli/commands/workspace.py +249 -0
- velune/cli/context.py +36 -0
- velune/cli/councilmodel_ui.py +199 -0
- velune/cli/display/council_view.py +254 -0
- velune/cli/display/memory_view.py +126 -0
- velune/cli/display/panels.py +35 -0
- velune/cli/display/progress.py +25 -0
- velune/cli/display/themes.py +25 -0
- velune/cli/main.py +15 -0
- velune/cli/model_selector.py +51 -0
- velune/cli/modes.py +86 -0
- velune/cli/pull_ui.py +123 -0
- velune/cli/registry.py +80 -0
- velune/cli/rendering/__init__.py +5 -0
- velune/cli/rendering/error_panel.py +79 -0
- velune/cli/rendering/markdown.py +63 -0
- velune/cli/repl.py +1855 -0
- velune/cli/session_manager.py +71 -0
- velune/cli/slash_commands.py +37 -0
- velune/cli/theme.py +8 -0
- velune/cognition/__init__.py +23 -0
- velune/cognition/agents/__init__.py +7 -0
- velune/cognition/agents/coder.py +209 -0
- velune/cognition/agents/planner.py +156 -0
- velune/cognition/agents/reviewer.py +195 -0
- velune/cognition/arbitrator.py +220 -0
- velune/cognition/architecture.py +415 -0
- velune/cognition/budget.py +65 -0
- velune/cognition/council/__init__.py +47 -0
- velune/cognition/council/base.py +217 -0
- velune/cognition/council/challenger.py +74 -0
- velune/cognition/council/coder.py +79 -0
- velune/cognition/council/critic_agent.py +43 -0
- velune/cognition/council/critic_configs.py +111 -0
- velune/cognition/council/critics.py +41 -0
- velune/cognition/council/debate.py +46 -0
- velune/cognition/council/factory.py +140 -0
- velune/cognition/council/messages.py +56 -0
- velune/cognition/council/planner.py +124 -0
- velune/cognition/council/reviewer.py +74 -0
- velune/cognition/council/synthesizer.py +67 -0
- velune/cognition/council/tiers.py +188 -0
- velune/cognition/council_orchestrator.py +282 -0
- velune/cognition/firewall.py +354 -0
- velune/cognition/module.py +46 -0
- velune/cognition/orchestrator.py +1205 -0
- velune/cognition/personality.py +238 -0
- velune/cognition/state.py +104 -0
- velune/cognition/style_resolver.py +64 -0
- velune/cognition/verification.py +205 -0
- velune/context/__init__.py +28 -0
- velune/context/assembler.py +240 -0
- velune/context/budget.py +97 -0
- velune/context/extractive.py +95 -0
- velune/context/prompt_adaptation.py +480 -0
- velune/context/sections.py +99 -0
- velune/context/token_counter.py +134 -0
- velune/context/utilization.py +33 -0
- velune/context/window.py +63 -0
- velune/core/__init__.py +89 -0
- velune/core/background.py +5 -0
- velune/core/config/__init__.py +37 -0
- velune/core/errors/__init__.py +90 -0
- velune/core/errors/catalog.py +188 -0
- velune/core/errors/execution.py +31 -0
- velune/core/errors/memory.py +25 -0
- velune/core/errors/orchestration.py +31 -0
- velune/core/errors/provider.py +37 -0
- velune/core/event_loop.py +35 -0
- velune/core/logging.py +83 -0
- velune/core/paths.py +165 -0
- velune/core/runtime.py +113 -0
- velune/core/startup_profiler.py +56 -0
- velune/core/task_registry.py +117 -0
- velune/core/trace.py +83 -0
- velune/core/types/__init__.py +48 -0
- velune/core/types/agent.py +53 -0
- velune/core/types/context.py +42 -0
- velune/core/types/inference.py +38 -0
- velune/core/types/memory.py +42 -0
- velune/core/types/model.py +70 -0
- velune/core/types/provider.py +62 -0
- velune/core/types/repository.py +38 -0
- velune/core/types/task.py +61 -0
- velune/core/types/workspace.py +28 -0
- velune/daemon/client.py +13 -0
- velune/daemon/server.py +127 -0
- velune/daemon/transport.py +179 -0
- velune/events.py +204 -0
- velune/execution/__init__.py +22 -0
- velune/execution/benchmarker.py +315 -0
- velune/execution/cancellation.py +53 -0
- velune/execution/checkpointer.py +130 -0
- velune/execution/command_spec.py +165 -0
- velune/execution/diff_preview.py +197 -0
- velune/execution/executor.py +181 -0
- velune/execution/module.py +18 -0
- velune/execution/multi_diff.py +67 -0
- velune/execution/path_guard.py +74 -0
- velune/execution/planner.py +91 -0
- velune/execution/rollback.py +89 -0
- velune/execution/sandbox.py +268 -0
- velune/execution/validator.py +115 -0
- velune/hardware/__init__.py +1 -0
- velune/hardware/detector.py +192 -0
- velune/kernel/__init__.py +55 -0
- velune/kernel/bootstrap.py +125 -0
- velune/kernel/config.py +426 -0
- velune/kernel/entrypoint.py +78 -0
- velune/kernel/health.py +54 -0
- velune/kernel/lifecycle.py +143 -0
- velune/kernel/module.py +17 -0
- velune/kernel/modules.py +23 -0
- velune/kernel/registry.py +96 -0
- velune/kernel/schemas.py +28 -0
- velune/main.py +9 -0
- velune/mcp/__init__.py +9 -0
- velune/mcp/client.py +115 -0
- velune/mcp/config.py +19 -0
- velune/mcp/server.py +624 -0
- velune/memory/__init__.py +32 -0
- velune/memory/compaction.py +506 -0
- velune/memory/embedding_pipeline.py +241 -0
- velune/memory/lifecycle.py +680 -0
- velune/memory/module.py +218 -0
- velune/memory/prioritizer.py +67 -0
- velune/memory/storage/episodic_schema.sql +53 -0
- velune/memory/storage/lancedb_store.py +282 -0
- velune/memory/storage/sqlite_manager.py +369 -0
- velune/memory/storage/sqlite_pool.py +149 -0
- velune/memory/tiers/episodic.py +588 -0
- velune/memory/tiers/graph.py +378 -0
- velune/memory/tiers/lineage.py +416 -0
- velune/memory/tiers/semantic.py +475 -0
- velune/memory/tiers/working.py +168 -0
- velune/memory/vitality.py +132 -0
- velune/models/__init__.py +15 -0
- velune/models/family.py +76 -0
- velune/models/module.py +20 -0
- velune/models/probes.py +192 -0
- velune/models/profile_cache.py +84 -0
- velune/models/profiler.py +108 -0
- velune/models/registry.py +251 -0
- velune/models/scorer.py +233 -0
- velune/models/specializations.py +205 -0
- velune/orchestration/__init__.py +19 -0
- velune/orchestration/engine.py +239 -0
- velune/orchestration/module.py +15 -0
- velune/orchestration/role_assignments.py +82 -0
- velune/orchestration/schemas.py +98 -0
- velune/plugins/__init__.py +20 -0
- velune/plugins/hooks.py +50 -0
- velune/plugins/loader.py +161 -0
- velune/plugins/registry.py +56 -0
- velune/plugins/schemas.py +21 -0
- velune/providers/__init__.py +23 -0
- velune/providers/adapters/anthropic.py +257 -0
- velune/providers/adapters/fireworks.py +115 -0
- velune/providers/adapters/google.py +234 -0
- velune/providers/adapters/groq.py +151 -0
- velune/providers/adapters/huggingface.py +210 -0
- velune/providers/adapters/llamacpp.py +208 -0
- velune/providers/adapters/lmstudio.py +175 -0
- velune/providers/adapters/ollama.py +233 -0
- velune/providers/adapters/openai.py +213 -0
- velune/providers/adapters/openrouter.py +81 -0
- velune/providers/adapters/together.py +134 -0
- velune/providers/adapters/xai.py +60 -0
- velune/providers/base.py +86 -0
- velune/providers/benchmarker.py +138 -0
- velune/providers/discovery/__init__.py +33 -0
- velune/providers/discovery/anthropic.py +79 -0
- velune/providers/discovery/benchmarks.py +44 -0
- velune/providers/discovery/classifier.py +69 -0
- velune/providers/discovery/fireworks.py +95 -0
- velune/providers/discovery/gguf.py +88 -0
- velune/providers/discovery/google.py +95 -0
- velune/providers/discovery/gpu.py +117 -0
- velune/providers/discovery/groq.py +21 -0
- velune/providers/discovery/huggingface.py +67 -0
- velune/providers/discovery/lmstudio.py +80 -0
- velune/providers/discovery/ollama.py +162 -0
- velune/providers/discovery/openai.py +96 -0
- velune/providers/discovery/openrouter.py +113 -0
- velune/providers/discovery/scanner.py +115 -0
- velune/providers/discovery/together.py +114 -0
- velune/providers/discovery/xai.py +57 -0
- velune/providers/health.py +67 -0
- velune/providers/health_monitor.py +169 -0
- velune/providers/keystore.py +142 -0
- velune/providers/local_paths.py +49 -0
- velune/providers/local_resolver.py +229 -0
- velune/providers/module.py +51 -0
- velune/providers/ollama_manager.py +193 -0
- velune/providers/registry.py +220 -0
- velune/providers/router.py +255 -0
- velune/providers/task_classifier.py +288 -0
- velune/py.typed +0 -0
- velune/repository/__init__.py +33 -0
- velune/repository/analyzer.py +127 -0
- velune/repository/ast_parser.py +822 -0
- velune/repository/blast_radius.py +298 -0
- velune/repository/boundary_classifier.py +295 -0
- velune/repository/cognition.py +316 -0
- velune/repository/grapher.py +179 -0
- velune/repository/import_graph.py +263 -0
- velune/repository/incremental_indexer.py +275 -0
- velune/repository/index_state.py +96 -0
- velune/repository/indexer.py +243 -0
- velune/repository/module.py +17 -0
- velune/repository/parser.py +474 -0
- velune/repository/project_type.py +300 -0
- velune/repository/rename_journal.py +287 -0
- velune/repository/scanner.py +193 -0
- velune/repository/schemas.py +102 -0
- velune/repository/symbol_registry.py +365 -0
- velune/repository/tracker.py +252 -0
- velune/retrieval/__init__.py +27 -0
- velune/retrieval/cache.py +110 -0
- velune/retrieval/fast_path.py +391 -0
- velune/retrieval/graph.py +124 -0
- velune/retrieval/hybrid.py +271 -0
- velune/retrieval/keyword.py +131 -0
- velune/retrieval/module.py +26 -0
- velune/retrieval/pipeline.py +303 -0
- velune/retrieval/reranker.py +102 -0
- velune/retrieval/schemas.py +59 -0
- velune/retrieval/slow_path.py +364 -0
- velune/retrieval/vector.py +203 -0
- velune/telemetry/__init__.py +59 -0
- velune/telemetry/cognition.py +267 -0
- velune/telemetry/cost_estimator.py +92 -0
- velune/telemetry/debug.py +304 -0
- velune/telemetry/doctor.py +244 -0
- velune/telemetry/logging.py +286 -0
- velune/telemetry/spans.py +277 -0
- velune/telemetry/token_tracker.py +140 -0
- velune/telemetry/usage_tracker.py +340 -0
- velune/tools/__init__.py +41 -0
- velune/tools/base/registry.py +87 -0
- velune/tools/base/tool.py +63 -0
- velune/tools/code/navigate.py +116 -0
- velune/tools/code/search.py +123 -0
- velune/tools/filesystem/read.py +75 -0
- velune/tools/filesystem/search.py +136 -0
- velune/tools/filesystem/write.py +163 -0
- velune/tools/git/history.py +177 -0
- velune/tools/git/operations.py +122 -0
- velune/tools/git/state.py +121 -0
- velune/tools/module.py +81 -0
- velune/tools/terminal/execute.py +72 -0
- velune/tools/terminal/history.py +47 -0
- velune/tools/web/fetch.py +55 -0
- velune/tools/web/validator.py +122 -0
- velune_cli-0.9.0.dist-info/METADATA +518 -0
- velune_cli-0.9.0.dist-info/RECORD +279 -0
- velune_cli-0.9.0.dist-info/WHEEL +4 -0
- velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|