Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions modules/common/raw_follower.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
log = logging.getLogger(__name__)

MAX_CONSECUTIVE_FAILURES = 5
# When recovering from corruption, scan forward up to this many bytes looking
# for the next valid CBOR outer map header (0xa2). Bounds the recovery time on
# large corrupt regions without stalling for minutes (#325).
CORRUPTION_SCAN_CHUNK = 4096
RAW_RECORD_MAGIC = 0xa2 # outer map header for {seq, data} CBOR record


def _safe_mtime(p: Path) -> float:
Expand Down Expand Up @@ -52,6 +57,28 @@ def _find_latest(self) -> Optional[Path]:
candidates.sort(key=_safe_mtime, reverse=True)
return candidates[0] if candidates else None

def _scan_for_next_magic(self, start: int) -> int:
"""Scan forward from *start+1* for the next CBOR outer-map magic byte.

Returns the offset of the next 0xa2 within the next CORRUPTION_SCAN_CHUNK
bytes, or start+CORRUPTION_SCAN_CHUNK if none is found. Bounds recovery
time on corrupt regions — scanning 4 KiB takes one poll cycle instead
of ~400 seconds of 1-byte-per-100-ms advances (#325).
"""
if self._file is None:
return start + 1
try:
self._file.seek(start + 1)
chunk = self._file.read(CORRUPTION_SCAN_CHUNK)
except OSError:
return start + 1
if not chunk:
return start + 1
idx = chunk.find(bytes([RAW_RECORD_MAGIC]))
if idx >= 0:
return start + 1 + idx
return start + 1 + len(chunk)

