Skip to content

Real-time friendly snapshot path: bounded allocation-free sink queue, tryTakeSnapshot(), startRecording() - #74

Open
facontidavide wants to merge 1 commit into
mainfrom
rt-friendly-snapshot
Open

Real-time friendly snapshot path: bounded allocation-free sink queue, tryTakeSnapshot(), startRecording()#74
facontidavide wants to merge 1 commit into
mainfrom
rt-friendly-snapshot

Conversation

@facontidavide

Copy link
Copy Markdown
Collaborator

Addresses the real-time items of #73 (blocking locks, per-tick heap allocations, sink setup on the first tick, unbounded queue). The std::array compile error and the schema hash gap from that issue are left for separate PRs.

What changes

DataSinkBase: bounded, allocation-free handoff (src/data_sink.cpp)

  • The ConcurrentQueue<Snapshot> is replaced by a pool of queue_size pre-allocated Snapshot slots and two ConcurrentQueue<Snapshot*> (free list and ready list) that never allocate on try_enqueue/try_dequeue.
  • pushSnapshot() takes a free slot, copies with vector::assign (no allocation once the slot's capacity covers the payload) and pushes the pointer. If no slot is free the snapshot is dropped and counted; memory never grows.
  • New constructor DataSinkBase(size_t queue_size, size_t reserved_payload_bytes = 0); default constructor keeps kDefaultQueueSize = 64. New getters queueSize() and droppedSnapshotsCount().

LogChannel::tryTakeSnapshot() (src/channel.cpp)

  • Same as takeSnapshot() but try_locks the channel mutex and returns false if another thread holds it. Dropping a sample is the failure mode a control loop wants.
  • The sink list is now copy-on-write (shared_ptr<const vector> swapped with std::atomic_store). Neither snapshot variant takes sinks_mutex anymore, so a concurrent addDataSink() doing I/O in addChannel() cannot stall the producer. addDataSink() also dedupes, matching the previous unordered_set semantics.
  • The push loop now runs while the channel mutex is still held, which protects _p->snapshot against two threads snapshotting the same channel (previously a race).

LogChannel::startRecording()

  • Freezes the schema and calls addChannel() on all sinks. Optional: the first snapshot still does this lazily, but a real-time caller can now do it at setup time. Idempotent; sinks added afterwards get the schema immediately, as before.

README: new "Real-time usage" section.

Behaviour changes to be aware of

  • A sink that cannot keep up now drops the newest snapshots after 64 queued (configurable) instead of growing the queue without bound. pushSnapshot() already documented false as "queue full", so the contract is unchanged, but the outcome is now reachable.
  • The first pushSnapshot() from a new thread lets the queue register that thread as a producer, which may allocate once. Pre-allocation covers up to 4 producer threads per sink.

Tests

tests/realtime_tests.cpp, four cases:

  • startRecording() registers the schema before any snapshot, freezes registration, is idempotent, and late sinks still get the schema.
  • tryTakeSnapshot() returns false while writeMutex() is held by the test, true afterwards.
  • With a consumer blocked inside storeSnapshot() and queue_size = 4, 10 pushes yield exactly 4 accepted and droppedSnapshotsCount() == 6; after release, pushing works again.
  • Steady-state tryTakeSnapshot() on a channel with a 100-double vector, a std::array<float,16> and two scalars performs zero heap allocations on the calling thread, measured by a replaced global operator new gated by a thread_local flag.

Full suite (28 tests) passes in Debug and Release (-Wall -Wextra, no new warnings). The RealTime.* tests pass 50 consecutive repeats. Not run here: the ROS 2 build (ros2_publisher_sink is untouched and only uses the DataSinkBase interface).

🤖 Generated with Claude Code

Three changes to the hot path, all on the producer side:

- DataSinkBase: replace the unbounded ConcurrentQueue<Snapshot> with a
  bounded pool of pre-allocated Snapshot slots. pushSnapshot() copies into
  a free slot (no allocation once the slot has grown to the payload size)
  and drops the snapshot when the pool is exhausted instead of growing
  memory. Queue size is a constructor parameter (default 64), and dropped
  snapshots are counted in droppedSnapshotsCount().

- LogChannel::tryTakeSnapshot(): non-blocking variant of takeSnapshot()
  that returns false instead of waiting for the channel mutex. The sink
  list is now copy-on-write and read atomically, so neither variant takes
  sinks_mutex, which addDataSink() holds while doing sink I/O.

- LogChannel::startRecording(): freezes the schema and registers it into
  the sinks explicitly, so that the first snapshot taken from a real-time
  thread does not perform sink setup (for MCAPSink, a disk write).

Tests cover the schema freeze, the non-blocking path, the drop-on-full
policy and, with a global operator new counter, that steady-state
snapshots perform zero heap allocations on the calling thread.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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