"""Background workers and job tracker for bincio.serve.""" from __future__ import annotations import logging import shutil import subprocess import threading import time import uuid from pathlib import Path from bincio.serve import deps log = logging.getLogger("bincio.serve") # ── Job tracker ─────────────────────────────────────────────────────────────── _jobs_lock = threading.Lock() _active_jobs: dict[str, dict] = {} def _job_start(user_handle: str, total_files: int) -> str: job_id = uuid.uuid4().hex[:8] with _jobs_lock: _active_jobs[job_id] = { "id": job_id, "user": user_handle, "started_at": int(time.time()), "total": total_files, "done": 0, "current": "", } return job_id def _job_update(job_id: str, done: int, current: str) -> None: with _jobs_lock: if job_id in _active_jobs: _active_jobs[job_id]["done"] = done _active_jobs[job_id]["current"] = current def _job_finish(job_id: str) -> None: with _jobs_lock: _active_jobs.pop(job_id, None) # ── Post-write rebuild ──────────────────────────────────────────────────────── _rebuild_lock = threading.Lock() _site_rebuild_event = threading.Event() def _site_rebuild_worker() -> None: """Single background thread: debounced Astro build + rsync after uploads. Waits for _site_rebuild_event, sleeps 60 s to let upload bursts settle, then runs one full build. Uploads that arrive during the build set the event again, so a follow-up build starts after the current one finishes. """ _webroot = str(deps.webroot) _data_dir = str(deps.data_dir) _site_dir = str(deps.site_dir) uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") while True: _site_rebuild_event.wait() _site_rebuild_event.clear() time.sleep(60) _site_rebuild_event.clear() log.info("site-rebuild: starting full build + rsync to %s", _webroot) try: result = subprocess.run( [uv, "run", "bincio", "render", "--data-dir", _data_dir, "--site-dir", _site_dir], capture_output=True, text=True, ) if result.returncode != 0: log.error("site-rebuild: build failed (rc=%d):\n%s\n%s", result.returncode, result.stdout, result.stderr) continue dist_data = Path(_site_dir) / "dist" / "data" if dist_data.exists(): shutil.rmtree(dist_data) rsync = subprocess.run( ["rsync", "-a", "--delete", "--exclude=data/", f"{_site_dir}/dist/", _webroot + "/"], capture_output=True, text=True, ) if rsync.returncode != 0: log.error("site-rebuild: rsync failed (rc=%d):\n%s\n%s", rsync.returncode, rsync.stdout, rsync.stderr) else: log.info("site-rebuild: done") except Exception: log.exception("site-rebuild: unexpected error") def _trigger_rebuild(handle: str) -> None: """Merge sidecars for handle asynchronously; signal the site-rebuild worker.""" if deps.site_dir is None: return if not deps._VALID_HANDLE.match(handle): return uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") _data_dir = str(deps.data_dir) _site_dir = str(deps.site_dir) _handle = handle def _run() -> None: try: log.info("rebuild[%s]: merge-only", _handle) with _rebuild_lock: result = subprocess.run( [uv, "run", "bincio", "render", "--data-dir", _data_dir, "--site-dir", _site_dir, "--handle", _handle, "--no-build"], capture_output=True, text=True, ) if result.returncode != 0: log.error("rebuild[%s]: merge failed (rc=%d):\n%s\n%s", _handle, result.returncode, result.stdout, result.stderr) else: log.info("rebuild[%s]: merge done", _handle) if deps.webroot is not None: _site_rebuild_event.set() except Exception: log.exception("rebuild[%s]: unexpected error", _handle) threading.Thread(target=_run, daemon=True).start()