"""Bounded in-process HUX-10 retention scheduling for one tenant pod.""" from __future__ import annotations import hashlib import threading from collections.abc import Callable, Mapping from datetime import datetime, timezone from pathlib import Path from typing import Any from hux import privacy from hux.errors import Unauthorized from hux.identity import SLOT_RE, Identity, _read_subject_binding from hux.store import TenantStore DAY_SECONDS = 86_400.0 MAX_JITTER_SECONDS = 3_600 MAX_BACKOFF_SECONDS = 900.0 class BindingUnavailable(ValueError): """The projected subject binding is absent or unsafe.""" def subject_binding(environ: Mapping[str, str]) -> Identity: """Read the canonical edge-published binding and combine it with the configured tenant slot.""" slot = environ.get("HUX_TENANT_SLOT", "") raw_path = environ.get("HUX_SUBJECT_BINDING_FILE", "") if not SLOT_RE.fullmatch(slot) or not raw_path: raise BindingUnavailable("binding configuration unavailable") try: subject = _read_subject_binding(Path(raw_path)) except Unauthorized as error: raise BindingUnavailable("binding file unavailable") from error if subject is None: raise BindingUnavailable("binding file unavailable") return Identity(slot, subject, "worker", "worker") class RetentionScheduler: """Run privacy retention without leaving the API process or tenant lock domain.""" def __init__( self, router: Any, *, clock: Callable[[], datetime] | None = None, runner: Callable[..., dict[str, Any]] | None = None, waiter: Callable[[float], bool] | None = None, daily_seconds: float = DAY_SECONDS, ) -> None: self.router = router self.clock = clock or (lambda: datetime.now(timezone.utc)) self.runner = runner or privacy.run_retention self.daily_seconds = max(1.0, daily_seconds) self._stop = threading.Event() self.waiter = waiter or self._stop.wait self._thread: threading.Thread | None = None self._lock = threading.Lock() self._health: dict[str, Any] = { "enabled": bool(router.flags.enabled("HUX-10")), "status": "starting" if router.flags.enabled("HUX-10") else "disabled", "last_run": None, "last_success": None, "errors": 0, } def health(self) -> dict[str, Any]: """Return bounded, credential-free scheduler evidence.""" with self._lock: return dict(self._health) def _set(self, **changes: Any) -> None: with self._lock: self._health.update(changes) def _jitter(self, identity: Identity) -> float: try: cap = int(self.router.environ.get("HUX_RETENTION_JITTER_SECONDS", "900")) except ValueError: cap = 900 cap = min(MAX_JITTER_SECONDS, max(0, cap)) if cap == 0: return 0.0 digest = hashlib.sha256(f"{identity.tenant_slot}\0{identity.subject}".encode()).digest() return float(int.from_bytes(digest[:4], "big") % (cap + 1)) @staticmethod def _backoff(failures: int) -> float: return min(MAX_BACKOFF_SECONDS, float(5 * (2 ** min(max(failures - 1, 0), 7)))) def run(self) -> None: """Wait for a binding, run stale startup work, then run once per day.""" if not self.health()["enabled"]: return failures = 0 first = True while not self._stop.is_set(): try: identity = subject_binding(self.router.environ) tenant = TenantStore(self.router.data_root, identity) now = self.clock() ran = not first or privacy.audit_stale(tenant, now) if ran: self.runner(tenant, now, identity) stamp = now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") self._set(last_run=stamp, last_success=stamp) failures = 0 first = False self._set(status="healthy", errors=0) if self.waiter(self.daily_seconds + self._jitter(identity)): break except BindingUnavailable: failures += 1 self._set(status="waiting_for_binding", errors=failures) if self.waiter(self._backoff(failures)): break except Exception: # noqa: BLE001 - background failure cannot take down the API failures += 1 self._set(status="error", errors=failures) if self.waiter(self._backoff(failures)): break self._set(status="stopped") def start(self) -> None: """Start one daemon thread when HUX-10 is enabled.""" if not self.health()["enabled"] or self._thread is not None: return self._thread = threading.Thread(target=self.run, name="hux-retention", daemon=True) self._thread.start() def stop(self) -> None: """Wake and join the scheduler during server shutdown.""" self._stop.set() if self._thread is not None and self._thread is not threading.current_thread(): self._thread.join(timeout=2.0) self._set(status="stopped")