C++ wrapper around the Rust core via the C FFI library (rust/ffi/zerobus.h).
Like Go, it consumes the C FFI directly; unlike Go there is no GC, so resource
management is RAII and errors are reported by throwing zerobus::ZerobusException.
cpp/
├── include/zerobus/ # Public headers (the API surface)
│ ├── zerobus.hpp # Umbrella header — include this
│ ├── sdk.hpp # Sdk + SdkBuilder + TableProperties
│ ├── stream.hpp # Stream (proto/JSON ingestion)
│ ├── arrow_stream.hpp # ArrowStream (Arrow Flight, Beta)
│ ├── proto_schema.hpp # ProtoSchema (UC JSON → descriptor + encoder)
│ ├── headers_provider.hpp # HeadersProvider interface
│ ├── ack_callback.hpp # AckCallback interface + AckCallback::from adapter
│ ├── config.hpp # StreamOptions / ArrowStreamOptions / CallbackWaitPolicy / enums
│ ├── record.hpp # UnackedRecord
│ ├── error.hpp # ZerobusException
│ └── version.hpp # ZEROBUS_CPP_VERSION
├── src/ # Implementation (the only place that includes zerobus.h)
│ ├── sdk.cpp / stream.cpp / arrow_stream.cpp / proto_schema.cpp
│ ├── ack_callback.cpp # AckCallback::from lambda adapter
│ ├── headers_callback.cpp # extern "C" trampoline for HeadersProvider
│ └── detail/ # Internal: ffi_util, config_convert, headers_callback, ack_callback
├── tests/ # Dependency-free unit tests (plain executables)
├── examples/ # Runnable usage examples (JSON, proto, Arrow)
├── cmake/
│ ├── BuildRustFfi.cmake # Builds libzerobus_ffi from local Rust source
│ └── zerobus-config.cmake.in # find_package(zerobus) package-config template
├── CMakeLists.txt
└── Makefile # Convenience wrappers (build/test/lint/fmt)
The public headers never include zerobus.h; they hold opaque, forward-declared
FFI handles. Only src/ includes the generated C header, keeping the C FFI an
implementation detail of the SDK.
Run from cpp/:
make build— Configure (CMake) and build the SDK, tests, and the examples underexamples/(gated onZEROBUS_BUILD_EXAMPLES, on by default)make build-ffi— Build only the Rust C FFI static librarymake test— Build and run the test suite (ctest)make lint— Formatting check + compiler warnings (-Wall -Wextra)make fmt— Format all sources with clang-format (Google style)make clean— Remove the build directorymake test SANITIZE=address— Build + run the suite under a sanitizer (address,thread, orundefined; CMake option-DZEROBUS_SANITIZE=). Off by default. Targets the memory/lifetime bugs this FFI wrapper is prone to (use-after-free, double-free).address/threadalso instrument the Rust FFI (nightly-Zsanitizer+-Zbuild-std) — a sanitizer only observes code compiled with it, so leaving the core uninstrumented would miss the races/UAF that live inside the FFI. This needs a nightly toolchain withrust-srcand build the C++ side with clang to match the LLVM sanitizer runtime (CLANG_CC/CLANG_CXX, defaultclang/clang++); GCC can't resolve the instrumented archive's runtime symbols.undefineduses the default compiler and a plain FFI build. Sanitizer builds use their own build dir (build-<sanitizer>), so they don't clash with a normalbuild/(CMake caches the compiler per build dir).
CMake builds the FFI library from local Rust source by default
(cargo build --release in rust/ffi). To link a prebuilt/vendored library
instead, configure with -DZEROBUS_FFI_LIBRARY=<path-to-.a> and
-DZEROBUS_FFI_HEADER_DIR=<dir>.
- Opaque handles: each wrapper class holds a raw pointer to a Rust-allocated
handle (
CZerobusSdk,CZerobusStream,CArrowStream,CZerobusProtoSchema). Rust owns the memory. - RAII cleanup: destructors call the matching free/close function
(
zerobus_sdk_free,zerobus_stream_close+zerobus_stream_free,zerobus_arrow_stream_close+zerobus_arrow_stream_free, etc.). Wrapper objects are move-only (no copies) — moving nulls the source handle so it is freed exactly once. - Error strings: FFI calls route their
CResultthrough a helper indetail/ffi_util.hpp, which converts failure into aZerobusExceptionand always freesCResult.error_messageviazerobus_free_error_message. - Returned arrays:
Stream::get_unacked_records/ArrowStream::get_unacked_batchescopy the payloads out, then free the FFI array (zerobus_free_record_array/zerobus_arrow_free_batch_array).ProtoSchema::encode_jsonfrees its buffer withzerobus_free_proto_bytes;descriptor_bytes()copies the borrowed bytes.
When using a custom HeadersProvider:
detail::zerobus_cpp_headers_trampolineis anextern "C"function passed tozerobus_sdk_create_stream_with_headers_provider.user_datais the rawHeadersProvider*.- The
Stream/ArrowStreamkeeps astd::shared_ptr<HeadersProvider>alive for its whole lifetime; the handle is freed in the destructor before the shared_ptr member is destroyed. Necessary but not always sufficient (seeheaders_provider.hpp): aget_headers()call still running whenclose()times out (~1s) can be invoked on a freed provider. Keepget_headers()well under that budget, or keep the provider alive past the stream. - The trampoline marshals the returned map into
CHeadersusing the allocators the Rust core'szerobus_free_headersexpects to free. Do not change the allocator without checkingzerobus_free_headersin the FFI crate. - Exceptions thrown from
get_headers()are caught and surfaced as a headers-provider error; they never cross the FFI boundary as C++ exceptions.
When a StreamOptions::ack_callback is set:
detail::zerobus_cpp_ack_on_ack_trampoline/zerobus_cpp_ack_on_error_trampolineare theextern "C"functions wired into the C config (ack_on_ack/ack_on_error);user_datais the rawAckCallback*.to_c()installs them only when the callback is non-null and zeroes all three fields otherwise (seedetail/config_convert.hpp).- The
Streamkeeps astd::shared_ptr<AckCallback>alive for its whole lifetime, but that is not always sufficient: a callback still running whenclose()hits its wait budget (StreamOptions::callback_wait_policy) can be invoked on a freed object once theStreamis destroyed.CallbackWaitPolicy:: forever()is the only policy that guarantees no callback runs afterclose()returns. This is the canonical FFI-side view of the contract ininclude/zerobus/ack_callback.hpp— keep the two in sync. AckCallback::on_ack/on_errorarenoexcept. The trampolines must not let anything unwind across the boundary; an escaping exception callsstd::terminateat the throw site.
detail::to_c (overloaded for StreamOptions and ArrowStreamOptions) seeds
the C config struct from the live FFI defaults (zerobus_get_default_config /
zerobus_arrow_get_default_config) and then overrides each known field. The
Stream variant expresses optional fields (e.g. stream_paused_max_wait_time_ms)
with the C struct's has_* presence flags; the Arrow variant uses a -1
sentinel for "wait the full server-specified pause duration". config_defaults_test
and arrow_config_defaults_test fail the build if the hand-kept scalar defaults
in StreamOptions / ArrowStreamOptions drift from the FFI defaults.
StreamOptions::callback_wait_policy (a CallbackWaitPolicy) also maps to a
has_*/value pair: forever() clears has_callback_max_wait_time_ms so Rust
reads None (drain indefinitely), duration(ms) sets an explicit finite budget,
and use_default() leaves the FFI seed in place. The ack-callback trampolines
and ack_user_data are set from StreamOptions::ack_callback (see Ack
callback).
- A
Stream/ArrowStreamis not safe for concurrent use — serialize access externally (same contract as the Rust core). - A
HeadersProvider::get_headers()may be invoked from an internal worker thread; the core guards against overlapping calls but implementations should be thread-safe with respect to their own state. - A single
ProtoSchemamay be used by concurrent readers (descriptor_bytes,encode_json); destruction must not race those calls.tests/concurrency_test.cppexercises this under ThreadSanitizer (which instruments the Rust core too), so a race in the read path is caught in CI. The destruction-vs-read contract, though, is not tested — it is the caller's responsibility, not something the TSan job enforces.
Public API is everything under include/zerobus/:
- Removing/renaming public types, methods, or struct fields, or changing a method signature, is breaking.
- Adding new methods, new overloads, or new struct fields with defaults is safe.
- Deprecate with
[[deprecated("Use X instead")]]and keep for at least one minor release before removal in the next major (semver, tagscpp/v*).
- Every FFI crossing serializes data; prefer the batch APIs
(
ingest_proto_records,ingest_json_records) over per-record calls in hot paths. - Ingestion is asynchronous:
ingest_*queues and returns. Never wait per record (wait_for_offset/flushin the loop); flush once at the end or flush periodically. Examples and doc comments must follow this pattern. - The static FFI library is linked at build time — no runtime loading overhead.
- Every PR must update
cpp/NEXT_CHANGELOG.mdunder the appropriate section if it changes user-facing behavior. - Update
cpp/README.mdif the change affects usage, setup, or API surface. - Add or update examples in
cpp/examples/for new or modified APIs. - Add doc comments (
///) for all new public items.
The distribution model is CMake + GitHub Releases only — no package manager (no
Conan, vcpkg, etc.), mirroring the C FFI. The C++ wrapper is always built from
the consumer's own compiler (its C++ ABI is not stable across compiler/stdlib),
so we do not ship a prebuilt libzerobus_cpp. What we ship prebuilt is the
Rust C FFI archive (libzerobus_ffi.a) — a stable C ABI, and the one piece a
consumer can't build without a Rust toolchain. A consumer then either builds
everything from source (needs Rust) via add_subdirectory / FetchContent /
find_package, or points -DZEROBUS_FFI_LIBRARY= at the matching prebuilt FFI
archive from a release and builds only the C++ (no Rust needed).
- Create the
cpp/v<semver>tag. - Manually dispatch the
release-zerobus-sdk-cpp.ymlorchestrator (insecure-public-registry-releases-eng) with the tag asref. It invokesrelease-cpp.ymlin this repo to build the per-platform bundles (the Rust C FFI archive cross-built withcargo-zigbuild, plus the platform-neutral C++ source tree), scans the artifacts, and cuts the GitHub Release with a per-platformtar.gzattached and notes fromcpp/CHANGELOG.md. - Version source:
cpp/include/zerobus/version.hpp(ZEROBUS_CPP_VERSION) and theproject(... VERSION ...)line incpp/CMakeLists.txt— keep them in sync (CMake fails configuration if they disagree). The release validates the tag version againstZEROBUS_CPP_VERSION. - The C++ SDK links the FFI static library. If Rust FFI code changed, an FFI
release (
ffi/v*) must happen first; for source builds the workspace must be present. - On version bump PR: move
NEXT_CHANGELOG.mdcontents toCHANGELOG.mdunder a## Release v<semver>heading (the release notes are extracted by matching that heading), resetNEXT_CHANGELOG.md.
- C++17 compiler (GCC, Clang, or MSVC)
- CMake ≥ 3.16
- Rust toolchain required for building the FFI from source
- Tests are dependency-free: each is a plain executable that returns
non-zero on failure and is registered with CTest (see
tests/CMakeLists.txt), so the build is hermetic — no vendored framework, no package manager, no network. Tests reach into the privatesrc/detailheaders, so each links the library target and addssrc/to its include path. If real third-party C++ test dependencies are ever needed, that's the point to revisit the tooling — not before.