Skip to content

feat: decouple storage sinks from USB drain — revised (supersedes #96) - #113

Draft
boringethan wants to merge 3 commits into
nextfrom
fix/96-histo-backpressure-revisions
Draft

boringethan wants to merge 3 commits into
nextfrom
fix/96-histo-backpressure-revisions

Conversation

@boringethan

@boringethan boringethan commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

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_stream fires on_row once per camera per capture — so a dual-sensor 8+8 scan pushes ~640 rows/s into the shared queue, and the original ceil(3.0 × CAPTURE_HZ / batch_size) bought ~0.19 s of buffering, not the documented 3 s. The depth is now derived from CAPTURE_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 for ScanDBSink potentially deleting the session as "empty" while its data was still buffered. The worker now owns finalization (runs on_complete after 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. Each on_scan_start now 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 that consume() 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 the Sink protocol docstring now state the latency budget (wrap I/O sinks in AsyncSink) and that payloads are dispatched by reference and consumed concurrently — never mutate them.

Tests

Follow-ups (out of scope)

  • Dense one-hot (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.
  • Runner-level async dispatch (or a Sink.blocking flag) would make the decoupling structural instead of wrap-by-convention.

🤖 Generated with Claude Code

boringethan and others added 3 commits July 2, 2026 11:26
…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

Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant