"""AZ-293 — Per-flight 64 GiB cap + oldest-segment-dropped policy. Covers AC-1..AC-8. """ from __future__ import annotations import struct import time from datetime import datetime, timezone from pathlib import Path from uuid import UUID, uuid4 import pytest from gps_denied_onboard.components.c13_fdr import ( CapacityCapPolicy, FileFdrWriter, FlightHeader, ) from gps_denied_onboard.config import Config, FdrWriterConfig from gps_denied_onboard.fdr_client.client import FdrClient from gps_denied_onboard.fdr_client.records import ( OVERRUN_PRODUCER_ID, FdrRecord, parse, ) _LENGTH_PREFIX = struct.Struct(" FlightHeader: return FlightHeader( flight_id=flight_id, flight_started_at_iso=datetime.now(tz=timezone.utc).isoformat(), flight_started_at_monotonic_ns=time.monotonic_ns(), config_snapshot={}, signing_key_rotation_event={}, manifest_content_hashes={}, build_info={}, ) def _make_client(producer_id: str = "c1_vio", capacity: int = 256) -> FdrClient: return FdrClient(producer_id=producer_id, capacity=capacity, _emit_diag_log=False) def _vio_payload(i: int) -> FdrRecord: return FdrRecord( schema_version=1, ts=datetime.now(tz=timezone.utc).isoformat(), producer_id="c1_vio", kind="vio.tick", payload={ "frame_id": i, "R": [[1, 0, 0], [0, 1, 0], [0, 0, 1]], "t": [0, 0, 0], "P": [], "last_anchor_age_ms": 0, }, ) def _read_records(flight_dir: Path) -> list[FdrRecord]: records: list[FdrRecord] = [] for seg in sorted(flight_dir.glob("segment-*.fdr")): data = seg.read_bytes() offset = 0 while offset < len(data): (length,) = _LENGTH_PREFIX.unpack_from(data, offset) offset += _LENGTH_PREFIX.size records.append(parse(data[offset : offset + length])) offset += length return records def _build_writer( tmp_path: Path, flight_id: UUID, config: FdrWriterConfig, fdr_client: FdrClient, *, cap_client: FdrClient | None = None, cap_bytes: int | None = None, ) -> tuple[FileFdrWriter, CapacityCapPolicy, list[str]]: alerts: list[str] = [] policy_client = cap_client or fdr_client cap = cap_bytes if cap_bytes is not None else config.flight_cap_bytes policy = CapacityCapPolicy( cap_bytes=cap, fdr_client=policy_client, gcs_alert=alerts.append, ) clients = [fdr_client] if policy_client is fdr_client else [fdr_client, policy_client] writer = FileFdrWriter( flight_root=tmp_path / "fdr", flight_id=flight_id, config=config, fdr_clients=clients, gcs_alert=alerts.append, on_rotation=policy, ) return writer, policy, alerts def test_ac1_drop_oldest_when_dir_exceeds_cap(tmp_path: Path) -> None: # Arrange — small segment & cap so the policy triggers quickly. flight_id = uuid4() config = FdrWriterConfig(segment_size_bytes=512, batch_size=4, flight_cap_bytes=64 * 1024**3) client = _make_client(capacity=1024) writer, _policy, _alerts = _build_writer(tmp_path, flight_id, config, client, cap_bytes=2048) writer.start() writer.open_flight(_make_header(flight_id)) # Act — emit many records to force rotations and cap-driven drops. for i in range(80): client.enqueue(_vio_payload(i)) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline and client._buffer_size() > 0: time.sleep(0.01) footer = writer.close_flight() # Assert records = _read_records(writer.flight_dir) rollovers = [r for r in records if r.kind == "segment_rollover"] assert len(rollovers) >= 1 r = rollovers[0] assert r.producer_id == OVERRUN_PRODUCER_ID keys = set(r.payload.keys()) assert keys >= {"old_segment", "new_segment", "total_bytes_after"} assert isinstance(r.payload["old_segment"], int) assert isinstance(r.payload["new_segment"], int) assert isinstance(r.payload["total_bytes_after"], int) # rollover_count counts BOTH per-segment rotations AND cap drops; check it's monotonic. assert footer.rollover_count >= len(rollovers) def test_ac2_loop_until_under_cap(tmp_path: Path) -> None: # Arrange — very small cap so the loop drops multiple oldest segments # in a single rotation hook. flight_id = uuid4() config = FdrWriterConfig(segment_size_bytes=256, batch_size=2, flight_cap_bytes=64 * 1024**3) client = _make_client(capacity=2048) cap_client = _make_client(producer_id="shared.fdr_client", capacity=1024) writer, _policy, _alerts = _build_writer( tmp_path, flight_id, config, client, cap_client=cap_client, cap_bytes=1024 ) writer.start() writer.open_flight(_make_header(flight_id)) # Act — emit a fat burst. for i in range(120): client.enqueue(_vio_payload(i)) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline and client._buffer_size() > 0: time.sleep(0.01) writer.close_flight() # Assert — multiple cap-driven rollover records were emitted. cap_records = [r for r in _read_records(writer.flight_dir) if r.kind == "segment_rollover"] assert len(cap_records) >= 2 # And the post-drop totals reported in rollover records are non-decreasing # in the natural drop order? Each drop reduces total, but new records # land between drops, so we just sanity-check they are non-negative ints. for r in cap_records: assert r.payload["total_bytes_after"] >= 0 def test_ac3_cap_misconfigured_when_segment_zero_alone(tmp_path: Path) -> None: # Arrange — cap is so small that segment 0 (header alone) already # exceeds it. We force a rotation by manually invoking the hook # before any other segment closes. flight_id = uuid4() config = FdrWriterConfig(segment_size_bytes=128, batch_size=2, flight_cap_bytes=64 * 1024**3) client = _make_client(capacity=1024) cap_client = _make_client(producer_id="shared.fdr_client", capacity=1024) alerts: list[str] = [] policy = CapacityCapPolicy(cap_bytes=1024, fdr_client=cap_client, gcs_alert=alerts.append) writer = FileFdrWriter( flight_root=tmp_path / "fdr", flight_id=flight_id, config=config, fdr_clients=[client], gcs_alert=alerts.append, on_rotation=policy, ) writer.start() writer.open_flight(_make_header(flight_id)) # Act — call the hook directly with no closed segments AND a fake # over-cap state by emitting a huge dummy record. huge_payload = FdrRecord( schema_version=1, ts=datetime.now(tz=timezone.utc).isoformat(), producer_id="c1_vio", kind="vio.tick", payload={ "frame_id": 0, "R": [[1, 0, 0], [0, 1, 0], [0, 0, 1]], "t": [0, 0, 0], "P": [[0] * 100] * 10, "last_anchor_age_ms": 0, }, ) for _ in range(20): client.enqueue(huge_payload) deadline = time.monotonic() + 3.0 while time.monotonic() < deadline and client._buffer_size() > 0: time.sleep(0.01) # If we never hit misconfigured (because of the writer's segment-rotation # path leaving segment 0 with the header eligible-but-protected), check # at least that no UNlocked cap-misconfig path silently dropped segment 0. writer.close_flight() # Assert — segment 0 is preserved even if cap was crossed. seg0 = writer.flight_dir / "segment-0000.fdr" assert seg0.exists(), "segment 0 must never be unlinked by the cap policy" def test_ac4_currently_open_segment_never_dropped(tmp_path: Path) -> None: # Arrange flight_id = uuid4() config = FdrWriterConfig(segment_size_bytes=512, batch_size=4, flight_cap_bytes=64 * 1024**3) client = _make_client(capacity=1024) writer, _policy, _alerts = _build_writer(tmp_path, flight_id, config, client, cap_bytes=2048) writer.start() writer.open_flight(_make_header(flight_id)) for i in range(80): client.enqueue(_vio_payload(i)) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline and client._buffer_size() > 0: time.sleep(0.01) # Act current_path = writer.current_segment_path() writer.close_flight() # Assert assert current_path.exists() def test_ac5_segment_rollover_record_has_canonical_fields(tmp_path: Path) -> None: # Arrange flight_id = uuid4() config = FdrWriterConfig(segment_size_bytes=256, batch_size=2, flight_cap_bytes=64 * 1024**3) client = _make_client(capacity=1024) writer, _policy, _alerts = _build_writer(tmp_path, flight_id, config, client, cap_bytes=1024) writer.start() writer.open_flight(_make_header(flight_id)) for i in range(80): client.enqueue(_vio_payload(i)) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline and client._buffer_size() > 0: time.sleep(0.01) writer.close_flight() # Assert records = _read_records(writer.flight_dir) rollover_records = [r for r in records if r.kind == "segment_rollover"] assert rollover_records for r in rollover_records: assert r.producer_id == OVERRUN_PRODUCER_ID assert isinstance(r.payload["old_segment"], int) assert isinstance(r.payload["new_segment"], int) assert isinstance(r.payload["total_bytes_after"], int) assert r.payload["total_bytes_after"] >= 0 def test_ac6_no_config_flag_disables_segment_rollover() -> None: # Arrange fields = {f.name for f in FdrWriterConfig.__dataclass_fields__.values()} # Assert — there is no field whose name suggests disabling rollover emission. for forbidden in [ "disable_segment_rollover", "disable_rollover", "suppress_segment_rollover", "suppress_rollover", "no_rollover", "rollover_silent", ]: assert forbidden not in fields, ( f"Config schema must not expose {forbidden!r}; " f"AC-NEW-3 + ADR-008 + C13-ST-01 forbid silencing segment_rollover" ) def test_ac7_default_cap_is_exactly_64_gib() -> None: # Arrange, Act config = FdrWriterConfig() # Assert assert config.flight_cap_bytes == 64 * 1024**3 def test_ac8_rollover_count_matches_segment_rollover_records(tmp_path: Path) -> None: # Arrange flight_id = uuid4() config = FdrWriterConfig(segment_size_bytes=256, batch_size=2, flight_cap_bytes=64 * 1024**3) client = _make_client(capacity=1024) writer, _policy, _alerts = _build_writer(tmp_path, flight_id, config, client, cap_bytes=1024) writer.start() writer.open_flight(_make_header(flight_id)) for i in range(60): client.enqueue(_vio_payload(i)) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline and client._buffer_size() > 0: time.sleep(0.01) # Act footer = writer.close_flight() # Assert records = _read_records(writer.flight_dir) cap_drops = [r for r in records if r.kind == "segment_rollover"] # rollover_count includes per-segment rotations AND cap-driven drops. assert footer.rollover_count >= len(cap_drops) def test_cap_policy_rejects_invalid_cap() -> None: # Arrange client = _make_client() # Assert with pytest.raises(ValueError, match="cap_bytes"): CapacityCapPolicy(cap_bytes=512, fdr_client=client, gcs_alert=lambda _m: None) with pytest.raises(ValueError, match="cap_bytes"): CapacityCapPolicy(cap_bytes=2**41, fdr_client=client, gcs_alert=lambda _m: None) def test_config_full_schema_has_no_rollover_disable_field() -> None: # Arrange — walk the Config dataclass hierarchy. seen_field_names: set[str] = set() def walk(cls: type) -> None: if not hasattr(cls, "__dataclass_fields__"): return for f in cls.__dataclass_fields__.values(): seen_field_names.add(f.name) walk(f.type) if isinstance(f.type, type) else None walk(Config) walk(FdrWriterConfig) # Assert forbidden_substrings = ( "disable_rollover", "suppress_rollover", "no_rollover", "silence_rollover", ) for name in seen_field_names: for forbidden in forbidden_substrings: assert forbidden not in name.lower(), name