Repository navigation
feat: decouple storage sinks from USB drain — revised (supersedes #96) - #113
Draft
boringethan wants to merge 3 commits into
Draft
boringethan wants to merge 3 commits into
boringethan wants to merge 3 commits into
Conversation
…ssure
The ScanRunner consume loop runs on the same thread that drains the live
USB histogram batch queue, so a slow disk/DB sink (SQLite commit/fsync, CSV
flush) stalls that drain, backpressures the parser and dev.read, and
overflows the sensor firmware's 4-deep histo queue — dropping histogram
frames. Root-caused on the 2026-06 long-scan soak ("HISTO enqueue fail:
queue full ... enq-deq=4 txfail=0": brief host-side drain stalls, not a USB
fault and not the persistent histo-wedge).
Fix #1 — decouple storage sinks from the drain:
- New AsyncSink wraps a sink and runs consume() on a dedicated worker
thread fed by a bounded FIFO buffer, so a transient sink stall is
absorbed instead of stalling the USB path. on_scan_start stays
synchronous (preserves critical-sink abort + ordering); on_complete
drains the buffer and joins before finalising, so no persisted data is
lost. ScanWorkflow now wraps CsvSink and ScanDBSink.
- LiveUsbSource._batch_queue depth is configurable and defaulted up from 4
(~1 s) to 16 so other runner hiccups (pipeline CPU spike, GC) are
absorbed before reaching dev.read.
Fix #3 — make sub-second host backpressure visible:
- StreamInterface times every packet-queue put. A put that blocks >=100 ms
(previously only the >1 s drop was logged) is counted per scan
(backpressure_stalls / backpressure_stall_s / dropped_chunks),
rate-limit warned, and summarised at stop_streaming.
(Firmware queue-depth bump is intentionally out of scope — RAM-bound.)
Tests: AsyncSink (FIFO ordering, drain-before-finalise, critical-abort
propagation, worker error isolation, Sink protocol, runner transparency,
wrapped accessor), configurable batch-queue depth, StreamInterface
backpressure counters.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the magic batch-queue default (16) with a value derived from a 3 s buffer target and the capture rate (CAPTURE_HZ) + batch size, so the intent is self-documenting and survives a different batch_size_frames. At the default 10-frame batch this is 12 batches (~3 s); the 64-deep packet queue adds ~1.6 s behind it. batch_queue_size stays overridable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…split backpressure warn tokens Review fixes on top of the histo-stream decoupling PR: - sources.py: a batch row is one per-camera sample, so the ~3 s batch-queue budget must be computed against CAPTURE_HZ x active_cameras (640 rows/s on a dual-sensor full-mask scan), not CAPTURE_HZ alone. The original formula bought ~0.19 s, not 3 s. ScanWorkflow now passes the mask popcounts; with no count the source worst-cases 8 cameras per connected side. The sizing test now checks wall-clock capacity at the row rate instead of validating the formula against itself. - AsyncSink: the worker now finalizes the wrapped sink after draining (always serialized with consume). On a join timeout on_complete no longer runs on_complete concurrently with a possibly mid-write worker (which could race the in-flight insert or delete a DB session as 'empty' while its data was still buffered); finalization is deferred to the worker. Each scan gets a fresh queue + worker so a wedged previous scan can never replay stale items or a stale sentinel into the next one. - StreamInterface: stall and drop warnings rate-limit on separate tokens so a benign sub-second stall warning cannot suppress the first data-loss warning. - docs/API.md + Sink protocol docstring: document the consume() latency budget (runner thread == USB drain thread; wrap I/O sinks in AsyncSink) and that payloads are shared by reference across sinks/threads and must not be mutated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
boringethan
force-pushed
the
fix/96-histo-backpressure-revisions
branch
from
July 2, 2026 18:29
7f6f07f to
3305b5c
Compare
This was referenced Jul 2, 2026
boringethan
marked this pull request as draft
July 2, 2026 22:48
This was referenced Jul 18, 2026
Contributor
Author
|
For the reviewer: #293 is a smaller alternative for the data-loss part of #116. It sizes the per-side packet queue, which holds raw bytes, to 10 s and leaves the sinks inline. The two were compared head to head in the same harness. Both prevent frame loss in every scenario; this PR additionally keeps the live display within ~0.15 s during a DB stall, where #293's display lags by the stall length. Suggest deciding between them together: merge #293 and either close this or cut it down to the AsyncSink part. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #116. (Refs, not Fixes — the ticket must stay open through pre-release validation.)
What
This is #96 plus the fixes from its review, rebased onto current
next(through #111). It carries the original two commits (AsyncSink decoupling + backpressure instrumentation) and adds one commit addressing the review findings. If this merges, #96 should be closed.Review fixes on top of #96
1. Batch-queue sizing was off ~16× (the PR's own success criterion). A batch row is one per-camera histogram sample —
parse_histogram_streamfireson_rowonce per camera per capture — so a dual-sensor 8+8 scan pushes ~640 rows/s into the shared queue, and the originalceil(3.0 × CAPTURE_HZ / batch_size)bought ~0.19 s of buffering, not the documented 3 s. The depth is now derived fromCAPTURE_HZ × active_cameras, where the camera count comes from the mask popcounts of the sides that actually stream (composed with #108's connected+masked gate; the source worst-cases 8 cams/side when unknown). The sizing test now asserts wall-clock capacity at the true row rate instead of validating the formula against itself. Memory note: the full 3 s budget can transiently pin ~125 MB during a worst-case dual-sensor stall (dense(N,2,8,1024)batch layout — see follow-up below); bounded and released as the runner drains.2. AsyncSink join-timeout finalize race. If the worker failed to drain within 60 s,
on_complete()previously finalized the wrapped sink on the runner thread while the worker could be mid-consume()on the same sink — racing the in-flight write, and forScanDBSinkpotentially deleting the session as "empty" while its data was still buffered. The worker now owns finalization (runson_completeafter the sentinel, always serialized with consume); on a join timeout the runner defers to the worker instead of racing it, so a late-unwedging disk still persists everything.3. AsyncSink reuse poisoning. The queue was created once and never drained, so reusing an instance after a timed-out scan could replay stale payloads into the new session or kill the new worker with the stale sentinel, after which
consume()'s blocking put would wedge the runner thread permanently. Eachon_scan_startnow gets a fresh queue, and workers bind their queue as an argument so a swap can never redirect a still-draining previous worker.4. Drop warnings could be suppressed by stall warnings. Both warning sites shared one 5 s rate-limit token, so a benign 100 ms-stall warning could swallow the first real data-loss warning for up to 5 s. They now rate-limit independently.
5. Docs (the convention → contract gap).
docs/API.md§6 advertised custom sinks with no mention thatconsume()runs on the drain-coupled runner thread — a user storage sink would silently reintroduce the exact frame-drop failure this PR root-causes. API.md and theSinkprotocol docstring now state the latency budget (wrap I/O sinks inAsyncSink) and that payloads are dispatched by reference and consumed concurrently — never mutate them.Tests
next@ c1f7b70:tests/test_pipeline/,tests/test_scan_workflow.py,tests/test_stream_interface_backpressure.py— 274 passed, 30 skipped, 0 failed (the fix: exclude unconnected/unmasked sensors from LiveUsbSource #108/fix: always emit OFF TriggerStateEvent even when stop_trigger raises #109 test fakes needed**kwargson their fakeLiveUsbSourcefactories to accept the newactive_camerasparameter).Follow-ups (out of scope)
(N,2,8,1024)FrameBatch layout inflates buffered-batch memory ~16×; a sparse per-row layout would cut the worst-case stall footprint from ~125 MB to ~8 MB.Sink.blockingflag) would make the decoupling structural instead of wrap-by-convention.🤖 Generated with Claude Code