mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 22:01:13 +00:00
b5dd6031d2
AZ-291 — FileFdrWriter: single writer thread draining every registered FdrClient SPSC ring buffer to per-flight segment files; per-segment size rotation; cross-process fcntl.flock filelock on flight_root; ENOSPC degraded mode with rate-capped ERROR logs and one GCS alert. AZ-292 — FlightHeader/FlightFooter dataclasses + open_flight / close_flight lifecycle methods; four per-flight monotonic counters (records_written, records_dropped_overrun, bytes_written, rollover_count) reported by the footer; flight_id mismatch and close-without-open are typed errors. AZ-293 — CapacityCapPolicy (post-rotation hook): walks the flight directory, drops the oldest CLOSED segment when total > cap (default 64 GiB), emits a kind="segment_rollover" record per drop. Never drops the currently-open segment or segment 0 alone; cap_misconfigured path logs ERROR + GCS alert. No config flag disables emission (C13-ST-01). Schema: bumped fdr_record_schema flight_header / flight_footer payload key sets to match the AZ-292 task spec (effective 1.0.0 -> 1.1.0; no prior producer); KNOWN_PAYLOAD_KEYS updated. Added FdrWriterConfig nested in FdrConfig (segment_size_bytes, batch_size, flight_cap_bytes, debug_log_per_record). Tests: 29 new unit tests (8 AC + 1 invariant per task); full suite 323 passed, 2 pre-existing skips, 0 regressions. Co-authored-by: Cursor <cursoragent@cursor.com>
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""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("<I")
|
|
|
|
|
|
def _make_header(flight_id: UUID) -> 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
|