Skip to content

Let sinks finish themselves and give the registry defaults and stopAll - #17

Merged
facontidavide merged 1 commit into
spsc-sink-queuefrom
sink-lifecycle
Oct 7, 2026
Merged

facontidavide merged 1 commit into
spsc-sink-queuefrom
sink-lifecycle

Conversation

@facontidavide

Copy link
Copy Markdown

Stacked on #16. Merge #16 first.

Sinks now finish their own work when they stop. The registry also gets defaults for new channels and a way to stop everything.

What changes

  • DataSink::onStop(). An empty virtual that SinkWorker::stop() calls on the stopping thread after the final drain, serialized with the other sink callbacks.
    • Once per stop. A second stop() without start() skips it, and destroying a running worker calls it.
    • Exceptions. A throw is counted in errors() and never escapes stop().
    • Built-in sinks.
      • MCAPSink closes its file.
      • MCAPRingSink writes a pending dump (flushPendingDump()).
      • ROS2PublisherSink flushes its batch.
    • The docs no longer tell users to call these after stop().
  • ChannelsRegistry::stopAll(). Stops each worker once: the default sinks and every sink attached to a channel the registry created. It runs outside the registry lock.
  • addDefaultSink() reaches existing channels. Prepared channels get the schema through the existing addDataSink() path. If one channel refuses the sink (eight sinks already, or onSchema() throws), the whole call is undone and the exception propagates.
  • setChannelDefaults(ChannelDefaults). Sets pool capacity, as a count or in time (SinkWorker: one queue per channel, sized by the pool #16), and payload capacity.
    • Values are validated when set.
    • getChannel() applies them to new channels before attaching the default sinks.
    • clear() resets them.
  • ABI. The DataSink and CustomSerializer headers now state that their vtables are frozen for 2.x. abi_tests pins the sizes of DataSink and ChannelDefaults.
  • README, the LLM manual, the examples and the CHANGELOG are updated.

Behaviour change

MCAPSink::stop() now ends the recording. To record again, call restartRecording() (a new file) before start(). Code that used stop and start as a pause within the same file has to change. Nothing in this repository does that.

Real-time path

Unchanged: tryTakeSnapshot() and tryPush() are the same as in #16.

Testing

Build Result
colcon, ROS 2 Jazzy 220 gtests + 2 ctest entries, 0 failures, 0 warnings
debug 206/206
asan 206/206; whole suite x10 clean
tsan 206/206
TSan loop: lifecycle, registry, ring-stop and queue tests, x100 pass

After rebasing onto #16 with its review fixes, the debug preset passes 210/210.

The MCAP finalize test fails if MCAPSink::onStop() is emptied.

Known limits of the undo in addDefaultSink():

  • A schema already announced to a sink stays announced, so an MCAP file can keep an empty channel record.
  • A concurrent addDataSink() of the same sink on the same channel could be undone too.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WASypwgBSXDzJkPN4HBtFD

Problem: shutting down needed an ordering rule per sink. Callers had to run
MCAPSink::stopRecording(), MCAPRingSink::flushPendingDump() or
ROS2PublisherSink::flush() after SinkWorker::stop(), or lose the end of a
file, a pending dump or a partial batch. ChannelsRegistry reached only the
channels created after addDefaultSink(), offered no way to stop its sinks, and
could not size the channels it created, so consumers wrapped it.

Change:
- New virtual DataSink::onStop(), empty by default. SinkWorker::stop() calls
  it on the stopping thread after the final drain, under the same locks as
  onSnapshot() and onSchema(), once per stop: a second stop() without start()
  skips it, the destructor of a running worker runs it. A throw is counted in
  errors(). MCAPSink closes its file, MCAPRingSink runs flushPendingDump(),
  ROS2PublisherSink runs flush().
- ChannelsRegistry::stopAll() stops, once each, every default sink and every
  sink attached to a channel the registry created.
- addDefaultSink() also attaches the sink to existing channels (prepared ones
  announce their schema through addDataSink()), and undoes the call if a
  channel refuses the sink.
- setChannelDefaults(ChannelDefaults): pool capacity as a count or in time,
  and payload capacity, validated when set and applied by getChannel() to new
  channels before the default sinks. Existing channels are left alone.
  clear() resets the defaults.
- DataSink and CustomSerializer headers state that their vtables are frozen
  for 2.x. abi_tests pins sizeof(DataSink) and sizeof(ChannelDefaults).

Compatibility: the onStop() slot changes the DataSink vtable, so sinks built
against an earlier build must be rebuilt (2.0 is untagged). MCAPSink stops
recording at SinkWorker::stop(); call restartRecording() before start() to
record again. The new registry functions are additive.

RT impact: none. tryTakeSnapshot() and SinkWorker::tryPush() are unchanged;
onStop() and the registry functions run on control threads.

Tested in the jazzy container: colcon (msgs + cpp, tests and examples),
presets debug, asan and tsan, Python tests, and a TSan loop of the lifecycle,
registry, ring and queue tests (x100).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WASypwgBSXDzJkPN4HBtFD
@facontidavide
facontidavide merged commit 6cdcf05 into spsc-sink-queue Oct 7, 2026
20 checks passed
facontidavide pushed a commit that referenced this pull request Oct 7, 2026
…emselves

Every channel published into one moodycamel queue per SinkWorker; explicit
producers keep the blocks they take, so a channel could be rejected while
its pool still had free slots, and users had to size queue_capacity by
hand. Each (channel, sink) attachment now has a single-producer
single-consumer ring of exactly the pool's capacity: the pool is the only
bound. The worker polls 20 us, then sleeps on an eventcount with a
std::binary_semaphore; a push costs one increment and one load and wakes
the worker only after it slept.

- Removed: queue_capacity, kDefaultQueueCapacity, the moodycamel names in
  data_sink.hpp and the vendored 3rdparty/ConcurrentQueue,
  LogChannel::getActiveFlags(), the public SnapshotRef(pool, slot).
- LogChannel::setPoolCapacity(stall, period); exact payload reserve for
  fixed-size schemas; addDataSink() returns whether it attached.
- DataSink::onStop() and onStart(): sinks close files, write pending dumps
  and flush batches when the worker stops, and MCAPSink reopens on start.
  The DataSink and CustomSerializer vtables are frozen for 2.x.
- ChannelsRegistry: stopAll(), setChannelDefaults(ChannelDefaults),
  addDefaultSink() also reaches existing channels (undone on refusal).
- SinkWorker and LogChannel keep their sink and type registry in the Pimpl;
  abi_tests pins the layouts.
- README, LLM manual, CLAUDE.md, header comments and CHANGELOG updated.

Reviewed twice (memory ordering, wake-ups, attach/detach, lifecycle) and
simplified; see PR #16 and #17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WASypwgBSXDzJkPN4HBtFD
facontidavide pushed a commit that referenced this pull request Oct 7, 2026
…emselves

Every channel published into one moodycamel queue per SinkWorker; explicit
producers keep the blocks they take, so a channel could be rejected while
its pool still had free slots, and users had to size queue_capacity by
hand. Each (channel, sink) attachment now has a single-producer
single-consumer ring of exactly the pool's capacity: the pool is the only
bound. The worker polls 20 us, then sleeps on an eventcount with a
std::binary_semaphore; a push costs one increment and one load and wakes
the worker only after it slept.

- Removed: queue_capacity, kDefaultQueueCapacity, the moodycamel names in
  data_sink.hpp and the vendored 3rdparty/ConcurrentQueue,
  LogChannel::getActiveFlags(), the public SnapshotRef(pool, slot).
- LogChannel::setPoolCapacity(stall, period); exact payload reserve for
  fixed-size schemas; addDataSink() returns whether it attached.
- DataSink::onStop() and onStart(): sinks close files, write pending dumps
  and flush batches when the worker stops, and MCAPSink reopens on start.
  The DataSink and CustomSerializer vtables are frozen for 2.x.
- ChannelsRegistry: stopAll(), setChannelDefaults(ChannelDefaults),
  addDefaultSink() also reaches existing channels (undone on refusal).
- SinkWorker and LogChannel keep their sink and type registry in the Pimpl;
  abi_tests pins the layouts.
- README, LLM manual, CLAUDE.md, header comments and CHANGELOG updated.

Reviewed twice (memory ordering, wake-ups, attach/detach, lifecycle) and
simplified; see PR #16 and #17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WASypwgBSXDzJkPN4HBtFD
facontidavide added a commit that referenced this pull request Oct 7, 2026
…emselves (#16)

Every channel published into one moodycamel queue per SinkWorker; explicit
producers keep the blocks they take, so a channel could be rejected while
its pool still had free slots, and users had to size queue_capacity by
hand. Each (channel, sink) attachment now has a single-producer
single-consumer ring of exactly the pool's capacity: the pool is the only
bound. The worker polls 20 us, then sleeps on an eventcount with a
std::binary_semaphore; a push costs one increment and one load and wakes
the worker only after it slept.

- Removed: queue_capacity, kDefaultQueueCapacity, the moodycamel names in
  data_sink.hpp and the vendored 3rdparty/ConcurrentQueue,
  LogChannel::getActiveFlags(), the public SnapshotRef(pool, slot).
- LogChannel::setPoolCapacity(stall, period); exact payload reserve for
  fixed-size schemas; addDataSink() returns whether it attached.
- DataSink::onStop() and onStart(): sinks close files, write pending dumps
  and flush batches when the worker stops, and MCAPSink reopens on start.
  The DataSink and CustomSerializer vtables are frozen for 2.x.
- ChannelsRegistry: stopAll(), setChannelDefaults(ChannelDefaults),
  addDefaultSink() also reaches existing channels (undone on refusal).
- SinkWorker and LogChannel keep their sink and type registry in the Pimpl;
  abi_tests pins the layouts.
- README, LLM manual, CLAUDE.md, header comments and CHANGELOG updated.

Reviewed twice (memory ordering, wake-ups, attach/detach, lifecycle) and
simplified; see PR #16 and #17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WASypwgBSXDzJkPN4HBtFD
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