CHANGELOG
Changelog for package data_tamer
Unreleased
Fixed, deadlocks:
unregister(),removeDataSink()or aLoggedValuedestructor insidescopedWrite()while a snapshot waited; a const query insidescopedWrite()while another thread ran one of those, orstartLogging();startLogging()insidescopedWrite(). The snapshot path takes the write mutex before its epoch, andstartLogging()before the control mutex.Behaviour change: a snapshot taken by a thread that holds the channel’s transaction or a
LoggedValueguard returnsblocked(it hung).New: several threads may take snapshots of one channel; they take turns on the write mutex.
blockednow takes precedence overpool_exhausted.Fixed: a consumer built with
-fvisibility=hiddenagainst a shared libdata_tamer hung inLoggedValue::set()insidescopedWrite();ChannelSharedStatehas default visibility.Behaviour change:
SinkWorker::stop(),start()anddrain()throwstd::logic_errorfrom a callback of their own sink (they deadlocked, orstop()closed the worker silently). Concurrentstop()andstart(), twostopAll()included, are serialized (they hung). A callback that releases the last reference to its own worker no longer callsstd::terminate.Fixed: a sink attached during a failing
startLogging()could miss the final schema, and a concurrentaddDataSink()of one worker announced it twice.Real-time: no
notify_all()per snapshot or refused push unless a thread waits; each one could be a futex call.Behaviour change: names must be non-empty and free of whitespace and control characters (only the space was refused);
IsCanonicalName()agrees. A ninth default sink throws inaddDefaultSink()(it made every latergetChannel()throw).Behaviour change: custom type names, their field names, the
typeName()of aregisterCustomValue()serializer and channel names are checked too: a line break in any of them split the schema text, and a space in a type or field name broke its field line. Type and field names follow the value name rules, and the registration throwsstd::runtime_error. A channel name can contain spaces, butLogChannel::create()andgetChannel()throw for an empty one or one with a control character.Fixed: a registration that throws leaves the channel unchanged (custom types, an allocation failure,
createLoggedValue()). Two C++ types that return one custom type name in a channel makeregisterValue()throw (the second used the first one’s serializer and read past its object).ToStr(Schema)rethrows allocation failures.Fixed:
long long,unsigned long long,wchar_t,char16_t,char32_tand enums over them are recorded by size and signedness; they were recorded asother, which no decoder reads.long doubleis a compile error. A class template with two or more type parameters can have aTypeDefinition.Fixed: a vector of structs was sized with padding, so
tryTakeSnapshot()could returnoversizefor a payload that fit.ValuePtrchecks the room before writing a number and writes little-endian on any host; a custom type without a serializer throwsstd::invalid_argument.Fixed, wire: a
std::string, registered or inside a custom type, is written aschar[](auint32count and the characters), as its schema says. It carried a 16-bit length that no decoder could read, and a string over 65535 bytes made the snapshot throw.Fixed:
MCAPSinkreset without rollover andrestartRecording()of the file in use wrote the old footer into the new file; the old writer is now closed first, and a failed reopen of that file stops the sink. A rollover that cannot open its next file is counted once and retried after another reset time.Fixed:
MCAPRingSink::flushPendingDump()andstop()on an untriggered request use the newest timestamp of the current run, so a simulation reset no longer dumps the previous run.dumps_writtenanddumps_failedcount a dump before its callback.Fixed:
MCAPRingSinkstarts a new run only when a channel’s clock steps back by more thanwindow. Snapshots of one channel taken by several threads can arrive slightly out of order, and each step back started a run, so a flush right after ended its dump before the newest snapshot.Behaviour change: MCAP sinks reject a negative snapshot timestamp (counted in
errors(), not written), andmcap_encoding::WriteMessage()throws for one.Parser and Python decoder: the work of a decode is bounded by the payload (a zero-size element type looped for minutes); a bool is true for any non-zero byte; empty schema text is rejected;
### key:valueheaders are read; version and hash are strict unsigned decimals; a separator is a whole line of at least 30=;ENCODING:sections are opaque types and must followMSG:; upper-case legacy names are read only without a### version:line. NewSplitMcapMessage()andsplit_mcap_message(), used bymcap_reader.Breaking, parser:
ParseSnapshot()loses its unusedcallback_customparameter;SizeOf,kMaxSchemaDepthandParseSnapshotRecursivemove toDataTamerParser::detailandNullCustomCallbackis removed; the header needs a little endian host and no longer includes<iostream>.SerializeMe: bounds checks for byte vectors and 1-byte arrays, exact
BufferSize()for arrays and padded structs, bool decoding, a container of 2^32 elements or more throws, an ADLTypeDefinition()may returnconst char*orstd::string,Span<const T>fromstd::array, and the big endian path compiles for enums andstd::byte.API:
[[nodiscard]]ontrySetEnabled(),LogChannel::create(),getNumberOfSinks(),MCAPRingSink::requestDump(),SnapshotPool::tryAcquire()andadopt(),ToStr(Schema),ToYaml(),RenderSchema(),ParseSnapshot(),BuildSchemaFromText(),BuildSchemaFromYaml(),ToText()andGetBit();noexceptonisEnabled(id),LoggedValue::setEnabled()andisEnabled(),requestDump(),dumpRequested(),SinkWorker::delivered(),errors(),queueHighWater()and theWriteMutextry paths.droppedSnapshots()is[[deprecated]].getConstPtr()isconst,SinkWorker::as<T>()has aconstoverload,logged_value.hppis self-sufficient andpoolExhausted()is lock-free.CustomSerializerT(std::string)isexplicit. Removed:getPointerTypeandWriteMutex::kPriorityInheritance. Source change:mcap_ring_sink.hppno longer includesmcap_sink.hpp.Real-time: a snapshot wakes sleeping sink workers after it releases the write mutex, so writers wait less (with four sinks the mutex was held 13 us per snapshot, 2.5 us now). Serializing a number checks the buffer once.
Fixed: a
ValuePtrcontainer count of 2^32 or more throws instead of being written truncated.SerializeMe::enable_if_same_tis removed. Some error texts changed: a number that does not fit reports “Buffer overrun”, andMCAPRingSinkreports a negative timestamp with the text ofmcap_encoding::CheckTimestamp().Decoders: the C++ parser reads a schema about twice as fast and decodes 20-26% faster (a top-level field name is no longer copied, and the minimum sizes of custom types are kept per decode without heap allocation); the Python decoder keeps them on the
Schema. Python reports a type the schema does not define as “unknown type ‘X’”, as C++ does.Clang:
discoverTypes()andTypesRegistryno longer trigger-Wunused-lambda-captureand-Wpotentially-evaluated-expression, which broke-Werrorbuilds of consumers that register a custom type.Fixed: a snapshot’s deferred wake of a sleeping worker read the
SinkWorkerafter leaving its epoch, freed meanwhile if adrain()callback had dropped the last reference (a crash, or heap-use-after-free under AddressSanitizer). The wake goes through the worker’s state, which lives until the worker thread is joined.Parser and Python decoder: the C++ parser rejects a type spec without a type name (
[3] x) or with text after its](float64[3]x y), as Python did. Both reject aMSG:line that does not follow a separator (C++ read it as a field, Python opened a section). Python trims only spaces and\r, as the specification says, so both reject### hash:<tab>7. Both reject a field name that holds whitespace or a control character, in either rendering. Python reads a text whose first line starts with###as the line format, as C++ does, even when YAML follows. An empty vector of a type the schema does not define is an error in C++ too, and an empty vector of an opaque type decodes as empty in Python too.Fixed: one type name for a
TypeDefinitiontype and aregisterCustomValue()serializer of the same channel made the schema describe the serializer’s values with the type’s fields, or hold two sections of that name. The registration that comes second throwsstd::runtime_errorand leaves the channel unchanged.ABI: new private exported functions,
LogChannel::registerValueWithTypesandTypesRegistry::find.Counters in one struct per channel and one per sink:
LogChannel::Statsgainsattempts(everytakeSnapshot()andtryTakeSnapshot()call),accepted(the calls whose snapshot at least one sink took:okandpartial) anddropped_by_sink(one{sink, dropped}entry per sink attached at the read, the publications it refused because its worker was stopped). The existing five fields keep their meaning and position. Within oneStats,accepted <= attempts; a window’sattempts - acceptedis approximate by the calls in flight, so clamp it at zero. NewSinkWorker::Statsreturnsdelivered(onSnapshot()calls that returned),errors,last_errorandqueue_high_water(per worker, across all its channels: a lower bound of the most snapshots found waiting in one queue, kept on the delivery thread, off the real-time path). New getters behind them:LogChannel::snapshotAttempts(),snapshotsAccepted(),sinkDropped(),LogChannel::kMaxSinksandSinkWorker::delivered(),queueHighWater(). Real-time cost: two atomic increments per snapshot call (attemptson entry, relaxed;acceptedwhen a sink took the snapshot, release, the same instruction on x86); nothing else is added to the path. Behaviour change:LogChannel::stats()now takes the control mutex twice and allocates, andLogChannel::Statsis no longer trivially copyable (it holds a vector).ABI:
LogChannel::stats(),SinkWorker::stats()andMCAPRingSink::stats()are inline in their headers and fill the struct from the exported getters, one per field, so a field added later never changes what the library returns across the ABI. They are declaredDATA_TAMER_INLINE_LOCAL(hidden visibility, from the newdata_tamer/details/abi.hpp) so that every binary uses the copy built against its own header: an exported weak copy would let the first loaded shared object’s olderstats()build the struct in a newer caller’s buffer.Statsitself is not exported.Deprecated:
LogChannel::droppedSnapshots(sink)stays for one release; readstats().dropped_by_sink.Sinks finish themselves: new
virtual void DataSink::onStop()andonStart(), empty by default.SinkWorker::stop()callsonStop()on the stopping thread after the last delivery, serialized with the other callbacks, once per stop (a secondstop()withoutstart()does not call it again; the destructor of a running worker does).start()after such a stop callsonStart()before accepting snapshots again. A throw from either is counted inerrors().MCAPSinkcloses its file inonStop()(behaviour change:stop()now ends the file) andstart()continues in its next numbered file, unlessrestartRecording()opened one;MCAPRingSinkwrites a pending dump (flushPendingDump()),ROS2PublisherSinkpublishes its partial batch (flush()). The “callstopRecording()/flushPendingDump()/flush()afterstop()” rules are gone.ABI:
onStop()andonStart()append two slots to theDataSinkvtable, so sinks compiled against an earlier build must be rebuilt. From 2.0 on, theDataSinkandCustomSerializervtables are frozen for 2.x (noted in their headers);tests/abi_tests.cpppinssizeof(DataSink)andsizeof(ChannelDefaults).ChannelsRegistry::stopAll()stops, once each, every default sink and every sink attached to a channel the registry created.ChannelsRegistry::addDefaultSink()also attaches the sink to the channels that exist already (a started one announces its schema at once, without the registry lock held), so it no longer has to come beforegetChannel(). If a channel refuses the sink, the call is undone and the exception propagates.Breaking, API:
LogChannel::addDataSink()returnsbool:trueif the call attached the sink,falseif the channel held it already.ChannelsRegistry::setChannelDefaults(ChannelDefaults): pool capacity (as a count or in time) and payload capacity applied bygetChannel()to the channels it creates afterwards, before the default sinks are attached. Existing channels are not changed.clear()resets them. The values are validated when set, by the newChannelDefaults::resolve().Breaking, sinks: the shared sink queue is gone, and with it
SinkWorker::kDefaultQueueCapacityand thequeue_capacityconstructor argument: the constructor isSinkWorker(std::unique_ptr<DataSink>, Delivery = Delivery::Threaded). Each channel attached to a worker publishes into a single-producer single-consumer ring of its own, holding exactly as many snapshots as the channel’s pool and allocated with the pool (startLogging(), oraddDataSink()on a started channel). A queued snapshot holds a pool slot, so the ring can’t fill while the pool has a free slot. Before, a channel’s snapshots could berejectedwith free slots once the moodycamel blocks of the shared queue were taken by other channels; nowpartialandrejectedmean only a stopped worker. The worker serves its channels in turn. When idle it polls about 20 us, then sleeps: a push wakes a sleeping worker with one futex wake, pushes while it polls cost none. Per-channel order now holds acrossremoveDataSink()andaddDataSink()too: what the old attachment had queued is delivered first, then its ring is freed. The vendored moodycamel ConcurrentQueue and its ThreadSanitizer suppression are removed, and no public header names a moodycamel type.LogChannel::setPoolCapacity(stall_tolerance, snapshot_period)sizes the pool in time:ceil(stall_tolerance / snapshot_period)slots, both positive, overflow-checked.startLogging()reserves per slot exactly the payload with every value enabled when all registered values are fixed-size (scalars,std::array, fixed-size custom types; newValuePtr::isFixedSize()), instead ofmax(2 x payload, 256), which still applies to schemas with a dynamic value.setPayloadCapacity()stays a floor. This halves the payload memory of a fixed schema larger than 128 bytes.Breaking, API:
LogChannel::getActiveFlags()is removed (read the mask from a delivered snapshot), andSnapshotRef(pool, slot)is private;SnapshotPool::adopt()(details/) builds one.ABI:
SinkWorkerkeeps its sink in the Pimpl (sink()is out of line, the object is one pointer) andLogChannelits types registry (sizeofdrops by oneTypesRegistry).tests/abi_tests.cpppins both sizes. Downstream binaries must be rebuilt.New
data_tamer/fwd.hpp, a forward-declaration header like<iosfwd>: it declaresChannelsRegistry,DataSink,LogChannel,RegistrationID,SinkWorker,SnapshotRef,Schema,SnapshotandLoggedValue<T>, so that headers that only name these types need not declare them by hand. It has no includes and can be combined with the full headers in any order; compile tests cover it alone and in both orders.New
MCAPRingSink(data_tamer/sinks/mcap_ring_sink.hpp, #95), a flight recorder: it keeps the lastwindowof every attached channel in a preallocated RAM ring (evicted by age and bycapacity_bytes) and writes an MCAP file only onrequestDump(post_trigger), which is lock-free and allocation-free for real-time threads. The dump covers[T - window, T + post_trigger]in snapshot time,Tbeing the first snapshot after the request, with the schema and channel records of every channel in it; it completes at the first snapshot stamped afterT + post_trigger. A writer thread of the sink writes the file from a second buffer, so the sink worker never waits for disk; a finished dump that finds the writer busy is handed over at the next snapshot.flushPendingDump()writes the active request at once and waits for the file, with the worker running (no need to stop and restart it).SinkWorker::stop()calls it after the last delivery, so a request made just before shutdown is written too. DumpNis written todetails::NumberedPath(filepath, N), skipping names that exist already. The dump callback reports write errors (disk full) andtruncatedwhen the ring was too small for the interval, including evictions while a complete dump waits for a busy writer.stats()returns the counters andoldest_timestamp/newest_timestampof the ring content, so the history the ring holds is one call away.stats()is inline and built from one exported getter per field (dumpsWritten()…newestTimestamp()), soMCAPRingStatscan gain fields without an ABI change. ExampleT04_flight_recorder.ROS 2: export the
Threadsdependency, so thatfind_package(data_tamer_cpp)works from a fresh CMake cache (Threads::Threadsis in the public link interface).ROS2PublisherSinkcan aggregate snapshots: passROS2PublisherOptionswithaggregate = trueto publishdata_tamer_msgs/SnapshotBatchon<prefix>/data_batchinstead of oneSnapshotper sample on<prefix>/data. A batch is sent when it reachesmax_batch_sizesnapshots, when a snapshot arrivesmax_batch_delayafter the first one of the batch, onflush()and when the sink is destroyed.embed_schemas(default on) puts the schemas of the batch’s snapshots in the message, so it decodes without theschemastopic. Schema texts are now serialized once, inonSchema(), instead of on every republish.ROS2PublisherSinkQoS (2.0 review item 18). The data topic (dataordata_batch) uses the newROS2PublisherOptions::data_qos. Its default isrclcpp::QoS(rclcpp::KeepLast(100)).reliable(): memory stays bounded and a slow or stalled subscriber loses the oldest messages (see the entry on safe defaults below).schemasis reliable, transient-localKeepLast(1): every message is the complete catalog, so a late subscriber still gets all schemas. The catalog is published as soon as the sink learns a schema (startLogging(), oraddDataSink()on a started channel) instead of with the next snapshot, so callstartLogging()explicitly outside the control loop. A failed publication does not failstartLogging(): it is retried by the next snapshot (counted inSinkWorker::errors()) or byflush(), which now publishes a pending catalog even without aggregation and throws if that fails.Parser helpers to decode the ROS messages without depending on ROS (templates on the message type):
DataTamerParser::SchemaRegistry(schemas by hash, filled fromSchemasor embedded batch schemas),ToSnapshotView()andForEachSnapshotInBatch(). Python equivalents indata_tamer_parser.py:SchemaRegistry,parse_snapshot_msg()anditer_snapshot_batch().YAML schema rendering (wire format version 6, section 2.1):
ToYaml(schema)writes the same schema as YAML, nesting fields whose names share a/-separated prefix, which is shorter for path-like names (the hash is unchanged).RenderSchema(schema, SchemaFormat)picks either rendering; opt in for ROS withROS2PublisherOptions::schema_format = SchemaFormat::Yaml.BuildSchemaFromText()and the Pythonparse_schema()detect and read both renderings without dependencies, and verify a YAML schema’s hash viaToText()/to_text(). The parser’sSchemagainscustom_schemas(opaque types, filled from YAML).Examples:
ros2_publishertakes--aggregateand--yaml;python/ros2_subscriber.pydecodesSnapshotandSnapshotBatchtopics with the Python decoder.The Python decoder is a package,
data-tamer-parser(python/pyproject.toml, still standard library only, importable asdata_tamer_parser; versiondata_tamer_parser.__version__):pip install ./python. Thepythonworkflow tests and builds it and publishes it to PyPI onX.Y.Ztags; it is versioned in lockstep with the library (release tag ==package.xmlversion ==data_tamer_parser.__version__). NewSchema.field_names()lists the flattened names from the schema alone (dynamic vector elements as the placeholdervec[]; bounded byMAX_SCHEMA_DEPTHandMAX_FIELD_NAMES), anditer_mcap(path)yields(timestamp, topic, values)for an MCAP file, with the optionalmcappackage (data-tamer-parser[mcap]).New customization point
DataTamer::TypeDefinitionTrait<T, Enable = void>(#94): describe a type with astatic define(T&, AddField&)specialization instead of aTypeDefinitionoverload in the type’s own namespace, so third-party types (Eigen, …) no longer require reopening their namespace. Partial specializations (includingenable_ifon the second parameter) are supported. An optionalstatic std::string name()builds the type name at runtime (once per type, per shared library), e.g. for class templates; adefine()returningstd::stringis cached the same way. When both a trait and an ADLTypeDefinitionexist, the trait wins; a specialization with an unusabledefine()is a compile error. ExistingTypeDefinitionoverloads keep working.CustomTypeName<T>::get()is no longerconstexpr.SerializeMe::DeserializeFromBuffernow compiles for custom types (it passed const field pointers and could not write the fields).Value names (#97): new header-only
DataTamer::JoinNames(parts...)(data_tamer/names.hpp, included bychannel.hpp) joins components with a single/and drops empty ones, so namespaces may carry trailing slashes:JoinNames("/loco/", "LF/", "x") == "loco/LF/x". Names with empty/-separated components (e.g."/loco//torso/x") are still accepted, but PlotJuggler shows them as empty path elements; the newDataTamer::IsCanonicalName(name)(non-empty, no spaces, no empty components) is an opt-in check to assert on. Registration rules are unchanged (only spaces in value names are rejected). Registration errors now name the channel and the value, e.g.channel 'controller/walk': value 'loco/LF/x' registered twice (unregister() it first); the no-spaces check runs before any custom type is discovered, so a rejected name leaves the channel unchanged.MCAP rollover (#98): when
setMaxTimeBeforeResetexpires (600 s by default),MCAPSinkcontinues in a new numbered file, or truncates the same file ifsetCreateNewFileOnReset(false)was called (see the entry on safe defaults below). Numbered names that already exist (e.g. from a previous run with the same path) are now skipped instead of overwritten, and the counter advances only once the new file is open. The counter is inserted before the extension, now the trailing run of alphabetic dot-segments, so multi-part extensions survive and dotted stems stay whole:run.tamer.mcaprolls over torun_1.tamer.mcap(it wasrun.tamer_1.mcap),log_2026.10.05.mcaptolog_2026.10.05_1.mcap.Safe defaults. Two defaults changed, because each lost data or grew memory silently in a long-running robot process.
MCAPSinkrolls over into new numbered files by default (setCreateNewFileOnResetdefaults totrue). Before, the file was truncated everysetMaxTimeBeforeReset(600 s), DISCARDING everything recorded before the reset. Nothing is lost now, but disk usage is unbounded: one file per 10 minutes for as long as the process runs. To bound the disk, callsetCreateNewFileOnReset(false)(the old behaviour) orsetMaxTimeBeforeReset(std::chrono::seconds(0))(one file, never reset); delete old files yourself otherwise.ROS2PublisherOptions::data_qosdefaults torclcpp::QoS(rclcpp::KeepLast(100)).reliable()instead of reliableKeepAll. A slow or stalled subscriber no longer makes the publishing process queue messages without bound: it loses the oldest messages instead. Withaggregateeach message is a batch, so size the depth accordingly. The QoS stays reliable, so reliable and best-effort subscribers still match. For the old behaviour setoptions.data_qos = rclcpp::QoS(rclcpp::KeepAll()).reliable().Real-time impact: none. Both changes are on the sink worker side;
tryTakeSnapshot()is untouched.
MCAP messages carry a per-channel
sequencenumber (1, 2, 3, … per MCAP channel and file) instead of always 1, so readers can detect gaps (#98).New
data_tamer/sinks/mcap_encoding.hpp(#96): inline helpersmcap_encoding::AddChannel(),WriteSnapshot()(for aSnapshot),WriteMessage()(from a timestamp and mask and payload spans, e.g. for stored data) andEncodeMessageBody()that write the MCAP records ofdocs/wire_format.md.MCAPSinkuses them. The caller provides the sequence number and a reusable scratch buffer (no allocation once it is large enough). They needdata_tamer::data_tamer(andmcap_vendor::mcapunder ROS 2); outside ROS 2 the bundled MCAP headers are now installed (underinclude/data_tamer_mcap) and exported with data_tamer through the header-onlydata_tamer::mcap_headerstarget, so a shared install exports no zstd/lz4 library paths; libdata_tamer already contains the MCAP implementation (do not defineMCAP_IMPLEMENTATION). A shared build no longer installslibmcap_lib.a.SerializeMe::Span<const T>converts from aconst std::vector<T>&. TheDataSinkdocumentation now explains that a slowonSnapshot()exhausts the channel’s shared snapshot pool for every sink.Breaking, channel API cleanup (2.0 review items 20-24, 33):
RegistrationIDis an opaque handle (no publicfirst_index/fields_count, nooperator+=). It denotes one registration: afterunregister()and a new registration of the same name the old handle is stale, andLogChannel::setEnabled/unregisterthrowstd::invalid_argumentinstead of acting on the replacement. NewLogChannel::isEnabled(id)and noexcepttrySetEnabled(id, enable). The per-series flags live in an append-only table whose reads are safe to race with registration (they were astd::dequebefore). Re-registering the same name more than 16 million times throws.LoggedValue::set(value)only stores; theauto_enableparameter is gone. Re-enable withsetEnabled(true).LoggedValue::getMutablePtr()/getConstPtr()exist only for non-scalar values (scalars:set()/get()), hold the channel’s write transaction instead of the raw mutex, so they nest insidescopedWrite(), and are no longer movable (likestd::lock_guard). Removed:getLockedPtr(), themutex()accessors,AtomicProxy/AtomicConstProxy.Removed
LogChannel::writeMutex(),sharedState()and the globalMutexalias.scopedWrite()returnsDataTamer::WriteTransaction.Parser:
BuildSchemaFromTextis the entry point; the misspelledBuilSchemaFromTextremains as a deprecated alias for one release.
Breaking, snapshots:
takeSnapshot()returns a[[nodiscard]]SnapshotResultinstead ofbool(ok,partial,rejected,no_sinks,not_started,pool_exhausted,oversize,blocked). NewtryTakeSnapshot()for real-time producers: never blocks on the write mutex and never grows a slot; it requiresstartLogging().setStrictMode()is removed (it wastryTakeSnapshot()’s no-growth behaviour); thedroppedOversize()counter stays.New
LogChannel::startLogging()/isLoggingStarted()(prepare()/isPrepared()during the 2.0 development;SnapshotResult::not_preparedis nownot_started): freeze the schema, allocate the pool and announce the schema to the sinks, so that the channel is ready to take snapshots. The firsttakeSnapshot()with a sink attached calls it automatically. AstartLogging()that fails while sizing or announcing leaves the channel exactly as it was (schema open, settable capacities) so it can be retried; if the schema changes afterwards, sinks that already heard it are announced again. Its last step allocates one queue per sink: if that throws (std::bad_alloc), the schema stays frozen with its pool, nothing is published, and callingstartLogging()again retries the allocation.takeSnapshot()without sinks now returnsno_sinkswithout freezing anything. SinkonSchemacallbacks run with the channel control mutex released, so a sink may query the channel.Breaking, sinks:
DataSinkBaseis replaced by composition. A sink implementsDataSink(onSchema(const Schema&),onSnapshot(const SnapshotRef&); throw to report a failure) and is owned by aSinkWorker, which holds the queues and the delivery thread and is whatLogChannel::addDataSinktakes. The worker is always stopped before the sink is destroyed, and its destructor delivers what is still queued, so the oldstopThread()-in-every-destructor rule and the four lifecycle calls (stopThread,stopAcceptingSnapshots,processQueuedSnapshots,startAcceptingSnapshots) are gone; useSinkWorker::stop(),start()anddrain().SinkWorker::Delivery::Manualruns without a thread for applications that deliver from their own loop.MCAPSink::create(path),ROS2PublisherSink::create(node, prefix)andSinkWorker::create<T>(...)return a ready-to-attach worker;worker->as<MCAPSink>()reaches the sink’s own methods.MCAPSink::finishQueueAndStop()isworker->stop()followed bystopRecording();restartRecording()no longer reopens admission, callworker->start()after a stop. A failed MCAP write now throws from the callback and is reported bySinkWorker::errors()/lastError().Breaking, snapshots:
Snapshotno longer carrieschannel_name(the schema hash identifies the channel;Schema::channel_namehas the name), so it is fully owning.SnapshotRefis now declared indata_sink.hppas an opaque move-only handle: keep a snapshot past the callback withref.clone();DataSinkBase::retainSnapshot()and its thread-local context are gone.Build: the library, tests and benchmarks compile as C++20; the installed headers remain C++17 (consumers need
cxx_std_17), enforced by a test target that compiles every core public header as strict C++17 and by building the examples as C++17. The ROS 2 sink header follows rclcpp’s standard, which is C++20 on recent distributions. Internally, the reader-quiescence and sink-admission barriers block onstd::atomic::waitinstead of yield-spinning, and the sink worker is astd::jthread.Build: CMake 3.22 is now required. The build is target-based: MCAP, rclcpp, rclcpp_lifecycle and the message package are linked as imported targets (
mcap_vendor::mcap,rclcpp::rclcpp, …) instead of*_INCLUDE_DIRSand*_LIBRARIESvariables, the library linksThreads::Threadsand exports adata_tamer::data_tameralias in-tree,DATA_TAMER_VERSIONreports the library’s own version when built as a subproject, and the installeddata_tamerConfig.cmakefinds its dependencies.Robustness fixes from the 2.0 review: the header-only parser and the Python decoder check bounds before every read, reject masks shorter than the schema, oversized dynamic counts and cyclic custom types, match type names exactly and validate fixed-array extents;
SchemaHash/schema_hashfields areuint64_tend to end;TypeField::operator!=is defined. Library: the fixed-size accumulator for arrays was uninitialized; custom types may now contain numericstd::array/std::vectormembers; a failed registration leaves the channel unchanged; null custom serializers are rejected; fixed array extents outside 1..65535 fail to compile; the active mask is refreshed under the write mutex so values enabled inside one transaction are captured together; schema text is locale independent.MCAPSinkreports write failures fromstoreSnapshot(), opens a new file before closing the old one on restart, and synchronizes its setters with the worker. The ROS 2 sink retries schema publication after a failure.ChannelsRegistry::clear()destroys channels outside its lock;addDefaultSink(nullptr)throws.Schema version 5: the schema hash is FNV-1a 64 of the schema text (minus its own hash line), so it is identical on every platform, verifiable by any decoder and covers custom type bodies. Readers accept version 4 files through their declared hash; parsers older than this release reject version 5 files with “Wrong SCHEMA_VERSION”. In the library
AddFieldToHashis replaced bySchemaTextHash/ComputeSchemaHash; the header-only parser keeps it for version 4 texts.Real-time front end, steps 0–8: scalar
LoggedValuevalues use lock-free atomics;set()/get()are wait-free. Non-scalar updates and snapshot serialization share a priority-inheriting mutex.LogChannel::scopedWrite()groups updates into one transaction and supports nested non-scalarset()/get(). Lone scalar updates do not promise consistency across values.takeSnapshot()now freezes schema and capacity state on its first attempt, then serializes directly into a 64-slot channel pool. Its fixed-size warmed library path takes no structure mutex and performs no heap allocation; user serializers and non-strict payload growth may still allocate or throw. Sequentially consistent sink, liveness, mask-dirty and reader-epoch publication makes concurrent value destruction and sink removal safe.Added pre-freeze
setPayloadCapacity()andsetPoolCapacity()plus runtimesetStrictMode(). Initial per-slot reservation is at least the maximum of the hint, twice the initial payload and 256 bytes. Strict mode drops an oversize snapshot against the acquired slot’s actual capacity, so a later input cannot grow that slot; non-strict mode grows that slot and retains the new capacity.Added
writeLockContended(),writeLockWaitMaxNs()andstats(). Contention counts acquisitions that block after spinning; maximum wait measures blocking acquisition time, excluding serialization.API:
Mutexnow aliases exclusiveWriteMutex(nolock_shared());LoggedValue::get()is const; scalargetMutablePtr()/getConstPtr()are deprecated in favor ofset()/get(). Proxymutex()is deprecated and proxy boolean conversion is explicit.LoggedValueis no longer movable.DummySinkexposes synchronized accessors instead of public members.MCAPSinkandROS2PublisherSinkstore private state behind a Pimpl. This release changes their ABI; downstream binaries must be rebuilt.Sink delivery now shares snapshots through a 64-slot channel pool and a preallocated blocking queue.
DataSinkBase(size_t queue_capacity = 1024)treats capacity as a block-rounded minimum shared by all channel producers;DummySinkandMCAPSinkforward the same final defaulted argument.DataSinkBase::pushSnapshotwas removed.Added
LogChannel::poolExhausted(), per-attachmentdroppedSnapshots(sink),Stats::pool_exhausted, andDataSinkBase::storeErrors(), plus payload reallocation and oversize-drop counters.Statsfields are plainuint64_tpoint-in-time snapshots of relaxed atomic counters;droppedSnapshots(sink)takes the control mutex.DataSinkBase::storeErrors()records thrown queued callbacks; a callback returningfalseis not an exception.A channel supports eight attached sinks. Same-name compatible re-registration reuses its schema slot after freeze; registration IDs identify slots rather than generations. Control operations must run outside writer guards and serializer callbacks.
Derived sinks must call
stopThread()before destroying callback state. Queued callbacks are serialized with manual draining, exceptions do not stop delivery, and protected callback-onlyretainSnapshot()can keep pooled payload, mask and channel-name storage alive without changing thestoreSnapshot(const Snapshot&)virtual API.Closing sink admission waits for already admitted enqueues and drains all accepted references.
MCAPSink::finishQueueAndStop()no longer polls or sleeps; explicit restart clears forced-stop state and reopens admission, while automatic rollover preserves an existing closure.Callbacks of one channel run in snapshot order (see the per-channel rings above). A sink shared by multiple channels does not promise global timestamp order; readers that require a merged timeline must sort or merge it.
Build: debug/release/asan/tsan presets, sanitizer CI, allocation-counting benchmarks and the
rt_latencyharness, including standalone mutex/pool measurements and validated CLI inputs. Conan’s benchmark option exports and builds the benchmark sources. Vendored MCAP builds with GCC 15.Versioning. The release number is 2.0.0 everywhere it is kept: CMake
project(VERSION)(was 1.0.0), bothpackage.xmlfiles (were 1.0.4),conanfile.py(was 0.9.4) and the Python decoder;tools/check_versions.pychecks that they agree and runs as the ctest caseversion_consistency. The shared library is versioned:libdata_tamer.so.2.0.0with the SONAMElibdata_tamer.so.2(SOVERSIONis the major version, for the ROS 2 and the plain CMake builds). Consumers built against 1.x must be rebuilt: 1.x had no SONAME, so an old binary (NEEDED libdata_tamer.so) still finds the new library through thelibdata_tamer.solink and then fails on missing symbols.ABI policy.
tests/abi_tests.cpppins the size, alignment and member offsets of every public by-value struct, options struct and class whose layout reaches consumers (exact bytes on x86_64 Linux with libstdc++, relations for what holds a mutex on other libstdc++ platforms); a failing pin means a SONAME bump. TheStatsstructs are built inline from exported getters and are not pinned. A newabiworkflow compares the library with the last release of the same major version (the merge base until 2.0.0 is tagged) with libabigail and fails on removed or changed exported functions, changed vtables and layouts; run it locally withtools/abi_check.sh. CLAUDE.md states what is frozen in 2.x and when to bump. Defaults that live in a header (member initializers, default arguments) apply only to rebuilt consumers; defaults inside a Pimpl apply to old binaries too.
1.0.4 (2026-07-26)
Merge pull request #68 from coderjake91/feature/update-ROS2PublisherSink-to-use-NodeInterfaces Update ROS 2 publisher sink to use NodeInterfaces
Merge pull request #65 from Basiljamal1/main Add thread-safe methods to add and remove data sinks in LogChannel
modify logging_started toggle to avoid possible (benign) race condition
clarify addDataSink logic with comment and simplify code
doxygen name fix
test: add and improve tests for adding and removing data sinks in LogChannel
Merge pull request #62 from PickNikRobotics/finish_queue_before_stop add ability to finish queue then stop recording
fix: Updated header file for channel.hpp
Add thread-safe methods to add and remove data sinks in LogChannel
Merge pull request #64 from Shibodd/cmake_benchmarks_option CMakeLists: add DATA_TAMER_BUILD_BENCHMARKS option
CMakeLists: add DATA_TAMER_BUILD_BENCHMARKS option
add ability to finish queue then stop recording
Inline operator== function to prevent multiple includes (#60) Co-authored-by: jlack <jlack@nauticusrobotics.com>
Fix compilation on Windows by exporting all symbols with CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS (#58)
Merge pull request #40 from damien-robotsix/custom_container_handling fix: handling of custom containers that has a TypeDefinition
add trait ‘tests’
fix: ub in SerializeIntoBuffer
fix: handling of custom containers that has a TypeDefinition
Contributors: Basil Jamal, Damien SIX, Henry Moore, Jacob Frazer, Silvio Traversaro, Unimore, jlack1987
1.0.3 (2025-05-23)
Remove ament_target_dependencies usage
1.0.2 (2025-05-12)
Merge pull request #52 from PickNikRobotics/fix_build_farm_3 Try installing test deps no matter what
try installing test deps no matter what
Merge pull request #51 from PickNikRobotics/fix_build_farm_2 ignore build ros argument in test CMake
ignore build ros argument in test
Merge pull request #50 from PickNikRobotics/fix_build_farm Fix ament GTest usage
use ament add gtest
Merge pull request #49 from PickNikRobotics/fix_gtest_link Get gtest from vendor on ROS
add myself to maintainer list, add package xml scheme
get gtest from vendor on ROS
Contributors: Henry Moore
1.0.1 (2025-03-03)
force the size of BasicType ot be 1 byte (#47) * force the size of BasicType ot be 1 byte This fix the issue when the log is generated on two different computers using different compilers (example Linux / QNX) * fix
add file reset capabilities (#37)
Enable users to build without ROS (#36) * make building with ROS an option * clarify warnings, allow for default-building without ROS * switch name of ros build flag * add build ros argument for examples * fix incorrect message
Merge pull request #32 from PickNikRobotics/default_increment_filename Allow endless recording
remove unused variable
allow for unlimited recording
use a new mutex wrapper API (#27) * use a new mutx wrapper API * fix compilation * change docstrings to reference non-deprecated function * add now-missing includes ——— Co-authored-by: Henry Moore <henry.moore@picknik.ai>
Locked ptr test, documentation, and example (#24) * add locked ptr usage to example * update comment relating to locked ptr * remove unused headers * add test for locked ptr and non-blocking method * remove unsafe lockedPtr get function
Merge pull request #23 from torsoelectronics/main Fix compile error
Fix build error
Contributors: Daniel Mouritzen, Davide Faconti, Henry Moore
1.0.0 (2024-04-30)
Support lifecycle node for ros2 publisher sink (#17) * Support lifecycle node for ros2 publisher sink * Remove unused member variable node_ * Add template for both constructors
more efficient locking of LoggedValue<T> and new clang format
refactoring custom types
fix compilation with and without conan
new clang format
add mcap to 3rdparty
Contributors: Davide Faconti, Victor Massagué Respall
0.9.4 (2024-02-02)
changed the way registerValue throws if you try registering the same address again
add unit tests to verify that vectors with changing size are OK
Contributors: Davide Faconti
0.9.3 (2024-02-01)
add std::hash<DataTamer::RegistrationID>
fix dead-lock
Contributors: Davide Faconti
0.9.2 (2024-01-30)
fix compilation in ament
Update CMakeLists.txt. Fix #11
Contributors: Davide Faconti
0.9.1 (2024-01-12)
add support for enums
renamed folder to data_tamer_cpp
Contributors: Davide Faconti
0.8.0 (2023-11-30)
API change related to CustomSerializers
Contributors: Davide Faconti
0.7.0 (2023-11-28)
recursive_mutex and call it a day
add MCAP option
add MCAPSink::stopRecording
add more types to mcap example
add ChannelsRegistry::clear()
extended tests
bug fixes and more tests
fix warning
compute fixed size at compilation time
new wrappying of TypeDefinition
refactoring type registry
major refactoring of custom types
Contributors: Davide Faconti
0.6.0 (2023-11-23)
@ add back compatibility to data_tamer_parser * works correctly with plotjuggler * fix ROS2 compilation * Contributors: Davide Faconti
0.5.0 (2023-11-22)
preliminary custom type support
Contributors: Davide Faconti
0.4.1 (2023-11-21)
0.4.0 (2023-11-21)
add again channel name to hash
bug fixes in schema hash and parsing
add benchmark
readme update
added data_tamer_parser with some samples and testing
add locked reference
bug fixes and tests
refactored API to support containers
Contributors: Davide Faconti
0.3.0 (2023-11-14)
add coverage
fix bug
add CI
unit test added
allow registering again with new pointer
add docs
use custom mutex on linux
adding ros2 example
ros2 publisher sink
Contributors: Davide Faconti
0.2.1 (2023-11-13)
fix conan
fix conan
Contributors: Davide Faconti
0.2.0 (2023-11-13)
First release: supports MCAP sink only
Contributors: Davide Faconti, Henry Moore