C++ Channel Messaging Per-Language Interface¶
C++ per-language interface table of contents · MeshNode · Framework API
1. RouteMesh Registration¶
The RouteMesh builder registers one physical mesh and its MeshNode. A logical channel is added to the same builder as membership, and doesn't create a separate socket. The RouteMesh status interface the application queries is defined by the C++ monitoring per-language interface.
Object roles and factories start at objects().client() or objects().server(). An Object
Server declares Spot constructor dependencies as template arguments, and the Framework service
provider injects them. Factory-lambda and policy-callback overloads remain for compatibility, but
samples and guides use the callback-free fluent surface below.
namespace zlink::framework {
struct mesh_peer_connection_t {
std::uint64_t intent_id = 0;
std::optional<zlink::routing_id_t> expected_routing_id;
std::string endpoint;
};
class mesh_peer_connections_t {
public:
void connect(std::string endpoint);
void connect(zlink::routing_id_t expected_routing_id, std::string endpoint);
void disconnect(std::string endpoint);
std::vector<mesh_peer_connection_t> list_connections() const;
};
class mesh_channel_server_builder_t;
class mesh_channel_client_builder_t {};
class mesh_object_role_builder_t;
class mesh_object_client_builder_t;
class mesh_object_server_builder_t;
template <typename TSpot, typename... TDependencies>
class user_spot_registration_builder_t;
template <typename TSpot, typename... TDependencies>
class instance_spot_registration_builder_t;
template <typename TActor, typename TActorFactory>
class actor_registration_builder_t;
class mesh_channel_builder_t {
public:
mesh_channel_client_builder_t client();
mesh_channel_server_builder_t server();
};
class mesh_channel_server_builder_t {
public:
mesh_channel_server_builder_t &set_weight(int weight);
mesh_channel_server_builder_t &add_handler_group(std::string group_name);
template <typename THandler, typename TMessage>
mesh_channel_server_builder_t &add_send_handler(std::string packet_name = {});
template <typename THandler, typename TRequest, typename TReply>
mesh_channel_server_builder_t &add_request_handler(std::string packet_name = {});
};
class network_options_t {
public:
std::string bind_host() const;
network_options_t &set_bind_host(std::string host);
std::optional<std::string> advertise_host() const;
network_options_t &set_advertise_host(std::optional<std::string> host);
};
class client_server_channel_client_builder_t {
public:
client_server_channel_client_builder_t &connect(std::string endpoint);
};
class client_server_channel_server_builder_t {
public:
client_server_channel_server_builder_t &listen(std::uint16_t port = 0);
client_server_channel_server_builder_t &set_bind_host(std::string host);
client_server_channel_server_builder_t &set_advertise_host(std::string host);
client_server_channel_server_builder_t &set_weight(int weight);
client_server_channel_server_builder_t &add_handler_group(std::string group_name);
template <typename THandler, typename TMessage>
client_server_channel_server_builder_t &add_send_handler(
std::string packet_name = {});
template <typename THandler, typename TRequest, typename TReply>
client_server_channel_server_builder_t &add_request_handler(
std::string packet_name = {});
};
struct mesh_node_socket_config_t {
zlink::byte_count_t send_high_water_mark =
zlink::byte_count_t::bytes(4'096'000);
zlink::byte_count_t receive_high_water_mark =
zlink::byte_count_t::bytes(4'096'000);
std::optional<std::chrono::milliseconds> receive_timeout;
};
enum class object_role_t : std::uint8_t {
none = 0,
client = 1,
server = 2
};
enum class user_spot_execution_mode_t {
spot_wide = 0,
per_actor = 1
};
enum class spot_relocation_coordination_mode_t {
framework_managed = 0,
application_signaled = 1
};
class mesh_node_builder_t {
public:
mesh_channel_builder_t channel(std::string channel_name);
mesh_object_role_builder_t objects();
mesh_node_builder_t &listen(std::string endpoint);
mesh_node_builder_t &listen(std::uint16_t port = 0);
mesh_node_builder_t &set_bind_host(std::string host);
mesh_node_builder_t &set_advertise_host(std::string host);
mesh_node_builder_t &set_routing_id(zlink::routing_id_t routing_id);
mesh_node_builder_t &set_automatic_routing_id_prefix(std::string prefix);
mesh_node_builder_t &set_object_role(object_role_t role);
mesh_node_builder_t &set_placement_weight(int weight);
mesh_node_builder_t &set_actor_limit(std::int32_t limit);
mesh_node_builder_t &set_spot_limit(std::int32_t limit);
mesh_node_builder_t &set_activation_concurrency(std::int32_t limit);
mesh_node_builder_t &set_instance_spot_idle_timeout(
std::chrono::milliseconds timeout);
mesh_node_socket_config_t &configure_router_socket();
mesh_peer_connections_t &peer_connections();
mesh_node_builder_t &set_default_request_timeout(std::chrono::milliseconds timeout);
template <typename THandler, typename TMessage>
mesh_node_builder_t &add_route_send_handler(std::string packet_name = {});
template <typename THandler, typename TRequest, typename TReply>
mesh_node_builder_t &add_route_request_handler(std::string packet_name = {});
template <typename TEntrySpot>
requires std::derived_from<
TEntrySpot, entry_spot_t<typename TEntrySpot::actor_type>>
mesh_node_builder_t &add_entry_spot();
template <typename TEntrySpot>
requires std::derived_from<
TEntrySpot, entry_spot_t<typename TEntrySpot::actor_type>>
mesh_node_builder_t &add_entry_spot(
std::function<std::shared_ptr<TEntrySpot>(
class entry_spot_context_t)> factory);
template <typename TSpot>
requires std::derived_from<
TSpot, spot_t<typename TSpot::actor_type>>
mesh_node_builder_t &add_spot_factory(
std::string stable_type,
std::function<std::shared_ptr<TSpot>(
class spot_context_t)> factory,
std::function<void(user_spot_factory_builder_t<TSpot> &)> configure);
template <typename TSpot>
requires std::derived_from<TSpot, instance_spot_t>
mesh_node_builder_t &add_instance_spot_factory(
std::string stable_type,
std::function<std::shared_ptr<TSpot>(
class instance_spot_context_t)> factory,
std::function<void(instance_spot_factory_builder_t<TSpot> &)> configure);
template <typename TActor, typename TActorFactory>
requires std::derived_from<TActor, actor_t> &&
std::derived_from<TActorFactory, actor_factory_t<TActor>>
mesh_node_builder_t &add_actor_factory(
std::string stable_type,
std::shared_ptr<TActorFactory> factory,
std::function<void(actor_factory_builder_t<TActor> &)> configure);
};
class mesh_object_role_builder_t {
public:
mesh_object_client_builder_t client();
mesh_object_server_builder_t server();
};
class mesh_object_client_builder_t {};
class mesh_object_server_builder_t {
public:
template <typename TEntrySpot, typename... TDependencies>
mesh_object_server_builder_t &add_entry_spot();
template <typename TSpot, typename... TDependencies>
user_spot_registration_builder_t<TSpot, TDependencies...>
add_spot_factory(std::string stable_type);
template <typename TSpot, typename... TDependencies>
instance_spot_registration_builder_t<TSpot, TDependencies...>
add_instance_spot_factory(std::string stable_type);
template <typename TActor, typename TActorFactory>
actor_registration_builder_t<TActor, TActorFactory>
add_actor_factory(std::string stable_type);
};
template <typename TSpot, typename... TDependencies>
class user_spot_registration_builder_t {
public:
user_spot_registration_builder_t &set_stable_type_limit(std::int32_t value);
user_spot_registration_builder_t &set_execution_mode(
user_spot_execution_mode_t value);
user_spot_registration_builder_t &set_relocation_coordination_mode(
spot_relocation_coordination_mode_t value);
mesh_object_server_builder_t disable_relocation();
mesh_object_server_builder_t recreate_on_relocation();
template <typename TAdapter>
mesh_object_server_builder_t preserve_state_with();
};
template <typename TSpot, typename... TDependencies>
class instance_spot_registration_builder_t {
public:
instance_spot_registration_builder_t &set_stable_type_limit(std::int32_t value);
mesh_object_server_builder_t disable_relocation();
mesh_object_server_builder_t recreate_on_relocation();
template <typename TAdapter>
mesh_object_server_builder_t preserve_state_with();
};
template <typename TActor, typename TActorFactory>
class actor_registration_builder_t {
public:
mesh_object_server_builder_t disable_relocation();
mesh_object_server_builder_t recreate_on_relocation();
template <typename TAdapter>
mesh_object_server_builder_t preserve_state_with();
};
enum class client_server_role_t { client, server, client_and_server };
struct client_server_target_snapshot_t {
zlink::routing_id_t node_rid;
int weight;
peer_state_t state;
std::optional<topology_reason_t> unavailable_reason;
};
struct client_server_channel_snapshot_t {
std::string channel_name;
client_server_role_t local_role;
topology_state_t state;
bool is_ready;
std::uint32_t ready_target_count;
std::vector<client_server_target_snapshot_t> targets;
std::uint64_t sequence;
std::chrono::system_clock::time_point observed_at;
};
class client_server_runtime_t {
public:
virtual client_server_channel_snapshot_t snapshot(
std::string channel_name) const = 0;
virtual std::unique_ptr<mesh_runtime_observation_t> observe(
std::string channel_name,
std::size_t capacity,
std::function<void(
const observed_status_t<client_server_channel_snapshot_t> &)> observer) = 0;
virtual bool is_ready(std::string channel_name) const = 0;
};
struct fanout_channel_snapshot_t {
std::string channel_name;
topology_state_t state;
bool is_ready;
std::uint32_t ready_publisher_count;
std::vector<mesh_peer_snapshot_t> publishers;
std::uint64_t sequence;
std::chrono::system_clock::time_point observed_at;
};
class fanout_runtime_observation_t {
public:
virtual ~fanout_runtime_observation_t() = default;
virtual void close() = 0;
};
class fanout_runtime_t {
public:
virtual fanout_channel_snapshot_t snapshot(std::string channel_name) const = 0;
virtual std::unique_ptr<fanout_runtime_observation_t> observe(
std::string channel_name,
std::size_t capacity,
std::function<void(
const observed_status_t<fanout_channel_snapshot_t> &)> observer) = 0;
};
class mesh_channel_runtime_options_t {
public:
virtual ~mesh_channel_runtime_options_t() = default;
virtual int weight() const = 0;
virtual void weight(int value) = 0;
};
class mesh_placement_runtime_options_t {
public:
virtual ~mesh_placement_runtime_options_t() = default;
virtual int placement_weight() const = 0;
virtual void placement_weight(int value) = 0;
};
class route_mesh_runtime_options_t {
public:
virtual ~route_mesh_runtime_options_t() = default;
virtual mesh_placement_runtime_options_t &mesh(std::string mesh_name) = 0;
virtual mesh_channel_runtime_options_t &channel(std::string channel_name) = 0;
};
} // namespace zlink::framework
After channel(channel_name), client() or server() is called
exactly once. Only the builder server() returns sets weight and
handler. A MeshNode with no
Server membership can also
start. add_client_server_channel(channel_name) puts one-way request
start authority only on the client, and the server only performs
receiving-send/request processing and reply. The ClientServer builder
can call one or both of client() and server(), but each role is
registered at most once. The registration key is (ChannelName, Role),
and duplicate registration of the same role is a startup error. A
different role shares the same ChannelName's topology as a separate
registration. The RouteMesh
single-role-selection and
ChannelName conflict rule
don't change.
A peer connection isn't needed only when both MeshNodes are Object
Client and neither has RouteMesh Channel Server membership. The same
applies when only Channel Client membership is registered. If either
side has Channel Server membership including weight 0, the connection
is made and liveness is kept. ClientServer and classic fanout are
separate physical topologies, so they aren't included in this
judgment. A RouteMesh Channel Server can also be registered on an
Object Client, but an application Node direct handler can't be
registered. Specifying an Object Client RID as a Node direct target
ends as not_found without switching to a different RID.
RouteMesh Channel Server, ClientServer Server, and node-wide placement
weight are all int, ranging 0..10000, defaulting to 100. A value
outside the range is a configuration error in both startup config and
runtime change. Weighted selection computes the sum of candidate
weight using at least a 64-bit integer.
Root BindHost's default is 127.0.0.1. If AdvertiseHost is omitted, the default of
Network listener identity §2.1 applies. If the automatic discovery listener's port is omitted, or
the listener call itself is omitted, port 0 is used.
A per-listener host setting takes priority over the root default.
The automatic RID prefix is [A-Za-z0-9._-] 1..64 characters. The
runtime builds an RID in the format
prefix-<lowercase-canonical-uuid-v4>, and limits the whole RID to 255
bytes or fewer. UUID v4 is expressed as a lowercase canonical string of
8-4-4-4-12 digits. If the active descriptor
owner CAS conflicts, it doesn't
retry with a new UUID — it immediately fails startup with
routing_id_conflict. Common MeshNode §3.3
defines where a fixed RID can be used and how its restart conflicts are handled.
Object role server includes client capability. client and
server require a Location Store, and none doesn't create a
manager, factory, or hidden local object runtime. Placement
weight is 0..10000, defaulting
to 100, and 0 excludes it only from a new create/relocation target. A
value outside the range is a configuration error in both startup
config and runtime change. Node Actor limit and Node Spot limit
default to 0, meaning no limit.
set_actor_limit(...) counts every Actor present in the Entry Spot and
User Spot, and set_spot_limit(...) counts User/Instance Spot but
excludes Entry Spot. Both node limits allow 0..2147483647, and 0
means no limit. If a User/Instance Spot factory's stable type limit is
specified, the range is 1..2147483647. If omitted, no separate limit
is put on that stable type.
A per-Actor-stable-type limit isn't provided.
set_activation_concurrency(...) sets the process-local concurrent
execution gate for factory and initialization, defaulting to 128 and
allowing only positive values. 0 or negative is a configuration
error before socket bind. Population capacity and activation
concurrency aren't merged into the same counter or option. Every value
is fixed before the MeshNode lifecycle starts.
set_instance_spot_idle_timeout(...) is the reference time for
cleaning up an idle
Instance Spot.
The default is std::chrono::milliseconds::zero(), and 0 means no
cleanup. The allowed range is 0 and positive values, and negative is
a configuration error before socket bind. The value is fixed before
the MeshNode lifecycle starts, and a runtime setter isn't provided.
It's a separate setting from the Worker's idle_timeout(...), and
they don't inherit each other's value. Only Instance Spot is a cleanup
target — Entry Spot and User Spot aren't affected by this setting. The
idle judgment condition, the delivery of
spot_close_reason_t::idle_evicted, and the cold activation rule after
cleanup are owned by
Spot Model §6.2.
Descriptor capacity is only used for a candidate filter. The Framework
only runs the factory after atomically obtaining the Location Store's
typed bundle reservation at the selected node. An Actor reserves one
Actor slot, and a Spot reserves one whole Spot slot plus that stable
type's slot. A SpotWide User Spot's and N member Actors' aggregate
relocation reserves 1 Spot total, 1 of that Spot's stable type, and N
Actor totals all-or-none. If every candidate's reservation fails due to
capacity, it completes with unavailable without calling an
application factory or handler.
An Actor/User Spot/Instance Spot
factory always specifies a
relocation policy, and there's no overload that omits it. A
state-preserving Actor factory needs
actor_relocation_adapter_t<TActor>, and a state-preserving
User/Instance Spot
factory needs spot_relocation_adapter_t<TSpot>. If the factory kind
and adapter kind or instance type don't match, it fails as a
configuration error before socket bind.
user_spot_execution_mode_t::per_actor only allows the recreate Spot
policy. Registering Disabled or a state-preserving Spot policy together
is a configuration error before socket bind. A PerActor Spot is a
stateless execution shell, and the Actor policy and
actor_relocation_adapter_t<TActor> each handle Actor state.
relocation_coordination_mode defaults to framework_managed.
application_signaled is only allowed with spot_wide, and
registering it together with per_actor is a configuration error
before socket bind. Since the Spot callback is a default no-op virtual
member, an application override isn't required.
The factory and Entry Spot member are only valid with Object role
server. Even though a single C++ builder exposes this member
together, a combination registering a factory with none or client
role fails as a configuration error before socket bind.
The Entry Spot ID is issued by the Framework at startup. A public member for the caller to pass a Spot ID or configure a per-Entry-Spot option isn't provided. Only after Entry Spot factory registration and initialization complete does the Framework publish the RID to the descriptor and resolver.
The RID format is <prefix>-entry-<lowercase-canonical-uuid-v4>, using
a UUID v4 generated separately from the MeshNode. The Framework's
internal MeshNode descriptor's entry_spot_id provides the
mapping of the lifecycle. If the global Spot ID conflicts with an
active owner, it doesn't retry with a new UUID — it immediately fails
startup with a configuration exception. If a caller-specified
User/Instance Spot ID matches this reserved format, it's rejected with
invalid_operation before starting the Store and factory.
The fully encoded MeshNode descriptor the Framework builds from every registration must be at most 1 MiB. Spot type and stateful object capability collection are each at most 1024. The runtime validates the completed descriptor all at once before socket bind. Exceeding the bound fails startup — it doesn't truncate/split the collection or publish part of the descriptor.
Topology registration only uses zlink_framework_options_t's three
entry points: add_route_mesh(...), add_client_server_channel(...),
and add_fanout_channel(...). RouteMesh picks its role with
add_route_mesh(...).channel(...).client() or .server(). A generic
channel builder or node builder that rebuilds the same topology isn't
kept. The Framework internals combine the binding types below.
zlink::context_tzlink::router_socket_tzlink::dealer_socket_tzlink::pub_socket_tzlink::sub_socket_t- MeshNode runtime handler
zlink::stream_socket_t
2. ClientServer · Fanout Builder¶
ClientServer and Fanout use ChannelName but compose different physical topologies. A ClientServer client starts send/request and the server performs handler/reply. Fanout composes a publisher/subscriber role.
namespace zlink::framework {
class endpoint_connections_t;
class fanout_channel_builder_t {
public:
fanout_channel_builder_t &enable_publisher(std::string endpoint);
fanout_channel_builder_t &enable_publisher(std::uint16_t port = 0);
fanout_channel_builder_t &set_bind_host(std::string host);
fanout_channel_builder_t &set_advertise_host(std::string host);
fanout_channel_builder_t &set_routing_id(
zlink::routing_id_t publisher_routing_id);
fanout_channel_builder_t &set_automatic_routing_id_prefix(
std::string prefix);
fanout_channel_builder_t &set_no_drop(bool no_drop = true);
template <typename Rep, typename Period>
fanout_channel_builder_t &set_send_timeout(
std::chrono::duration<Rep, Period> timeout);
fanout_channel_builder_t &enable_subscriber();
fanout_channel_builder_t &subscribe(std::string topic);
fanout_channel_builder_t &connect(std::string endpoint);
endpoint_connections_t subscriber_connections();
fanout_channel_builder_t &add_handler_group(std::string group_name);
};
class client_server_channel_builder_t {
public:
client_server_channel_client_builder_t client();
client_server_channel_server_builder_t server();
};
} // namespace zlink::framework
Request timeout is set on the call object's .timeout(...) and the
route request fluent surface. The table that records requests waiting
for an answer has no bound. The C++ public contract doesn't create a
per-role timeout option absent from .NET's role builder.
The internal mapping is below.
| Capability | Binding implementation basis |
|---|---|
| server | zlink::router_socket_t |
| client | zlink::dealer_socket_t |
| publisher | zlink::pub_socket_t |
| subscriber | zlink::sub_socket_t |
Since ClientServer and Fanout are different physical topologies, they can't share a ChannelName in the same process. Each topology's connection set and descriptor are kept separate.
Automatic RouteMesh compares RID in canonical byte order, and only the MeshNode with the smaller RID connects to the counterpart endpoint. A manual topology can connect from one or both sides depending on application endpoint configuration. If bidirectional connection or automatic discovery contention/a stale snapshot produces two pipes for the same RID, the common rule in channel topology applies.
A ClientServer client can use manual endpoint and location store automatic discovery together. If the two sources point to the same Server RID and lifecycle generation, the connection intent and ready target are merged into one. In both automatic and manual, only the client connects to server — the server doesn't look for a client endpoint or start an outbound connect.
If both Client and Server are registered on the same process, a local
Server that finished listener and service admission is also included
in the same candidate set as a remote Server. The same
Ready, positive weight, and
non-draining conditions apply — there's no local priority or remote
exclusion rule. Even when a local Server is selected, the actual
transport message is delivered from the client DEALER to the server
ROUTER. A direct handler call doesn't bypass codec, HWM, timeout,
cancellation, correlation, or terminal completion.
client_and_server is used only for the aggregate projection of the
channel snapshot, and isn't selected in the builder or used as a
registration key.
A fanout publisher that registered a location store selects either a
fixed Publisher RID or an automatic RID prefix before startup, and
publishes a dedicated descriptor. A publisher without a Store can be
used as a target the listener endpoint is passed to manually, but
doesn't perform automatic discovery registration. enable_subscriber()
with no argument looks up every valid publisher descriptor of the same
ChannelName from the location store and connects to all of them.
connect(endpoint) composes a manual subscriber using only the
specified endpoint. Setting both subscriber modes together on one
channel fails startup. An automatic subscriber needs a location store,
and a host that only uses manual publisher and manual subscriber
doesn't need a store if it has no other location capability. A
publisher only publishes a descriptor and doesn't start an outbound
connect to a subscriber endpoint. Only a subscriber connects to a
publisher endpoint, and an automatic subscriber makes one connection
intent per Publisher RID and lifecycle generation.
subscriber_connections() is a runtime handle pointing to the same
manual endpoint set as
the builder's connect(endpoint). This handle provides endpoint
connect, disconnect, and current listing, and doesn't change automatic
discovery results.
An automatic subscriber registered with no endpoint reads
fanout_channel_snapshot_t per ChannelName from fanout_runtime_t and
observes it. This runtime is read-only and doesn't change
subscriber_connections()'s manual endpoint set. Looking up a
ChannelName registered only with a manual subscriber is a configuration
error.
ClientServer and fanout snapshots use the same topology_state_t,
peer_state_t, and topology_reason_t as the RouteMesh snapshot, and
the range of public fields follows the
Monitoring interface. The meaning of topology
state, readiness, and target state is owned by
Runtime Monitoring.
client_server_runtime_t::observe(...) and
fanout_runtime_t::observe(...) deliver this snapshot as the status of
observed_status_t<TStatus>. The common meaning of observation items
(the first item, coalescing, and the loss count) is owned by
Runtime Monitoring §§6–7.
fanout_runtime_observation_t only owns the lifetime of a fanout
observer registration and close(). Since the status field of the
fanout_runtime_t::observe(...) callback argument contains the
fanout_channel_snapshot_t for that ChannelName, it doesn't mix with a
RouteMesh/ClientServer status or a raw socket event.
close() stops putting a new item into that one observation's queue
and discards any not-yet-consumed item. An already-running callback
can still return, but a new callback isn't started after close()
returns. Closing its own observation inside a callback doesn't create
a deadlock. Close doesn't change a different observer, automatic
connection, or subscriber_connections()'s manual endpoint set.
close() guarantees the same result no matter how many times it's
called. A derived observation handle's destructor performs the same
deregistration as close(), so destroying a
std::unique_ptr<fanout_runtime_observation_t> without an explicit
close() leaves no observer registration behind.
A publisher's ready judgment is owned by
transport liveness §4,
and the snapshot shows its result.
So connection configuration such as listen, connect, and
enable_subscriber is put on the ClientServer server, ClientServer
client, and Fanout builder, not a generic channel builder.
3. Handler Registry¶
The handler registry is a surface that handles a typed payload at the function level.
namespace zlink::framework {
enum class handler_execution_t {
inline_on_runtime = 0,
offload = 1
};
struct handler_options_t {
std::optional<std::string> packet_name;
handler_execution_t execution = handler_execution_t::inline_on_runtime;
};
class endpoint_connections_t {
public:
void connect(std::string endpoint);
void disconnect(std::string endpoint);
std::vector<std::string> list_connections() const;
};
// the numeric value is a stable key for observation/diagnostic data, so it's fixed (framework API §13).
enum class framework_error_kind_t {
not_found = 0,
already_exists = 1,
type_mismatch = 2,
not_configured = 3,
rejected = 4,
unavailable = 5,
deadline_exceeded = 6,
shutting_down = 7,
protocol_error = 8,
invalid_operation = 9,
data_lost = 10,
internal_failure = 11
};
class framework_exception_t : public std::exception {
public:
framework_error_kind_t kind() const noexcept;
// provides a diagnostic error_code together if there's a platform cause.
// the application's error branch uses kind().
std::error_code code() const noexcept;
const char *what() const noexcept override;
};
template <typename TReply>
class request_call_t {
public:
request_call_t &timeout(std::chrono::milliseconds timeout);
request_call_t &metadata(std::string key, std::string value);
task_t<TReply> async();
TReply submit(); // synchronous blocking; InvalidOperation in a runtime execution context (F2-a)
task_t<TReply> yield();
};
class channel_request_call_t {
public:
channel_request_call_t &timeout(std::chrono::milliseconds timeout);
channel_request_call_t &metadata(std::string key, std::string value);
template <typename TReply>
task_t<TReply> async();
template <typename TReply>
TReply submit(); // synchronous blocking; InvalidOperation in a runtime execution context (F2-a)
template <typename TReply>
task_t<TReply> yield();
};
class send_call_t {
public:
send_call_t &metadata(std::string key, std::string value);
task_t<void> async();
void submit(); // synchronous blocking; InvalidOperation in a runtime execution context (F2-a)
};
class bound_session_send_call_t {
public:
bound_session_send_call_t &metadata(std::string key, std::string value);
task_t<void> async();
void submit(); // synchronous blocking; InvalidOperation in a runtime execution context (F2-a)
};
class stream_send_call_t {
public:
~stream_send_call_t();
stream_send_call_t(stream_send_call_t &&) noexcept;
stream_send_call_t &operator=(stream_send_call_t &&) noexcept;
stream_send_call_t(const stream_send_call_t &) = delete;
stream_send_call_t &operator=(const stream_send_call_t &) = delete;
stream_send_call_t &metadata(std::string key, std::string value);
stream_send_call_t &packet_name(std::string packet_name);
stream_send_call_t &compress();
task_t<void> async();
void submit(); // synchronous blocking; InvalidOperation in a runtime execution context (F2-a)
};
class stream_write_call_t {
public:
using metadata_map_t = std::map<std::string, std::string>;
~stream_write_call_t();
stream_write_call_t(stream_write_call_t &&) noexcept;
stream_write_call_t &operator=(stream_write_call_t &&) noexcept;
stream_write_call_t(const stream_write_call_t &) = delete;
stream_write_call_t &operator=(const stream_write_call_t &) = delete;
stream_write_call_t &metadata(std::string key, std::string value);
stream_write_call_t &compress();
task_t<void> async();
void submit(); // synchronous blocking; InvalidOperation in a runtime execution context (F2-a)
};
template <typename TActor>
class bind_actor_call_t {
public:
bind_actor_call_t &timeout(std::chrono::milliseconds timeout);
task_t<TActor> async();
};
class message_metadata_t {
public:
std::optional<std::string_view> find(std::string_view key) const;
bool contains(std::string_view key) const;
bool empty() const noexcept;
const std::map<std::string, std::string> &values() const noexcept;
};
struct message_context_t {
std::optional<std::string> mesh_name;
std::optional<std::string> channel_name;
std::string packet_name;
std::optional<std::string> content_type;
message_metadata_t metadata;
std::optional<std::string> correlation_id;
};
struct publish_message_context_t : message_context_t {
std::string topic;
std::optional<std::string> source;
};
enum class handler_dispatch_kind_t {
node_direct_send = 0,
node_direct_request = 1,
channel_send = 2,
channel_request = 3,
classic_fanout = 4
};
struct handler_filter_context_t : message_context_t {
handler_dispatch_kind_t dispatch_kind;
};
using handler_next_t = std::function<task_t<void>()>;
} // namespace zlink::framework
message_context_t only provides inbound message information and
doesn't include cancellation state. Request reply wait cancellation is
handled by the call object, and STREAM connection close and
cancellation are handled by the session lifecycle.
The handler owner type is resolved from the service collection. An
ordinary application registers handler and service together inside
add_zlink_framework(...).
options.services().add_transient<order_handler_t>();
options.handlers()
.group ("orders-api")
.add<order_created_handler_t> ();
The STREAM application work path doesn't directly receive a header
object. The C++ stream session and actor relay use one zlink::message_t
payload, and the header value needed for reply and relay is kept by
runtime-internal dispatch state. A separate _raw-named public API
isn't kept.
Handler dispatch receives the binding's zlink::message_t and
zlink::multipart_t, converts them to a typed payload through the
serializer, resolves the owner from DI, and then calls the method.
A handler method can take only the payload, or the payload followed by
a typed context. A request and send handler receives
message_context_t, and an event/publish handler receives
publish_message_context_t. The channel context provides nullable
ChannelName, packet name, nullable content type, immutable metadata,
and correlation ID. The Node direct context only adds the source RID
to this common information. A raw multipart header or dispatch table
isn't exposed as a public context.
A handler filter, like .NET's handler filter, handles common
processing before and after a handler call. An ordinary application
registers it with options.use_filter<TFilter>() in configuration. A
filter type provides
task_t<void> invoke(const handler_filter_context_t &, handler_next_t).
To continue processing, it calls co_await next(). A filter doesn't
build or replace a request reply.
The application scope is Node direct send/request, RouteMesh/ClientServer Channel send/request, and the Classic Fanout subscription handler. It doesn't apply to a Spot/Actor handler, Logical Multicast subscription, or a STREAM session.
A send that didn't call next() completes normally without running
the current handler. Classic Fanout only ends the current handler and
continues processing other matching handlers. A request completes as
rejected instead of a normal reply. next() can be called only
once, and a second call is an invalid_operation error.
A handler and filter are resolved in the same handler invocation scope built per dispatch. Classic Fanout builds a separate scope per matching handler, and one handler's filter interruption or failure doesn't cancel another handler. Descriptor lookup, serializer selection, DI resolve order, and how the filter chain is stored aren't exposed as public API.
A STREAM handler is separate from an ordinary request/send/event handler. The Framework runtime only supports the typed packet approach, and doesn't put a user-defined header framing on the Framework public surface.
The Framework-internal recv loop receives a STREAM raw part,
completes header framing and queue admission, and then runs the
stream callback. A core packet callback or raw receive callback
doesn't bypass queue admission. The packet/lifecycle callback of the
same stream session is processed serially in a managed queue. A stream
handler that's CPU-bound or might block specifies an offload execution
policy.
A request handler's return value allows TReply or task_t<TReply>.
A handler returning task_t<TReply> has the same meaning as .NET's
async Task<TReply> handler, and a call that must wait for a result
like an internal request is used as co_await call.async(). A
one-way send/push receives the admission result with co_await call.async().
Wait termination is defined by Submit and completion §7. If accepted immediately, the
prepared task can complete right away, without waiting for remote
handler completion.
A Handler coroutine completes as task_t<T> without a blocking wait.
The same task's terminal result is confirmed only once, and a
duplicate completion doesn't change the previously confirmed result.
Handler execution scheduler and continuation placement aren't exposed
in the public API.
4. Messaging API¶
The messaging surface user code injects and uses instead of a raw socket is below.
namespace zlink::framework {
struct send_options_t {
std::optional<std::string> packet_name;
};
struct request_options_t {
std::optional<std::string> packet_name;
std::optional<std::chrono::milliseconds> timeout;
};
class publisher_t {
public:
template <typename TEvent>
fanout_publish_call_t publish(std::string channel_name,
TEvent event);
template <typename TEvent>
fanout_publish_call_t publish(std::string channel_name,
std::string topic, TEvent event);
};
class spot_publisher_client_t {
public:
template <typename TEvent>
publish_call_t publish(std::string channel_name, std::string topic,
const TEvent &event) const;
};
class request_client_t {
public:
template <typename TCommand>
send_call_t send(std::string_view channel_name, const TCommand &command,
send_options_t options = {});
template <typename TRequest>
channel_request_call_t request(std::string_view channel_name,
const TRequest &request,
request_options_t options = {});
};
class message_bus_t {
public:
~message_bus_t();
message_bus_t(message_bus_t &&) noexcept;
message_bus_t &operator=(message_bus_t &&) noexcept;
message_bus_t(const message_bus_t &) = default;
message_bus_t &operator=(const message_bus_t &) = default;
template <typename TRequest>
channel_request_call_t request(
std::string channel_name,
TRequest request);
template <typename TMessage>
send_call_t send(std::string channel_name, TMessage message);
template <typename TEvent>
send_call_t publish(
std::string channel_name,
std::string topic,
TEvent event);
std::chrono::milliseconds default_request_timeout(
const std::string &channel_name) const;
};
class spot_send_call_t;
class spot_request_call_t;
class route_client_t {
public:
~route_client_t();
route_client_t(route_client_t &&) noexcept;
route_client_t &operator=(route_client_t &&) noexcept;
route_client_t(const route_client_t &) = default;
route_client_t &operator=(const route_client_t &) = default;
// node target — for the infra layer and owner-consistent routing.
template <typename TMessage>
route_send_call_t send_to_node(std::string mesh_name,
zlink::routing_id_t target_node_rid,
TMessage message);
template <typename TRequest>
channel_request_call_t request_to_node(std::string mesh_name,
zlink::routing_id_t target_node_rid,
TRequest request);
template <typename TMessage>
route_send_call_t send_to_channel(std::string channel_name,
TMessage message);
template <typename TRequest>
channel_request_call_t request_to_channel(std::string channel_name,
TRequest request);
template <typename TMessage>
spot_send_call_t send_to_spot(spot_id_t target, TMessage message);
template <typename TRequest>
spot_request_call_t request_to_spot(spot_id_t target, TRequest request);
};
class route_send_call_t {
public:
route_send_call_t &metadata(std::string key, std::string value);
task_t<void> async();
};
class spot_send_call_t {
public:
spot_send_call_t &metadata(std::string key, std::string value);
spot_send_call_t &instance_spot();
spot_send_call_t &instance_spot(std::string stable_type);
spot_send_call_t &in_mesh(std::string mesh_name);
task_t<void> async();
};
class spot_request_call_t {
public:
spot_request_call_t &timeout(std::chrono::milliseconds timeout);
spot_request_call_t &metadata(std::string key, std::string value);
spot_request_call_t &instance_spot();
spot_request_call_t &instance_spot(std::string stable_type);
spot_request_call_t &in_mesh(std::string mesh_name);
template <typename TReply>
task_t<TReply> async();
template <typename TReply>
task_t<TReply> yield();
};
class fanout_publish_call_t {
public:
task_t<void> async();
};
class publish_call_t {
public:
publish_call_t &metadata(std::string key, std::string value);
task_t<void> async();
};
} // namespace zlink::framework
send_to_spot(...)'s and request_to_spot(...)'s target is always one
global spot_id_t. The fluent option expresses a Missing Instance
Spot's cold activation intent, and doesn't add
MeshName, stable type, owner
RID, or generation to the address. A call with no instance marker set
is existing-only, and ends with not_found for a Missing RID.
instance_spot() omits the
stable type, and
instance_spot(stable_type) specifies the stable type. in_mesh(...)
applies only to the first placement of a Missing RID together with the
Instance marker. The marker and this option can each be set only once
per call, and a duplicate setting is invalid_operation. A terminal
operation among async() or yield<TReply>() can also be started
only once, and a second call is invalid_operation.
The public API stays based on channel name and typed payload
regardless of transport kind. publisher_t::publish(...) provides
both a convenience call that uses the typed event's
packet name as topic and
a call that specifies the
topic explicitly. Both calls
are used for classic fanout, and the Framework decides the codec. If
the topic used by either publish overload, or a topic registered with subscribe, is one that
Channel messaging §7 forbids, it raises framework_exception_t.
fanout_publish_call_t::async() completes normally once the local
publisher transport accepts the event. It doesn't return subscriber
count or receive completion. publish_call_t is
Logical Multicast-only.
It completes normally once the publisher local queue accepts the
event, even with 0 subscribers.
Every server one-way call's async() and Session Actor relay(...)
don't produce a normal completion value. Normal completion means the
operation family's defined source-local queue accepted the message.
It doesn't wait for remote handler execution, subscriber receipt,
remote Spot queue acceptance, or application callback completion. If
queue capacity is short, it waits for a capacity signal and submits the
message exactly once when space opens. This wait has no time limit
(Submit and completion §7). backpressured isn't a public terminal
result or an immediately-thrown application exception. Route
disconnection completes as unavailable,
and runtime shutdown as a framework_exception_t of shutting_down
kind. Absence of Actor/Spot/Mesh/session target uses the existing
error kind the operation family defines. A C++ server call has no
separate cancellation argument. Not keeping or destroying the returned
task doesn't cancel the operation, and the same
operation isn't automatically resubmitted after shutdown.
An invalid argument/state and a duplicate submit complete as
framework_exception_t. A STREAM reply's valid first terminator
atomically claims and consumes a one-shot reply token before starting
transport. If two calls built from the same token race, the call that
fails the claim doesn't attempt transport and completes as
framework_exception_t. Even if the call that consumed the token ends
with a failure, the token can't be used again, and an
already-used token is also treated as exceptional completion. A STREAM
reply isn't given the client request timeout.
Only the Classic fanout publisher uses a
send timeout; its value rules and default are defined by
Submit and completion §7. Other one-way calls and STREAM send/reply
have neither a per-call timeout(...) nor a socket send timeout.
Interaction model §5
and Cancellation and shutdown §4
define Logical Multicast worker admission, commit, terminal, and retry.
C++ publish_call_t::async() returns task_t<void>.
The framework registers the following services by default. The user can inject and use them from DI instead of constructing them directly.
message_bus_tpublisher_t(classic fanout client)spot_publisher_client_t(MeshNode Logical Multicast client)request_client_troute_client_t
5. Channel Surface¶
struct route_message_context_t : message_context_t {
zlink::routing_id_t source_node_rid;
};
class channel_client_t {
public:
template <typename TRequest>
channel_request_call_t request_to_channel(
std::string channel_name,
TRequest request);
template <typename TMessage>
send_call_t send_to_channel(
std::string channel_name,
TMessage message);
};
add_entry_spot<TEntrySpot>() with no argument uses the
TEntrySpot(entry_spot_context_t) constructor. An explicit factory
also receives the Context the Framework built first, by value, and
moves it into the application object. A User/Instance Spot factory
must also each receive the Context, and an overload that injects
or replaces the Context after creation isn't provided.
6. Handler¶
yield() declared in this document's request and Spot request builder
is only valid while the caller owns a SpotWide User Spot's or
Instance Spot's shared turn. In any other execution context, it
completes with invalid_operation without submitting a message or
operation, or returning the turn. Actor Join is registered only through
defer() inside the current handler, and doesn't provide async() or
yield().