sql-code-graph 1.2.2__py3-none-any.whl → 1.3.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.
- {sql_code_graph-1.2.2.dist-info → sql_code_graph-1.3.0.dist-info}/METADATA +1 -1
- {sql_code_graph-1.2.2.dist-info → sql_code_graph-1.3.0.dist-info}/RECORD +15 -14
- sqlcg/__init__.py +1 -1
- sqlcg/cli/commands/db.py +23 -0
- sqlcg/cli/commands/git.py +11 -4
- sqlcg/cli/commands/index.py +167 -4
- sqlcg/cli/commands/mcp.py +70 -3
- sqlcg/cli/commands/reindex.py +146 -76
- sqlcg/core/kuzu_backend.py +10 -6
- sqlcg/metrics/store.py +48 -0
- sqlcg/server/server.py +165 -70
- sqlcg/server/tools.py +155 -14
- sqlcg/server/writer.py +634 -0
- {sql_code_graph-1.2.2.dist-info → sql_code_graph-1.3.0.dist-info}/WHEEL +0 -0
- {sql_code_graph-1.2.2.dist-info → sql_code_graph-1.3.0.dist-info}/entry_points.txt +0 -0
sqlcg/cli/commands/reindex.py
CHANGED
|
@@ -70,92 +70,37 @@ def reindex_cmd( # noqa: B008
|
|
|
70
70
|
Exits with an error if the database schema version does not match the current
|
|
71
71
|
build — run 'sqlcg db reset && sqlcg db init && sqlcg index <path>' to re-init.
|
|
72
72
|
"""
|
|
73
|
-
import json
|
|
74
|
-
import socket as _socket
|
|
75
|
-
|
|
76
73
|
from sqlcg.core.config import config_file_present, get_backend, get_db_path, get_dialect
|
|
77
74
|
from sqlcg.core.schema import SCHEMA_VERSION
|
|
78
75
|
from sqlcg.indexer.indexer import Indexer
|
|
79
|
-
from sqlcg.server.control import sock_path
|
|
80
76
|
|
|
81
77
|
# Resolve to absolute path so ignore-spec and git delta receive an absolute root
|
|
82
78
|
path = path.resolve()
|
|
83
79
|
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
with _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) as s:
|
|
89
|
-
s.settimeout(_NOTIFY_SOCKET_TIMEOUT_S)
|
|
90
|
-
s.connect(str(sp))
|
|
91
|
-
# Resolve SHAs before sending — standalone mode reads from DB via socket
|
|
92
|
-
effective_from = from_sha
|
|
93
|
-
if effective_from is None:
|
|
94
|
-
# Standalone mode: we cannot read stored SHA here without opening the
|
|
95
|
-
# DB (which would conflict with the running server). If no --from is
|
|
96
|
-
# given with --notify, we send from="stored" as a sentinel and fall
|
|
97
|
-
# back to direct write; the caller should pass --from explicitly.
|
|
98
|
-
raise OSError( # noqa: TRY301
|
|
99
|
-
"--notify without --from requires direct DB access; falling through"
|
|
100
|
-
)
|
|
101
|
-
# Resolve symbolic refs (HEAD, branch names) to concrete 40-char SHAs
|
|
102
|
-
# before sending — prevents literal "HEAD" from being stored in the graph.
|
|
103
|
-
effective_from = _resolve_ref(path, effective_from)
|
|
104
|
-
effective_to = _resolve_ref(path, to_sha) if to_sha else _get_head(path)
|
|
105
|
-
payload = {
|
|
106
|
-
"op": "reindex",
|
|
107
|
-
"root": str(path),
|
|
108
|
-
"from": effective_from,
|
|
109
|
-
"to": effective_to,
|
|
110
|
-
"dialect": dialect,
|
|
111
|
-
}
|
|
112
|
-
s.sendall(json.dumps(payload).encode() + b"\n")
|
|
113
|
-
data = s.recv(65536)
|
|
114
|
-
result = json.loads(data)
|
|
115
|
-
if "error" in result:
|
|
116
|
-
console.print(f"[red]Server reindex error: {result['error']}[/red]")
|
|
117
|
-
raise typer.Exit(1)
|
|
118
|
-
if not quiet:
|
|
119
|
-
srv_summary = result.get("summary", {})
|
|
120
|
-
console.print(
|
|
121
|
-
f"[green]Resynced via server[/green] "
|
|
122
|
-
f"+{srv_summary.get('added', 0)} added, "
|
|
123
|
-
f"~{srv_summary.get('modified', 0)} modified, "
|
|
124
|
-
f"-{srv_summary.get('deleted', 0)} deleted"
|
|
125
|
-
)
|
|
126
|
-
raise typer.Exit(0)
|
|
127
|
-
except TimeoutError:
|
|
128
|
-
# Bug 1 fix: server is alive and working (accepted the connection, holds the
|
|
129
|
-
# lock, will finish and persist). Do NOT fall through to the direct-write
|
|
130
|
-
# path — that would hit the held lock and produce a false "Database is locked"
|
|
131
|
-
# error. Exit 0 so the git hook stays non-fatal; the server will complete.
|
|
132
|
-
# (socket.timeout is an alias of TimeoutError, a subclass of OSError — this
|
|
133
|
-
# clause must be listed before the broad OSError clause below.)
|
|
134
|
-
import sys
|
|
135
|
-
|
|
136
|
-
print(
|
|
137
|
-
f"Server is still applying the reindex (timed out waiting after "
|
|
138
|
-
f"{_NOTIFY_SOCKET_TIMEOUT_S}s); the graph will update when it finishes "
|
|
139
|
-
f"— check 'sqlcg mcp status'.",
|
|
140
|
-
file=sys.stderr,
|
|
141
|
-
)
|
|
142
|
-
raise typer.Exit(0) from None
|
|
143
|
-
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
144
|
-
# R3: no live server (stale socket, socket absent, fallback condition) —
|
|
145
|
-
# fall through to the existing direct-write path unchanged.
|
|
146
|
-
# NOTE: socket.timeout / TimeoutError is an OSError subclass, so the
|
|
147
|
-
# dedicated timeout clause above must be listed first (already is).
|
|
148
|
-
pass
|
|
149
|
-
except typer.Exit:
|
|
150
|
-
raise
|
|
151
|
-
except Exception as exc:
|
|
152
|
-
console.print(f"[red]--notify routing failed: {exc}[/red]")
|
|
153
|
-
raise typer.Exit(1) from exc
|
|
154
|
-
|
|
155
|
-
# Resolve dialect
|
|
80
|
+
# Resolve dialect before routing so the WriterRequest always carries a concrete
|
|
81
|
+
# dialect (never the literal sentinel "auto"). Bug A: the route call was before
|
|
82
|
+
# this resolution, causing the server to receive "auto" and fail with
|
|
83
|
+
# "Unknown dialect 'auto'" on every server-routed reindex.
|
|
156
84
|
if dialect == "auto":
|
|
157
85
|
dialect = get_dialect(path)
|
|
158
86
|
|
|
87
|
+
# Step 3.3 — route manual reindex through the socket when a server is live.
|
|
88
|
+
# The --notify flag is kept for backward compatibility but no longer required;
|
|
89
|
+
# manual reindex (no --notify) now also probes the socket by default.
|
|
90
|
+
# W3: from=null is sent when from_sha is None — the server resolves the stored
|
|
91
|
+
# SHA at drain start (no more "requires direct DB access" refusal).
|
|
92
|
+
_is_hook_path = notify # hook path: fire-and-forget; manual path: wait by default
|
|
93
|
+
_routed = _try_route_reindex_via_server(
|
|
94
|
+
path=path,
|
|
95
|
+
from_sha=from_sha,
|
|
96
|
+
to_sha=to_sha,
|
|
97
|
+
dialect=dialect,
|
|
98
|
+
wait=not _is_hook_path,
|
|
99
|
+
quiet=quiet,
|
|
100
|
+
)
|
|
101
|
+
if _routed:
|
|
102
|
+
return
|
|
103
|
+
|
|
159
104
|
if not quiet and not config_file_present(path):
|
|
160
105
|
console.print(
|
|
161
106
|
f"[yellow]No .sqlcg.toml found at {path}/.sqlcg.toml — "
|
|
@@ -248,6 +193,131 @@ def reindex_cmd( # noqa: B008
|
|
|
248
193
|
)
|
|
249
194
|
|
|
250
195
|
|
|
196
|
+
def _try_route_reindex_via_server(
|
|
197
|
+
*,
|
|
198
|
+
path: Path,
|
|
199
|
+
from_sha: str | None,
|
|
200
|
+
to_sha: str | None,
|
|
201
|
+
dialect: str | None,
|
|
202
|
+
wait: bool,
|
|
203
|
+
quiet: bool,
|
|
204
|
+
) -> bool:
|
|
205
|
+
"""Probe for a live server and route the reindex through the socket if found.
|
|
206
|
+
|
|
207
|
+
W3: ``from`` may be ``None`` — the server resolves the stored indexed SHA
|
|
208
|
+
at drain start. Symbolic refs are resolved to concrete SHAs before sending
|
|
209
|
+
(prevents literal "HEAD" being stored in the graph).
|
|
210
|
+
|
|
211
|
+
Returns True if the reindex was handled via the server (caller should return).
|
|
212
|
+
Returns False if no server is live (caller should fall through to direct path).
|
|
213
|
+
"""
|
|
214
|
+
import json
|
|
215
|
+
import socket as _socket
|
|
216
|
+
|
|
217
|
+
from sqlcg.server.control import sock_path
|
|
218
|
+
|
|
219
|
+
sp = sock_path()
|
|
220
|
+
if not sp.exists():
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
# Resolve symbolic SHAs if provided (the hook path already resolves them).
|
|
224
|
+
effective_from = _resolve_ref(path, from_sha) if from_sha is not None else None
|
|
225
|
+
effective_to = _resolve_ref(path, to_sha) if to_sha is not None else None
|
|
226
|
+
|
|
227
|
+
payload = {
|
|
228
|
+
"op": "reindex",
|
|
229
|
+
"root": str(path),
|
|
230
|
+
"from": effective_from, # None → server resolves at drain start (W3)
|
|
231
|
+
"to": effective_to,
|
|
232
|
+
"dialect": dialect,
|
|
233
|
+
"wait": wait,
|
|
234
|
+
"requested_by": "hook" if not wait else "cli",
|
|
235
|
+
}
|
|
236
|
+
payload_bytes = json.dumps(payload).encode()
|
|
237
|
+
frame = f"{len(payload_bytes)}\n".encode() + payload_bytes
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
with _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) as s:
|
|
241
|
+
s.settimeout(_NOTIFY_SOCKET_TIMEOUT_S)
|
|
242
|
+
s.connect(str(sp))
|
|
243
|
+
s.sendall(frame)
|
|
244
|
+
|
|
245
|
+
f = s.makefile("rb")
|
|
246
|
+
if not wait:
|
|
247
|
+
# Fire-and-forget: read one framed acknowledgement.
|
|
248
|
+
length_line = f.readline()
|
|
249
|
+
if length_line:
|
|
250
|
+
try:
|
|
251
|
+
body_len = int(length_line.strip())
|
|
252
|
+
resp_bytes = f.read(body_len)
|
|
253
|
+
result = json.loads(resp_bytes)
|
|
254
|
+
if "error" in result:
|
|
255
|
+
console.print(f"[red]Server reindex error: {result['error']}[/red]")
|
|
256
|
+
raise typer.Exit(1)
|
|
257
|
+
if not quiet:
|
|
258
|
+
pos = result.get("position", "?")
|
|
259
|
+
console.print(
|
|
260
|
+
f"[green]Reindex queued via server[/green] (position {pos})"
|
|
261
|
+
)
|
|
262
|
+
except (ValueError, json.JSONDecodeError):
|
|
263
|
+
pass
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
# wait=True: stream framed frames until done:true.
|
|
267
|
+
while True:
|
|
268
|
+
length_line = f.readline()
|
|
269
|
+
if not length_line:
|
|
270
|
+
break
|
|
271
|
+
try:
|
|
272
|
+
body_len = int(length_line.strip())
|
|
273
|
+
except ValueError:
|
|
274
|
+
break
|
|
275
|
+
frame_bytes = f.read(body_len)
|
|
276
|
+
frame_resp = json.loads(frame_bytes)
|
|
277
|
+
|
|
278
|
+
if frame_resp.get("done"):
|
|
279
|
+
if not frame_resp.get("ok"):
|
|
280
|
+
err = frame_resp.get("error", "unknown error")
|
|
281
|
+
console.print(f"[red]Server reindex error: {err}[/red]")
|
|
282
|
+
raise typer.Exit(1)
|
|
283
|
+
srv_summary = frame_resp.get("summary", {})
|
|
284
|
+
if not quiet:
|
|
285
|
+
if srv_summary.get("fell_back_to_full"):
|
|
286
|
+
console.print(
|
|
287
|
+
"[yellow]Closure exceeded depth cap — fell back to full index "
|
|
288
|
+
"(via server).[/yellow]"
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
console.print(
|
|
292
|
+
f"[green]Resynced via server[/green] "
|
|
293
|
+
f"+{srv_summary.get('added', 0)} added, "
|
|
294
|
+
f"~{srv_summary.get('modified', 0)} modified, "
|
|
295
|
+
f"-{srv_summary.get('deleted', 0)} deleted"
|
|
296
|
+
)
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
except TimeoutError:
|
|
302
|
+
import sys
|
|
303
|
+
|
|
304
|
+
print(
|
|
305
|
+
f"Server is still applying the reindex (timed out waiting after "
|
|
306
|
+
f"{_NOTIFY_SOCKET_TIMEOUT_S}s); the graph will update when it finishes "
|
|
307
|
+
"— check 'sqlcg mcp status'.",
|
|
308
|
+
file=sys.stderr,
|
|
309
|
+
)
|
|
310
|
+
raise typer.Exit(0) from None
|
|
311
|
+
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
312
|
+
# No live server — fall through to direct path.
|
|
313
|
+
return False
|
|
314
|
+
except typer.Exit:
|
|
315
|
+
raise
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
console.print(f"[red]Socket routing failed: {exc}[/red]")
|
|
318
|
+
raise typer.Exit(1) from exc
|
|
319
|
+
|
|
320
|
+
|
|
251
321
|
def _resolve_ref(root: Path, ref: str) -> str:
|
|
252
322
|
"""Resolve a git ref (HEAD, branch, tag, or concrete SHA) to a 40-char SHA.
|
|
253
323
|
|
sqlcg/core/kuzu_backend.py
CHANGED
|
@@ -23,11 +23,14 @@ from sqlcg.utils.logging import getLogger
|
|
|
23
23
|
logger = getLogger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def
|
|
26
|
+
def find_lock_holder(db_path: str) -> str:
|
|
27
27
|
"""Return a human-readable PID string for the process holding the DB lock.
|
|
28
28
|
|
|
29
29
|
Uses lsof on Linux/macOS. Returns a descriptive fallback if lsof is
|
|
30
30
|
unavailable or returns no results.
|
|
31
|
+
|
|
32
|
+
Public so server/writer.py can import it without crossing into a private.
|
|
33
|
+
Returns e.g. ``"PID 1234"`` or ``"PID unknown"``.
|
|
31
34
|
"""
|
|
32
35
|
import shutil
|
|
33
36
|
import subprocess
|
|
@@ -58,10 +61,11 @@ class KuzuBackend(GraphBackend):
|
|
|
58
61
|
Args:
|
|
59
62
|
db_path: Path to the KùzuDB database file (or ':memory:' for in-memory)
|
|
60
63
|
buffer_pool_size_mb: Buffer pool size in MB (0 = use KuzuDB default)
|
|
61
|
-
read_only: Open in read-only mode.
|
|
62
|
-
opens (reader/reader concurrency)
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
read_only: Open in read-only mode. Takes a *shared* lock that
|
|
65
|
+
permits concurrent read-only opens (reader/reader concurrency)
|
|
66
|
+
but still **blocks any read-write open** (and a read-write open
|
|
67
|
+
blocks all opens, including read-only ones). Kuzu 0.11.3 lock
|
|
68
|
+
matrix: RO+RO → ok; RO+RW → blocked; RW+anything → blocked.
|
|
65
69
|
|
|
66
70
|
Raises:
|
|
67
71
|
RuntimeError: If the database is locked or cannot be opened.
|
|
@@ -75,7 +79,7 @@ class KuzuBackend(GraphBackend):
|
|
|
75
79
|
except RuntimeError as exc:
|
|
76
80
|
if "Could not set lock" in str(exc) or "lock" in str(exc).lower():
|
|
77
81
|
# Attempt to find the holding PID via lsof
|
|
78
|
-
pid_hint =
|
|
82
|
+
pid_hint = find_lock_holder(db_path)
|
|
79
83
|
pid_str = pid_hint.split()[-1] if pid_hint else "<PID>"
|
|
80
84
|
msg = (
|
|
81
85
|
f"Database is locked — another sqlcg process is running "
|
sqlcg/metrics/store.py
CHANGED
|
@@ -117,6 +117,19 @@ class MetricsStore:
|
|
|
117
117
|
)
|
|
118
118
|
"""
|
|
119
119
|
)
|
|
120
|
+
self._conn.execute(
|
|
121
|
+
"""
|
|
122
|
+
CREATE TABLE IF NOT EXISTS writer_queue_events (
|
|
123
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
124
|
+
timestamp TEXT NOT NULL,
|
|
125
|
+
event TEXT NOT NULL,
|
|
126
|
+
op TEXT,
|
|
127
|
+
reason TEXT,
|
|
128
|
+
queue_depth INTEGER,
|
|
129
|
+
duration_ms REAL
|
|
130
|
+
)
|
|
131
|
+
"""
|
|
132
|
+
)
|
|
120
133
|
self._conn.commit()
|
|
121
134
|
except sqlite3.Error as e:
|
|
122
135
|
logger.warning(f"Failed to initialize metrics schema: {e}")
|
|
@@ -228,6 +241,41 @@ class MetricsStore:
|
|
|
228
241
|
except sqlite3.Error as e:
|
|
229
242
|
logger.warning(f"Failed to record feedback: {e}")
|
|
230
243
|
|
|
244
|
+
def record_queue_event(
|
|
245
|
+
self,
|
|
246
|
+
event: str,
|
|
247
|
+
*,
|
|
248
|
+
op: str | None = None,
|
|
249
|
+
reason: str | None = None,
|
|
250
|
+
queue_depth: int | None = None,
|
|
251
|
+
duration_ms: float | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Record a writer-queue lifecycle event.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
event: One of ``"enqueued"``, ``"coalesced"``, ``"drained"``.
|
|
257
|
+
op: The write op type (``"index"`` or ``"reindex"``).
|
|
258
|
+
reason: Coalesce reason constant (set for ``"coalesced"`` events).
|
|
259
|
+
queue_depth: Pending-queue depth at event time.
|
|
260
|
+
duration_ms: Drain wall-clock duration (set for ``"drained"``).
|
|
261
|
+
"""
|
|
262
|
+
if not self._enabled or self._conn is None:
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
timestamp = datetime.now(UTC).isoformat()
|
|
267
|
+
self._conn.execute(
|
|
268
|
+
"""
|
|
269
|
+
INSERT INTO writer_queue_events
|
|
270
|
+
(timestamp, event, op, reason, queue_depth, duration_ms)
|
|
271
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
272
|
+
""",
|
|
273
|
+
(timestamp, event, op, reason, queue_depth, duration_ms),
|
|
274
|
+
)
|
|
275
|
+
self._conn.commit()
|
|
276
|
+
except sqlite3.Error as e:
|
|
277
|
+
logger.warning(f"Failed to record queue event: {e}")
|
|
278
|
+
|
|
231
279
|
def execute_query(self, query: str, params: tuple | None = None) -> list[tuple]:
|
|
232
280
|
"""Execute a read-only query.
|
|
233
281
|
|