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.
@@ -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
- # --notify: if a server is live, route reindex through the socket (R3 fallback)
85
- if notify:
86
- sp = sock_path()
87
- try:
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
 
@@ -23,11 +23,14 @@ from sqlcg.utils.logging import getLogger
23
23
  logger = getLogger(__name__)
24
24
 
25
25
 
26
- def _find_lock_holder(db_path: str) -> str:
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. Enables concurrent read-only
62
- opens (reader/reader concurrency) by not taking the exclusive
63
- write lock. Does NOT allow reads while a read-write writer
64
- holds the lock KùzuDB's exclusive lock is process-level.
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 = _find_lock_holder(db_path)
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