Snapshot track history deques before off-thread iteration - #317
Conversation
`Track.history["measurements"]` became a bounded deque in retina-tracker #25. `build_combined_aircraft_json` runs on the aircraft-flush executor, not on the frame worker that owns the tracker, and it reverse-scans that buffer for the newest associated measurement. A deque raises `RuntimeError: deque mutated during iteration` when the worker appends mid-scan — the plain list it replaced tolerated exactly that — and each failure aborted a whole 1 s feed tick, which is what `task_error_counts {'aircraft_flush': N}` was recording. `list(dq)` is one C call, so under the GIL it is an atomic snapshot. The reverse scan now runs off that copy; nothing else about the arc path changes. Audit of the other off-worker readers of track history in backend/: analytics_refresh.py (get_recent_detections) and any Track.to_dict caller are covered by the library-side fix; passive_radar.py and frame_processor.py run on the frame worker itself and are left alone. New backend/tests/test_feed_history_snapshot.py drives the arc builder in a loop for ~0.6 s while a second thread appends to a real deque history (including the None entries the coast path writes). It reproduced the live traceback on 3 of 5 runs without this change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Picks up retina-tracker#26: `Track.get_recent_detections` and `Track.to_dict` snapshot their history deques with `list()` before iterating, so the analytics executor and the state-snapshot readers stop racing the frame worker. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
CI's ruff resolves retina_tracker as a third-party package and wants it in its own block ahead of the first-party imports. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Claude finished @jehanazad's task in 1m 52s —— View job Claude's ReviewTasks
SummaryThis is a small, well-scoped fix and the fix itself is correct. I verified the audit table's claims directly rather than taking them on faith, and every line reference and thread attribution checks out:
Test
Minor, non-blocking observations:
No correctness issues found. The library pin bump to |
Summary
retina-tracker#25 turned the five
Track.history[*]buffers from lists intodeque(maxlen=600). AdequeraisesRuntimeError: deque mutated during iterationif it is appended to while a Python-level loop walks it; alisttolerated that silently. Only the frame worker appends to a given node's tracker, but several readers run on other threads — and one of them was failing live:Each failure aborted the whole 1 s feed tick, so every websocket client lost that frame of aircraft, arcs and ground truth.
list(some_deque)is a single C call and therefore cannot be interleaved by another Python thread: it is an atomic snapshot under the GIL. That is already the idiom elsewhere on this path (reversed(list(dq))inanalytics_refresh.py). This PR applies it at the one server-side site and pins the library fix for the rest.Changes
backend/services/aircraft_feed.py:535— the single-node arc builder insidebuild_combined_aircraft_jsonnow snapshots before the reverse scan:with a comment recording why. Nothing else about the arc path changes; the
or ()keeps the existing "no history, skip this track" behaviour for a track that has none.libs/retina-tracker— bumped to the merged retina-tracker#26, which makesTrack.get_recent_detectionsandTrack.to_dictsnapshot their buffers the same way.Audit: readers of
track.history/get_recent_detections/to_dictinbackend/services/aircraft_feed.py:535(history["measurements"])services/tasks/analytics_refresh.py:536(get_recent_detections)services/state_snapshot.py:43+routes/custody.py:175(to_dict)Identity.to_dict, notTrack.to_dict; no history deque involved.Track.to_dictis reachable only from the library's own surfaces, and is fixed by the library bumppipeline/passive_radar.py:160, 572(get_recent_detections)services/frame_processor.py:364(get_recent_detections)process_one_frame)services/tasks/analytics_refresh.py:558(reversed(list(dq))onknown_claims)Test coverage
New
backend/tests/test_feed_history_snapshot.py: a background thread appends to a realdequehistory (including theNoneentries the coast path writes) while the main thread drivesbuild_combined_aircraft_jsonin a loop for ~0.6 s, with the arc-refresh timer cleared each pass so every build really walks the history. Asserts no exception and that the loop did meaningful work. ~0.7 s wall.aircraft_feed.py:538on 3 of 5 runs.pre-commit run --all-filesclean in both repos (ruff, ruff-format, vulture, ruff-config).Review notes
list()copy of a ≤600-entry deque per node-track per arc refresh, which happens at most once perARC_REFRESH_S, not once per flush.zip(reversed(meas), reversed(ts))is deliberately left as it is. The mutator appends timestamps before measurements, so a reader landing mid-append can see one extra timestamp andziptruncates to the shorter buffer — the same behaviour these buffers had as lists. Aligning them properly is a separate question and not a regression.2d31f00.🤖 Generated with Claude Code