composite is a lightweight framework for building componentized streaming applications. Components are independent shared libraries, loaded at runtime and wired into a data-flow graph; the framework owns the threading, the lock-free data path, the property/configuration plane, and a REST control plane for runtime inspection and reconfiguration.
- Modular architecture — build applications by composing reusable component
.sos. - Lock-free data path — bounded single-producer/single-consumer ring ports with event-driven (doorbell) wakeups; minimal copies via move-on-last-receiver buffer transfer.
- Typed configuration — properties are native, schema-validated JSON bound directly to component
members, with optional whole-struct
config<T>reflection. - Observable — a lock-free metrics registry exposed over REST/SSE, with optional OpenTelemetry export.
- CMake 3.15 or higher (3.22+ recommended)
- A C++20 compiler (GCC 12+, Clang 15+)
- OpenSSL 3.0+ — only if building with
-DCOMPOSITE_USE_OPENSSL=ON
cmake -B build
cmake --build build [--parallel N]
cmake --install buildThe install ships a relocatable CMake package and a pkg-config file; a downstream project consumes
it with find_package(composite) (or pkg-config composite) and links the imported target
composite::composite. find_package(composite) also works against the build tree (point
CMAKE_PREFIX_PATH at the build directory) without installing.
COMPOSITE_USE_OPENSSL(default OFF) — compile a TLS REST server (requires OpenSSL 3). When ON,composite-clirequires client cert/key arguments at launch (see below).COMPOSITE_USE_OPENTELEMETRY(default OFF) — enable OpenTelemetry OTLP metrics export (requires opentelemetry-cpp).COMPOSITE_USE_DPDK(default OFF) — build DPDK-backed source support.
The repository builds two framework-only Rocky Linux 9 image roles:
composite:<version>— a non-root runtime containingcomposite-cliandlibcomposite;composite:<version>-devel— the matching SDK/toolchain for building external component modules.
The runtime and SDK are produced from one multi-stage build and separate CMake Runtime and Development install components. The official component fleet will be distributed separately by composite-comps on top of these exact framework images.
See docker/README.md for local builds, runtime use, GitLab publication variables, tag behavior, and runner requirements.
The framework ships composite-cli, which loads a JSON application file, wires the graph, starts the
components, and serves the REST control plane.
Usage: composite-cli [--help] [--version] [--server VAR] [--port VAR] [--log-level VAR] config-file
Positional arguments:
config-file application configuration file
Optional arguments:
-h, --help shows help message and exits
-v, --version prints version information and exits
-s, --server REST server bind address [default: "localhost"]
-p, --port REST server port [default: 5000]
-l, --log-level trace|debug|info|warning|error|critical|off [default: "info"]
Additional arguments appear depending on build options:
- With
-DCOMPOSITE_USE_OPENSSL=ON(TLS), client authentication is required:-c, --client-certificate <file>(required),-k, --client-key <file>(required),-a, --certificate-authority <file>(optional). - With
-DCOMPOSITE_USE_DPDK=ON:--list-dpdk-portsenumerates DPDK-capable ports and exits.
The default (non-TLS) build serves plain, unauthenticated HTTP with a wide-open CORS policy. Run it on a trusted interface, or build with OpenSSL for mutual-TLS.
The configuration file defines the components, their properties, and how they are connected.
Top-level keys:
-
name (optional, string) — application name; a default is generated if omitted.
-
properties (optional, object) — application-level properties applied to all components before their component-level properties. Property values are native JSON, typed to the target C++ field — the framework validates and decodes each value against the property's registered schema (a
boolfield takestrue, a numeric field takes123/2.5, a list field takes a JSON array, an enum field takes the enum-name string). They are not strings.
{
"name": "my_streaming_application",
"properties": {
"feature_flags": { "enable_x": true }
}
}- components (required, array) — each object defines a component instance:
- id (required, string) — unique instance identifier; used for connections and passed to the component constructor.
- library (required, string) — the shared library to load. A bare name (
"fft"or"libfft.so") is resolved viaLD_LIBRARY_PATHand standard paths; an absolute path loads directly. A name without a.sosuffix is expanded tolib<name>.so. - properties (optional, object) — component-specific property values (native JSON, as above).
- args (optional, object) — construction-time arguments passed to the factory, chiefly the
template
"type"discriminator (e.g.{ "type": "cf32" }) — distinct from runtimeproperties. The legacy scalar"create_arg": "cf32"form is still accepted and mapped to{ "type": "cf32" }.
{
"components": [
{
"id": "my_component_instance",
"library": "libmy_component.so",
"properties": { "threshold": 123, "processing_gain": 2.5 }
}
]
}- connections (required, array) — each object connects one output port to one input port:
- output —
{ "component": "<source id>", "port": "<output port name>" } - input —
{ "component": "<target id>", "port": "<input port name>" }
- output —
{
"components": [
{ "id": "sensor", "library": "libsensor_reader.so" },
{ "id": "processor", "library": "libdata_processor.so" },
{ "id": "writer", "library": "libfile_writer.so" }
],
"connections": [
{ "output": { "component": "sensor", "port": "raw_data" },
"input": { "component": "processor", "port": "data_in" } },
{ "output": { "component": "processor", "port": "processed_data" },
"input": { "component": "writer", "port": "data_in" } }
]
}Note: an output port may fan out to multiple input ports, but an input port accepts exactly one producer (fan-in is rejected at connect time — it is the single-producer precondition of the SPSC ring).
Components are dynamically loaded shared libraries:
-
Library path — the
libraryfield resolves a bare name (viaLD_LIBRARY_PATH/standard locations) or an absolute path; a missing.sosuffix becomeslib<name>.so. -
Factory ABI — each component library exports exactly one factory plus an ABI-version handshake. Declare them with the
COMPOSITE_REGISTER_*macros from<composite/core/register.hpp>rather than hand-writing theextern "C"block:#include <composite/core/register.hpp> // Non-templated component with a `MyComp(std::string_view id)` constructor: COMPOSITE_REGISTER_SIMPLE(MyComp) // Component whose concrete type is chosen at construction (a "type" discriminator): COMPOSITE_REGISTER_COMPONENT([](std::string_view id, const composite::create_args& args) -> std::shared_ptr<composite::component> { const auto type = args.type(); // e.g. "cf32" if (type == "cf32") return composite::make_component<my_fft<std::complex<float>>>(id); if (type == "cf64") return composite::make_component<my_fft<std::complex<double>>>(id); throw std::runtime_error("my_fft: unknown type '" + std::string{type} + "'"); })
Both macros emit the single signature the loader calls —
create(std::string_view id, const composite::create_args& args)— and acomposite_abi_version()symbol. The loader checks the ABI version (currently1) before callingcreate()and refuses a library built against an incompatible framework, so a stale.sofails cleanly instead of being called through a mismatched signature. -
Construction args — pass construction-time arguments in an
"args"object on the component config; the factory reads them viaargs.type()/args.value<T>("key"). These are distinct from runtimeproperties.{ "id": "fft1", "library": "fft", "args": { "type": "cf32" } } -
Component ID — each instance must have a unique
id. The factory must thread the providedidinto the constructor (composite::component(id)); the loader rejects a component whoseid()differs from the configured id. Thedlopenhandle is tied to the returned component's lifetime, so the library is unmapped only after the component is destroyed.
- Construction — ports and properties are registered in the constructor.
- Initialize —
initialize()runs once (optional override) before the worker loop. - Start — the component's worker thread starts (if
enabledistrue). - Process loop —
process()is called repeatedly on the worker thread. - Stop — the worker is joined and resources are released.
Each component runs in its own std::jthread, named after the component id for debugging.
start()/stop() are final. One lifecycle path (the enabled reconcile used by
application::start() and RUNTIME enabled writes) never calls the virtual entry points, so an
override would run on a direct start() but silently not on a reconcile. Subclasses that own
worker-scoped resources (a receiver thread, a worker pool) hook on_worker_start() /
on_worker_stop() instead — those run on every start/stop path.
Ownership. Create heap-allocated components with composite::make_component<T>(args...): the
returned shared_ptr's deleter stops the component before destruction begins, while the leaf
vtable and derived members are still intact — closing the "destroyed while its worker still runs"
use-after-free. The deleter survives the upcast to shared_ptr<component>, and
COMPOSITE_REGISTER_SIMPLE builds through it (custom factory lambdas should too). A
stack-allocated component instead declares component::auto_stop as its last data member.
enabled is a framework spec/status virtual, not a stored property. It defaults to true. Reads
report the desired state; the component report also includes an observed running flag.
- A RUNTIME-context write to
enabledis the start/stop action — it reconciles the worker immediately. There is no separate "apply" step. - An INITIALIZE-context write only records the desired state; the application start sequence (or
apply_lifecycle_changes()) reconciles it. enabledmust be written as a JSON boolean; a non-boolean is rejected.
Disabling a component stops its worker and pauses its input ports (sets their depth to 0, so incoming data is dropped rather than queued); re-enabling restarts the worker and restores the port depths.
// Disable a running component (RUNTIME context -> takes effect immediately):
component->set_properties({{"enabled", false}}, composite::properties::config_type::RUNTIME);
// Re-enable it:
component->set_properties({{"enabled", true}}, composite::properties::config_type::RUNTIME);Over the REST API,
PATCH/PUTwrites are always RUNTIME-context, so writingenabledthere starts/stops the component immediately.
process() returns a composite::retval that tells the worker loop what to do next:
| Return | Meaning |
|---|---|
NORMAL |
Useful work was done. The worker loops, yielding (sched_yield) once per yield_interval consecutive NORMALs (default 32) so a busy component doesn't monopolize a core. |
NOOP |
No work was available (e.g. no input data). The worker arms the input doorbell and sleeps until a producer delivers data, a stop/reconfigure is requested, or the noop_thread_delay backstop (default 1 ms) elapses. At end-of-stream (every input drained and producer-closed) a NOOP is auto-promoted to FINISH so the component self-completes — see below. |
AWAIT_OUTPUT |
The component is blocked on a full downstream output (it paced with can_send()). The worker sleeps until a consumer drains the downstream ring (reverse doorbell), or the backstop elapses. Use this for lossless backpressure. |
FINISH |
Graceful shutdown — the worker exits the loop permanently. Synthesized automatically if process() throws, or when a NOOP coincides with end-of-stream (unless finish_at_end is set false). |
NO_YIELD |
Work was done; loop again immediately without yielding (does not count toward the yield_interval streak). |
The doorbell is a latency optimization, not the liveness mechanism: noop_thread_delay/AWAIT_OUTPUT
always carry a timeout backstop, so a missed fast-wake degrades to that delay — never a hang.
A component that reaches the end of its work self-terminates — the basis for batch and file-processing pipelines that run to completion and stop on their own:
- EOS is the default — you don't write it. When your
process()returnsNOOPand every input has drained and its producer has closed (inputs_at_end()), the base auto-promotes thatNOOPtoFINISH. So a plain consumer that returnsNOOPon an empty read already self-completes when the stream ends; noinputs_at_end()check is needed. Opt out with thefinish_at_endproperty (set false) for the rare component that must keep running past its inputs. - Flushing held state. If your component buffers data (a delay line, a framer's partial residue,
an accumulator), override
on_end_of_stream()— it's called once on the worker thread just before the synthesizedFINISH/EOS, and may emit final packets viasend_data(). (Distinct fromon_finished(reason), which runs later in the completion tail for resource release / recording — keep that one prompt; it runs before a concurrent property write can complete.) - Returning
FINISHexplicitly (or an unhandledprocess()throw) also ends the worker loop, andon_finished(reason)is called once (reasoniscompletedforFINISH,errorfor a throw). - On a
completedfinish the framework sends end-of-stream (EOS) on every output port. EOS is out-of-band (a flag, not a ring packet), so the drop-on-full ring cannot lose it. A downstream input reachesat_end()once drained, so the next consumer self-completes in turn — completion propagates through the whole graph. is_finished()/finished_reason()(and thefinished/finish_reasonfields of the component report) expose the terminal state.wait_until_finished([timeout])— on bothcomponentandapplication— blocks until the worker(s) exit; the app-level form is the natural join for a batch run.
composite::application app{"batch"};
// ... add components, connect ...
app.start();
app.wait_until_finished(); // returns once a finite source drives the graph to completion
app.stop();Sources. Derive source_component<OutBuf> for a component with no data input: implement
produce() returning emit(buffer, ts), idle() (nothing yet — back off), or done() (end of
stream → EOS + FINISH). Backpressure is automatic — when the downstream ring is full the base returns
AWAIT_OUTPUT and does not call produce(), so a source paces to consumption instead of dropping.
A concrete source must stop its worker while still fully alive — create it with
composite::make_component<T>() (its deleter stops before destruction), or for a stack instance
declare component::auto_stop as its last data member (the base cannot do this for you, since
produce() is pure).
Resilience. Set the error_restart_max / error_restart_backoff_ms properties to retry a
throwing process() with exponential, stop-interruptible backoff instead of finishing on the first
error. Graceful shutdown: application::drain_stop(timeout) stops the sources and lets EOS drain
the graph to completion, then hard-stops any straggler.
Ports transfer time-stamped, contiguous data buffers (and optional per-packet metadata) between components over a bounded, lock-free, single-producer/single-consumer ring.
Each port is an input_port<BufferType> or output_port<BufferType>, where BufferType is one of:
mutable_buffer<T>— exclusive-ownership, move-only bufferimmutable_buffer<T>— shared-ownership, read-only buffer
mutable_buffer<T> — exclusive, move-only (the copy constructor is deleted; use .copy() for
a deep copy). Supports in-place modification via operator[]/iterators/as_span(). Can be promoted to
an immutable buffer with std::move(buf).to_immutable() (rvalue-qualified — it empties the source).
When backed by a dynamic container it supports resize/reserve/capacity/shrink_to_fit/clear
(these throw on an empty or non-dynamic buffer); truncate(n) logically shrinks (it cannot grow).
immutable_buffer<T> — read-only, reference-counted (cheap to copy — a refcount bump). Ideal for
fan-out. share() returns a zero-copy alias; slice(offset, count) / slice_from(offset) are
zero-copy views over the same storage (use immutable_buffer<T>::npos for "to end").
aligned_mem<T> — a SIMD-aligned backing store (requires T trivially copyable and trivially
destructible). Construct buffers over it with make_aligned_buffer<T>(alignment, count) /
make_aligned_immutable_buffer<T>(alignment, count); the alignment must be a power of two and at least
alignof(std::max_align_t).
Factory helpers (<composite/buffers/buffer.hpp>): make_mutable<T>(size) / make_mutable<T>({...}),
make_immutable<T>(size) / make_immutable<T>({...}), wrap_mutable/wrap_immutable, and the
aligned variants above.
output_port<BufferType> publishes time-stamped buffers (with optional metadata) to one or more
connected input ports.
out.send_data(std::move(buffer), ts); // no metadata
out.send_data(std::move(buffer), ts, md_ptr); // shared metadata rides with the packet
out.send_batch(buffers_span, ts, md_ptr); // amortized publish for a single consumer
// Build the shared instance ONCE per metadata change, not per packet:
composite::metadata md;
md.sample_rate = 1e6;
auto md_ptr = composite::make_metadata(std::move(md)); // shared_ptr<const metadata>- Single-producer:
send_data/send_batchmust be called from one thread only (the component's worker). The send path reads a lock-free cached connection snapshot; concurrent senders on the same output are a data race. (Wiring/introspection methods are thread-safe — see below.) - Minimal copies (move-on-last): for the common 1:1 case the buffer (and its metadata) is moved to the receiver — zero copies. On fan-out, earlier receivers get a share/copy and the last receiver gets the move. An immutable→mutable hop deep-copies every frame (flagged with a connect-time warning); a mutable→immutable hop promotes in place.
- Metadata travels atomically with its packet as the third argument — there is no separate
"send metadata" call and no metadata/data race. It is carried as a
composite::metadata_ptr(shared_ptr<const metadata>): the producer rebuilds the instance only when a field changes, every packet in between attaches the same pointer (a refcount bump, not a map copy), fan-out receivers share one instance, and consumers detect "unchanged" by pointer identity instead of a deep compare. A convenience overload still accepts a plainmetadatavalue and wraps it (one allocation per call) — fine for tests, avoid on hot paths. - Backpressure:
can_send()returnstrueif at least one connected input has capacity. Pair it with theAWAIT_OUTPUTreturn value for lossless pacing.
connect() is normally driven by the application graph, but ports also support runtime
reconfiguration, all under a mutex and safe to call while data flows:
bool was = out.disconnect(&input1); // disconnect one; false if it wasn't connected
std::size_t n = out.disconnect(); // disconnect all; returns the count
out.is_connected(); out.is_connected_to(&input2);
out.connection_count(); out.connected_ports(); // introspectioninput_port<BufferType> receives packets into a bounded lock-free SPSC ring.
- Default depth is 1024 (rounded up to a power of two); configurable per port and at runtime via
depth(std::size_t). The ring only grows while empty; raising the depth at runtime adjusts a soft limit, it never reallocates a live ring. - Drop-on-full at the producer: when the ring is full,
send_datadrops the packet (it does not block and the queue is not unbounded), incrementspackets_dropped, and fires the overflow callback. Usecan_send()/AWAIT_OUTPUTupstream for lossless flow. depth(0)pauses the port — every packet is dropped (used byenabled=false).
try_get() is the canonical non-blocking read. It returns
std::optional<std::tuple<BufferType, timestamp, metadata_ptr>>: std::nullopt when the
ring is empty, otherwise the packet — even one carrying a genuine zero-length buffer (the
metadata_ptr is nullptr when the packet carries no metadata):
auto pkt = in.try_get();
if (!pkt) {
return composite::retval::NOOP; // empty ring — idle on the doorbell (base auto-FINISHes at EOS)
}
auto& [buffer, ts, metadata] = *pkt;
if (metadata.has_value()) { /* use *metadata */ }
get_data()also exists and returns the tuple directly, signalling empty with an empty buffer (size() == 0) — prefertry_get(), which distinguishes an empty ring from a real zero-length packet. The worker, not the port, handles idle backoff (via the doorbell /noop_thread_delay), so there is no blocking read; the old no-opget_data(timeout)/get_data(blocking)overloads were removed.
A batch consumer can drain several packets with a single ring advance: get_batch(std::span<...> out)
returns the number moved.
Both port directions expose stats() — packets_transferred(), packets_dropped(),
bytes_transferred(), throughput_mbps(), drop_rate(), time_since_last_activity() — and
reset_stats(). (Live queue depth is published separately as the composite.port.queue_depth
metric gauge, not via stats().) Input ports expose is_full() / available_capacity(), and
set_overflow_callback([](std::size_t dropped){ ... }) to be notified of drops.
There is no transport machinery on output ports. Publishing data outside the application (to NATS, ZeroMQ, UDP, a file, etc.) is modeled as an ordinary sink component with an input port, wired into the graph like any other connection. This keeps the hot path free of any per-send transport branch, makes egress opt-in per graph, and lets an egress backend be developed and tested as a normal component.
Properties are native, schema-validated JSON bound to component members. A property's value lives
in the member you register; the framework validates and commits writes, runs reactions, and exposes
the value over config and REST. There are three authoring styles, all registered in the constructor
and all coexisting in one component: per-property add_property, whole-struct add_config<T>, and
keyed-map add_keyed.
Every registration takes a configurability argument (default INITIALIZE):
composite::properties::config_type::INITIALIZE(aliasinitialize) — settable only during initialization (config-file load /set_properties(..., INITIALIZE)). A runtime write to an INITIALIZE-only property is rejected (config_violation).composite::properties::config_type::RUNTIME(aliasruntime) — settable while the component runs.
The REST control plane always writes in RUNTIME context, so only RUNTIME properties/fields are
wire-writable on a live component.
add_property(name, member_ref, configurability = INITIALIZE) binds a member and returns a
typed_property<T>& for fluent configuration:
class MyComponent : public composite::component {
public:
explicit MyComponent(std::string_view id) : composite::component(id) {
using enum composite::properties::config_type;
add_property("threshold", m_threshold, RUNTIME)
.units("dB")
.validate([](const std::int32_t& v) { return v >= 0 && v <= 100; },
"threshold must be in [0, 100]")
.on_change([this](const composite::properties::json& /*diff*/) {
logger()->info("threshold is now {}", m_threshold);
});
add_property("api_key", m_api_key); // std::optional<std::string>, INITIALIZE-only
}
private:
std::int32_t m_threshold{};
std::optional<std::string> m_api_key{};
};.validate(fn)/.validate(fn, "reason")— the validator runs on a candidate value before the live member is touched. Returningfalserejects the write (nothing mutates); the optional reason is surfaced in the error. The member already holds the candidate value inside the validator..on_change(fn)— a post-commit listener receiving the property's own JSON diff. A throwing listener is logged as a warning (success-with-warnings), not turned into an error — the value is already live..units("...")— an informational unit string for schema/introspection.Tmay be a scalar,bool,std::string, an enum (declared withCOMPOSITE_ENUM),std::optional<T>,std::vector<T>, or a reflected struct. The type is deduced from the member.
Group related fields into one reflected struct. Each field is projected as a top-level property (the
wire format is unchanged), but the whole struct is the validate/commit unit, and you read fields
through a zero-overhead operator->.
struct AmpConfig {
double gain{1.0};
std::uint32_t fft_size{1024};
std::string window{"hann"};
COMPOSITE_FIELDS(AmpConfig,
(gain, runtime, range(0.0, 10.0), unit("dB"), doc("output gain")),
(fft_size, runtime, power_of_two, range(64, 65536)),
(window, runtime, one_of("hann", "hamming", "blackman_harris")));
};
class Amp : public composite::component {
public:
explicit Amp(std::string_view id) : composite::component(id) {
// Set whole-struct invariants and the reaction BEFORE add_config():
m_cfg.validate([](const AmpConfig& c) { return c.fft_size >= 64; }, "fft_size too small");
m_cfg.on_apply([this](const AmpConfig& prev, const composite::changes<AmpConfig>& ch) {
if (ch.changed(&AmpConfig::fft_size)) { rebuild_window(); } // runs at worker loop-top
});
add_config(m_cfg); // baseline INITIALIZE; per-field `runtime` opts in
}
auto process() -> composite::retval override {
const double g = m_cfg->gain; // zero-overhead live read
// ...
return composite::retval::NORMAL;
}
private:
composite::config<AmpConfig> m_cfg;
void rebuild_window();
};Per-field attributes (inside the COMPOSITE_FIELDS(...) parentheses): runtime (makes that field
RUNTIME-configurable), range(lo, hi), unit("..."), doc("..."), power_of_two,
one_of("a", "b", ...). An attribute on an incompatible field type is a compile error.
The reaction on_apply(prev, changes<T>) queries what changed: ch.changed(&T::field) (the member
pointer is compile-checked) and ch.new_value(&T::field) (the committed value, or nullopt if
unchanged). Important: on_apply does not run on the writer's thread — it is staged and runs
at the worker loop-top (the same thread as process()), so derived state is rebuilt safely before
the next process(). When there is no live worker (initialization, a stopped component, a source with
no loop), it runs inline. Do not assume the reaction has already executed when a set_properties /
PATCH returns on a running component.
COMPOSITE_STRUCT(T, ...)is the simpler reflection macro (no per-field attributes). UseCOMPOSITE_FIELDS_EXTERNin the type's namespace for a struct you can't edit. Apply exactly one reflection macro per type.
add_keyed(name, std::map<std::string, E>& member, configurability = INITIALIZE) registers a map of
reflected structs, addressed as nested JSON under the property name. It extends RFC-7396 over the map:
a key set to null erases it, a key set to an object upserts/merges, and a top-level null clears the
whole map.
set_properties(const json& values, config_type = INITIALIZE, bool allow_unknown = false) applies a
batch of values. It is native JSON in (not strings), validate-all-then-commit-all per
component (a rejected value aborts the batch and nothing is committed — there is no post-commit
rollback), and runs under a worker park so process() never observes a half-applied state.
Writes follow RFC-7396 JSON Merge Patch: a nested object merges, an array or scalar replaces
wholesale, and null resets a field to its registered default (re-running the member's
initializer — not zero). Unknown fields are rejected with a "did you mean" hint.
A component can react to a completed batch by overriding:
auto property_change_handler(const composite::properties::json& diff) -> void override {
// `diff` is the aggregate {property: sub-diff} of everything that changed this batch.
if (diff.contains("threshold")) { /* react narrowly */ }
}(The no-argument property_change_handler() is deprecated; prefer the diff form. For config<T>,
prefer on_apply over this hook.)
composite-cli serves a REST control plane (default localhost:5000; configure with --server /
--port). All routes are under /app. Values are native JSON — 2.5, true, "hann" — not
stringified.
Application & components
| Method & path | Description |
|---|---|
GET /app/healthz |
Liveness — always returns 200. |
GET /app/openapi.json |
OpenAPI 3.1 description of this control plane (for client/UI generation). |
GET /app |
Full application graph: { name, components, connections }. |
POST /app/start |
Start (reconcile to desired-enabled) every component. |
POST /app/stop |
Stop every component's worker (the server keeps running). |
GET /app/components |
Array of component documents. |
POST /app/components |
Create + add a component at runtime ({library, id, properties?}). 201 on success; 409 on duplicate id. |
GET /app/components/:id |
One component document (404 if unknown). |
DELETE /app/components/:id |
Stop, disconnect, and unload a component (404 if unknown). |
PATCH /app/components/:id |
Set a batch of { "properties": { ... } } on one component (atomic per component). |
PATCH /app/components |
Multi-component batch { "components": [ { id, properties }, ... ] }. Per-component atomic but not transactional across components — returns 207 Multi-Status if any component fails, with a per-component results array. |
Properties
| Method & path | Description |
|---|---|
GET /app/components/:id/schema |
JSON Schema of the component's properties (types, defaults, units, ranges, enum choices) — drives auto-generated config UIs. |
GET /app/components/:id/properties |
Full property state. |
GET /app/components/:id/properties/:name |
One property value. |
PUT or PATCH /app/components/:id/properties/:name |
Set/merge one property. Body is the raw JSON value, or { "value": ... }. |
DELETE /app/components/:id/properties/:name |
Reset to the registered default (RFC-7396 null). |
To mutate an element of a list/struct/keyed property, PATCH the whole property with a partial JSON
object or array (null resets/erases). There are no /items or /fields sub-routes.
Ports & connections
| Method & path | Description |
|---|---|
GET /app/components/:id/ports |
List ports with connection status. |
GET /app/components/:id/ports/:port_name |
One port's details. |
POST /app/connections |
Create a connection ({output:{component,port}, input:{component,port}}). 201 on success. |
DELETE /app/connections |
Remove a specific connection (same body shape as POST). |
DELETE /app/components/:id/ports/:port_name/connections |
Disconnect all of a port's connections. |
Status codes & limits. 201 on create; 207 on a partially-failed multi-component batch;
403 for a write to a non-runtime property (config_violation); 404 for an unknown property or
component; 400 for a validation/decoding error; 409 for a duplicate component id; 500 otherwise.
Request bodies are capped at 8 MiB; CORS is *. Only RUNTIME properties are writable over REST.
# Inspect
curl -s http://localhost:5000/app/healthz
curl -s http://localhost:5000/app/components/my_component/properties
# Set one property (raw value, or {"value": ...} — both accepted) — note: native JSON, not strings
curl -s -X PUT http://localhost:5000/app/components/my_component/properties/threshold \
-H 'Content-Type: application/json' -d '75.5'
# Reset a property to its default
curl -s -X DELETE http://localhost:5000/app/components/my_component/properties/threshold
# Update several properties on one component atomically
curl -s -X PATCH http://localhost:5000/app/components/my_component \
-H 'Content-Type: application/json' \
-d '{"properties": {"threshold": 75.5, "enabled": true}}'
# Multi-component batch (may return 207 Multi-Status with a per-component results array)
curl -s -X PATCH http://localhost:5000/app/components \
-H 'Content-Type: application/json' \
-d '{"components": [{"id":"src","properties":{"rate":500}},
{"id":"snk","properties":{"gain":2}}]}'
# Connect two ports (201 Created)
curl -s -X POST http://localhost:5000/app/connections \
-H 'Content-Type: application/json' \
-d '{"output": {"component": "src", "port": "data_out"},
"input": {"component": "snk", "port": "data_in"}}'A TLS build (-DCOMPOSITE_USE_OPENSSL=ON) requires client cert/key at launch and https://:
composite-cli app.json -c client.crt -k client.key -a ca.crt
curl --cert client.crt --key client.key --cacert ca.crt https://localhost:5000/app/healthzThe framework includes a lock-free metrics registry for observability, always available and independent of build options (OpenTelemetry is one optional export path).
Metric names are global and used verbatim — they are not prefixed with the component id.
A component's identity is carried in an automatically-added component_id label, so several
components can share a metric name (e.g. packets_sent) and be told apart by label. A metric's
identity is its name plus its (order-independent) label set. On component destruction, the framework
removes that component's metrics by label.
The only dotted metric names are the framework's own lifecycle metrics (
composite.component.process_calls,noop_count,process_time,state).
| Type | Use | Operations |
|---|---|---|
counter<uint64_t> |
monotonic totals | inc(), add(), ++, += |
updown_counter<int64_t> |
non-monotonic sums | inc(), dec(), add(), ++, --, +=, -= |
gauge<double> |
point-in-time values | set(), = |
histogram |
distributions | record() |
All instances are cache-line aligned (no false sharing) and use relaxed atomics — recording is
allocation-free and safe to call concurrently. (histogram::reset() is not safe against concurrent
record().)
Register at construction (not on the hot path); cache the returned reference; record in process():
class MyComponent : public composite::component {
public:
explicit MyComponent(std::string_view id) : composite::component(id) {
// Name is used verbatim; a {"component_id", id} label is added automatically.
m_packets = &create_counter("packets_processed", "Total packets processed", "1");
m_latency = &create_histogram_pow2("processing_time_ns", "Processing time", "ns", 20);
// Extra labels may be passed as the last argument: create_counter(..., {{"port","eth0"}});
}
auto process() -> composite::retval override {
const auto start = std::chrono::steady_clock::now();
// ... process ...
++(*m_packets);
m_latency->record(std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now() - start).count());
return composite::retval::NORMAL;
}
private:
composite::metrics::counter<std::uint64_t>* m_packets{};
composite::metrics::histogram* m_latency{};
};Convenience methods on component: create_counter, create_updown_counter, create_gauge,
create_histogram(name, desc, unit, boundaries, labels), and
create_histogram_pow2(name, desc, unit, num_buckets, labels).
create_histogram_pow2 pre-computes power-of-two boundaries (1, 2, 4, …, 2^(num_buckets-2));
num_buckets is in [2, 64] (default 20 → top boundary 2^18 = 262144). Bucket lookup is the same
O(log n) binary search as any histogram — the powers of two are just convenient boundaries.
# Snapshot of all metrics (optionally filtered)
curl -s "http://localhost:5000/app/metrics"
curl -s "http://localhost:5000/app/metrics?prefix=composite.component"
curl -s "http://localhost:5000/app/metrics?label_key=component_id&label_value=processor1"
# Server-Sent Events stream (interval ms in [100, 60000], default 1000; max 8 concurrent streams)
curl -N "http://localhost:5000/app/metrics/stream?interval=2000"Each metric serializes as { name, description, unit, type, labels, value, timestamp } (timestamps
are ISO-8601 UTC). For a histogram, value is { count, sum, boundaries, bucket_counts }. The
envelope is { timestamp, metrics: [...], count }.
auto& registry = composite::metrics::registry::instance();
auto all = registry.snapshot_all();
auto some = registry.snapshot_by_prefix("composite.component.");
auto byl = registry.snapshot_by_label("component_id", "processor1");
auto n = registry.metric_count();Built with -DCOMPOSITE_USE_OPENTELEMETRY=ON, metrics can be pushed via OTLP. Configure under a
telemetry key in the application JSON:
{
"telemetry": {
"enabled": true,
"service_name": "my_streaming_app",
"export_interval": 10000,
"exporter": { "endpoint": "http://otel-collector:4318", "protocol": "http/protobuf" }
}
}protocol is http/protobuf (default), http/json, or grpc (auto-mapped to http/protobuf,
port 4317→4318). The standard OTEL_* environment variables are honored as fallbacks
(OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL,
OTEL_EXPORTER_OTLP_TIMEOUT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_METRIC_EXPORT_INTERVAL); the JSON
configuration takes precedence.
A minimal component subclasses composite::component, registers its ports and properties in the
constructor, implements process(), and registers a factory. A working, CI-built version of this
lives in examples/passthrough_gain/.
// component.hpp
#include <composite/core/component.hpp>
class passthrough_gain : public composite::component {
public:
explicit passthrough_gain(std::string_view id);
~passthrough_gain() override = default;
auto process() -> composite::retval override;
private:
composite::input_port<composite::mutable_buffer<float>> m_in{"data_in"};
composite::output_port<composite::mutable_buffer<float>> m_out{"data_out"};
float m_gain{1.0F};
// MUST be the last data member: its destructor stops the worker first, while the
// members process() touches are still alive (otherwise: destruction-order use-after-free).
composite::component::auto_stop m_auto_stop{*this};
};// component.cpp
#include "component.hpp"
#include <composite/core/register.hpp>
passthrough_gain::passthrough_gain(std::string_view id) : composite::component(id) {
using enum composite::properties::config_type;
add_port(&m_in);
add_port(&m_out);
add_property("gain", m_gain, RUNTIME)
.units("factor")
.validate([](const float& g) { return g >= 0.0F; }, "gain must be >= 0");
}
auto passthrough_gain::process() -> composite::retval {
using enum composite::retval;
auto pkt = m_in.try_get(); // canonical read: nullopt == empty ring
if (!pkt) { return NOOP; } // idle on the doorbell; base auto-FINISHes at end-of-stream
auto& [buffer, ts, metadata] = *pkt;
for (std::size_t i = 0; i < buffer.size(); ++i) { buffer[i] *= m_gain; }
// Metadata (if any) rides atomically with the packet as a shared immutable instance;
// forwarding it is a refcount bump.
m_out.send_data(std::move(buffer), ts, metadata);
return NORMAL;
}
// Emits the create(id, args) ABI + the composite_abi_version() handshake.
COMPOSITE_REGISTER_SIMPLE(passthrough_gain)Factory registration:
COMPOSITE_REGISTER_SIMPLE(Class)— for a component with aClass(std::string_view id)constructor and no construction-time discriminator.COMPOSITE_REGISTER_COMPONENT(factory)— when the concrete type is chosen at construction:factoryis any callable(std::string_view id, const composite::create_args& args) -> std::shared_ptr<composite::component>that inspectsargs.type()/args.value<T>("key")and returns the right instance (throw on an unknown type — the loader turns it into a clean load failure).
Both macros also emit composite_abi_version(); never hand-write the extern "C" create block.
A component is a CMake MODULE library linking the imported composite::composite target:
cmake_minimum_required(VERSION 3.20)
project(passthrough_gain LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(composite REQUIRED)
add_library(passthrough_gain MODULE component.cpp) # -> libpassthrough_gain.so (dlopen'd at runtime)
target_link_libraries(passthrough_gain PRIVATE composite::composite)
include(GNUInstallDirs)
install(TARGETS passthrough_gain LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR})For one-in/one-out components that benefit from an internal worker pool with order-preserving
output, derive from pipeline_component<InBuf, OutBuf> instead of component. It owns the pool and a
slot ring that re-serializes out-of-order completions back to submission order; process() is final.
Override three hooks instead:
prepare(metadata&)— main thread, arrival order (e.g. stamp annotations). Metadata is shared across packets, so this runs only when it actually needs rebuilding: when the incoming packet carries a different metadata instance than the previous one, or afterinvalidate_prepared_metadata()(call that from your property handler whenprepare()stamps values derived from config). Packets in between share the prepared result.work(InBuf in, timestamp ts, const metadata& md) -> OutBuf— runs concurrently on the pool.finalize(OutBuf& out, timestamp ts, const metadata& md) -> bool— main thread, submission order; returnfalseto drop the packet. Metadata is read-only here — annotate inprepare().
num_workers is auto-registered as a RUNTIME property (range 1..1024); changing it drains the pool and
rebuilds it at the new size. Because only the main thread sends, the single-producer invariant holds.
The fft and psd components in composite-comps are built on it.
Pool workers run concurrently with property commits — the park quiesces only the main worker —
so work() must not read live config members (torn scalars; a freed string/vector is a
use-after-free). Publish an immutable value through composite::snapshot<T>
(<composite/properties/snapshot.hpp>) from your property handler and load() it in work(): the
returned shared_ptr<const T> keeps that value alive for as long as the worker holds it, however
many times the publisher has since moved on. The same applies to any thread the park does not
quiesce (e.g. a source's receiver threads).
- The package is versioned with SemVer; the installed CMake config declares
COMPATIBILITY SameMinorVersion, sofind_package(composite x.y)accepts the samex.yline. - The component ABI version is independent and currently
1(composite::abi_version). The loader refuses any.sowhosecomposite_abi_version()doesn't match. Rebuild components against the framework when the ABI version changes.
See CHANGELOG.md for the 0.5 API migration table (old → new) and release notes.