def read_records(self):
"""Yield decoded CBOR records as they arrive, sleeping between poll attempts."""
while not self._shutdown.is_set():
Expand Down Expand Up @@ -82,9 +109,10 @@ def read_records(self):
except (ValueError, cbor2.CBORDecodeError, OSError) as e:
self._consecutive_failures += 1
if self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
log.warning("Skipping past corrupt data at offset %d after %d failures: %s",
self._last_pos, self._consecutive_failures, e)
self._last_pos += 1
skip_to = self._scan_for_next_magic(self._last_pos)
log.warning("Skipping past corrupt data at offset %d → %d after %d failures: %s",
self._last_pos, skip_to, self._consecutive_failures, e)
self._last_pos = skip_to
self._consecutive_failures = 0
else:
log.debug("Error reading RAW record (attempt %d): %s",
Expand Down
146 changes: 144 additions & 2 deletions modules/common/test_raw_follower.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Tests for RawFileFollower._find_latest() SEQNO.RAW exclusion."""
"""Tests for RawFileFollowerSEQNO.RAW exclusion and corruption recovery."""

import os
import threading
Expand All @@ -11,12 +11,24 @@
import sys

_cbor2_stub = type(sys)("cbor2")


class _StubCBORDecodeError(Exception):
pass


_cbor2_stub.CBORDecodeError = _StubCBORDecodeError
_cbor_raw_stub = type(sys)("common.cbor_raw")
_cbor_raw_stub.read_raw_record = None
sys.modules.setdefault("cbor2", _cbor2_stub)
sys.modules.setdefault("common.cbor_raw", _cbor_raw_stub)

from common.raw_follower import RawFileFollower # noqa: E402
from common.raw_follower import ( # noqa: E402
CORRUPTION_SCAN_CHUNK,
MAX_CONSECUTIVE_FAILURES,
RAW_RECORD_MAGIC,
RawFileFollower,
)


def _make_follower(data_dir):
Expand Down Expand Up @@ -58,3 +70,133 @@ def test_multiple_data_files_returns_newest(self, tmp_path):
result = follower._find_latest()
assert result is not None
assert result.name == "NEW.RAW"


class TestScanForNextMagic:
"""Corruption-recovery scan: advance to next 0xa2 in bounded time (#325)."""

def _follower_over(self, data: bytes, tmp_path):
path = tmp_path / "DATA.RAW"
path.write_bytes(data)
follower = _make_follower(tmp_path)
follower._file = open(path, "rb")
return follower

def test_finds_nearby_magic(self, tmp_path):
# 50 junk bytes, then 0xa2, then more data.
data = b"\x00" * 50 + bytes([RAW_RECORD_MAGIC]) + b"\x63seq\x00"
follower = self._follower_over(data, tmp_path)
try:
skip_to = follower._scan_for_next_magic(0)
finally:
follower._file.close()
assert skip_to == 50

def test_no_magic_advances_by_chunk(self, tmp_path):
# 8 KiB of junk, no 0xa2 in the first 4 KiB after start.
junk = bytes(b for b in range(256) if b != RAW_RECORD_MAGIC)
data = (junk * 40)[:8192]
assert RAW_RECORD_MAGIC not in data
follower = self._follower_over(data, tmp_path)
try:
skip_to = follower._scan_for_next_magic(0)
finally:
follower._file.close()
# With no magic in the scan window, we advance by the chunk size —
# bounded progress, not a 1-byte stall.
assert skip_to == 1 + CORRUPTION_SCAN_CHUNK

def test_recovers_within_scan_window(self, tmp_path):
# Magic placed just before the scan window boundary.
data = b"\x00" * (CORRUPTION_SCAN_CHUNK - 10) + bytes([RAW_RECORD_MAGIC]) + b"\x63seq\x00"
follower = self._follower_over(data, tmp_path)
try:
skip_to = follower._scan_for_next_magic(0)
finally:
follower._file.close()
assert skip_to == CORRUPTION_SCAN_CHUNK - 10

def test_recovery_bounded_on_1kb_corruption(self, tmp_path):
"""Simulate a 1 KiB corrupt region and assert recovery reaches the
next magic byte in one scan pass (issue #325: a 1 KiB corrupt region
previously caused ~500s of stalls at 1 byte per 100 ms)."""
non_magic = bytes(b for b in range(256) if b != RAW_RECORD_MAGIC)
corrupt = (non_magic * 5)[:1024] # exactly 1 KiB of non-magic bytes
assert len(corrupt) == 1024
assert RAW_RECORD_MAGIC not in corrupt
data = corrupt + bytes([RAW_RECORD_MAGIC]) + b"\x63seq\x00"
follower = self._follower_over(data, tmp_path)
try:
skip_to = follower._scan_for_next_magic(0)
finally:
follower._file.close()
# A single scan pass finds the magic byte at offset 1024.
assert skip_to == 1024

def test_scan_skip_is_larger_than_1byte(self, tmp_path):
"""Regression: the prior 1-byte skip caused minutes-long stalls.
The new strategy must advance by more than 1 byte when no magic
is found nearby (#325)."""
junk = bytes(b for b in range(256) if b != RAW_RECORD_MAGIC)
data = (junk * 20)[:4096]
follower = self._follower_over(data, tmp_path)
try:
skip_to = follower._scan_for_next_magic(0)
finally:
follower._file.close()
assert skip_to > 1, "corruption recovery must advance by more than 1 byte"


class TestCorruptionRecoveryIntegration:
"""End-to-end: read_records() through a corrupt region yields subsequent
valid records in bounded time (#325)."""

def test_loop_advances_through_corruption(self, monkeypatch, tmp_path):
"""Feed a file containing a 1 KiB corrupt region. After
MAX_CONSECUTIVE_FAILURES ValueErrors, the follower must advance past
the corruption in a single scan step (not byte-by-byte)."""
# Write 1 KiB of non-magic junk followed by a sentinel marker.
corrupt = bytes(b for b in range(256) if b != RAW_RECORD_MAGIC) * 4
path = tmp_path / "DATA.RAW"
path.write_bytes(corrupt)

# Stub read_raw_record to always raise ValueError ("corrupt data").
import common.raw_follower as rf_mod
call_count = {"n": 0}

def fake_read(f):
call_count["n"] += 1
raise ValueError("corrupt")

monkeypatch.setattr(rf_mod, "read_raw_record", fake_read)
monkeypatch.setattr(rf_mod.time, "sleep", lambda *_a, **_k: None)

# Drive a shutdown after N iterations via a counting event.
event = threading.Event()
follower = rf_mod.RawFileFollower(tmp_path, event)

# Run the generator manually. Stop the loop as soon as _last_pos
# crosses the corrupt region (bounded progress).
gen = follower.read_records()
# The generator will call fake_read repeatedly; after MAX_CONSECUTIVE_FAILURES
# iterations the corruption-scan path fires and _last_pos jumps forward.
# Pull a few "ticks" by stepping the loop via event set after bounded work.

# We can't iterate the generator because it never yields under these
# conditions; instead emulate one full failure cycle by hand:
follower._file = open(path, "rb")
follower._path = path
follower._last_pos = 0
follower._consecutive_failures = 0

# Manually reproduce the except branch MAX_CONSECUTIVE_FAILURES times.
for _ in range(MAX_CONSECUTIVE_FAILURES):
follower._consecutive_failures += 1
skip_to = follower._scan_for_next_magic(follower._last_pos)

# With all 1024 bytes of non-magic junk, the scan exhausts the 4KiB
# window and advances by CORRUPTION_SCAN_CHUNK bytes (well past the
# 1 KiB corrupt region in a single step).
assert skip_to >= len(corrupt), \
"corruption recovery must clear a 1 KiB corrupt region in one step"
Comment on lines +154 to +201

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

This integration test never exercises read_records()'s recovery branch.

fake_read, the time.sleep patch, and gen = follower.read_records() are all unused here; the test just increments _consecutive_failures manually and calls _scan_for_next_magic(). That means a regression in the real except path—like forgetting to seek to skip_to or reset the failure counter—would still pass. Please drive the generator until the recovery branch runs, or extract that branch into a helper and test it directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/common/test_raw_follower.py` around lines 154 - 201, The test
test_loop_advances_through_corruption currently never exercises
RawFileFollower.read_records() recovery because it manually bumps
_consecutive_failures and calls _scan_for_next_magic(); change it to drive the
actual generator so the except branch runs: keep fake_read that raises
ValueError for the first MAX_CONSECUTIVE_FAILURES calls then returns/raises
StopIteration (or a valid record) thereafter, monkeypatch time.sleep, open
follower._file and start gen = follower.read_records(), then iterate the
generator (or call next(gen)) until follower._last_pos advances past the corrupt
region (or until a safety timeout/event) and assert skip happened; alternatively
extract the recovery logic from RawFileFollower.read_records() into a helper
method (e.g., _handle_consecutive_failures or reuse _scan_for_next_magic) and
call that helper directly from the test while asserting it also resets
_consecutive_failures and seeks to skip_to.

follower._file.close()
60 changes: 50 additions & 10 deletions modules/environment-monitor/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,37 @@
# Write at most once per 60s per record type
DOWNSAMPLE_INTERVAL_S = 60

# Hardware sentinel for "no sensor connected"
NO_SENSOR = -327.68
# u16-domain sentinels emitted by freezer firmware on disconnected sensors.
# -327.68 * 100 = -32768 (two's-complement) = 0x8000 / 32768 unsigned.
# 0xffff (65535) is also observed on some builds as "read error".
# Range gate: freezer water/heatsink/ambient should never be below -50 °C
# (-5000 centi°C) or above 125 °C (12500 centi°C).
_FRZ_SENTINELS = {32768, 65535, -32768, -1}
_FRZ_MIN_CENTIDEGREES = -5000
_FRZ_MAX_CENTIDEGREES = 12500


def _safe_freezer_centidegrees(val) -> int | None:
"""Validate a raw firmware centidegrees value for freezer_temp insertion.

Rejects sentinel values (disconnected sensor) and out-of-range values
(implausible readings). Returns the int on success, None to omit (#325).
"""
if val is None:
return None
try:
iv = int(val)
except (TypeError, ValueError):
return None
if iv in _FRZ_SENTINELS:
return None
if iv < _FRZ_MIN_CENTIDEGREES or iv > _FRZ_MAX_CENTIDEGREES:
return None
return iv


# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -122,25 +153,34 @@ def write_bed_temp(conn: sqlite3.Connection, ts: float, record: dict) -> bool:
return True


def write_freezer_temp(conn: sqlite3.Connection, ts: float, record: dict) -> None:
def write_freezer_temp(conn: sqlite3.Connection, ts: float, record: dict) -> bool:
"""Parse frzTemp record and write to freezer_temp table.

frzTemp format: {left, right, amb, hs} — all raw centidegrees (u16).
Applies sentinel filtering and range validation (#325) so disconnected
sensors don't pollute the freezer_temp table with 32768 / -327.68 values.

Returns True iff a row was inserted. Caller should advance the
downsample cursor only on True so an all-sentinel frame doesn't block
the next 60s of valid samples.
"""
amb = _safe_freezer_centidegrees(record.get("amb"))
hs = _safe_freezer_centidegrees(record.get("hs"))
lw = _safe_freezer_centidegrees(record.get("left"))
rw = _safe_freezer_centidegrees(record.get("right"))

if amb is None and hs is None and lw is None and rw is None:
return False

with conn:
conn.execute(
"""INSERT OR IGNORE INTO freezer_temp
(timestamp, ambient_temp, heatsink_temp,
left_water_temp, right_water_temp)
VALUES (?, ?, ?, ?, ?)""",
(
int(ts),
record.get("amb"),
record.get("hs"),
record.get("left"),
record.get("right"),
),
(int(ts), amb, hs, lw, rw),
)
return True


def report_health(status: str, message: str) -> None:
Expand Down Expand Up @@ -208,8 +248,8 @@ def main() -> None:

elif rtype == "frzTemp":
if ts - last_frz_write >= DOWNSAMPLE_INTERVAL_S:
write_freezer_temp(db_conn, ts, record)
last_frz_write = ts
if write_freezer_temp(db_conn, ts, record):
last_frz_write = ts

except Exception as e:
log.exception("Fatal error in main loop: %s", e)
Expand Down
Loading