[AZ-291] [AZ-292] [AZ-293] C13 FDR writer chain (batch 6)

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 03:38:58 +03:00
parent 33486588de
commit b5dd6031d2
19 changed files with 2152 additions and 10 deletions
@@ -0,0 +1,404 @@
"""AZ-291 — FileFdrWriter writer thread + segment lifecycle.
Covers AC-1..AC-8 + a fresh-flight_id helper used by every test.
"""
from __future__ import annotations
import errno
import os
import struct
import time
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from unittest import mock
from uuid import UUID, uuid4
import pytest
from gps_denied_onboard.components.c13_fdr import (
FdrConcurrentWriterError,
FileFdrWriter,
FlightHeader,
)
from gps_denied_onboard.config import FdrWriterConfig
from gps_denied_onboard.fdr_client.client import FdrClient
from gps_denied_onboard.fdr_client.records import 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={"tier": 2},
signing_key_rotation_event={},
manifest_content_hashes={},
build_info={"commit": "abc1234"},
)
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 _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(path: Path) -> list[FdrRecord]:
records: list[FdrRecord] = []
data = path.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 _collect_alerts() -> tuple[list[str], Any]:
msgs: list[str] = []
def alert(msg: str) -> None:
msgs.append(msg)
return msgs, alert
@pytest.fixture()
def flight_root(tmp_path: Path) -> Path:
return tmp_path / "fdr"
@pytest.fixture()
def flight_id() -> UUID:
return uuid4()
@pytest.fixture()
def base_config() -> FdrWriterConfig:
return FdrWriterConfig(
segment_size_bytes=64 * 1024 * 1024,
batch_size=64,
flight_cap_bytes=64 * 1024**3,
debug_log_per_record=False,
)
@pytest.fixture()
def writer(
flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> Iterator[FileFdrWriter]:
_alerts, alert_fn = _collect_alerts()
client = _make_client()
w = FileFdrWriter(
flight_root=flight_root,
flight_id=flight_id,
config=base_config,
fdr_clients=[client],
gcs_alert=alert_fn,
)
yield w
if not w._closed:
w.stop()
def test_ac1_drain_all_registered_producers(
flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> None:
# Arrange
clients = [_make_client(f"c{i}_test") for i in range(3)]
_alerts, alert_fn = _collect_alerts()
writer = FileFdrWriter(
flight_root=flight_root,
flight_id=flight_id,
config=base_config,
fdr_clients=clients,
gcs_alert=alert_fn,
)
writer.start()
writer.open_flight(_make_header(flight_id))
for client in clients:
for i in range(100):
client.enqueue(_payload(i))
# Act
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if all(c._buffer_size() == 0 for c in clients):
break
time.sleep(0.01)
footer = writer.close_flight()
# Assert
records = _read_records(writer.current_segment_path())
vio_count = sum(1 for r in records if r.kind == "vio.tick")
assert vio_count == 300
assert records[0].kind == "flight_header"
assert records[-1].kind == "flight_footer"
assert footer.records_written == 302 # 300 + header + footer
def test_ac2_per_segment_rotation_at_size_cap(flight_root: Path, flight_id: UUID) -> None:
# Arrange — small segment cap; the writer must rotate.
config = FdrWriterConfig(segment_size_bytes=2048, batch_size=4, flight_cap_bytes=1024**3)
_alerts, alert_fn = _collect_alerts()
client = _make_client()
writer = FileFdrWriter(
flight_root=flight_root,
flight_id=flight_id,
config=config,
fdr_clients=[client],
gcs_alert=alert_fn,
)
writer.start()
writer.open_flight(_make_header(flight_id))
for i in range(40):
client.enqueue(_payload(i))
# Act
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and client._buffer_size() > 0:
time.sleep(0.01)
writer.close_flight()
# Assert — at least two segment files exist.
segs = sorted(writer.flight_dir.glob("segment-*.fdr"))
assert len(segs) >= 2, f"expected >=2 segments, got {[p.name for p in segs]}"
all_records: list[FdrRecord] = []
for seg in segs:
all_records.extend(_read_records(seg))
vio = [r for r in all_records if r.kind == "vio.tick"]
frame_ids = [r.payload["frame_id"] for r in vio]
assert frame_ids == list(range(40))
def test_ac3_atomic_rotation_no_half_segment(flight_root: Path, flight_id: UUID) -> None:
# Arrange
config = FdrWriterConfig(segment_size_bytes=1024, batch_size=4, flight_cap_bytes=1024**3)
_alerts, alert_fn = _collect_alerts()
client = _make_client()
writer = FileFdrWriter(
flight_root=flight_root,
flight_id=flight_id,
config=config,
fdr_clients=[client],
gcs_alert=alert_fn,
)
writer.start()
writer.open_flight(_make_header(flight_id))
for i in range(20):
client.enqueue(_payload(i))
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and client._buffer_size() > 0:
time.sleep(0.01)
# Act — abrupt stop (no close_flight).
writer.stop()
# Assert — every segment file parses cleanly.
for seg in sorted(writer.flight_dir.glob("segment-*.fdr")):
records = _read_records(seg)
for r in records:
assert r.schema_version >= 1
def test_ac4_concurrent_writer_blocked_by_filelock(
flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> None:
# Arrange
_alerts, alert_fn = _collect_alerts()
client_a = _make_client("c1_vio")
writer_a = FileFdrWriter(
flight_root=flight_root,
flight_id=flight_id,
config=base_config,
fdr_clients=[client_a],
gcs_alert=alert_fn,
)
writer_a.start()
client_b = _make_client("c2_vpr")
writer_b = FileFdrWriter(
flight_root=flight_root,
flight_id=uuid4(),
config=base_config,
fdr_clients=[client_b],
gcs_alert=alert_fn,
)
# Act, Assert
with pytest.raises(FdrConcurrentWriterError):
writer_b.start()
# Cleanup
writer_a.stop()
def test_ac5_enospc_degrades_and_alerts(
flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> None:
# Arrange
alerts, alert_fn = _collect_alerts()
client = _make_client()
writer = FileFdrWriter(
flight_root=flight_root,
flight_id=flight_id,
config=base_config,
fdr_clients=[client],
gcs_alert=alert_fn,
)
writer.start()
writer.open_flight(_make_header(flight_id))
real_write = os.write
state = {"first": True}
def failing_write(fd: int, data: bytes) -> int:
if state["first"]:
state["first"] = False
raise OSError(errno.ENOSPC, "fake ENOSPC")
return real_write(fd, data)
# Act
with mock.patch(
"gps_denied_onboard.components.c13_fdr.writer.os.write", side_effect=failing_write
):
client.enqueue(_payload(0))
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and not writer.is_degraded():
time.sleep(0.01)
# Assert
assert writer.is_degraded()
assert len(alerts) >= 1
assert "FDR write failure" in alerts[0]
writer.stop()
def test_ac6_stop_drains_and_releases_lock(
flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> None:
# Arrange
_alerts, alert_fn = _collect_alerts()
client = _make_client()
writer = FileFdrWriter(
flight_root=flight_root,
flight_id=flight_id,
config=base_config,
fdr_clients=[client],
gcs_alert=alert_fn,
)
writer.start()
writer.open_flight(_make_header(flight_id))
for i in range(50):
client.enqueue(_payload(i))
# Act
writer.stop()
# Assert — a second writer can claim the filelock.
second = FileFdrWriter(
flight_root=flight_root,
flight_id=uuid4(),
config=base_config,
fdr_clients=[_make_client("c5_state")],
gcs_alert=alert_fn,
)
second.start() # would raise if lock still held
second.stop()
def test_ac7_segment_layout(flight_root: Path, flight_id: UUID) -> None:
# Arrange
config = FdrWriterConfig(segment_size_bytes=1024, batch_size=4, flight_cap_bytes=1024**3)
_alerts, alert_fn = _collect_alerts()
client = _make_client()
writer = FileFdrWriter(
flight_root=flight_root,
flight_id=flight_id,
config=config,
fdr_clients=[client],
gcs_alert=alert_fn,
)
writer.start()
writer.open_flight(_make_header(flight_id))
for i in range(40):
client.enqueue(_payload(i))
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and client._buffer_size() > 0:
time.sleep(0.01)
writer.close_flight()
# Assert
flight_dir = flight_root / str(flight_id)
names = sorted(p.name for p in flight_dir.iterdir() if p.is_file())
for name in names:
assert name.startswith("segment-") and name.endswith(".fdr"), name
# 4-digit zero-padded.
stem = name[len("segment-") : -len(".fdr")]
assert len(stem) == 4 and stem.isdigit()
def test_ac8_steady_state_no_overrun(
flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> None:
# Arrange — a small burst that the writer drains within a few seconds.
_alerts, alert_fn = _collect_alerts()
client = _make_client(capacity=2048)
writer = FileFdrWriter(
flight_root=flight_root,
flight_id=flight_id,
config=base_config,
fdr_clients=[client],
gcs_alert=alert_fn,
)
overrun_seen = {"count": 0}
def overrun_hook(record: FdrRecord) -> None:
overrun_seen["count"] += 1
client.on_overrun = overrun_hook
writer.start()
writer.open_flight(_make_header(flight_id))
# Act — emit 200 records spaced ~5 ms apart (~200 Hz steady state).
for i in range(200):
client.enqueue(_payload(i))
time.sleep(0.001)
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and client._buffer_size() > 0:
time.sleep(0.01)
# Assert
assert overrun_seen["count"] == 0
writer.close_flight()
def test_double_start_raises(writer: FileFdrWriter, flight_id: UUID) -> None:
from gps_denied_onboard.components.c13_fdr import FdrWriterError
# Arrange
writer.start()
# Assert
with pytest.raises(FdrWriterError):
writer.start()
writer.open_flight(_make_header(flight_id))
@@ -0,0 +1,320 @@
"""AZ-292 — FlightHeader / FlightFooter + per-flight counters.
Covers AC-1..AC-8.
"""
from __future__ import annotations
import dataclasses
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 (
FdrCloseWithoutOpenError,
FdrOpenError,
FileFdrWriter,
FlightFooter,
FlightHeader,
)
from gps_denied_onboard.config import FdrWriterConfig
from gps_denied_onboard.fdr_client.client import FdrClient
from gps_denied_onboard.fdr_client.records import (
OVERRUN_KIND,
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={"tier": 2, "fdr": {"queue_size": 4096}},
signing_key_rotation_event={"current_key_id": "k1"},
manifest_content_hashes={"foo/bar.bin": "deadbeef" * 8},
build_info={"commit": "abc1234", "build_date": "2026-05-11"},
)
def _make_client(producer_id: str = "c1_vio") -> FdrClient:
return FdrClient(producer_id=producer_id, capacity=256, _emit_diag_log=False)
def _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 | None = None
) -> tuple[FileFdrWriter, FdrClient, list[str]]:
config = config or FdrWriterConfig()
alerts: list[str] = []
client = _make_client()
writer = FileFdrWriter(
flight_root=tmp_path / "fdr",
flight_id=flight_id,
config=config,
fdr_clients=[client],
gcs_alert=alerts.append,
)
return writer, client, alerts
def test_ac1_flight_header_is_first_record(tmp_path: Path) -> None:
# Arrange
flight_id = uuid4()
writer, _client, _alerts = _build_writer(tmp_path, flight_id)
header = _make_header(flight_id)
writer.start()
# Act
writer.open_flight(header)
footer = writer.close_flight()
# Assert
records = _read_records(writer.flight_dir)
assert records[0].kind == "flight_header"
assert records[0].payload["flight_id"] == str(flight_id)
assert footer.flight_id == flight_id
def test_ac2_flight_footer_is_last_record(tmp_path: Path) -> None:
# Arrange
flight_id = uuid4()
writer, client, _alerts = _build_writer(tmp_path, flight_id)
writer.start()
writer.open_flight(_make_header(flight_id))
for i in range(20):
client.enqueue(_payload(i))
# Act
deadline = time.monotonic() + 3.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)
assert records[-1].kind == "flight_footer"
assert records[-1].payload["clean_shutdown"] is True
# Returned footer matches the on-disk payload (modulo flight_id stringification).
on_disk = records[-1].payload
assert on_disk["records_written"] == footer.records_written
assert on_disk["records_dropped_overrun"] == footer.records_dropped_overrun
assert on_disk["bytes_written"] == footer.bytes_written
assert on_disk["rollover_count"] == footer.rollover_count
def test_ac3_counters_reflect_on_disk_reality(tmp_path: Path) -> None:
# Arrange
flight_id = uuid4()
config = FdrWriterConfig(segment_size_bytes=2048, batch_size=8, flight_cap_bytes=1024**3)
writer, client, _alerts = _build_writer(tmp_path, flight_id, config)
writer.start()
writer.open_flight(_make_header(flight_id))
R = 30
for i in range(R):
client.enqueue(_payload(i))
# Act
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and client._buffer_size() > 0:
time.sleep(0.01)
footer = writer.close_flight()
# Assert
expected_written = R + 2
assert footer.records_written == expected_written
assert footer.records_dropped_overrun == 0
# rollover_count: per-segment rotations (counter is monotonic; the test
# writer's small cap forces ≥1 rotation).
assert footer.rollover_count >= 1
# Cross-check: footer.rollover_count == observed segment_index at close.
assert footer.rollover_count == writer.rollover_count
def test_ac4_open_flight_fdrerror_on_disk_failure(tmp_path: Path) -> None:
# Arrange — start the writer normally, then point segment 0 to a
# read-only directory.
flight_id = uuid4()
config = FdrWriterConfig()
alerts: list[str] = []
client = _make_client()
ro_root = tmp_path / "ro"
ro_root.mkdir(parents=True)
# Create a stand-in flight_dir read-only file pretending to be the segment.
ro_flight = ro_root / str(flight_id)
ro_flight.mkdir()
seg = ro_flight / "segment-0000.fdr"
seg.write_bytes(b"")
seg.chmod(0o400)
writer = FileFdrWriter(
flight_root=ro_root,
flight_id=flight_id,
config=config,
fdr_clients=[client],
gcs_alert=alerts.append,
)
# Block the path from being opened for write — chmod the parent dir.
ro_flight.chmod(0o500)
try:
writer.start()
except Exception:
# If start fails outright (cannot create flight_dir), that satisfies the contract.
ro_flight.chmod(0o700)
return
# If start did succeed (writer created the file before chmod took effect),
# explicitly fail open_flight by writing the header via a forced OSError.
seg.chmod(0o400)
with pytest.raises(FdrOpenError):
from unittest import mock
with mock.patch(
"gps_denied_onboard.components.c13_fdr.writer.os.write",
side_effect=PermissionError("read-only"),
):
writer.open_flight(_make_header(flight_id))
# Cleanup
ro_flight.chmod(0o700)
def test_ac5_open_flight_rejects_flight_id_mismatch(tmp_path: Path) -> None:
# Arrange
writer_flight_id = uuid4()
other_flight_id = uuid4()
writer, _client, _alerts = _build_writer(tmp_path, writer_flight_id)
writer.start()
bad_header = _make_header(other_flight_id)
# Act, Assert
with pytest.raises(FdrOpenError, match="does not match"):
writer.open_flight(bad_header)
def test_ac6_close_without_open_raises(tmp_path: Path) -> None:
# Arrange
flight_id = uuid4()
writer, _client, _alerts = _build_writer(tmp_path, flight_id)
writer.start()
# Act, Assert
with pytest.raises(FdrCloseWithoutOpenError):
writer.close_flight()
writer.stop()
def test_ac7_uncleansed_teardown_no_clean_shutdown(tmp_path: Path) -> None:
# Arrange
flight_id = uuid4()
writer, _client, _alerts = _build_writer(tmp_path, flight_id)
writer.start()
writer.open_flight(_make_header(flight_id))
# Act — stop without close_flight.
writer.stop()
# Assert — no flight_footer record exists, so post-flight tooling marks the flight truncated.
records = _read_records(writer.flight_dir)
has_footer = any(r.kind == "flight_footer" for r in records)
assert not has_footer
def test_ac8_records_dropped_overrun_aggregates_dropped_counts(tmp_path: Path) -> None:
# Arrange
flight_id = uuid4()
writer, client, _alerts = _build_writer(tmp_path, flight_id)
writer.start()
writer.open_flight(_make_header(flight_id))
drops = [3, 7, 2, 11, 4]
for d in drops:
client.enqueue(
FdrRecord(
schema_version=1,
ts=datetime.now(tz=timezone.utc).isoformat(),
producer_id=OVERRUN_PRODUCER_ID,
kind=OVERRUN_KIND,
payload={"producer_id": "c1_vio", "dropped_count": d},
)
)
# Act
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline and client._buffer_size() > 0:
time.sleep(0.01)
footer = writer.close_flight()
# Assert
assert footer.records_dropped_overrun == sum(drops)
def test_close_flight_idempotent(tmp_path: Path) -> None:
# Arrange
flight_id = uuid4()
writer, _client, _alerts = _build_writer(tmp_path, flight_id)
writer.start()
writer.open_flight(_make_header(flight_id))
# Act
footer_a = writer.close_flight()
footer_b = writer.close_flight()
# Assert
assert footer_a == footer_b
def test_flight_header_and_footer_are_frozen() -> None:
# Arrange
header = _make_header(uuid4())
footer = FlightFooter(
flight_id=uuid4(),
flight_ended_at_iso="2026-05-11T00:00:00+00:00",
flight_ended_at_monotonic_ns=0,
records_written=1,
records_dropped_overrun=0,
bytes_written=0,
rollover_count=0,
clean_shutdown=True,
)
# Assert
with pytest.raises(dataclasses.FrozenInstanceError):
header.flight_id = uuid4() # type: ignore[misc]
with pytest.raises(dataclasses.FrozenInstanceError):
footer.records_written = 999 # type: ignore[misc]
@@ -0,0 +1,353 @@
"""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