Source code for exlab_wizard.orchestrator.quiescence_poller

"""Operator-free, per-file quiescence-driven NAS sync poller.

Operator-free per-file NAS sync design (2026-05-21). The
:class:`QuiescenceSyncPoller` is the single auto-sync trigger for **every**
run pending NAS sync -- orchestrator-staged runs *and* runs acquired
directly on ``nas``-mode equipment. It supersedes the sentinel/manifest
``StagingWatcher`` and its five-state ``ingest.json`` machine.

Each sweep (``poll_once``):

1. **Discover runs** in both roots:
   * stage-mode -- run-leaf directories under
     ``config.orchestrator.staging_root``;
   * nas-mode -- run-leaf directories under each ``nas``-mode equipment's
     ``local_root`` tree (``<local_root>/<equipment_id>/<project>/{Runs,
     TestRuns}/<Run_*>``).
2. **Per-file quiescence.** The poller keeps an in-memory snapshot across
   sweeps: for each file, its ``(st_size, st_mtime_ns)`` signature and the
   wall-clock time the signature was *first observed*. A file is **quiet**
   once that signature has been observed unchanged for at least
   ``config.sync.quiescence_minutes``. Files matching any
   ``config.sync.ignore_globs`` glob and the ``.exlab-wizard/`` cache dir
   are skipped. Eligibility is measured from the poller's own observations
   across sweeps -- *not* the absolute age of ``mtime`` -- because
   transports (``rsync -t``, ``rclone``) preserve the source ``mtime``.
3. **Per-file eligibility.** A quiet file is *eligible* when its current
   ``(st_size, st_mtime_ns)`` signature differs from the
   ``synced_signature`` recorded for it in the run's ``sync_state.json``
   (a file with no record, or a record carrying a stale signature, is
   eligible; a file matching its recorded signature has already synced at
   its current state and is skipped).
4. **Enqueue.** Each discovered run with at least one eligible file is
   enqueued via ``nas_sync.enqueue(run_path, files=[...])`` carrying the
   run-relative paths of the eligible files. ``enqueue`` itself owns the
   re-queue / no-op decision (a terminal job with a fresh subset is
   re-armed; an active job is a no-op), so the poller no longer needs the
   coarse "skip run with a job" guard.

The poller is safe to cancel at any await point: it carries no on-disk
state of its own (the snapshot is purely in-memory and is rebuilt by
re-observing the filesystem on the next sweep).
"""

from __future__ import annotations

import asyncio
import contextlib
import fnmatch
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol

from exlab_wizard.config.models import Config
from exlab_wizard.constants import CACHE_DIR_NAME, SyncMode
from exlab_wizard.logging import get_logger
from exlab_wizard.orchestrator._scan import walk_equipment_run_leaves, walk_run_leaves

if TYPE_CHECKING:
    from exlab_wizard.cache.sync_state_writer import SyncStateWriter

__all__ = ["FileSnapshot", "NASSyncLike", "QuiescenceSyncPoller"]

_log = get_logger(__name__)


# ---------------------------------------------------------------------------
# Protocols (kept loose so production NASSyncClient and test stubs both fit)
# ---------------------------------------------------------------------------


