Addresses the A2 review:
1. Every dolt subprocess is now bounded by a hard timeout
(dolt_command_timeout_seconds, default 600s); on expiry the process is killed
and DoltError raised — a hung pull/sql can no longer pin the import
connection and advisory lock indefinitely. Tested (timeout + non-zero exit).
2. Initial-load validate is stronger: besides zero-future, an initial load now
requires a real forward horizon (>= 21d, under the ~35d observed on the
clone) AND universe coverage >= 50% (a broken symbol join can't seed a hollow
calendar). Subsequent runs keep the 50% collapse gate.
3. Revision uses DOLT_HASHOF('HEAD') — formally HEAD, not dolt_log-by-timestamp.
4. Free-disk floor raised 2 GB -> 5 GB (safe headroom over the ~1.7 GB clone).
Full suite 702 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
3.7 KiB
Python
106 lines
3.7 KiB
Python
"""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)))
|