184 lines
6.5 KiB
Python
184 lines
6.5 KiB
Python
"""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
|