Snapshot track history deques before off-thread iteration - #26
Conversation
The five `Track.history` buffers became `deque(maxlen=...)` in #25. A deque raises `RuntimeError: deque mutated during iteration` when it is appended to while a Python-level loop walks it — something the plain lists tolerated. Only the frame worker appends, but `get_recent_detections` and `to_dict` are called from other threads (feed flush, analytics, admin state snapshots), so those two readers were raising in production and dropping the tick that hit the race. `list(deque)` is a single C call and therefore an atomic snapshot under the GIL, so both readers now snapshot each buffer before iterating: - `get_recent_detections`: snapshot measurements and timestamps back to back, then run the same `zip(reversed(...), reversed(...))` reverse scan. The zip is deliberately unchanged — the mutator appends timestamps before measurements, so a reader landing mid-append can see one extra timestamp and zip truncates to the shorter buffer, exactly as it did when these were lists. - `to_dict`: snapshot all four emitted buffers, then truncate to their common length so the diagnostic arrays stay aligned with each other. tracker.py's own readers are untouched: they run on the mutating thread. New `tests/test_history_snapshot.py` stresses both readers from the main thread for ~0.5 s while a second thread drives `update()`/`mark_missed()`. It fails on 5 of 5 runs without this change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Claude finished @jehanazad's task in 3m 52s —— View job Reviewing PR #26
ReviewCorrectness of the core fix: sound.
One theoretical edge case worth being aware of (not a blocker, given this is diagnostic-only output): the four
Scope check: confirmed via grep that Style note: the project's Tests: Note: I wasn't able to execute Overall: a well-targeted, correctly-reasoned concurrency fix with good test coverage. No blocking issues found. |
Summary
#25 turned the five
Track.historybuffers from lists intodeque(maxlen=TRACK_HISTORY_MAX). AdequeraisesRuntimeError: deque mutated during iterationif it is appended to while a Python-level loop walks it; alisttolerated exactly that. Only the frame worker appends to a given node's tracker, but twoTrackreaders are called from other threads, and both were raising in production — each failure dropping one consumer tick.list(some_deque)is a single C call, so under the GIL it cannot be interleaved by another Python thread: it is an atomic snapshot. Both off-thread readers now snapshot before iterating.Changes
retina_tracker/track.pyget_recent_detections— snapshothistory["measurements"]andhistory["timestamps"]back to back withlist(), then run the identicalzip(reversed(...), reversed(...))reverse scan with the same early exit. Same output.The
zipis deliberately left alone: the mutator appends timestamps → frames → states → measurements, so a reader that lands mid-append can always see one more timestamp than measurement. That was already true when these were lists, andziptruncating to the shorter buffer is the pre-existing behaviour.to_dict— snapshot the four emitted buffers (timestamps,states,measurements,state_status) before the comprehensions, then truncate to their common length so the diagnostic arrays stay aligned with one another.tracker.py's own history readers are not changed: they run on the mutating thread and cannot race.Test coverage
New
tests/test_history_snapshot.py::test_readers_survive_concurrent_appends: a background thread drivestrack.update()/track.mark_missed()in a tight loop for ~0.5 s while the main thread repeatedly callsget_recent_detections(n=5)andto_dict(), asserting no exception, well-formed detection dicts (timestamp/delay/doppler/snr), and mutually alignedto_dicthistory arrays. ~0.6 s wall.RuntimeError: deque mutated during iteration).Review notes
list()copy per call of each reader, bounded byTRACK_HISTORY_MAX.to_dict's new truncation can drop the newest sample from a dump when a reader lands mid-append. That is the intended trade for aligned arrays in a diagnostic payload.🤖 Generated with Claude Code