"""Minimal async client for a local Dolt clone. The application never runs a long-lived Dolt sql-server; it shells out to the `dolt` CLI against a persistent clone and reads results as CSV. Every call goes through ``asyncio.create_subprocess_exec`` because the scheduler shares one event loop with the API (`app/scheduler.py:73`) — a blocking `subprocess.run` here would stall request handling. Production keeps the clone in ``DOLT_DATA_DIR`` outside the deploy tree; the binary path and data dir are configured (see ``app/config.py``). Read via ``dolt sql -r csv``; refresh with ``pull`` and record the resulting commit hash as the import revision. """ from __future__ import annotations import asyncio import csv import io import logging import shutil from pathlib import Path logger = logging.getLogger(__name__) # Default subprocess timeout. A hung `dolt pull`/`sql` would otherwise pin the # import's connection and its advisory lock indefinitely, so every call is # bounded; callers may override per operation. DEFAULT_TIMEOUT = 600.0 class DoltError(RuntimeError): """A dolt subprocess failed, timed out, or exited non-zero.""" async def _run( binary: str, args: list[str], *, cwd: Path, timeout: float = DEFAULT_TIMEOUT ) -> str: proc = await asyncio.create_subprocess_exec( binary, *args, cwd=str(cwd), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: proc.kill() try: await proc.wait() except ProcessLookupError: pass raise DoltError(f"dolt {args[0] if args else ''} timed out after {timeout:.0f}s") if proc.returncode != 0: raise DoltError( f"dolt {' '.join(args)} failed ({proc.returncode}): " f"{stderr.decode('utf-8', 'replace').strip()[:500]}" ) return stdout.decode("utf-8", "replace") def ensure_free_disk(path: Path, min_free_gb: float) -> None: """Raise if free space at ``path`` is below the threshold (checked before a pull that could grow the clone). Uses the nearest existing ancestor so it works before the clone dir exists.""" probe = path while not probe.exists() and probe.parent != probe: probe = probe.parent free_gb = shutil.disk_usage(probe).free / (1024**3) if free_gb < min_free_gb: raise DoltError( f"insufficient disk for dolt at {path}: {free_gb:.1f} GB free " f"< {min_free_gb:.1f} GB required" ) async def pull(repo_dir: Path, *, binary: str, timeout: float = DEFAULT_TIMEOUT) -> None: """`dolt pull` the persistent clone to the latest upstream revision.""" await _run(binary, ["pull"], cwd=repo_dir, timeout=timeout) async def current_commit( repo_dir: Path, *, binary: str, timeout: float = DEFAULT_TIMEOUT ) -> str: """The HEAD commit hash of the clone — used as the import revision. Uses ``DOLT_HASHOF('HEAD')`` (which formally identifies HEAD) rather than ordering ``dolt_log`` by timestamp.""" rows = await query_csv( repo_dir, "SELECT DOLT_HASHOF('HEAD') AS commit_hash", binary=binary, timeout=timeout ) if not rows or not rows[0].get("commit_hash"): raise DoltError("could not read HEAD commit hash") return rows[0]["commit_hash"] async def query_csv( repo_dir: Path, sql: str, *, binary: str, timeout: float = DEFAULT_TIMEOUT ) -> list[dict[str, str]]: """Run a read query and parse the CSV result into a list of dict rows.""" out = await _run(binary, ["sql", "-q", sql, "-r", "csv"], cwd=repo_dir, timeout=timeout) if not out.strip(): return [] return list(csv.DictReader(io.StringIO(out)))