"""HUX-10 same-process retention scheduler lifecycle and failure bounds.""" from __future__ import annotations import sys import threading from datetime import datetime, timezone from http.client import HTTPConnection from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation" if str(FOUNDATION) not in sys.path: sys.path.insert(0, str(FOUNDATION)) from hux import identity, privacy from hux.errors import Unauthorized from hux.http import serve from hux.retention_scheduler import RetentionScheduler, subject_binding from hux.server import build_router from hux.store import TenantStore SUBJECT = "usr_0123456789abcdef" NOW = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc) FLAGS = "hux.foundation,hux.privacy" def binding(tmp_path: Path, subject: str = SUBJECT, mode: int = 0o440) -> Path: path = tmp_path / "subject-binding" path.write_text(subject + "\n") path.chmod(mode) return path def router_for(tmp_path: Path, path: Path | None, flags: str = FLAGS): environ = {"HUX_FLAGS": flags, "HUX_TENANT_SLOT": "slot-3"} if path is not None: environ["HUX_SUBJECT_BINDING_FILE"] = str(path) return build_router(tmp_path / "data", environ) def test_scheduler_runs_stale_startup_then_stops_from_daily_wait(tmp_path): router = router_for(tmp_path, binding(tmp_path)) calls = [] waits = [] def run(store, now, who): calls.append((store.identity, now, who)) return privacy.run_retention(store, now, who) scheduler = RetentionScheduler( router, clock=lambda: NOW, runner=run, waiter=lambda delay: waits.append(delay) or True, daily_seconds=100 ) scheduler.run() assert len(calls) == 1 and calls[0][0] == calls[0][2] assert calls[0][0] == identity.Identity("slot-3", SUBJECT, "worker", "worker") assert waits == [100 + scheduler._jitter(calls[0][0])] assert scheduler.health() == { "enabled": True, "status": "stopped", "last_run": "2026-08-24T12:00:00Z", "last_success": "2026-08-24T12:00:00Z", "errors": 0, } def test_fresh_startup_skips_work_but_next_daily_cycle_runs(tmp_path): path = binding(tmp_path) router = router_for(tmp_path, path) who = subject_binding(router.environ) privacy.run_retention(TenantStore(router.data_root, who), NOW, who) calls = [] waits = [] def wait(delay): waits.append(delay) return len(waits) == 2 scheduler = RetentionScheduler(router, clock=lambda: NOW, runner=lambda *args: calls.append(args) or {}, waiter=wait, daily_seconds=20) scheduler.run() assert len(calls) == 1 and len(waits) == 2 assert all(delay == 20 + scheduler._jitter(who) for delay in waits) def test_binding_and_job_errors_back_off_and_recover_without_api_failure(tmp_path): path = tmp_path / "subject-binding" router = router_for(tmp_path, path) waits = [] attempts = [] def wait(delay): waits.append(delay) if len(waits) == 1: binding(tmp_path) return len(waits) == 3 def run(*args): attempts.append(args) if len(attempts) == 1: raise RuntimeError("sensitive provider-looking detail") return {} scheduler = RetentionScheduler(router, clock=lambda: NOW, runner=run, waiter=wait, daily_seconds=30) scheduler.run() assert len(attempts) == 2 assert waits[:2] == [5.0, 10.0] assert waits[2] == 30 + scheduler._jitter(subject_binding(router.environ)) health = scheduler.health() assert health["status"] == "stopped" and health["errors"] == 0 assert "sensitive" not in str(health) def test_clock_error_is_isolated_and_retried(tmp_path): router = router_for(tmp_path, binding(tmp_path)) clocks = [RuntimeError("clock detail"), NOW] waits = [] def clock(): value = clocks.pop(0) if isinstance(value, Exception): raise value return value scheduler = RetentionScheduler(router, clock=clock, runner=lambda *args: {}, waiter=lambda delay: waits.append(delay) or len(waits) > 1) scheduler.run() assert waits[0] == 5.0 and len(waits) == 2 assert scheduler.health()["last_success"] == "2026-08-24T12:00:00Z" def test_server_close_wakes_and_joins_scheduler_and_health_exposes_state(tmp_path): router = router_for(tmp_path, None) server = serve(router, "127.0.0.1", 0) serving = threading.Thread(target=server.serve_forever, daemon=True) serving.start() conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=5) conn.request("GET", "/healthz") body = __import__("json").loads(conn.getresponse().read()) assert body["status"] == "ok" and body["retention"]["enabled"] is True assert body["retention"]["status"] in {"starting", "waiting_for_binding"} server.shutdown() server.server_close() assert router.retention_scheduler.health()["status"] == "stopped" assert not router.retention_scheduler._thread.is_alive() def test_scheduler_is_off_unless_hux10_dependency_chain_is_enabled(tmp_path): router = router_for(tmp_path, binding(tmp_path), "hux.foundation") scheduler = RetentionScheduler(router) scheduler.start() assert scheduler.health()["status"] == "disabled" and scheduler._thread is None scheduler.run() def test_binding_reader_and_jitter_fail_closed_at_edge_cases(tmp_path, monkeypatch): path = binding(tmp_path, mode=0o600) router = router_for(tmp_path, path) with pytest.raises(Exception, match="binding file unavailable"): subject_binding(router.environ) path.chmod(0o440) router.environ["HUX_RETENTION_JITTER_SECONDS"] = "bad" scheduler = RetentionScheduler(router) who = subject_binding(router.environ) assert 0 <= scheduler._jitter(who) <= 900 router.environ["HUX_RETENTION_JITTER_SECONDS"] = "0" assert scheduler._jitter(who) == 0 monkeypatch.setattr( "hux.retention_scheduler._read_subject_binding", lambda unused: (_ for _ in ()).throw(Unauthorized("unsafe internal detail")), ) with pytest.raises(Exception, match="binding file unavailable") as denied: subject_binding(router.environ) assert "unsafe internal" not in str(denied.value) def test_scheduler_stop_is_idempotent_and_backoff_is_bounded(tmp_path): scheduler = RetentionScheduler(router_for(tmp_path, binding(tmp_path))) scheduler._thread = threading.current_thread() scheduler.stop() scheduler.stop() assert scheduler.health()["status"] == "stopped" assert scheduler._backoff(1000) == 640.0