Lossless packet-decode capture to FileWriter-compatible per-source binary files with size-capped rotation, config + WS control, and double-buffered flush on the push thread (Approach C). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8.4 KiB
StreamHub Binary Recorder — Design
Date: 2026-06-25
Component: Source/Applications/StreamHub/
Status: Approved (design phase)
Goal
Add an option to the headless C++ StreamHub app to store incoming signal data to
disk in MARTe2 FileWriter-compatible binary format. The operator can record all
signals ("full packets") or a user-specified subset. Capture is lossless
(full source rate) and produces files byte-identical to a MARTe2 FileWriter
capture, so they validate against the existing Test/E2E/.../validate_binary.py
tooling.
Locked Decisions
| Decision | Choice |
|---|---|
| Tap point | Packet-decode time (lossless, full source rate) |
| Data types | Native packet types (byte-identical to FileWriter) |
| File layout | One .bin file per source/session |
| Retention | Rotating files: size cap (MaxFileMB) + keep newest KeepFiles |
| Control | Auto-start on launch (config) + WS recStart/recStop |
| Signal subset | Config default ("all" or list) + WS override per run |
| Disk-write threading | Approach C: receive thread serializes into a per-source double-buffer; the existing 30 Hz push thread flushes to disk |
Architecture
Component & ownership
New BinaryRecorder class (BinaryRecorder.{h,cpp}), one instance owned by each
UDPSourceSession — the per-source analog of the global HistoryWriter.
UDPSourceSession
├─ rings_[] (float64, live/zoom/trigger) ← unchanged
├─ stats_ ← unchanged
└─ recorder_ (BinaryRecorder) ← NEW: native-type, lossless, file-per-source
- The session configures its recorder when it knows its signal layout (first CONFIG,
in
ParseConfigPayload), since the on-disk header is derived from the signal descriptors and the resolved subset. - Lifecycle mirrors the session:
Configure(descs, subset) → [recording] → Stop(). A CONFIG change closes the current file and re-opens with a new header. - The recorder object always exists; it only writes when armed (auto-armed at startup if config enables it; toggled by WS).
On-disk format (byte-identical to MARTe2 FileWriter)
[u32 numSigs]
per signal: [u16 typeCode][char[32] name][u32 numElements] (38 B)
then rows: each row = one cycle, all subset signals, signal-major,
native types, little-endian
numSigs, descriptors, andnumElementscome from the resolved subset, so a subset capture is a valid standalone FileWriter file containing only the chosen signals.- Native-type reconstruction: the wire payload may be quantized
(
quantType != NONE). The recorder dequantizes to the physical value then re-encodes to the descriptor'stypeCode(float32/uint32/int16/...). ForquantType == NONEit is a straight byte copy.
Rotation (size cap + keep N)
- Files named
<sourceId>_<UTC-timestamp>.binin the configured directory. - When the active file reaches
MaxFileMB: close it, open a new one (fresh header), delete the oldest until at mostKeepFilesremain for that source. MinDiskFreeMBguard (like HistoryWriter): if free space drops below it, stop writing (disarm) and log, rather than filling the disk.
ACCUMULATE publish mode
A packet can carry numSamples values for an accumulated scalar while other signals
carry 1. To keep fixed-width FileWriter rows, the recorder emits numSamples rows
per packet, repeating the non-accumulated signals' values across those rows (the
natural "one row per cycle" expansion, matching how the rings already receive
numSamples writes).
Capture path (receive thread)
In ParseDataPayload, after the existing decode/ring-write loop, if the recorder is
armed:
- Serialize the packet into native-type FileWriter rows (1 row, or
numSamplesrows in ACCUMULATE) and append the bytes into the active staging buffer. - Staging is a per-source double buffer (two growable byte buffers,
front/back). The receive thread always appends tofront. A shortFastPollingMutexSemprotects only the buffer-swap and append bookkeeping — never the disk. - Overflow guard: each staging buffer has a soft cap (
StagingMB, default 8 MB). If the push thread has fallen so far behind thatfrontwould exceed the cap, the recorder increments adroppedRowscounter and skips the append (keeping reception alive). Reported via stats/WS so loss is observable, not silent. - No disk syscalls ever run on the receive thread.
Flush path (push thread)
Inside the existing 30 Hz push loop, after rings/zoom for each session, the recorder does its disk work on the push thread:
- Swap
front/backunder the short mutex (receive thread keeps appending to the newfrontimmediately). - Write the filled
backbuffer withpwrite()at the running offset; clearbackfor reuse. - Rotate if the file offset crossed
MaxFileMB(close, open new with header, prune toKeepFiles, checkMinDiskFreeMB). - fsync cadence:
fdatasync()everyFlushIntervalSec(like HistoryWriter), not every tick.
Arm/disarm/rotate requests from WS (other threads) are applied here via a small pending-flag, so all file open/close/unlink happens on one thread — no cross-thread file races. Durability latency is bounded to one push tick (~33 ms) plus the fsync interval.
Configuration
New +Recorder block in the StreamHub cfg (parsed in Initialise, try
+Recorder/Recorder):
+Recorder = {
Enabled = 1 // master enable; arms at startup if AutoStart=1
AutoStart = 1 // begin recording on launch
Directory = "/var/streamhub/rec" // required
Signals = "all" // "all" OR comma-separated "src:sig" keys
MaxFileMB = 256 // size cap → roll
KeepFiles = 8 // newest N kept per source
StagingMB = 8 // per-source staging soft cap (overflow guard)
FlushIntervalSec= 5 // fdatasync cadence
MinDiskFreeMB = 500 // stop-writing guard
}
Signals = "all"→ every signal of every source. A subset list selects per-source signals bysrc:sigkey; a source with no selected signals does not record.
WebSocket control
Dispatched in OnWSCommand via the existing strcmp-on-type pattern; replies
unicast.
recStart—{type:"recStart", signals?:"all"|["src:sig",...]}→ arm; optionalsignalsoverrides the config subset for this run.recStop— disarm, flush, close files.recInfo— returns{enabled, recording, perSource:[{id, file, bytesWritten, rowsWritten, droppedRows, freeMB}]}.recStatusevent broadcast on arm/disarm/rotate/overflow so clients reflect state without polling.
Testing
Unit tests (GTest, Test/Applications/StreamHub/)
- Header/format: configure with a known descriptor set + subset → assert written
header bytes match the FileWriter layout (
numSigs, 38-byte descriptors, nativetypeCode/numElements). - Native-type re-encode: feed quantized and non-quantized inputs → assert on-disk bytes equal the expected native-type encoding.
- ACCUMULATE expansion: packet with
numSamples>1for a scalar → assertnumSamplesrows emitted, non-accumulated signals repeated. - Rotation: drive past
MaxFileMB→ assert roll, new header, prune toKeepFiles, oldest deleted. - Overflow guard: stall the flush, overfill staging → assert
droppedRowsincrements and the reception path does not block. - Subset selection:
"all"vs explicitsrc:siglist → assert only chosen signals appear in the file.
E2E (extend Test/E2E/)
- Add a recorder scenario: run StreamHub with
+Recorderagainst a live UDPStreamer source, then validate the produced.binwith the existingvalidate_binary.py(checks signal count/sizes + row matching). - Reuse
run_e2e_report.sh's output-dir + JSON + Typst-report plumbing so the recorder result lands in the report alongside the unicast/multicast results.
Risks / Notes
- Native re-encode vs quantization: correctness depends on the dequantize + re-encode path matching the original type exactly; covered by a dedicated unit test.
- Staging memory:
StagingMBper source bounds RAM; overflow is observable viadroppedRowsrather than unbounded growth. - Rotation hiccups: file open/unlink on the push thread (non-RT), never on the receive path.
- CONFIG mid-recording: re-headers a new file; old file is closed cleanly.