Source code for exlab_wizard.sync.file_stability

"""Pre-rclone file-stability guard: confirm files have stopped growing.

Polls ``st_size`` on a fixed interval and reports whether a file's size has
been identical across N consecutive observations. Used as a pre-flight check
before ``rclone`` sync invocations so only complete files transfer.

The module is intentionally stdlib-only and free of project imports so it can
be reused unchanged by external job scripts / a CLI as well as by the
in-process sync worker.
"""

from __future__ import annotations

import logging
import time
from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

_log = logging.getLogger(__name__)

__all__ = ["is_stable", "wait_until_stable"]


def _poll_size(path: Path) -> int | None:
    """Return ``path``'s current size, or ``None`` if it cannot be stat-ed."""
    try:
        return path.stat().st_size
    except OSError:
        return None


[docs] def is_stable(path: Path, interval: float, checks: int, timeout: float) -> bool: """Return True once ``path``'s size is constant across ``checks`` polls. Args: path: File to observe (``stat()`` follows symlinks). interval: Seconds between polls. Must be > 0. checks: Consecutive equal observations required. Must be >= 2. timeout: Wall-clock budget in seconds; exceeding it returns False. Returns: True if the size was identical across ``checks`` consecutive polls (an all-zero-size file counts as stable). False if the file disappears at any poll or the timeout elapses first. Raises: ValueError: If ``interval <= 0`` or ``checks < 2``. Notes: On NFS mounts set ``interval >= actimeo`` (typically >= 30 s) so the attribute cache does not mask a still-growing file. On Windows an exclusively-locked file may report ``size == 0`` via ``stat()``; account for that at the call site. No busy-wait: ``time.sleep`` is used between polls. Thread-safe: no shared mutable state. """ if interval <= 0: raise ValueError("interval must be > 0") if checks < 2: raise ValueError("checks must be >= 2") deadline = time.monotonic() + timeout last = _poll_size(path) if last is None: _log.debug("unstable (missing): %s", path) return False stable_count = 1 while stable_count < checks: if time.monotonic() >= deadline: _log.debug("unstable (timeout): %s", path) return False time.sleep(interval) size = _poll_size(path) if size is None: _log.debug("unstable (vanished): %s", path) return False if size == last: stable_count += 1 else: stable_count = 1 last = size _log.debug("stable: %s (size=%d)", path, last) return True
[docs] def wait_until_stable( paths: Iterable[Path], interval: float, checks: int, timeout: float, max_workers: int = 8, ) -> tuple[list[Path], list[Path]]: """Poll ``paths`` concurrently; return ``(stable, unstable)``. Args: paths: Files to observe. interval: Seconds between polls (forwarded to :func:`is_stable`). checks: Consecutive equal observations required. timeout: Per-file wall-clock budget in seconds. max_workers: Thread cap (one thread per file, bounded) to avoid NFS overload. Defaults to 8. Returns: Two lists: files that reached stability, and files that did not (missing, still growing, or timed out). Raises: ValueError: If ``interval <= 0`` or ``checks < 2`` (validated per file by :func:`is_stable`). """ if interval <= 0: raise ValueError("interval must be > 0") if checks < 2: raise ValueError("checks must be >= 2") items = list(paths) if not items: return [], [] workers = max(1, min(max_workers, len(items))) # never more threads than files with ThreadPoolExecutor(max_workers=workers) as pool: results = list(pool.map(lambda p: (p, is_stable(p, interval, checks, timeout)), items)) stable = [p for p, ok in results if ok] unstable = [p for p, ok in results if not ok] _log.debug("wait_until_stable: %d stable, %d unstable", len(stable), len(unstable)) return stable, unstable