blob: 4c48c0514bee18c184bc53fa9e7ffb2f687ab7ce [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Wire-response builders for the non-replicated read path.
//!
//! Assemble `get_me` / `get_clients` / `get_stream(s)` / `get_topic(s)` /
//! `get_user(s)` / `get_personal_access_tokens` / stats / cluster-metadata
//! responses from per-shard session state and the metadata state machine, plus the
//! `NonReplicatedResponse` dispatch shim and the partition-namespace
//! resolvers.
use crate::cluster_meta::ClusterRoster;
use crate::session_manager::SessionManager;
use crate::shell::{ShellBus, ShellShard};
use crate::wire::{transport_kind_to_wire, usize_to_u32};
use bytes::{Bytes, BytesMut};
use consensus::{MetadataHandle, VsrConsensus};
use iggy_binary_protocol::PrepareHeader;
use iggy_binary_protocol::codes::{
DESCRIBE_OPTIONS_CODE, FLUSH_UNSAVED_BUFFER_CODE, GET_CLUSTER_METADATA_CODE,
GET_CONSUMER_GROUP_CODE, GET_CONSUMER_GROUPS_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE,
GET_SNAPSHOT_FILE_CODE, GET_STATS_CODE, GET_STREAM_CODE, GET_STREAMS_CODE, GET_TOPIC_CODE,
GET_TOPICS_CODE, GET_USER_CODE, GET_USERS_CODE,
};
use iggy_binary_protocol::consensus::{RESULT_COUNT_LEN, result_code};
use iggy_binary_protocol::primitives::consumer::WireConsumer;
use iggy_binary_protocol::requests::consumer_groups::{
GetConsumerGroupRequest, GetConsumerGroupsRequest,
};
use iggy_binary_protocol::requests::consumer_offsets::{
DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
};
use iggy_binary_protocol::requests::messages::SendMessagesHeader;
use iggy_binary_protocol::requests::personal_access_tokens::GetPersonalAccessTokensRequest;
use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
use iggy_binary_protocol::requests::streams::{GetStreamRequest, GetStreamsRequest};
use iggy_binary_protocol::requests::system::{
DescribeOptionsRequest, OPTIONS_SCOPE_STREAM, OPTIONS_SCOPE_TOPIC, OPTIONS_SCOPE_USER,
};
use iggy_binary_protocol::requests::topics::{GetTopicRequest, GetTopicsRequest};
use iggy_binary_protocol::requests::users::GetUserRequest;
use iggy_binary_protocol::responses::clients::client_response::ClientResponse;
use iggy_binary_protocol::responses::clients::client_response::ConsumerGroupInfoResponse;
use iggy_binary_protocol::responses::clients::get_client::ClientDetailsResponse;
use iggy_binary_protocol::responses::consumer_groups::GetConsumerGroupsResponse;
use iggy_binary_protocol::responses::personal_access_tokens::RawPersonalAccessTokenResponse;
use iggy_binary_protocol::responses::personal_access_tokens::get_personal_access_tokens::{
GetPersonalAccessTokensResponse, PersonalAccessTokenResponse,
};
use iggy_binary_protocol::responses::streams::StreamResponse;
use iggy_binary_protocol::responses::streams::get_stream::{
GetStreamResponse, TopicHeader as StreamTopicHeader,
};
use iggy_binary_protocol::responses::streams::get_streams::GetStreamsResponse;
use iggy_binary_protocol::responses::system::get_cluster_metadata::{
ClusterMetadataResponse, ClusterNodeResponse,
};
use iggy_binary_protocol::responses::system::get_stats::StatsResponse;
use iggy_binary_protocol::responses::system::{DescribeOptionsResponse, OptionDescriptor};
use iggy_binary_protocol::responses::topics::get_topic::{GetTopicResponse, PartitionResponse};
use iggy_binary_protocol::responses::topics::get_topics::GetTopicsResponse;
use iggy_binary_protocol::responses::users::LoginRegisterResponse;
use iggy_binary_protocol::responses::users::get_user::UserDetailsResponse;
use iggy_binary_protocol::responses::users::get_users::GetUsersResponse;
use iggy_binary_protocol::responses::users::user_response::UserResponse;
use iggy_binary_protocol::{
Command, GenericHeader, IGGY_PROTOCOL_VERSION, KIND_CONSUMER_GROUP, Operation, ReplyHeader,
RoutedRequestHeader, WireDecode, WireEncode, WireIdentifier, WireName, WirePartitioning,
};
use iggy_common::wire_conversions::{resource_options_to_wire, resource_options_to_wire_split};
use iggy_common::{
EncryptorKind, HeaderKind, Identifier, IggyError, IggyTimestamp, OptionsProvenance,
topic_option_keys,
};
use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use message_bus::BusMessage;
use metadata::impls::metadata::StreamsFrontend;
use partitions::{Fragment, PollFragments};
use server_common::iobuf::{Frozen, Owned};
use server_common::send_messages;
use server_common::sharding::IggyNamespace;
use server_common::{MESSAGE_ALIGN, Message, ResponseBacking, ResponseFragments};
use shard::ConnectedClientInfo;
use std::cell::RefCell;
use std::net::IpAddr;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::{Arc, OnceLock};
use sysinfo::System as SysinfoSystem;
use system_stats::SystemProbe;
/// Build the `get_me` reply for the requesting connection. Identity
/// (`user_id`, transport kind, peer address) comes from the per-shard
/// [`SessionManager`]; the `consumer_groups` list is read from the
/// (replicated) consumer-group STM by the connection's bound VSR client id.
pub fn build_get_personal_access_tokens_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
) -> GetPersonalAccessTokensResponse
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
// PATs are per-user; list the requesting connection's own tokens, resolved
// from this shard's `SessionManager` (like `get_me`) then read out of the
// replicated Users STM.
let Some(user_id) = sessions.borrow().get_user_id(transport_client_id) else {
return GetPersonalAccessTokensResponse { tokens: Vec::new() };
};
shard.plane.metadata().mux_stm.users().read(|users| {
let tokens = users
.personal_access_tokens
.get(&user_id)
.map(|pats| {
pats.values()
.filter_map(|pat| {
Some(PersonalAccessTokenResponse {
name: WireName::new(pat.name.as_ref()).ok()?,
expiry_at: pat.expiry_at.map_or(0, |expiry| expiry.as_micros()),
})
})
.collect()
})
.unwrap_or_default();
GetPersonalAccessTokensResponse { tokens }
})
}
pub fn build_get_me_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
) -> ClientDetailsResponse
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let mut client = sessions
.borrow()
.client_record(transport_client_id)
.map_or_else(
|| {
// No session record (shouldn't happen on an auth-gated
// read). Report the connection id with the "no user"
// sentinel + TCP default rather than impersonating root
// (user id 0 is a real user; server is 0-based).
#[allow(clippy::cast_possible_truncation)]
ClientResponse {
client_id: transport_client_id as u32,
user_id: u32::MAX,
transport: 1,
address: String::new(),
consumer_groups_count: 0,
}
},
|record| connected_client_to_response(shard, &record),
);
// The wire `consumer_groups` list keys off the connection's bound VSR
// client id (the same id recorded as a group member by the replicated
// Join op), not the transport id.
let consumer_groups = sessions
.borrow()
.get_session(transport_client_id)
.map(|(vsr_client_id, _)| {
shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_memberships(vsr_client_id)
})
.unwrap_or_default()
.into_iter()
.map(
|(stream_id, topic_id, group_id)| ConsumerGroupInfoResponse {
stream_id,
topic_id,
group_id,
},
)
.collect::<Vec<_>>();
#[allow(clippy::cast_possible_truncation)]
{
client.consumer_groups_count = consumer_groups.len() as u32;
}
ClientDetailsResponse {
client,
consumer_groups,
}
}
/// Convert a [`ConnectedClientInfo`] (one connected client, from the local
/// `SessionManager` or a `get_clients` gather) into the wire
/// [`ClientResponse`]. Shared by `get_me`, `get_clients`, and `get_client`.
///
/// `consumer_groups_count` is resolved from the connection's bound VSR client
/// id against the replicated `Streams` STM (memberships are keyed by VSR id, not
/// transport id). Connections that never bound (pre-register) count 0.
pub fn connected_client_to_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
info: &ConnectedClientInfo,
) -> ClientResponse
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let consumer_groups_count = info.vsr_client_id.map_or(0, |vsr_client_id| {
#[allow(clippy::cast_possible_truncation)]
let count = shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_memberships(vsr_client_id)
.len() as u32;
count
});
// The transport client id is a u128 `(shard << 112) | seq`; the wire
// `client_id` is the u32 seq tail.
#[allow(clippy::cast_possible_truncation)]
ClientResponse {
client_id: info.client_id as u32,
user_id: info.user_id.unwrap_or(u32::MAX),
transport: transport_kind_to_wire(info.transport),
address: info.address.to_string(),
consumer_groups_count,
}
}
/// Fence a consumer-group offset commit/delete: a group consumer may only
/// touch the offset of a partition it currently owns. `Ok` for individual
/// consumers (no fence) and for owned group partitions; `Err` otherwise so a
/// stale client re-syncs instead of corrupting the shared group offset.
fn fence_group_offset<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
consumer: &WireConsumer,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
partition_id: Option<u32>,
client_id: u128,
) -> Result<(), IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
if consumer.kind != KIND_CONSUMER_GROUP {
return Ok(());
}
let partition_id = partition_id.ok_or(IggyError::InvalidIdentifier)?;
#[allow(clippy::cast_possible_truncation)]
shard
.plane
.metadata()
.mux_stm
.streams()
// Commit fence: allow a pending-revoked partition (the source commits it
// to drain the cooperative handoff), so `require_pollable = false`.
.consumer_group_fence(
stream_id,
topic_id,
&consumer.id,
client_id,
partition_id,
false,
)
.map(|_| ())
.ok_or(IggyError::ConsumerGroupPartitionNotOwned(
client_id as u32,
partition_id,
))
}
/// Fence a consumer-group offset op then resolve its target partition
/// namespace. Shared by the four `Store`/`Delete` consumer-offset arms.
fn fence_and_resolve_offset_namespace<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
consumer: &WireConsumer,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
partition_id: Option<u32>,
client_id: u128,
) -> Result<IggyNamespace, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
fence_group_offset(
shard,
consumer,
stream_id,
topic_id,
partition_id,
client_id,
)?;
resolve_partition_namespace(shard, stream_id, topic_id, partition_id)
}
pub fn resolve_partition_request_namespace<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
operation: Operation,
body: &[u8],
client_id: u128,
) -> Result<u64, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let namespace = match operation {
Operation::SendMessages => {
if body.len() < 4 {
return Err(IggyError::InvalidCommand);
}
let metadata_length = u32::from_le_bytes(
body[..4]
.try_into()
.map_err(|_| IggyError::InvalidNumberEncoding)?,
) as usize;
if body.len() < 4 + metadata_length {
return Err(IggyError::InvalidCommand);
}
let header = SendMessagesHeader::decode_from(&body[4..4 + metadata_length])
.map_err(|_| IggyError::InvalidCommand)?;
resolve_send_messages_namespace(shard, &header)?
}
Operation::StoreConsumerOffset => {
let request = StoreConsumerOffsetRequest::decode_from(body)
.map_err(|_| IggyError::InvalidCommand)?;
fence_and_resolve_offset_namespace(
shard,
&request.consumer,
&request.stream_id,
&request.topic_id,
request.partition_id,
client_id,
)?
}
Operation::DeleteConsumerOffset => {
let request = DeleteConsumerOffsetRequest::decode_from(body)
.map_err(|_| IggyError::InvalidCommand)?;
fence_and_resolve_offset_namespace(
shard,
&request.consumer,
&request.stream_id,
&request.topic_id,
request.partition_id,
client_id,
)?
}
Operation::DeleteSegments => {
let request =
DeleteSegmentsRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
resolve_partition_namespace(
shard,
&request.stream_id,
&request.topic_id,
Some(request.partition_id),
)?
}
_ => return Err(IggyError::FeatureUnavailable),
};
Ok(namespace.inner())
}
fn resolve_send_messages_namespace<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
header: &SendMessagesHeader,
) -> Result<IggyNamespace, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let partition_id = match &header.partitioning {
WirePartitioning::PartitionId(partition_id) => *partition_id,
WirePartitioning::Balanced => shard
.plane
.metadata()
.mux_stm
.streams()
.next_balanced_partition(&header.stream_id, &header.topic_id)
.ok_or(IggyError::InvalidIdentifier)?,
WirePartitioning::MessagesKey(key) => shard
.plane
.metadata()
.mux_stm
.streams()
.partition_by_messages_key(&header.stream_id, &header.topic_id, key)
.ok_or(IggyError::InvalidIdentifier)?,
};
resolve_partition_namespace(
shard,
&header.stream_id,
&header.topic_id,
Some(partition_id),
)
}
pub fn resolve_partition_namespace<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
partition_id: Option<u32>,
) -> Result<IggyNamespace, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let partition_id = partition_id.ok_or(IggyError::InvalidIdentifier)?;
let streams = shard.plane.metadata().mux_stm.streams();
if let Some(namespace) = streams.namespace_from_partition(stream_id, topic_id, partition_id) {
return Ok(namespace);
}
// Name the level that missed - partition, topic, or stream - with the
// legacy typed not-found, so a client can tell an addressing typo from an
// empty partition. Callers that shape their own reply (empty poll, group
// gather) treat every variant the same, so the split is reply-visible only
// where a caller denies typed.
if streams.topic_partition_ids(stream_id, topic_id).is_some() {
return Err(IggyError::PartitionNotFound(
partition_id as usize,
wire_identifier_for_display(topic_id),
wire_identifier_for_display(stream_id),
));
}
Err(streams.read(|inner| {
let Some(resolved_stream) = resolve_stream_id(inner, stream_id) else {
return stream_not_found(stream_id);
};
if resolve_topic_id(inner, resolved_stream, topic_id).is_none() {
return topic_not_found(stream_id, topic_id);
}
// Unreachable while `topic_partition_ids` misses only on stream/topic;
// kept as the safe generic rejection should that invariant drift.
IggyError::InvalidIdentifier
}))
}
/// Best-effort conversion for error payloads only: the wire reply carries just
/// the error code, so a failed conversion may fall back to a default without
/// changing what the client sees.
fn wire_identifier_for_display(id: &WireIdentifier) -> Identifier {
match id {
WireIdentifier::Numeric(numeric_id) => Identifier::numeric(*numeric_id),
WireIdentifier::String(name) => Identifier::named(name.as_str()),
}
.unwrap_or_default()
}
/// `user_id` is the authenticated caller, used only by the identity-scoped
/// reads (currently the PAT list); every other arm ignores it. Authorization
/// stays with the per-transport gates that run before this builder. `client_ip`
/// is the caller's transport-level peer address, used only by the
/// cluster-metadata read to pick each node's advertised address; `None`
/// degrades to the catch-all address. `clients_count` is the cross-shard
/// connected-client total, used only by the stats read: it comes from the async
/// `ListClients` scatter-gather, which this sync builder cannot run, so both
/// transport callers gather it up front (0 for every other opcode).
pub fn build_non_replicated_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
code: u32,
body: &[u8],
user_id: Option<u32>,
roster: &ClusterRoster,
client_ip: Option<IpAddr>,
clients_count: u32,
) -> Result<NonReplicatedResponse, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
match code {
DESCRIBE_OPTIONS_CODE => Ok(NonReplicatedResponse::Bytes(
build_describe_options_response(body)?.to_bytes(),
)),
GET_CLUSTER_METADATA_CODE => Ok(NonReplicatedResponse::Bytes(
build_cluster_metadata_response(roster, shard, client_ip).to_bytes(),
)),
GET_STATS_CODE => Ok(NonReplicatedResponse::Bytes(
build_stats_response(shard, clients_count)?.to_bytes(),
)),
GET_STREAM_CODE => {
let request =
GetStreamRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
build_get_stream_response(shard, &request.stream_id).map(|response| {
response.map_or(NonReplicatedResponse::Empty, |response| {
NonReplicatedResponse::Bytes(response.to_bytes())
})
})
}
GET_STREAMS_CODE => {
let _ = GetStreamsRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
Ok(NonReplicatedResponse::Bytes(
build_get_streams_response(shard)?.to_bytes(),
))
}
GET_TOPIC_CODE => {
let request =
GetTopicRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
build_get_topic_response(shard, &request.stream_id, &request.topic_id).map(|response| {
response.map_or(NonReplicatedResponse::Empty, |response| {
NonReplicatedResponse::Bytes(response.to_bytes())
})
})
}
GET_TOPICS_CODE => {
let request =
GetTopicsRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
Ok(NonReplicatedResponse::Bytes(
build_get_topics_response(shard, &request.stream_id)?.to_bytes(),
))
}
GET_USERS_CODE => Ok(NonReplicatedResponse::Bytes(
build_get_users_response(shard)?.to_bytes(),
)),
GET_USER_CODE => {
let request =
GetUserRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
build_get_user_response(shard, &request.user_id).map(|response| {
response.map_or(NonReplicatedResponse::Empty, |response| {
NonReplicatedResponse::Bytes(response.to_bytes())
})
})
}
GET_PERSONAL_ACCESS_TOKENS_CODE => {
let _ = GetPersonalAccessTokensRequest::decode_from(body)
.map_err(|_| IggyError::InvalidCommand)?;
// Caller-scoped: both transport gates reject unauthenticated
// callers before this read runs, so a missing id is a gate
// bug; fail closed rather than serve another scope.
let user_id = user_id.ok_or(IggyError::Unauthenticated)?;
let tokens = shard
.plane
.metadata()
.mux_stm
.users()
.read(|users| users.personal_access_tokens_of(user_id));
Ok(NonReplicatedResponse::Bytes(
personal_access_tokens_response(tokens)?.to_bytes(),
))
}
GET_CONSUMER_GROUP_CODE => build_consumer_group_response(shard, body),
GET_CONSUMER_GROUPS_CODE => build_consumer_groups_response(shard, body),
// The server has no on-demand flush primitive, so it denies honestly.
// The non-replicated catch-all's empty-ok would otherwise attest a
// durability guarantee the server never gave.
FLUSH_UNSAVED_BUFFER_CODE => Err(IggyError::FeatureUnavailable),
// Snapshot collection blocks on shell-outs, so the dedicated dispatch
// and HTTP handlers await it off-thread; this synchronous builder
// cannot, and reaching it here is a routing bug. Fail closed rather
// than let the catch-all's empty-ok attest an artifact that was never
// produced.
GET_SNAPSHOT_FILE_CODE => Err(IggyError::InvalidCommand),
_ => match iggy_binary_protocol::dispatch::lookup_command(code) {
Some(meta) if !meta.is_replicated() => Ok(NonReplicatedResponse::Empty),
Some(_) => Err(IggyError::FeatureUnavailable),
None => Err(IggyError::InvalidCommand),
},
}
}
fn build_consumer_group_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
body: &[u8],
) -> Result<NonReplicatedResponse, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let request =
GetConsumerGroupRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
ensure_topic_exists(shard, &request.stream_id, &request.topic_id)?;
let response = shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_details(&request.stream_id, &request.topic_id, &request.group_id);
Ok(response.map_or(NonReplicatedResponse::Empty, |response| {
NonReplicatedResponse::Bytes(response.to_bytes())
}))
}
fn build_consumer_groups_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
body: &[u8],
) -> Result<NonReplicatedResponse, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let request =
GetConsumerGroupsRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
ensure_topic_exists(shard, &request.stream_id, &request.topic_id)?;
let groups = shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_list(&request.stream_id, &request.topic_id);
Ok(groups.map_or(NonReplicatedResponse::Empty, |groups| {
NonReplicatedResponse::Bytes(GetConsumerGroupsResponse { groups }.to_bytes())
}))
}
/// Build the binary `GetClusterMetadata` reply from the shared roster assembly.
/// The leader marking comes from this shard's consensus view; a shard without
/// consensus (any shard but 0) still serves the full roster, only with no node
/// marked leader.
fn build_cluster_metadata_response<B, MJ, S, SB>(
roster: &ClusterRoster,
shard: &Rc<ShellShard<B, MJ, S, SB>>,
client_ip: Option<IpAddr>,
) -> ClusterMetadataResponse
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
// Shard 0 reads its live consensus; delegated shards use the view shard 0
// publishes into the roster, so leader marking works on every shard.
let primary_index = shard
.plane
.metadata()
.consensus
.as_ref()
.and_then(|consensus| {
let primary_index = consensus.primary_index(consensus.view());
// A restarted replica that ceded the primaryship its stale view
// assigns it must not advertise itself as leader: clients would
// pin to a node that never heartbeats. Report "no leader" until
// the election resolves the role.
(!(consensus.has_ceded_primaryship() && primary_index == consensus.replica()))
.then_some(primary_index)
})
.or_else(|| roster.current_primary_replica_id());
let metadata = roster.cluster_metadata(primary_index, client_ip);
ClusterMetadataResponse {
name: metadata.name,
nodes: metadata
.nodes
.into_iter()
.map(|node| ClusterNodeResponse {
name: node.name,
ip: node.ip,
tcp_port: node.endpoints.tcp,
quic_port: node.endpoints.quic,
http_port: node.endpoints.http,
websocket_port: node.endpoints.websocket,
role: node.role as u8,
status: node.status as u8,
})
.collect(),
}
}
/// `(streams, topics, partitions, segments, message bytes, messages)` for the
/// whole node, from committed metadata plus the shared stats registry.
///
/// Segments are summed PER PARTITION through the same floor the detail
/// responses apply (see [`partition_response`]), not from the stream's rolled-up
/// counter: that counter only advances once a partition materialises, which
/// trails its commit by a reconciler pass. Summing it made `[stats]` report
/// fewer segments than `get_topic` did for the same partitions, and let the
/// total climb between two reads with no write in between.
fn aggregate_stats_totals(
streams: &metadata::stm::stream::StreamsInner,
) -> Result<(u32, u32, u32, u32, u64, u64), IggyError> {
let mut topics_count = 0u32;
let mut partitions_count = 0u32;
let mut segments_count = 0u32;
let mut messages_size_bytes = 0u64;
let mut messages_count = 0u64;
for (_, stream) in &streams.items {
topics_count = topics_count.saturating_add(usize_to_u32(stream.topics.len())?);
messages_size_bytes =
messages_size_bytes.saturating_add(stream.stats.size_bytes_inconsistent());
messages_count = messages_count.saturating_add(stream.stats.messages_count_inconsistent());
for (_, topic) in &stream.topics {
partitions_count =
partitions_count.saturating_add(usize_to_u32(topic.partitions.len())?);
for partition in &topic.partitions {
segments_count = segments_count.saturating_add(partition_segments_count(
streams,
stream.id,
topic.id,
partition.id,
));
}
}
}
Ok((
usize_to_u32(streams.items.len())?,
topics_count,
partitions_count,
segments_count,
messages_size_bytes,
messages_count,
))
}
fn build_stats_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
clients_count: u32,
) -> Result<StatsResponse, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let (
streams_count,
topics_count,
partitions_count,
segments_count,
messages_size_bytes,
messages_count,
) = shard
.plane
.metadata()
.mux_stm
.streams()
.read(aggregate_stats_totals)?;
let consumer_groups_count = usize_to_u32(
shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_count(),
)?;
let system = probe_system_stats();
// Disk usage of the volume holding iggy data. The data directory is
// captured process-globally at bootstrap (the shard doesn't carry server
// config on the read path); absent that, or on a probe error, report 0.
let (free_disk_space, total_disk_space) = STATS_DATA_PATH.get().map_or((0, 0), |path| {
(
fs2::available_space(path).unwrap_or(0),
fs2::total_space(path).unwrap_or(0),
)
});
Ok(StatsResponse {
process_id: system.process_id,
cpu_usage: system.cpu_usage,
total_cpu_usage: system.total_cpu_usage,
memory_usage: system.memory_usage,
total_memory: system.total_memory,
available_memory: system.available_memory,
run_time: system.run_time,
start_time: system.start_time,
read_bytes: system.read_bytes,
written_bytes: system.written_bytes,
messages_size_bytes,
streams_count,
topics_count,
partitions_count,
segments_count,
messages_count,
clients_count,
consumer_groups_count,
hostname: system.hostname,
os_name: system.os_name,
os_version: system.os_version,
kernel_version: system.kernel_version,
iggy_server_version: crate::VERSION.to_owned(),
iggy_server_semver: crate::SEMANTIC_VERSION.get_numeric_version().ok(),
cache_metrics: Vec::new(),
threads_count: system.threads_count,
free_disk_space,
total_disk_space,
})
}
/// Process- and host-level portion of the stats reply, probed via `sysinfo`.
/// These describe the whole process, not shard or metadata state, so any one
/// shard can serve them without aggregation. The CPU fields are deltas over the
/// serving thread's own [`SYSINFO`] refresh history, so they vary by serving
/// shard (a shard's first probe reports zero CPU).
struct SystemStats {
process_id: u32,
cpu_usage: f32,
total_cpu_usage: f32,
memory_usage: u64,
total_memory: u64,
available_memory: u64,
run_time: u64,
start_time: u64,
read_bytes: u64,
written_bytes: u64,
threads_count: u32,
hostname: String,
os_name: String,
os_version: String,
kernel_version: String,
}
thread_local! {
// `cpu_usage` is a delta since the previous refresh, so the sampled
// `System` is kept alive across `GetStats` calls (a freshly created one
// reports zero CPU). Mirrors the legacy shard-0 stats path.
static SYSINFO: RefCell<Option<SysinfoSystem>> = const { RefCell::new(None) };
}
/// Host / OS identity is process-static (unlike the per-call CPU and memory
/// samples), so probe it once and clone from the cache on each `GetStats`
/// rather than re-querying sysinfo every call. Process-global, so a `OnceLock`
/// fits better than the per-thread [`SYSINFO`] cell.
struct HostIdentity {
hostname: String,
os_name: String,
os_version: String,
kernel_version: String,
}
impl HostIdentity {
fn probe() -> Self {
Self {
hostname: SysinfoSystem::host_name().unwrap_or_else(|| "unknown_hostname".to_owned()),
os_name: SysinfoSystem::name().unwrap_or_else(|| "unknown_os_name".to_owned()),
os_version: SysinfoSystem::long_os_version()
.unwrap_or_else(|| "unknown_os_version".to_owned()),
kernel_version: SysinfoSystem::kernel_version()
.unwrap_or_else(|| "unknown_kernel_version".to_owned()),
}
}
}
static HOST_IDENTITY: OnceLock<HostIdentity> = OnceLock::new();
/// Configured data directory, captured once at bootstrap so the sync stats
/// read path can report disk usage of the volume that holds iggy data rather
/// than an unrelated mount. Unset (disk stats fall back to 0) until bootstrap.
static STATS_DATA_PATH: OnceLock<PathBuf> = OnceLock::new();
/// Capture the configured data directory for `GetStats` disk reporting.
/// Idempotent: only the first call (process bootstrap) takes effect.
pub fn init_stats_data_path(path: PathBuf) {
let _ = STATS_DATA_PATH.set(path);
}
fn probe_system_stats() -> SystemStats {
let host = HOST_IDENTITY.get_or_init(HostIdentity::probe);
let probe = SYSINFO.with_borrow_mut(|slot| {
let sys = slot.get_or_insert_with(SysinfoSystem::new);
SystemProbe::capture(sys)
});
SystemStats {
process_id: probe.process_id,
cpu_usage: probe.cpu_usage,
total_cpu_usage: probe.total_cpu_usage,
memory_usage: probe.memory_usage,
total_memory: probe.total_memory,
available_memory: probe.available_memory,
// sysinfo reports whole seconds; the wire fields are micros (the
// SDK decodes them via `IggyDuration` / `IggyTimestamp::from`, both
// micro-based).
run_time: probe.run_time_secs.saturating_mul(1_000_000),
start_time: probe.start_time_secs.saturating_mul(1_000_000),
read_bytes: probe.read_bytes,
written_bytes: probe.written_bytes,
threads_count: probe.threads_count,
hostname: host.hostname.clone(),
os_name: host.os_name.clone(),
os_version: host.os_version.clone(),
kernel_version: host.kernel_version.clone(),
}
}
fn build_get_stream_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
) -> Result<Option<GetStreamResponse>, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.streams().read(|streams| {
let Some(stream_id) = resolve_stream_id(streams, stream_id) else {
return Ok(None);
};
let stream = streams
.items
.get(stream_id)
.ok_or(IggyError::InvalidIdentifier)?;
Ok(Some(GetStreamResponse {
stream: stream_response(stream)?,
topics: stream
.topics
.iter()
.map(|(_, topic)| topic_header(topic))
.collect::<Result<Vec<_>, _>>()?,
}))
})
}
fn build_get_streams_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
) -> Result<GetStreamsResponse, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.streams().read(|streams| {
streams
.items
.iter()
.map(|(_, stream)| stream_response(stream))
.collect::<Result<Vec<_>, _>>()
.map(|streams| GetStreamsResponse { streams })
})
}
/// Every key `CreateTopic` accepts, with the kind, default and bounds of each.
///
/// Split out of [`build_describe_options_response`] so the descriptions have room
/// to state the bounds each value is checked against: this catalog is the only
/// place an operator learns them.
///
/// Every default is a build constant: these knobs stopped being config-derived
/// when the `[system.*]` keys became topic options, so the catalog reads them
/// straight from `iggy_common`.
fn topic_option_descriptors() -> Result<Vec<OptionDescriptor>, IggyError> {
Ok(vec![
OptionDescriptor {
key: WireName::new(topic_option_keys::COMPRESSION_ALGORITHM)
.map_err(|_| IggyError::InvalidFormat)?,
kind: HeaderKind::String.as_code(),
default_value: Bytes::from_static(b"none"),
description: "Compression algorithm (none, gzip)".to_string(),
},
OptionDescriptor {
key: WireName::new(topic_option_keys::MESSAGE_EXPIRY)
.map_err(|_| IggyError::InvalidFormat)?,
kind: HeaderKind::Uint64.as_code(),
default_value: Bytes::copy_from_slice(
&iggy_common::DEFAULT_MESSAGE_EXPIRY.to_le_bytes(),
),
description: "Message expiry in microseconds, or a humantime string \
(e.g. 7 days)"
.to_string(),
},
OptionDescriptor {
key: WireName::new(topic_option_keys::MAX_TOPIC_SIZE)
.map_err(|_| IggyError::InvalidFormat)?,
kind: HeaderKind::Uint64.as_code(),
default_value: Bytes::copy_from_slice(
&iggy_common::DEFAULT_MAX_TOPIC_SIZE.to_le_bytes(),
),
description: "Topic size cap in bytes, or a byte-size string (e.g. 1 GiB); \
must be at least the segment size"
.to_string(),
},
OptionDescriptor {
key: WireName::new(topic_option_keys::SEGMENT_SIZE)
.map_err(|_| IggyError::InvalidFormat)?,
kind: HeaderKind::Uint64.as_code(),
default_value: Bytes::copy_from_slice(&iggy_common::DEFAULT_SEGMENT_SIZE.to_le_bytes()),
description: format!(
"Segment size in bytes, or a byte-size string (e.g. 128 MiB); a 512-byte \
multiple within {}..={}",
iggy_common::MIN_TOPIC_SEGMENT_SIZE,
iggy_common::MAX_TOPIC_SEGMENT_SIZE
),
},
OptionDescriptor {
key: WireName::new(topic_option_keys::ENFORCE_FSYNC)
.map_err(|_| IggyError::InvalidFormat)?,
kind: HeaderKind::Bool.as_code(),
default_value: Bytes::copy_from_slice(&[u8::from(iggy_common::DEFAULT_ENFORCE_FSYNC)]),
description: "Whether writes to this topic's partitions fsync".to_string(),
},
OptionDescriptor {
key: WireName::new(topic_option_keys::MESSAGES_REQUIRED_TO_SAVE)
.map_err(|_| IggyError::InvalidFormat)?,
kind: HeaderKind::Uint32.as_code(),
default_value: Bytes::copy_from_slice(
&iggy_common::DEFAULT_MESSAGES_REQUIRED_TO_SAVE.to_le_bytes(),
),
description: format!(
"Flush the journal once it holds this many messages; \
1..={}. A threshold no segment can reach leaves committed \
messages in the journal, which a crash does not preserve",
iggy_common::MAX_MESSAGES_REQUIRED_TO_SAVE
),
},
OptionDescriptor {
key: WireName::new(topic_option_keys::SIZE_OF_MESSAGES_REQUIRED_TO_SAVE)
.map_err(|_| IggyError::InvalidFormat)?,
kind: HeaderKind::Uint64.as_code(),
default_value: Bytes::copy_from_slice(
&iggy_common::DEFAULT_SIZE_OF_MESSAGES_REQUIRED_TO_SAVE.to_le_bytes(),
),
description: format!(
"Flush the journal once it holds this many bytes, or a byte-size \
string; whichever threshold trips first flushes. At most {}",
iggy_common::MAX_SIZE_OF_MESSAGES_REQUIRED_TO_SAVE
),
},
OptionDescriptor {
key: WireName::new(topic_option_keys::PREALLOCATE_SEGMENTS)
.map_err(|_| IggyError::InvalidFormat)?,
kind: HeaderKind::Bool.as_code(),
default_value: Bytes::copy_from_slice(&[u8::from(
iggy_common::DEFAULT_PREALLOCATE_SEGMENTS,
)]),
description: format!(
"Reserve each segment's bytes up front where the filesystem supports \
it; pairs with segment_size. The reservation is real disk and runs \
inline on the owning shard, at every rotation and once per owned \
partition at boot, so segment_size * partitions_count is capped at \
{} bytes",
iggy_common::MAX_PREALLOCATED_TOPIC_BYTES
),
},
])
}
/// Serve the option catalog for one resource scope.
///
/// Streams and users have no catalog keys yet, so their scopes return empty
/// (every key is rejected at create until one lands).
fn build_describe_options_response(body: &[u8]) -> Result<DescribeOptionsResponse, IggyError> {
let request =
DescribeOptionsRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
let entries = match request.scope {
OPTIONS_SCOPE_TOPIC => topic_option_descriptors()?,
OPTIONS_SCOPE_STREAM | OPTIONS_SCOPE_USER => Vec::new(),
_ => return Err(IggyError::InvalidCommand),
};
Ok(DescribeOptionsResponse { entries })
}
#[allow(clippy::cast_possible_truncation)]
fn user_response(user: &metadata::stm::user::User) -> Result<UserResponse, IggyError> {
Ok(UserResponse {
id: user.id,
created_at: user.created_at.as_micros(),
status: user.status.as_code(),
username: WireName::new(user.username.as_ref()).map_err(|_| IggyError::InvalidFormat)?,
options: resource_options_to_wire(&user.options, OptionsProvenance::Explicit)?,
})
}
fn build_get_users_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
) -> Result<GetUsersResponse, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.users().read(|users| {
users
.items
.iter()
.map(|(_, user)| user_response(user))
.collect::<Result<Vec<_>, _>>()
.map(|users| GetUsersResponse { users })
})
}
fn build_get_user_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
user_id: &WireIdentifier,
) -> Result<Option<UserDetailsResponse>, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.users().read(|users| {
let Some(id) = users.resolve_user_id(user_id) else {
return Ok(None);
};
let user = users.items.get(id).ok_or(IggyError::InvalidIdentifier)?;
Ok(Some(UserDetailsResponse {
user: user_response(user)?,
permissions: user
.permissions
.as_ref()
.map(|p| iggy_common::wire_conversions::permissions_to_wire(p)),
}))
})
}
fn personal_access_tokens_response(
tokens: Vec<(Arc<str>, Option<IggyTimestamp>)>,
) -> Result<GetPersonalAccessTokensResponse, IggyError> {
let tokens = tokens
.into_iter()
.map(|(name, expiry_at)| {
Ok(PersonalAccessTokenResponse {
name: WireName::new(name.as_ref()).map_err(|_| IggyError::InvalidFormat)?,
// 0 is the wire encoding for a never-expiring token, matching
// the legacy handler and the SDK-side decode.
expiry_at: expiry_at.map_or(0, |expiry_at| expiry_at.as_micros()),
})
})
.collect::<Result<Vec<_>, IggyError>>()?;
Ok(GetPersonalAccessTokensResponse { tokens })
}
fn build_get_topic_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
) -> Result<Option<GetTopicResponse>, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.streams().read(|streams| {
let Some(stream_id) = resolve_stream_id(streams, stream_id) else {
return Ok(None);
};
let Some(topic_id) = resolve_topic_id(streams, stream_id, topic_id) else {
return Ok(None);
};
let stream = streams
.items
.get(stream_id)
.ok_or(IggyError::InvalidIdentifier)?;
let topic = stream
.topics
.get(topic_id)
.ok_or(IggyError::InvalidIdentifier)?;
Ok(Some(GetTopicResponse {
topic: topic_header(topic)?,
partitions: topic
.partitions
.iter()
.map(|partition| partition_response(streams, stream_id, topic_id, partition))
.collect::<Result<Vec<_>, _>>()?,
}))
})
}
fn build_get_topics_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
) -> Result<GetTopicsResponse, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.streams().read(|streams| {
// Legacy parity: a missing stream lists as empty, not StreamNotFound.
let Some(resolved_stream) = resolve_stream_id(streams, stream_id) else {
return Ok(GetTopicsResponse { topics: Vec::new() });
};
let stream = streams
.items
.get(resolved_stream)
.ok_or(IggyError::InvalidIdentifier)?;
stream
.topics
.iter()
.map(|(_, topic)| topic_header(topic))
.collect::<Result<Vec<_>, _>>()
.map(|topics| GetTopicsResponse { topics })
})
}
/// Reject a consumer-group read whose parent stream/topic is absent with the
/// legacy typed error naming the level that missed; the group itself missing
/// stays the shared not-found reply (empty over TCP, 404 over HTTP).
fn ensure_topic_exists<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
) -> Result<(), IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.streams().read(|streams| {
let resolved_stream =
resolve_stream_id(streams, stream_id).ok_or_else(|| stream_not_found(stream_id))?;
resolve_topic_id(streams, resolved_stream, topic_id)
.ok_or_else(|| topic_not_found(stream_id, topic_id))?;
Ok(())
})
}
/// Convert a `WireIdentifier` to the domain `Identifier`.
fn wire_id_to_identifier(wire: &WireIdentifier) -> Result<Identifier, IggyError> {
match wire {
WireIdentifier::Numeric(id) => Identifier::numeric(*id),
WireIdentifier::String(name) => Identifier::named(name.as_str()),
}
}
/// Typed miss for a read's parent stream, matching the legacy servers' error
/// shape. The identifier only feeds the error message; a wire form with no
/// domain equivalent (numeric 0 is a live slab id here but not a legacy id)
/// falls back to the default identifier.
fn stream_not_found(stream_id: &WireIdentifier) -> IggyError {
IggyError::StreamIdNotFound(wire_id_to_identifier(stream_id).unwrap_or_default())
}
/// Typed miss for a read's parent topic; see [`stream_not_found`]. The variant's
/// display order is (topic, stream).
fn topic_not_found(stream_id: &WireIdentifier, topic_id: &WireIdentifier) -> IggyError {
IggyError::TopicIdNotFound(
wire_id_to_identifier(topic_id).unwrap_or_default(),
wire_id_to_identifier(stream_id).unwrap_or_default(),
)
}
pub fn resolve_stream_id(
streams: &metadata::stm::stream::StreamsInner,
identifier: &WireIdentifier,
) -> Option<usize> {
match identifier {
WireIdentifier::Numeric(id) => {
let id = *id as usize;
streams.items.contains(id).then_some(id)
}
WireIdentifier::String(name) => streams.index.get(name.as_str()).copied(),
}
}
pub fn resolve_topic_id(
streams: &metadata::stm::stream::StreamsInner,
stream_id: usize,
identifier: &WireIdentifier,
) -> Option<usize> {
let stream = streams.items.get(stream_id)?;
match identifier {
WireIdentifier::Numeric(id) => {
let id = *id as usize;
stream.topics.contains(id).then_some(id)
}
WireIdentifier::String(name) => stream.topic_index.get(name.as_str()).copied(),
}
}
fn stream_response(stream: &metadata::stm::stream::Stream) -> Result<StreamResponse, IggyError> {
Ok(StreamResponse {
id: usize_to_u32(stream.id)?,
created_at: stream.created_at.as_micros(),
topics_count: usize_to_u32(stream.topics.len())?,
size_bytes: stream.stats.size_bytes_inconsistent(),
messages_count: stream.stats.messages_count_inconsistent(),
name: WireName::new(stream.name.as_ref()).map_err(|_| IggyError::InvalidFormat)?,
options: resource_options_to_wire(&stream.options, OptionsProvenance::Explicit)?,
})
}
/// Stored `message_expiry` and `max_topic_size` echo verbatim, `ServerDefault`
/// as the wire sentinel (0), matching legacy: create admission resolves the
/// sentinels against server config before replication, so a stored sentinel
/// came from an update and must read back as `ServerDefault`, not as the node
/// default frozen at read time.
fn topic_header(topic: &metadata::stm::stream::Topic) -> Result<StreamTopicHeader, IggyError> {
let (options, derived_options) = resource_options_to_wire_split(&topic.options)?;
Ok(StreamTopicHeader {
id: usize_to_u32(topic.id)?,
created_at: topic.created_at.as_micros(),
partitions_count: usize_to_u32(topic.partitions.len())?,
message_expiry: u64::from(topic.message_expiry),
compression_algorithm: topic.compression_algorithm.as_code(),
max_topic_size: topic.max_topic_size.as_bytes_u64(),
size_bytes: topic.stats.size_bytes_inconsistent(),
messages_count: topic.stats.messages_count_inconsistent(),
name: WireName::new(topic.name.as_ref()).map_err(|_| IggyError::InvalidFormat)?,
options,
derived_options,
})
}
/// Segments a committed partition reports before its storage exists.
/// [`partition_response`] carries the reasoning.
const MATERIALIZED_SEGMENTS_FLOOR: u32 = 1;
/// Segments one committed partition reports. The single source for every
/// client-facing segment count, so the `[stats]` total and the per-partition
/// detail cannot disagree about the same partition.
fn partition_segments_count(
streams: &metadata::stm::stream::StreamsInner,
stream_id: usize,
topic_id: usize,
partition_id: usize,
) -> u32 {
streams
.stats_registry
.partition_get(stream_id, topic_id, partition_id)
.map_or(MATERIALIZED_SEGMENTS_FLOOR, |stats| {
stats
.segments_count_inconsistent()
.max(MATERIALIZED_SEGMENTS_FLOOR)
})
}
fn partition_response(
streams: &metadata::stm::stream::StreamsInner,
stream_id: usize,
topic_id: usize,
partition: &metadata::stm::stream::Partition,
) -> Result<PartitionResponse, IggyError> {
// Per-partition counters live in the shared stats registry (one `Arc`
// across all shards and both left-right buffers).
//
// Registration is NOT materialization: the owning shard's reconciler mints
// the entry (get-or-create in `fetch_partition_stats`) before it builds the
// partition, and `ensure_initial_segment` only bumps `segments_count` once
// the segment file is open. So a registry MISS and a registered entry still
// reading zero segments are the same thing to a caller -- committed, not yet
// holding storage -- and both report the deterministic shape every
// materialization lands on: one empty segment at offset 0. A bare zero
// would read as "no storage" to a client polling right after `create_topic`.
//
// Cost of the clamp: a partition fenced for rebuild (tombstoned after a
// refused chain) also reads as one empty segment rather than zero. Telling
// the two apart needs a materialization signal the registry does not carry
// today; the counters are still the honest source for size and messages.
// TODO(hubcio): carry that materialization signal in the stats registry
// (segment planted vs fenced-for-rebuild) so monitoring can see a real
// zero-segment partition instead of this clamp.
let stats = streams
.stats_registry
.partition_get(stream_id, topic_id, partition.id);
let (current_offset, size_bytes, messages_count) = stats.map_or((0, 0, 0), |stats| {
(
stats.current_offset(),
stats.size_bytes_inconsistent(),
stats.messages_count_inconsistent(),
)
});
Ok(PartitionResponse {
id: usize_to_u32(partition.id)?,
created_at: partition.created_at.as_micros(),
segments_count: partition_segments_count(streams, stream_id, topic_id, partition.id),
current_offset,
size_bytes,
messages_count,
})
}
pub enum NonReplicatedResponse {
Empty,
Bytes(Bytes),
}
impl NonReplicatedResponse {
pub(crate) fn into_reply(
self,
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
commit: u64,
) -> Message<ReplyHeader> {
match self {
Self::Empty => build_empty_reply(request_header, client_id, session, commit),
Self::Bytes(body) => {
build_reply_from_bytes(request_header, client_id, session, commit, &body)
}
}
}
}
pub fn build_empty_reply(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
commit: u64,
) -> Message<ReplyHeader> {
build_reply_with_body(request_header, client_id, session, commit, 0, |_| {})
}
/// Build an empty reply that denies a dispatch-time authorization check: the
/// same request echo as [`build_empty_reply`] but with `ReplyHeader.status`
/// set to the rule's error code -- the request-level failure channel the SDK
/// peeks before any body decode. Every deny frame shares this shape (empty
/// body, nonzero status); op carries the builder's session argument like every
/// reply, and only the partition primary's pre-pipeline deny pins it to 0,
/// stamped through `consensus::build_deny_reply_from_request`.
pub fn build_deny_reply(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
commit: u64,
status: u32,
) -> Message<ReplyHeader> {
let mut reply = build_empty_reply(request_header, client_id, session, commit);
let header_len = std::mem::size_of::<ReplyHeader>();
let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(
&mut reply.as_mut_slice()[..header_len],
)
.expect("empty reply header is a valid ReplyHeader");
header.status = status;
reply
}
/// Server build version advertised in the login-register response.
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Build a metadata reply carrying `payload` behind a success result section.
///
/// The SDK strips a result section off exactly the replies whose operation is
/// [`iggy_binary_protocol::Operation::is_result_framed`] (every metadata op plus the
/// four consumer-offset ops), and a non-empty `Register`, which it handles on its
/// own. For those, a payload missing the leading zero count has its first four bytes
/// eaten as a result count, and the decode fails or, worse, succeeds on the shifted
/// remainder: the raw-PAT reply shipped once without the prefix and broke the SDK.
///
/// The only way to BUILD a result-framed success body, though not the only path to a
/// success reply with one: [`build_reply_from_bytes`] passes a committed body
/// through, framed or not according to the operation. Framing a reply whose operation
/// is not result-framed breaks decoding just as badly, so the choice belongs with the
/// operation rather than here.
fn build_result_framed_reply(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
commit: u64,
payload: &impl WireEncode,
) -> Message<ReplyHeader> {
let mut encoded = BytesMut::with_capacity(payload.encoded_size());
payload.encode(&mut encoded);
build_reply_with_body(
request_header,
client_id,
session,
commit,
RESULT_COUNT_LEN + encoded.len(),
|out| {
let (count, body) = out.split_at_mut(RESULT_COUNT_LEN);
count.copy_from_slice(&0u32.to_le_bytes());
body.copy_from_slice(&encoded);
},
)
}
pub fn build_login_register_reply(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
commit: u64,
user_id: u32,
) -> Message<ReplyHeader> {
// A transient Register instead ships a `[count=1][index=0]
// [TransientNotCommitted]` frame (`build_transient_reply`), which the SDK
// decodes and replays.
let payload = LoginRegisterResponse {
user_id,
session,
server_protocol_version: IGGY_PROTOCOL_VERSION,
server_version: WireName::new(SERVER_VERSION).expect("SERVER_VERSION is 1-255 bytes"),
};
build_result_framed_reply(request_header, client_id, session, commit, &payload)
}
pub fn build_reply_from_bytes(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
commit: u64,
body: &Bytes,
) -> Message<ReplyHeader> {
build_reply_with_body(
request_header,
client_id,
session,
commit,
body.len(),
|out| out.copy_from_slice(body),
)
}
/// If a raw PAT token was minted (`CreatePersonalAccessToken`) and the commit
/// succeeded, replace the committed reply -- whose body is empty because the
/// raw token never entered consensus -- with a `RawPersonalAccessTokenResponse`,
/// reusing the confirmed commit position from the committed reply. Otherwise
/// (no token, a committed business rejection, or an eviction frame) the
/// committed reply passes through unchanged.
pub fn build_raw_pat_reply(
request_header: &RoutedRequestHeader,
committed: Message<GenericHeader>,
raw_token: Option<String>,
) -> Result<Message<GenericHeader>, IggyError> {
let Some(raw) = raw_token else {
return Ok(committed);
};
// `submit_request_in_process` hands back an `EvictionHeader`-backed message
// on the evict outcome (e.g. a `CreatePersonalAccessToken` whose session
// was evicted between bind and request). Its byte pattern is a valid
// `ReplyHeader`, so the checked cast below would silently pass and we would
// both swallow the eviction and ship a raw token whose hash never
// committed. Only rewrite a genuine committed `Reply`; pass anything else
// (the eviction) through untouched so the client learns its session died.
if committed.header().command != Command::Reply {
return Ok(committed);
}
let header_len = std::mem::size_of::<ReplyHeader>();
let committed_header =
bytemuck::checked::try_from_bytes::<ReplyHeader>(&committed.as_slice()[..header_len])
.map_err(|_| IggyError::InvalidFormat)?;
let commit = committed_header.commit;
let size = committed_header.size as usize;
// A `Reply` whose result section is nonzero is not a successful commit:
// a committed business rejection (duplicate name, invalid expiry) or a
// `TransientNotCommitted` retry frame, both with no payload and no token
// to ship. Splice the secret only into a genuine success; pass everything
// else through untouched so the client decodes the typed result (and, for
// a transient, replays) instead of having a raw token grafted onto a
// rejection body. Mirrors the HTTP handler's `committed_payload` gate.
//
// Bounded by the header's own `size` rather than running to the end of the
// buffer, so a short frame reads as "no result section" instead of into
// allocation padding.
let reply_body = committed
.as_slice()
.get(header_len..size)
.unwrap_or_default();
if result_code(reply_body) != Some(0) {
return Ok(committed);
}
let token = WireName::new(raw.as_str()).map_err(|_| IggyError::InvalidFormat)?;
let response = RawPersonalAccessTokenResponse { token };
let reply = build_result_framed_reply(
request_header,
request_header.client,
request_header.session,
commit,
&response,
);
Ok(reply.into_generic())
}
pub fn build_reply_with_body(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
commit: u64,
body_len: usize,
write_body: impl FnOnce(&mut [u8]),
) -> Message<ReplyHeader> {
let header_len = std::mem::size_of::<ReplyHeader>();
let total_size = header_len + body_len;
let size = u32::try_from(total_size).expect("reply size must fit into u32");
let mut reply = Message::<ReplyHeader>::new(total_size);
let header = reply_header(request_header, client_id, session, commit, size);
reply.as_mut_slice()[..header_len].copy_from_slice(bytemuck::bytes_of(&header));
write_body(&mut reply.as_mut_slice()[header_len..total_size]);
reply
}
/// The header of a `size`-byte reply frame answering `request_header`.
fn reply_header(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
commit: u64,
size: u32,
) -> ReplyHeader {
ReplyHeader {
cluster: request_header.cluster,
size,
view: request_header.view,
release: request_header.release,
command: Command::Reply,
replica: request_header.replica,
request_checksum: request_header.request_checksum,
client: client_id,
op: session,
commit,
timestamp: request_header.timestamp,
request: request_header.request,
operation: request_header.operation,
..Default::default()
}
}
pub fn current_metadata_commit<B, MJ, S, SB>(shard: &Rc<ShellShard<B, MJ, S, SB>>) -> u64
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
shard
.plane
.metadata()
.consensus
.as_ref()
.map_or(0, VsrConsensus::commit_max)
}
/// Body head of a `PolledMessages` reply:
/// `[partition_id:4][current_offset:8][count:4]`, before the batch records.
const POLLED_HEAD_LEN: usize = 16;
/// Build the `PolledMessages` reply for the wire as a vectored frame: one
/// buffer holding the reply header and the body head, then the poll
/// fragments as they are. The record bytes are never copied or gathered;
/// their reply encoding IS the storage encoding (see
/// [`build_polled_messages_body`]), so `count` comes from walking the batch
/// headers in place. At-rest decryption is the one case that must rewrite
/// records, and it takes the flattening builder instead.
pub fn build_polled_messages_reply(
request_header: &RoutedRequestHeader,
commit: u64,
partition_id: u32,
current_offset: u64,
fragments: PollFragments,
encryptor: Option<&EncryptorKind>,
) -> Result<BusMessage, IggyError> {
let client_id = request_header.client;
let session = request_header.session;
if encryptor.is_some() {
let body = build_polled_messages_body(partition_id, current_offset, fragments, encryptor)?;
let reply = build_reply_from_bytes(request_header, client_id, session, commit, &body);
return Ok(reply.into_generic().into_frozen().into());
}
let mut frames = ResponseFragments::with_capacity(fragments.len() + 1);
frames.extend(fragments.into_iter().map(Fragment::into_frozen));
let count = polled_message_count(&frames)?;
let records_len: usize = frames.iter().map(Frozen::len).sum();
let header_len = std::mem::size_of::<ReplyHeader>();
let size = u32::try_from(header_len + POLLED_HEAD_LEN + records_len)
.map_err(|_| IggyError::InvalidCommand)?;
let header = reply_header(request_header, client_id, session, commit, size);
let mut head = Owned::<MESSAGE_ALIGN>::zeroed(header_len + POLLED_HEAD_LEN);
let (header_bytes, body_head) = head.as_mut_slice().split_at_mut(header_len);
header_bytes.copy_from_slice(bytemuck::bytes_of(&header));
body_head[..4].copy_from_slice(&partition_id.to_le_bytes());
body_head[4..12].copy_from_slice(&current_offset.to_le_bytes());
body_head[12..].copy_from_slice(&count.to_le_bytes());
frames.insert(0, head.into());
// Re-checks the header and that the fragments cover `size`.
Message::<ReplyHeader, ResponseBacking>::try_from(frames)
.map(Message::into_inner)
.map_err(|_| IggyError::InvalidCommand)
}
/// Sum of `message_count` over the batch records spanning `fragments`, read
/// from each batch header in place. Rejects a stream that is not a whole
/// number of batches, as [`build_polled_messages_body`] does.
fn polled_message_count(fragments: &[Frozen<MESSAGE_ALIGN>]) -> Result<u32, IggyError> {
let mut cursor = FragmentCursor::new(fragments);
let mut count = 0u32;
let mut header = [0u8; send_messages::COMMAND_HEADER_SIZE];
while !cursor.is_exhausted() {
cursor.read_exact(&mut header)?;
let batch =
send_messages::BatchHeader::decode(&header).map_err(|_| IggyError::InvalidCommand)?;
cursor.skip(batch.blob_len().map_err(|_| IggyError::InvalidCommand)?)?;
count = count
.checked_add(batch.message_count)
.ok_or(IggyError::InvalidCommand)?;
}
Ok(count)
}
/// Byte cursor over the virtual concatenation of `fragments`. Rests on an
/// unread byte or at the end of the stream, never inside an exhausted
/// fragment, so a batch header split across fragments reads the same as one
/// stored whole.
struct FragmentCursor<'a> {
fragments: &'a [Frozen<MESSAGE_ALIGN>],
index: usize,
offset: usize,
}
impl<'a> FragmentCursor<'a> {
fn new(fragments: &'a [Frozen<MESSAGE_ALIGN>]) -> Self {
let mut cursor = Self {
fragments,
index: 0,
offset: 0,
};
cursor.settle();
cursor
}
const fn is_exhausted(&self) -> bool {
self.index == self.fragments.len()
}
fn read_exact(&mut self, out: &mut [u8]) -> Result<(), IggyError> {
let mut filled = 0;
while filled < out.len() {
let available = self.available()?;
let take = available.len().min(out.len() - filled);
out[filled..filled + take].copy_from_slice(&available[..take]);
filled += take;
self.advance(take);
}
Ok(())
}
fn skip(&mut self, mut len: usize) -> Result<(), IggyError> {
while len > 0 {
let take = self.available()?.len().min(len);
len -= take;
self.advance(take);
}
Ok(())
}
/// Unread bytes of the current fragment; `Err` past the end of the stream.
fn available(&self) -> Result<&'a [u8], IggyError> {
self.fragments
.get(self.index)
.map(|fragment| &fragment.as_slice()[self.offset..])
.ok_or(IggyError::InvalidCommand)
}
fn advance(&mut self, len: usize) {
self.offset += len;
self.settle();
}
/// Step past the current fragment once it is used up, and past empty ones.
fn settle(&mut self) {
while let Some(fragment) = self.fragments.get(self.index) {
if self.offset < fragment.len() {
break;
}
self.offset = 0;
self.index += 1;
}
}
}
/// Build the `PolledMessages` reply body from the owning shard's poll
/// fragments, gathered into one buffer.
///
/// Fragments carry the stored batch records (a 256-byte batch header plus
/// `[48B header][payload][user_headers]` frames, deltas resolved against the
/// stamped bases) and are served to the client as they are - the reply's
/// message encoding IS the storage encoding. The one rewrite left is at-rest
/// decryption: stored sections are ciphertext, and this reply is the single
/// decrypt point, so encrypted records are rebuilt over the plaintext.
///
/// The binary transports reply through [`build_polled_messages_reply`], which
/// ships the fragments without gathering them; this builder serves the
/// decrypt path and the HTTP handler, which decodes the body into JSON.
///
/// Body layout: `[partition_id:4][current_offset:8][count:4][batch records...]`.
pub fn build_polled_messages_body(
partition_id: u32,
current_offset: u64,
fragments: PollFragments,
encryptor: Option<&EncryptorKind>,
) -> Result<Bytes, IggyError> {
// Body head: [partition_id:4][current_offset:8][count:4]. `count` sits at
// COUNT_OFFSET and is backpatched once the walk below knows it.
const HEAD_LEN: usize = 16;
const COUNT_OFFSET: usize = 12;
// Batches may arrive split across fragments (rewritten batch header +
// sliced blob); concatenate into one stream before walking records.
let mut stream: Vec<u8> = Vec::new();
for fragment in fragments {
let frozen = fragment.into_frozen();
stream.extend_from_slice(frozen.as_slice());
}
let mut body: Vec<u8> = Vec::with_capacity(HEAD_LEN + stream.len());
body.extend_from_slice(&partition_id.to_le_bytes());
body.extend_from_slice(&current_offset.to_le_bytes());
body.extend_from_slice(&[0u8; 4]); // count placeholder, backpatched below
let mut count: u32 = 0;
let mut position = 0usize;
while position < stream.len() {
let batch = send_messages::BatchHeader::decode(&stream[position..])
.map_err(|_| IggyError::InvalidCommand)?;
let batch_end = position
.checked_add(batch.total_size())
.ok_or(IggyError::InvalidCommand)?;
if batch_end > stream.len() {
return Err(IggyError::InvalidCommand);
}
let record = &stream[position..batch_end];
if let Some(encryptor) = encryptor {
let decrypted = send_messages::decrypt_batch_record(record, encryptor)?;
body.extend_from_slice(&decrypted);
} else {
body.extend_from_slice(record);
}
count = count
.checked_add(batch.message_count)
.ok_or(IggyError::InvalidCommand)?;
position = batch_end;
}
body[COUNT_OFFSET..HEAD_LEN].copy_from_slice(&count.to_le_bytes());
Ok(Bytes::from(body))
}
/// Build the `ConsumerOffsetResponse` reply body:
/// `[partition_id:4][current_offset:8][stored_offset:8]`.
pub fn build_consumer_offset_body(
partition_id: u32,
current_offset: u64,
stored_offset: u64,
) -> Bytes {
let mut body = Vec::with_capacity(20);
body.extend_from_slice(&partition_id.to_le_bytes());
body.extend_from_slice(&current_offset.to_le_bytes());
body.extend_from_slice(&stored_offset.to_le_bytes());
Bytes::from(body)
}
#[cfg(test)]
mod tests {
use super::*;
fn pat_request_header() -> RoutedRequestHeader {
let zeroed = [0u8; std::mem::size_of::<RoutedRequestHeader>()];
let mut header = *bytemuck::checked::try_from_bytes::<RoutedRequestHeader>(&zeroed)
.expect("zeroed bytes form a valid RoutedRequestHeader");
header.command = Command::Request;
header.operation = Operation::CreatePersonalAccessToken;
header.client = 42;
header.session = 7;
header.request = 3;
header
}
#[test]
fn login_register_reply_carries_the_success_result_prefix() {
// The other `build_result_framed_reply` caller. The SDK strips the result
// section off every metadata reply, so a payload emitted without the prefix
// loses its first four bytes to a phantom result count -- the decode break
// the raw-PAT reply shipped once. Pin it on both callers, not just the one
// that regressed.
let mut header = pat_request_header();
header.operation = Operation::Register;
let reply = build_login_register_reply(&header, 42, 7, 9, 5);
let header_len = std::mem::size_of::<ReplyHeader>();
let body = &reply.as_slice()[header_len..reply.header().size as usize];
assert_eq!(result_code(body), Some(0));
let payload = LoginRegisterResponse::decode_from(&body[RESULT_COUNT_LEN..])
.expect("login-register payload decodes past the result section");
assert_eq!(payload.user_id, 5);
assert_eq!(payload.session, 7);
}
/// A committed metadata reply whose body is the given result section
/// (`[count][{index, result}]*`), as the commit path emits it.
fn committed_reply(result_body: &[u8]) -> Message<GenericHeader> {
let request_header = pat_request_header();
build_reply_from_bytes(
&request_header,
42,
7,
9,
&Bytes::copy_from_slice(result_body),
)
.into_generic()
}
#[test]
fn raw_pat_reply_splices_token_into_a_committed_success() {
let success = committed_reply(&0u32.to_le_bytes());
let reply =
build_raw_pat_reply(&pat_request_header(), success, Some("raw-token".to_owned()))
.expect("splice succeeds");
let header_len = std::mem::size_of::<ReplyHeader>();
let body = &reply.as_slice()[header_len..reply.header().size as usize];
// Framed like every committed metadata reply: the SDK reads the result
// section first, then decodes the token payload past it.
assert_eq!(result_code(body), Some(0));
let response = RawPersonalAccessTokenResponse::decode_from(&body[RESULT_COUNT_LEN..])
.expect("token body decodes");
assert_eq!(response.token.as_str(), "raw-token");
}
#[test]
fn raw_pat_reply_passes_a_committed_rejection_through_untouched() {
let rejection_code =
IggyError::PersonalAccessTokenAlreadyExists(String::new(), 0).as_code();
let mut result_body = Vec::new();
result_body.extend_from_slice(&1u32.to_le_bytes());
result_body.extend_from_slice(&0u32.to_le_bytes());
result_body.extend_from_slice(&rejection_code.to_le_bytes());
let rejection = committed_reply(&result_body);
let original = rejection.as_slice().to_vec();
let reply = build_raw_pat_reply(
&pat_request_header(),
rejection,
Some("raw-token".to_owned()),
)
.expect("pass-through succeeds");
assert_eq!(
reply.as_slice(),
original.as_slice(),
"a committed rejection must not be rewritten into a token reply"
);
}
#[test]
fn raw_pat_reply_without_a_token_passes_through() {
let success = committed_reply(&0u32.to_le_bytes());
let original = success.as_slice().to_vec();
let reply =
build_raw_pat_reply(&pat_request_header(), success, None).expect("pass-through");
assert_eq!(reply.as_slice(), original.as_slice());
}
#[test]
fn personal_access_tokens_response_preserves_order_and_encodes_never_as_zero() {
let expiry = IggyTimestamp::from(123_456u64);
let tokens: Vec<(Arc<str>, Option<IggyTimestamp>)> = vec![
(Arc::from("alpha"), Some(expiry)),
(Arc::from("zeta"), None),
];
let response = personal_access_tokens_response(tokens).expect("mapping succeeds");
assert_eq!(response.tokens.len(), 2);
assert_eq!(response.tokens[0].name.as_str(), "alpha");
assert_eq!(response.tokens[0].expiry_at, expiry.as_micros());
assert_eq!(response.tokens[1].name.as_str(), "zeta");
assert_eq!(response.tokens[1].expiry_at, 0);
}
#[test]
fn personal_access_tokens_response_encodes_empty_list_as_empty_body() {
let response = personal_access_tokens_response(Vec::new()).expect("mapping succeeds");
// An empty body is the wire shape the SDK decodes as "no tokens"; it
// must stay `Bytes` (not the not-found `Empty` variant) end to end.
assert!(response.to_bytes().is_empty());
}
#[test]
fn probe_system_stats_reports_this_process_and_host_memory() {
let stats = probe_system_stats();
// Straight from `sysinfo`, independent of shard state: the pid is our
// own and any host the test runs on has nonzero total memory. A zero
// here means the probe wired nothing (the pre-fix stubbed literal).
assert_eq!(stats.process_id, std::process::id());
assert!(stats.total_memory > 0);
assert!(!stats.hostname.is_empty());
}
#[test]
fn partition_response_reports_the_initial_shape_until_a_segment_exists() {
use iggy_common::{StreamStats, TopicStats};
use metadata::stm::stream::{Partition, StreamsInner};
let streams = StreamsInner::new();
let partition = Partition::new(0, 1, IggyTimestamp::from(1u64), 0, 0);
// Registry miss: the owning shard has not started building.
let predicted = partition_response(&streams, 0, 0, &partition).expect("response builds");
assert_eq!(predicted.segments_count, 1);
assert_eq!(predicted.messages_count, 0);
// Registered but not yet segmented: the reconciler mints the entry
// before `ensure_initial_segment` runs, so this is the SAME state to a
// caller and must not read as "no storage".
let topic_stats = Arc::new(TopicStats::new(Arc::new(StreamStats::default())));
let stats = streams.stats_registry.partition(0, 0, 0, topic_stats);
let mid_build = partition_response(&streams, 0, 0, &partition).expect("response builds");
assert_eq!(mid_build.segments_count, 1);
// Materialized: the real counters answer from here on.
stats.increment_segments_count(1);
stats.increment_messages_count(7);
stats.increment_size_bytes(64);
let live = partition_response(&streams, 0, 0, &partition).expect("response builds");
assert_eq!(live.segments_count, 1);
assert_eq!(live.messages_count, 7);
assert_eq!(live.size_bytes, 64);
}
#[test]
fn stats_totals_count_every_committed_partition_before_it_materialises() {
use iggy_common::{StreamStats, TopicStats};
use metadata::stm::stream::{Partition, Stream, StreamsInner, Topic};
use std::sync::atomic::AtomicUsize;
let created_at = IggyTimestamp::from(1u64);
let mut streams = StreamsInner::new();
let mut stream = Stream::new(Arc::from("stream"), created_at);
let topic_stats = Arc::new(TopicStats::new(stream.stats.clone()));
stream.topics.insert(Topic {
id: 0,
name: Arc::from("topic"),
created_at,
message_expiry: iggy_common::IggyExpiry::NeverExpire,
compression_algorithm: iggy_common::CompressionAlgorithm::None,
max_topic_size: iggy_common::MaxTopicSize::Unlimited,
options: iggy_common::ResourceOptions::default(),
stats: topic_stats.clone(),
partitions: vec![
Partition::new(0, 1, created_at, 0, 0),
Partition::new(1, 1, created_at, 0, 0),
],
round_robin_counter: Arc::new(AtomicUsize::new(0)),
consumer_groups: ahash::AHashMap::default(),
consumer_group_index: ahash::AHashMap::default(),
next_consumer_group_id: 0,
});
streams.items.insert(stream);
// Only partition 0 has materialised. Counting the stream's rolled-up
// counter reported 1 here, so a caller polling `[stats]` twice saw the
// total climb to 2 with no write in between (and `get_topic` already
// reported 2 for the same partitions).
let stats = streams.stats_registry.partition(0, 0, 0, topic_stats);
stats.increment_segments_count(1);
let (_, _, partitions, segments, _, _) =
aggregate_stats_totals(&streams).expect("totals aggregate");
assert_eq!(partitions, 2);
assert_eq!(
segments, 2,
"an unmaterialised partition must contribute the same floor the detail response reports"
);
// Materialising the second partition changes nothing: the total was
// already the steady-state answer.
let late = streams.stats_registry.partition(
0,
0,
1,
Arc::new(TopicStats::new(Arc::new(StreamStats::default()))),
);
late.increment_segments_count(1);
let (_, _, _, segments_after, _, _) =
aggregate_stats_totals(&streams).expect("totals aggregate");
assert_eq!(segments_after, 2);
}
#[test]
fn topic_header_echoes_stored_size_and_expiry_verbatim() {
use iggy_common::{
CompressionAlgorithm, IggyDuration, IggyExpiry, MaxTopicSize, ResourceOptions,
StreamStats, TopicStats,
};
use std::sync::atomic::AtomicUsize;
let parent = Arc::new(StreamStats::default());
let topic_with = |max_topic_size, message_expiry| metadata::stm::stream::Topic {
id: 0,
name: Arc::from("topic"),
created_at: IggyTimestamp::from(1u64),
message_expiry,
compression_algorithm: CompressionAlgorithm::None,
max_topic_size,
options: ResourceOptions::default(),
stats: Arc::new(TopicStats::new(parent.clone())),
partitions: Vec::new(),
round_robin_counter: Arc::new(AtomicUsize::new(0)),
consumer_groups: ahash::AHashMap::default(),
consumer_group_index: ahash::AHashMap::default(),
next_consumer_group_id: 0,
};
// Stored `ServerDefault` sentinels echo the wire sentinel (0)
// verbatim, so an update to `ServerDefault` reads back as
// `ServerDefault` instead of the node default frozen at read time.
let sentinel = topic_header(&topic_with(
MaxTopicSize::ServerDefault,
IggyExpiry::ServerDefault,
))
.expect("topic header builds");
assert_eq!(sentinel.max_topic_size, 0);
assert_eq!(sentinel.message_expiry, 0);
// Explicit values round-trip unchanged.
let custom = topic_header(&topic_with(
MaxTopicSize::from(1024u64),
IggyExpiry::ExpireDuration(IggyDuration::from(5_000_000u64)),
))
.expect("topic header builds");
assert_eq!(custom.max_topic_size, 1024);
assert_eq!(custom.message_expiry, 5_000_000);
let unlimited = topic_header(&topic_with(
MaxTopicSize::Unlimited,
IggyExpiry::NeverExpire,
))
.expect("topic header builds");
assert_eq!(unlimited.max_topic_size, u64::MAX);
assert_eq!(unlimited.message_expiry, u64::MAX);
}
// Vectored `PolledMessages` replies against the flattening builder as the
// byte-for-byte oracle.
use iggy_common::Aes256GcmEncryptor;
use server_common::send_messages::{
BatchHeader, COMMAND_HEADER_SIZE, IggyMessage, IggyMessageHeader, IggyMessages,
PREPARE_SPLIT_POINT, SendMessagesOwned, encrypt_batch_request, frozen_batch_header,
};
use server_common::sharding::IggyNamespace;
const POLL_PARTITION_ID: u32 = 9;
const POLL_CURRENT_OFFSET: u64 = 1_234;
const POLL_COMMIT: u64 = 17;
fn poll_request_header() -> RoutedRequestHeader {
pat_request_header()
}
/// A stored batch record over an opaque blob. Both builders decode only
/// the 256-byte batch header, so the blob needs no message framing.
fn batch_record(base_offset: u64, message_count: u32, blob: &[u8]) -> Frozen<MESSAGE_ALIGN> {
let batch_length = u64::try_from(COMMAND_HEADER_SIZE + blob.len()).expect("fits u64");
let mut header =
BatchHeader::new(u64::from(POLL_PARTITION_ID), 5, batch_length, message_count);
header.base_offset = base_offset;
let mut bytes = vec![0u8; COMMAND_HEADER_SIZE + blob.len()];
header.encode_into(&mut bytes[..COMMAND_HEADER_SIZE]);
bytes[COMMAND_HEADER_SIZE..].copy_from_slice(blob);
Owned::<MESSAGE_ALIGN>::copy_from_slice(&bytes).into()
}
/// The wire bytes the flattening builder ships for `fragments`.
fn flattened_reply(fragments: PollFragments, encryptor: Option<&EncryptorKind>) -> Vec<u8> {
let header = poll_request_header();
let body = build_polled_messages_body(
POLL_PARTITION_ID,
POLL_CURRENT_OFFSET,
fragments,
encryptor,
)
.expect("flattening builder accepts the fragments");
build_reply_from_bytes(&header, header.client, header.session, POLL_COMMIT, &body)
.into_generic()
.into_frozen()
.as_slice()
.to_vec()
}
fn vectored_reply(
fragments: PollFragments,
encryptor: Option<&EncryptorKind>,
) -> Result<BusMessage, IggyError> {
build_polled_messages_reply(
&poll_request_header(),
POLL_COMMIT,
POLL_PARTITION_ID,
POLL_CURRENT_OFFSET,
fragments,
encryptor,
)
}
/// The vectored reply must be byte-identical to the flattened one and
/// ship exactly `fragment_count` buffers.
fn assert_vectored_matches_flattened(fragments: PollFragments, fragment_count: usize) {
let expected = flattened_reply(fragments.clone(), None);
let reply =
vectored_reply(fragments, None).expect("vectored builder accepts the fragments");
assert_eq!(reply.fragments().len(), fragment_count);
assert_eq!(reply.total_len(), expected.len());
assert_eq!(reply.into_contiguous().as_slice(), expected.as_slice());
}
fn polled_count(reply: &[u8]) -> u32 {
let count_at = std::mem::size_of::<ReplyHeader>() + 12;
u32::from_le_bytes(reply[count_at..count_at + 4].try_into().expect("4 bytes"))
}
#[test]
fn polled_reply_single_fragment_matches_flattened_builder() {
let record = batch_record(0, 3, &[0xAB; 100]);
let fragments = PollFragments::from_iter([Fragment::whole(record)]);
assert_vectored_matches_flattened(fragments.clone(), 2);
let reply = vectored_reply(fragments, None)
.expect("reply")
.into_contiguous();
let header = bytemuck::checked::try_from_bytes::<ReplyHeader>(
&reply.as_slice()[..std::mem::size_of::<ReplyHeader>()],
)
.expect("reply header decodes");
assert_eq!(header.size as usize, reply.len());
assert_eq!(header.client, 42);
assert_eq!(header.op, 7);
assert_eq!(header.commit, POLL_COMMIT);
assert_eq!(polled_count(reply.as_slice()), 3);
}
#[test]
fn polled_reply_split_batch_matches_flattened_builder() {
// The journal slices a partially selected batch into a rewritten header
// plus a blob slice, exactly how `push_selected_batch_fragments` does.
let source = batch_record(10, 4, &[0x11; 400]);
let (start, end) = (100, 300);
let batch_length = u64::try_from(COMMAND_HEADER_SIZE + (end - start)).expect("fits u64");
let mut rewritten = BatchHeader::new(u64::from(POLL_PARTITION_ID), 5, batch_length, 2);
rewritten.base_offset = 10;
let fragments = PollFragments::from_iter([
Fragment::whole(frozen_batch_header(&rewritten)),
Fragment::slice(
source,
COMMAND_HEADER_SIZE + start,
COMMAND_HEADER_SIZE + end,
),
]);
assert_vectored_matches_flattened(fragments.clone(), 3);
let reply = vectored_reply(fragments, None)
.expect("reply")
.into_contiguous();
assert_eq!(polled_count(reply.as_slice()), 2);
}
#[test]
fn polled_reply_multiple_batches_counts_every_header() {
let first = batch_record(0, 1, &[0x01; 50]);
let second = batch_record(1, 4, &[0x02; 700]);
let third = batch_record(5, 7, &[0x03; 20]);
// `second` arrives cut mid-header so the count walk has to read a batch
// header spanning two fragments.
let fragments = PollFragments::from_iter([
Fragment::whole(first),
Fragment::slice(second.clone(), 0, 100),
Fragment::slice(second.clone(), 100, second.len()),
Fragment::whole(third),
]);
assert_vectored_matches_flattened(fragments.clone(), 5);
let reply = vectored_reply(fragments, None)
.expect("reply")
.into_contiguous();
assert_eq!(polled_count(reply.as_slice()), 12);
}
#[test]
fn polled_reply_empty_poll_is_the_head_alone() {
assert_vectored_matches_flattened(PollFragments::new(), 1);
let reply = vectored_reply(PollFragments::new(), None)
.expect("reply")
.into_contiguous();
assert_eq!(
reply.len(),
std::mem::size_of::<ReplyHeader>() + POLLED_HEAD_LEN
);
assert_eq!(polled_count(reply.as_slice()), 0);
}
#[test]
fn polled_reply_rejects_a_truncated_record() {
let record = batch_record(0, 3, &[0xAB; 100]);
let truncated =
PollFragments::from_iter([Fragment::slice(record, 0, COMMAND_HEADER_SIZE + 99)]);
assert!(matches!(
build_polled_messages_body(
POLL_PARTITION_ID,
POLL_CURRENT_OFFSET,
truncated.clone(),
None
),
Err(IggyError::InvalidCommand)
));
assert!(matches!(
vectored_reply(truncated, None),
Err(IggyError::InvalidCommand)
));
}
/// A stored record encrypted the way the primary encrypts at ingestion.
fn encrypted_record(encryptor: &EncryptorKind) -> Frozen<MESSAGE_ALIGN> {
let namespace = IggyNamespace::new(1, 1, 3);
let mut messages = IggyMessages::with_capacity(2);
for (id, payload) in [(7u128, &b"first-payload"[..]), (8, &b"second-payload"[..])] {
messages.push(IggyMessage {
header: IggyMessageHeader {
id,
origin_timestamp: 1_000,
..Default::default()
},
payload: Bytes::copy_from_slice(payload),
user_headers: None,
});
}
let owned = SendMessagesOwned::from_messages(namespace, &messages).expect("build batch");
let header_size = std::mem::size_of::<RoutedRequestHeader>();
let total = header_size + owned.header.total_size();
let mut buffer = Owned::<MESSAGE_ALIGN>::zeroed(total);
{
let header: &mut RoutedRequestHeader =
bytemuck::checked::try_from_bytes_mut(&mut buffer.as_mut_slice()[..header_size])
.expect("zeroed bytes form a valid RoutedRequestHeader");
header.command = Command::Request;
header.operation = Operation::SendMessages;
header.client = 1;
header.session = 1;
header.request = 1;
header.size = u32::try_from(total).expect("size fits u32");
}
let bytes = buffer.as_mut_slice();
owned
.header
.encode_into(&mut bytes[header_size..header_size + COMMAND_HEADER_SIZE]);
bytes[PREPARE_SPLIT_POINT..].copy_from_slice(&owned.blob);
let canonical = Message::try_from(buffer).expect("request message is valid");
let encrypted = encrypt_batch_request(canonical, encryptor).expect("encrypt batch");
let record = &encrypted.as_slice()[header_size..encrypted.header().size as usize];
Owned::<MESSAGE_ALIGN>::copy_from_slice(record).into()
}
#[test]
fn polled_reply_encrypted_records_take_the_flattening_path() {
let encryptor =
EncryptorKind::Aes256Gcm(Aes256GcmEncryptor::new(&[7u8; 32]).expect("valid 32B key"));
let fragments = PollFragments::from_iter([Fragment::whole(encrypted_record(&encryptor))]);
let expected = flattened_reply(fragments.clone(), Some(&encryptor));
let reply = vectored_reply(fragments, Some(&encryptor)).expect("decrypting reply");
assert_eq!(reply.fragments().len(), 1);
assert_eq!(reply.into_contiguous().as_slice(), expected.as_slice());
assert_eq!(polled_count(&expected), 2);
}
}