[docs] class NASSyncLike(Protocol): """The NAS-sync surface the poller and the Phase 3 staging code use. ``enqueue`` is what the poller itself calls; the ``staging`` router and ``ui/mount`` run-status code additionally consult ``status`` / ``get_by_run_path`` / ``list_all``. They are declared here so the protocol documents the full surface that ``deps.nas_sync`` must satisfy. Tests pass in-memory stubs that record the calls. """
[docs] async def enqueue(self, run_path: Path, files: list[str] | None = ...) -> Any: ...
[docs] async def status(self, run_path: Path) -> str: ...
[docs] async def get_by_run_path(self, run_path: Path) -> Any: ...
[docs] async def list_all(self) -> Any: ...
[docs] @dataclass(slots=True) class FileSnapshot: """One file's observation record carried forward across sweeps. * ``signature`` -- the ``(st_size, st_mtime_ns)`` last observed. * ``first_seen_monotonic`` -- the ``time.monotonic()`` value at which that exact signature was *first* observed. Reset whenever the signature changes; the settle window is measured from it. """ signature: tuple[int, int] first_seen_monotonic: float
[docs] class QuiescenceSyncPoller: """Polls every run pending NAS sync and enqueues runs with eligible files. Constructor dependencies are a :class:`Config`, a :class:`NASSyncClient`-shaped sync client, and a :class:`SyncStateWriter` used (read-only) to skip files already synced at their current ``(st_size, st_mtime_ns)`` signature. The start/stop/loop lifecycle mirrors the retired ``StagingWatcher``; ``poll_once`` is exposed so tests can drive the poller synchronously. """ def __init__( self, *, config: Config, nas_sync: NASSyncLike, sync_state_writer: SyncStateWriter, ) -> None: self._config = config self._nas_sync = nas_sync self._sync_state_writer = sync_state_writer self._task: asyncio.Task[None] | None = None self._stopping = False # Per-file observation snapshot, keyed by absolute path, carried # across sweeps so the settle window can be measured. self._snapshots: dict[Path, FileSnapshot] = {}
[docs] def apply_config(self, config: Config) -> None: """Swap the cached config in place so a live settings save applies. ``poll_once`` re-reads ``quiescence_minutes``, the equipment list, the staging root, and the ignore globs from ``self._config`` every sweep, and :meth:`_loop` re-reads ``poll_interval_seconds`` each iteration -- so reassigning the config here makes every sync setting take effect on the next sweep without a tray relaunch. """ self._config = config
# ------------------------------------------------------------------ lifecycle
[docs] async def start(self) -> None: """Start the background polling task. Idempotent. Returns immediately; the task runs until :meth:`stop` is called or the surrounding event loop tears down. """ if self._task is not None and not self._task.done(): return self._stopping = False self._task = asyncio.create_task(self._loop(), name="exlab-quiescence-poller") _log.info( "quiescence poller started: poll_interval_s=%d quiescence_minutes=%d", self._config.sync.poll_interval_seconds, self._config.sync.quiescence_minutes, )
[docs] async def stop(self) -> None: """Cancel the background task and wait for it to exit. Idempotent.""" self._stopping = True if self._task is None: return self._task.cancel() with contextlib.suppress(asyncio.CancelledError, Exception): await self._task self._task = None _log.info("quiescence poller stopped")
# ------------------------------------------------------------------ poll loop async def _loop(self) -> None: try: while not self._stopping: # Re-read each iteration so a live ``apply_config`` swap of # ``poll_interval_seconds`` takes effect on the next sweep # without a tray relaunch. interval = float(self._config.sync.poll_interval_seconds) try: await self.poll_once() except asyncio.CancelledError: raise except Exception: # pragma: no cover -- defensive _log.exception("quiescence poller sweep failed") with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for( asyncio.shield(asyncio.sleep(interval)), timeout=interval + 1.0, ) except asyncio.CancelledError: raise
[docs] async def poll_once(self, *, now_monotonic: float | None = None) -> list[Path]: """Run one discovery + quiescence + enqueue sweep. ``now_monotonic`` is injectable so tests can drive the settle window with a controllable clock; production callers leave it ``None`` and the poller reads ``time.monotonic()``. Returns the list of run paths enqueued on this sweep (in discovery order) so tests can assert exactly which runs fired. """ now = time.monotonic() if now_monotonic is None else now_monotonic runs = self._discover_runs() settle_seconds = self._config.sync.quiescence_minutes * 60 # Build the next-sweep snapshot fresh; files that vanished simply # fall out of the carried-forward dict. next_snapshots: dict[Path, FileSnapshot] = {} enqueued: list[Path] = [] for run_path in runs: quiet_files: list[Path] = [] for file_path in self._iter_run_files(run_path): signature = self._signature(file_path) if signature is None: continue prior = self._snapshots.get(file_path) if prior is not None and prior.signature == signature: snap = FileSnapshot(signature, prior.first_seen_monotonic) else: snap = FileSnapshot(signature, now) next_snapshots[file_path] = snap if now - snap.first_seen_monotonic >= settle_seconds: quiet_files.append(file_path) if not quiet_files: continue # Per-file eligibility: a quiet file is enqueued only when its # current signature differs from the ``synced_signature`` # recorded in ``sync_state.json`` (a file with no record, or a # record with a stale signature, is eligible). ``enqueue`` # itself owns the re-queue / no-op decision against any # existing job, so the poller no longer pre-filters on job # state. eligible = await self._eligible_files(run_path, quiet_files) if eligible: await self._nas_sync.enqueue(run_path, eligible) enqueued.append(run_path) self._snapshots = next_snapshots return enqueued
# ------------------------------------------------------------------ discovery def _discover_runs(self) -> list[Path]: """Return every run-leaf directory pending NAS sync. Stage-mode runs sit under ``orchestrator.staging_root`` (an equipment-first root). Nas-mode runs sit under each ``nas``-mode equipment's *own* subtree ``<local_root>/<equipment_id>`` -- NOT the whole ``local_root`` tree, which is shared across equipment (a co-rooted ``stage``-mode equipment's runs must not be swept in here, they reach the NAS via the orchestrator's staging area). A run discovered through both roots is de-duplicated. """ seen: set[Path] = set() runs: list[Path] = [] def _add(leaf: Path) -> None: resolved = _safe_resolve(leaf) if resolved in seen: return seen.add(resolved) runs.append(leaf) staging_root = self._config.orchestrator.staging_root if staging_root: for leaf in walk_run_leaves(Path(staging_root)): _add(leaf) data_root = self._config.paths.local_root for equipment in self._config.equipment: if equipment.sync_mode != SyncMode.NAS: continue # Runs live at ``<data_root>/<equipment_id>/<project>/...`` where # ``data_root`` is the single derived ``<app_root>/data`` -- the # same base run creation composes against, so the poller never # watches a different tree than runs are written to. Walk only this # equipment's own subtree so a co-rooted ``stage``-mode equipment is # never discovered here. equipment_dir = Path(data_root) / equipment.id for leaf in walk_equipment_run_leaves(equipment_dir): _add(leaf) return runs # ------------------------------------------------------------------ quiescence def _iter_run_files(self, run_path: Path) -> list[Path]: """Return every non-ignored file under ``run_path``. Skips the ``.exlab-wizard/`` cache dir and any file whose name matches a ``config.sync.ignore_globs`` glob. """ ignore_globs = self._config.sync.ignore_globs out: list[Path] = [] stack: list[Path] = [run_path] while stack: current = stack.pop() try: entries = list(os.scandir(current)) except (FileNotFoundError, NotADirectoryError, PermissionError): continue for entry in entries: if entry.name == CACHE_DIR_NAME and current == run_path: continue try: if entry.is_dir(follow_symlinks=False): stack.append(Path(entry.path)) continue if not entry.is_file(follow_symlinks=False): continue except OSError: continue if _matches_any_glob(entry.name, ignore_globs): continue out.append(Path(entry.path)) return out @staticmethod def _signature(file_path: Path) -> tuple[int, int] | None: """Return ``(st_size, st_mtime_ns)`` for ``file_path`` or None.""" try: stat = file_path.stat() except OSError: return None return (stat.st_size, stat.st_mtime_ns) async def _eligible_files( self, run_path: Path, quiet_files: list[Path], ) -> list[str]: """Return the run-relative paths of quiet files needing a (re-)sync. A quiet file is eligible when its current ``(st_size, st_mtime_ns)`` signature differs from the ``synced_signature`` recorded for it in the run's ``sync_state.json`` -- i.e. it has never synced, or it was modified after a prior sync. A file whose signature matches its recorded ``synced_signature`` has already synced at its current state and is skipped. Returns paths sorted for determinism. On a ``sync_state.json`` read failure the poller treats every quiet file as eligible (fail-open: re-syncing an already-synced file is wasteful but safe). """ try: state = await self._sync_state_writer.read(run_path) except Exception as exc: # pragma: no cover -- defensive _log.warning("sync_state.json read failed for %s: %s", run_path, exc) synced: dict[str, tuple[int, int] | None] = {} else: synced = {rel: rec.synced_signature for rel, rec in state.files.items()} eligible: list[str] = [] for file_path in quiet_files: signature = self._signature(file_path) if signature is None: continue rel = file_path.relative_to(run_path).as_posix() recorded = synced.get(rel) if recorded is not None and tuple(recorded) == signature: continue eligible.append(rel) return sorted(eligible)
def _matches_any_glob(name: str, globs: list[str]) -> bool: """Return True if ``name`` matches any glob in ``globs``.""" return any(fnmatch.fnmatch(name, pattern) for pattern in globs) def _safe_resolve(path: Path) -> Path: """Resolve ``path`` for de-dup, falling back to the path itself.""" try: return path.resolve() except OSError: return path