blob: 028adb38cfc5f819c7734617acbacbd11d0e4934 [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.
use bytes::{Bytes, BytesMut};
use iggy_binary_protocol::codes::POLL_MESSAGES_CODE;
use iggy_binary_protocol::primitives::consumer::WireConsumer;
use iggy_binary_protocol::requests::consumer_groups::{
CreateConsumerGroupRequest, DeleteConsumerGroupRequest,
};
use iggy_binary_protocol::requests::consumer_offsets::{
DeleteConsumerOffset2Request, DeleteConsumerOffsetRequest, StoreConsumerOffset2Request,
StoreConsumerOffsetRequest,
};
use iggy_binary_protocol::requests::messages::{
PollMessagesRequest, RawMessage, SendMessagesEncoder,
};
use iggy_binary_protocol::requests::partitions::{
CreatePartitionsRequest, DeletePartitionsRequest,
};
use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
use iggy_binary_protocol::requests::streams::{
CreateStreamRequest, DeleteStreamRequest, PurgeStreamRequest, UpdateStreamRequest,
};
use iggy_binary_protocol::requests::topics::{
CreateTopicRequest, DeleteTopicRequest, PurgeTopicRequest, UpdateTopicRequest,
};
use iggy_binary_protocol::requests::users::{
ChangePasswordRequest, CreateUserRequest, DeleteUserRequest, LoginRegisterRequest,
UpdatePermissionsRequest, UpdateUserRequest,
};
use iggy_binary_protocol::{
AckLevel, ClientVersionInfo, IGGY_PROTOCOL_VERSION, Operation, RequestHeader, WireEncode,
WireIdentifier, WireName, WirePartitioning, WirePollingStrategy,
};
use metadata::stm::user::{CreatePersonalAccessTokenRequest, DeletePersonalAccessTokenRequest};
use secrecy::SecretString;
use server_common::sharding::{IggyNamespace, METADATA_CONSENSUS_NAMESPACE};
use server_common::{Message, iobuf::Owned};
use std::cell::Cell;
/// Partition-plane request ids are offset into the top half of the `u64` space
/// so they never collide with the small, contiguous metadata ids in the
/// auditor's `(client, request)` map. The metadata sequence would have to reach
/// `2^63` to overlap, which no run approaches.
const PARTITION_ID_BASE: u64 = 1 << 63;
// TODO: Proper client which implements the full client SDK API
pub struct SimClient {
client_id: u128,
/// Contiguous `1, 2, 3, …` request ids for metadata/replicated ops, the
/// sequence the server's `ClientTable` dedups and requires gap-free.
request_counter: Cell<u64>,
/// Separate id sequence for partition-plane ops, offset into a disjoint
/// range ([`PARTITION_ID_BASE`]). The partition plane has no client-table
/// dedup and treats the id as an opaque echo, so a partition id never
/// collides with a metadata id, even under reply duplication. See
/// [`SimClient::request_id_for`].
partition_counter: Cell<u64>,
/// Deterministic per-message id source for produced messages. The real SDK
/// sends `id: 0` and lets the server mint a random UUID
/// (`transcode_legacy_request` -> `random_id::get_uuid`); that
/// mint is unseeded, so under the deterministic executor a produce's
/// replicated body bytes (and their checksums) would differ run to run,
/// silently breaking seeded replay. Stamping a deterministic id here keeps
/// the body a pure function of the seed. See [`SimClient::next_message_id`].
message_counter: Cell<u64>,
session: Cell<u64>,
}
impl SimClient {
#[must_use]
pub const fn new(client_id: u128) -> Self {
Self {
client_id,
request_counter: Cell::new(0),
partition_counter: Cell::new(0),
message_counter: Cell::new(0),
session: Cell::new(0),
}
}
#[must_use]
pub const fn client_id(&self) -> u128 {
self.client_id
}
/// Next deterministic, non-zero, cross-client-unique message id, standing
/// in for the SDK's `id: 0` / server-side random UUID mint so a produce's
/// replicated bytes are a pure function of the seed. The `client_id` fills
/// the high 64 bits (ids stay disjoint across clients, whose ids are minted
/// small) and a monotonic counter fills the low 64 bits (starts at 1, so
/// the id is never 0 and never re-triggers the server's random mint). See
/// [`SimClient::message_counter`].
fn next_message_id(&self) -> u128 {
let seq = self.message_counter.get() + 1;
self.message_counter.set(seq);
(self.client_id << 64) | u128::from(seq)
}
/// Bind the session assigned by the consensus layer after registration.
///
/// # Panics
/// Panics if `session` is 0.
pub fn bind_session(&self, session: u64) {
assert!(session > 0, "bind_session: session must be > 0");
self.session.set(session);
}
/// Assign the wire request id for `operation`, keyed by plane.
///
/// Metadata/replicated ops advance a contiguous `1, 2, 3, …` counter: the
/// `ClientTable` dedups them and rejects anything but `committed + 1`, so a
/// gap opens a permanent `RequestGap` and wedges the client's metadata
/// plane. Partition ops are at-least-once with no dedup and the server
/// treats their id as an opaque echo, so they draw from a separate counter
/// offset into a disjoint range ([`PARTITION_ID_BASE`]). A partition id can
/// therefore never equal a metadata id, so a delayed or duplicated partition
/// reply is never misattributed to a metadata entry in the auditor's
/// `(client, request)` map (which would trip the namespace guard and drop a
/// live metadata op). This holds regardless of reply duplication, not only
/// while clients are one-in-flight.
fn request_id_for(&self, operation: Operation) -> u64 {
if operation.is_partition() {
let next = self.partition_counter.get() + 1;
self.partition_counter.set(next);
PARTITION_ID_BASE + next
} else {
let next = self.request_counter.get() + 1;
self.request_counter.set(next);
next
}
}
fn session_id(&self) -> u64 {
let s = self.session.get();
assert!(
s > 0,
"session not bound — call register() + bind_session() first"
);
s
}
/// Build a `Register` request for this client.
///
/// Register uses `session=0, request=0` per the protocol spec.
/// The consensus layer assigns a session on commit.
///
/// # Panics
/// Panics if the register request buffer is invalid.
#[allow(clippy::cast_possible_truncation)]
pub fn register(&self) -> Message<RequestHeader> {
let header_size = std::mem::size_of::<RequestHeader>();
let header = RequestHeader {
command: iggy_binary_protocol::Command2::Request,
operation: Operation::Register,
size: header_size as u32,
client: self.client_id,
session: 0,
request: 0,
// Register is a vsr-reserved op: the shard router picks its
// target by comparing this against the metadata consensus
// namespace, not by op class.
namespace: METADATA_CONSENSUS_NAMESPACE,
..Default::default()
};
let header_bytes = bytemuck::bytes_of(&header);
let buffer = header_bytes.to_vec();
Message::try_from(Owned::<4096>::copy_from_slice(&buffer))
.expect("register request must be valid")
}
/// Build a credentialed login-register request: the shell-mode
/// counterpart of [`SimClient::register`].
///
/// Same `Register` / `session=0` / `request=0` envelope, but the body
/// carries the `ClientVersionInfo` prefix plus username/password that
/// `handle_login_register_request` verifies against the seeded root
/// user before the consensus layer assigns the session on commit. Used
/// only on the dispatch-shell path (the raw fast path uses `register`).
///
/// # Panics
/// Panics if a credential exceeds the wire name/secret bounds or the
/// request buffer is invalid.
#[allow(clippy::cast_possible_truncation)]
pub fn login(&self, username: &str, password: &str) -> Message<RequestHeader> {
let body = LoginRegisterRequest {
version_info: ClientVersionInfo {
protocol_version: IGGY_PROTOCOL_VERSION,
sdk_name: WireName::new("sim-sdk").expect("sim sdk name is valid"),
sdk_version: WireName::new("1.0.0").expect("sim sdk version is valid"),
},
username: WireName::new(username).expect("login username is a valid wire name"),
password: SecretString::from(password.to_owned()),
client_context: None,
}
.to_bytes();
let header_size = std::mem::size_of::<RequestHeader>();
let total_size = header_size + body.len();
let header = RequestHeader {
command: iggy_binary_protocol::Command2::Request,
operation: Operation::Register,
size: total_size as u32,
client: self.client_id,
session: 0,
request: 0,
namespace: METADATA_CONSENSUS_NAMESPACE,
..Default::default()
};
let mut buffer = Vec::with_capacity(total_size);
buffer.extend_from_slice(bytemuck::bytes_of(&header));
buffer.extend_from_slice(&body);
Message::try_from(Owned::<4096>::copy_from_slice(&buffer))
.expect("login request must be valid")
}
/// # Panics
/// Panics if the stream name is not a valid wire name.
pub fn create_stream(&self, name: &str) -> Message<RequestHeader> {
let wire = CreateStreamRequest {
name: WireName::new(name).expect("stream name must be valid"),
};
let payload = wire.to_bytes();
self.build_request(Operation::CreateStream, &payload)
}
/// # Panics
/// Panics if the stream name cannot be converted to a `WireIdentifier`.
pub fn delete_stream(&self, name: &str) -> Message<RequestHeader> {
let wire = DeleteStreamRequest {
stream_id: WireIdentifier::named(name).expect("stream name must be valid"),
};
let payload = wire.to_bytes();
self.build_request(Operation::DeleteStream, &payload)
}
/// # Panics
/// Panics if the new name or the existing stream name is not a valid
/// `WireName`.
pub fn update_stream(&self, stream: &str, new_name: &str) -> Message<RequestHeader> {
let wire = UpdateStreamRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
name: WireName::new(new_name).expect("stream name must be valid"),
};
self.build_request(Operation::UpdateStream, &wire.to_bytes())
}
/// # Panics
/// Panics if `stream` is not a valid `WireName`.
pub fn purge_stream(&self, stream: &str) -> Message<RequestHeader> {
let wire = PurgeStreamRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
};
self.build_request(Operation::PurgeStream, &wire.to_bytes())
}
/// # Panics
/// Panics if `stream` or `name` is not a valid `WireName`.
pub fn create_topic(
&self,
stream: &str,
name: &str,
partitions_count: u32,
) -> Message<RequestHeader> {
let wire = CreateTopicRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
partitions_count,
compression_algorithm: 0,
message_expiry: 0,
max_topic_size: 0,
replication_factor: 1,
name: WireName::new(name).expect("topic name must be valid"),
};
self.build_request(Operation::CreateTopic, &wire.to_bytes())
}
/// # Panics
/// Panics if `stream`, `topic`, or `new_name` is not a valid `WireName`.
pub fn update_topic(
&self,
stream: &str,
topic: &str,
new_name: &str,
) -> Message<RequestHeader> {
let wire = UpdateTopicRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
topic_id: WireIdentifier::named(topic).expect("topic name must be valid"),
compression_algorithm: 0,
message_expiry: 0,
max_topic_size: 0,
replication_factor: 1,
name: WireName::new(new_name).expect("topic name must be valid"),
};
self.build_request(Operation::UpdateTopic, &wire.to_bytes())
}
/// # Panics
/// Panics if `stream` or `topic` is not a valid `WireName`.
pub fn delete_topic(&self, stream: &str, topic: &str) -> Message<RequestHeader> {
let wire = DeleteTopicRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
topic_id: WireIdentifier::named(topic).expect("topic name must be valid"),
};
self.build_request(Operation::DeleteTopic, &wire.to_bytes())
}
/// # Panics
/// Panics if `stream` or `topic` is not a valid `WireName`.
pub fn purge_topic(&self, stream: &str, topic: &str) -> Message<RequestHeader> {
let wire = PurgeTopicRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
topic_id: WireIdentifier::named(topic).expect("topic name must be valid"),
};
self.build_request(Operation::PurgeTopic, &wire.to_bytes())
}
/// # Panics
/// Panics if `stream` or `topic` is not a valid `WireName`.
pub fn create_partitions(
&self,
stream: &str,
topic: &str,
partitions_count: u32,
) -> Message<RequestHeader> {
let wire = CreatePartitionsRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
topic_id: WireIdentifier::named(topic).expect("topic name must be valid"),
partitions_count,
};
self.build_request(Operation::CreatePartitions, &wire.to_bytes())
}
/// # Panics
/// Panics if `stream` or `topic` is not a valid `WireName`.
pub fn delete_partitions(
&self,
stream: &str,
topic: &str,
partitions_count: u32,
) -> Message<RequestHeader> {
let wire = DeletePartitionsRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
topic_id: WireIdentifier::named(topic).expect("topic name must be valid"),
partitions_count,
};
self.build_request(Operation::DeletePartitions, &wire.to_bytes())
}
/// # Panics
/// Panics if `stream` or `topic` is not a valid `WireName`.
pub fn delete_segments(
&self,
stream: &str,
topic: &str,
partition_id: u32,
segments_count: u32,
) -> Message<RequestHeader> {
let wire = DeleteSegmentsRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
topic_id: WireIdentifier::named(topic).expect("topic name must be valid"),
partition_id,
segments_count,
};
self.build_request(Operation::DeleteSegments, &wire.to_bytes())
}
/// # Panics
/// Panics if `stream`, `topic`, or `name` is not a valid `WireName`.
pub fn create_consumer_group(
&self,
stream: &str,
topic: &str,
name: &str,
) -> Message<RequestHeader> {
let wire = CreateConsumerGroupRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
topic_id: WireIdentifier::named(topic).expect("topic name must be valid"),
name: WireName::new(name).expect("consumer group name must be valid"),
};
self.build_request(Operation::CreateConsumerGroup, &wire.to_bytes())
}
/// # Panics
/// Panics if `stream`, `topic`, or `group` is not a valid `WireName`.
pub fn delete_consumer_group(
&self,
stream: &str,
topic: &str,
group: &str,
) -> Message<RequestHeader> {
let wire = DeleteConsumerGroupRequest {
stream_id: WireIdentifier::named(stream).expect("stream name must be valid"),
topic_id: WireIdentifier::named(topic).expect("topic name must be valid"),
group_id: WireIdentifier::named(group).expect("group name must be valid"),
};
self.build_request(Operation::DeleteConsumerGroup, &wire.to_bytes())
}
/// # Panics
/// Panics if `username` is not a valid `WireName`.
pub fn create_user(
&self,
username: &str,
password: &str,
status: u8,
) -> Message<RequestHeader> {
let wire = CreateUserRequest {
username: WireName::new(username).expect("username must be valid"),
password: password.to_string(),
status,
permissions: None,
};
self.build_request(Operation::CreateUser, &wire.to_bytes())
}
/// # Panics
/// Panics if `user` (existing username) or `new_username` (when
/// provided) is not a valid `WireName`.
pub fn update_user(
&self,
user: &str,
new_username: Option<&str>,
status: Option<u8>,
) -> Message<RequestHeader> {
let wire = UpdateUserRequest {
user_id: WireIdentifier::named(user).expect("username must be valid"),
username: new_username.map(|n| WireName::new(n).expect("username must be valid")),
status,
};
self.build_request(Operation::UpdateUser, &wire.to_bytes())
}
/// # Panics
/// Panics if `user` is not a valid `WireName`.
pub fn delete_user(&self, user: &str) -> Message<RequestHeader> {
let wire = DeleteUserRequest {
user_id: WireIdentifier::named(user).expect("username must be valid"),
};
self.build_request(Operation::DeleteUser, &wire.to_bytes())
}
/// # Panics
/// Panics if `user` is not a valid `WireName`.
pub fn change_password(
&self,
user: &str,
current_password: &str,
new_password: &str,
) -> Message<RequestHeader> {
let wire = ChangePasswordRequest {
user_id: WireIdentifier::named(user).expect("username must be valid"),
current_password: current_password.to_string(),
new_password: new_password.to_string(),
};
self.build_request(Operation::ChangePassword, &wire.to_bytes())
}
/// # Panics
/// Panics if `user` is not a valid `WireName`.
pub fn update_permissions(&self, user: &str) -> Message<RequestHeader> {
let wire = UpdatePermissionsRequest {
user_id: WireIdentifier::named(user).expect("username must be valid"),
permissions: None,
};
self.build_request(Operation::UpdatePermissions, &wire.to_bytes())
}
/// # Panics
/// Panics if `name` is not a valid `WireName`.
pub fn create_personal_access_token(&self, name: &str, expiry: u64) -> Message<RequestHeader> {
let wire = CreatePersonalAccessTokenRequest {
user_id: 0,
name: WireName::new(name).expect("PAT name must be valid"),
expiry,
// Deterministic stub for the simulator. Production servers mint
// this in `maybe_rewrite_pat_request` on the primary; the
// simulator drives the wire path directly without that rewrite
// step.
token_hash: [b'a'; 64],
};
self.build_request(Operation::CreatePersonalAccessToken, &wire.to_bytes())
}
/// # Panics
/// Panics if `name` is not a valid `WireName`.
pub fn delete_personal_access_token(&self, name: &str) -> Message<RequestHeader> {
let wire = DeletePersonalAccessTokenRequest {
user_id: 0,
name: WireName::new(name).expect("PAT name must be valid"),
only_if_expired: false,
};
self.build_request(Operation::DeletePersonalAccessToken, &wire.to_bytes())
}
/// Build a `SendMessages` request in the legacy `SendMessagesEncoder` wire
/// shape, byte-compatible with what the real SDK sends (`common` binary
/// client). VSR clients resolve to an explicit partition before sending, so
/// the sim always emits `WirePartitioning::PartitionId`: that is the shape
/// the shell's `resolve_partition_request_namespace` decodes, and the raw
/// path converts it to `SendMessages2` via `transcode_legacy_request`.
///
/// # Panics
/// Panics if a namespace id exceeds `u32` or the request buffer is invalid.
pub fn send_messages(
&self,
namespace: IggyNamespace,
messages: &[Bytes],
) -> Message<RequestHeader> {
let to_u32 = |v: usize| u32::try_from(v).expect("namespace id fits u32");
let stream_id = WireIdentifier::Numeric(to_u32(namespace.stream_id()));
let topic_id = WireIdentifier::Numeric(to_u32(namespace.topic_id()));
let partitioning = WirePartitioning::PartitionId(to_u32(namespace.partition_id()));
// Stamp a deterministic, non-zero id per message. `id: 0` (the real
// SDK's server-assigned path) would make the server mint an unseeded
// random UUID into the replicated body, breaking seeded replay of any
// produce; see `next_message_id`. `origin_timestamp: 0` keeps the batch
// bytes seed-independent.
let raw: Vec<RawMessage<'_>> = messages
.iter()
.map(|message| RawMessage {
id: self.next_message_id(),
origin_timestamp: 0,
headers: None,
payload: message.as_ref(),
})
.collect();
let size = SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &raw);
let mut buf = BytesMut::with_capacity(size);
SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &raw);
self.build_request_with_namespace(Operation::SendMessages, &buf, namespace)
}
/// Build a `POLL_MESSAGES` request for an individual consumer, reading
/// `count` messages from offset 0 of `namespace`'s partition.
///
/// A `NonReplicated` read: the command code sits in the header's
/// `reserved` prefix, and the request id ECHOES the current metadata
/// counter without advancing it (matching the SDK), so a read never
/// gaps the replicated sequence the server's `ClientTable` requires
/// gap-free. Requires a bound session (polls are auth-gated).
///
/// # Panics
/// Panics if the session is unbound or the request buffer is invalid.
#[allow(clippy::cast_possible_truncation)]
pub fn poll_messages(&self, namespace: IggyNamespace, count: u32) -> Message<RequestHeader> {
let (stream_id, topic_id, partition_id) = namespace_ids(namespace);
let body = PollMessagesRequest {
consumer: WireConsumer::consumer(WireIdentifier::Numeric(self.client_id as u32)),
stream_id,
topic_id,
partition_id,
strategy: WirePollingStrategy::first(),
count,
auto_commit: false,
}
.to_bytes();
let header_size = std::mem::size_of::<RequestHeader>();
let total_size = header_size + body.len();
let mut reserved = [0u8; 52];
reserved[..4].copy_from_slice(&POLL_MESSAGES_CODE.to_le_bytes());
let header = RequestHeader {
command: iggy_binary_protocol::Command2::Request,
operation: Operation::NonReplicated,
size: total_size as u32,
client: self.client_id,
session: self.session_id(),
request: self.request_counter.get(),
reserved,
namespace: namespace.inner(),
..Default::default()
};
let mut buffer = Vec::with_capacity(total_size);
buffer.extend_from_slice(bytemuck::bytes_of(&header));
buffer.extend_from_slice(&body);
Message::try_from(Owned::<4096>::copy_from_slice(&buffer))
.expect("poll request must be valid")
}
pub fn store_consumer_offset(
&self,
namespace: IggyNamespace,
consumer_kind: u8,
consumer_id: u32,
offset: u64,
) -> Message<RequestHeader> {
let (stream_id, topic_id, partition_id) = namespace_ids(namespace);
let request = StoreConsumerOffsetRequest {
consumer: namespace_consumer(consumer_kind, consumer_id),
stream_id,
topic_id,
partition_id,
offset,
};
self.build_request_with_namespace(
Operation::StoreConsumerOffset,
&request.to_bytes(),
namespace,
)
}
pub fn delete_consumer_offset(
&self,
namespace: IggyNamespace,
consumer_kind: u8,
consumer_id: u32,
) -> Message<RequestHeader> {
let (stream_id, topic_id, partition_id) = namespace_ids(namespace);
let request = DeleteConsumerOffsetRequest {
consumer: namespace_consumer(consumer_kind, consumer_id),
stream_id,
topic_id,
partition_id,
};
self.build_request_with_namespace(
Operation::DeleteConsumerOffset,
&request.to_bytes(),
namespace,
)
}
/// Store offset with explicit `AckLevel`. `NoAck` takes the primary's
/// fast path (no replication); `Quorum` goes through VSR.
///
/// # Panics
/// Panics on payload too large for `Owned::<4096>` or invalid
/// `Message<RequestHeader>` parse; both are simulator misconfig.
pub fn store_consumer_offset_2(
&self,
namespace: IggyNamespace,
consumer_kind: u8,
consumer_id: u32,
offset: u64,
ack: AckLevel,
) -> Message<RequestHeader> {
let (stream_id, topic_id, partition_id) = namespace_ids(namespace);
let request = StoreConsumerOffset2Request {
consumer: namespace_consumer(consumer_kind, consumer_id),
stream_id,
topic_id,
partition_id,
offset,
ack,
};
self.build_request_with_namespace(
Operation::StoreConsumerOffset2,
&request.to_bytes(),
namespace,
)
}
/// Delete offset with explicit `AckLevel`.
///
/// # Panics
/// Panics on payload too large for `Owned::<4096>` or invalid
/// `Message<RequestHeader>` parse; both are simulator misconfig.
pub fn delete_consumer_offset_2(
&self,
namespace: IggyNamespace,
consumer_kind: u8,
consumer_id: u32,
ack: AckLevel,
) -> Message<RequestHeader> {
let (stream_id, topic_id, partition_id) = namespace_ids(namespace);
let request = DeleteConsumerOffset2Request {
consumer: namespace_consumer(consumer_kind, consumer_id),
stream_id,
topic_id,
partition_id,
ack,
};
self.build_request_with_namespace(
Operation::DeleteConsumerOffset2,
&request.to_bytes(),
namespace,
)
}
fn build_request_with_namespace(
&self,
operation: Operation,
payload: &[u8],
namespace: IggyNamespace,
) -> Message<RequestHeader> {
let header_size = std::mem::size_of::<RequestHeader>();
let total_size = header_size + payload.len();
let header = self.header(operation, namespace.inner(), total_size);
let header_bytes = bytemuck::bytes_of(&header);
let mut buffer = Vec::with_capacity(total_size);
buffer.extend_from_slice(header_bytes);
buffer.extend_from_slice(payload);
Message::try_from(Owned::<4096>::copy_from_slice(&buffer))
.expect("request buffer must contain a valid request message")
}
fn build_request(&self, operation: Operation, payload: &[u8]) -> Message<RequestHeader> {
let header_size = std::mem::size_of::<RequestHeader>();
let total_size = header_size + payload.len();
// Every `build_request` caller is a metadata-plane op (partition
// ops go through `build_request_with_namespace`), and metadata
// requests carry the metadata consensus namespace on the wire.
let header = self.header(operation, METADATA_CONSENSUS_NAMESPACE, total_size);
let header_bytes = bytemuck::bytes_of(&header);
let mut buffer = Vec::with_capacity(total_size);
buffer.extend_from_slice(header_bytes);
buffer.extend_from_slice(payload);
Message::try_from(Owned::<4096>::copy_from_slice(&buffer))
.expect("request buffer must contain a valid request message")
}
#[allow(clippy::cast_possible_truncation)]
fn header(&self, operation: Operation, namespace: u64, total_size: usize) -> RequestHeader {
RequestHeader {
command: iggy_binary_protocol::Command2::Request,
operation,
size: total_size as u32,
cluster: 0, // TODO: Get from config
checksum: 0,
checksum_body: 0,
view: 0,
release: 0,
replica: 0,
reserved_frame: [0; 66],
client: self.client_id,
request_checksum: 0,
timestamp: 0, // TODO: Use actual timestamp
session: self.session_id(),
request: self.request_id_for(operation),
namespace,
..Default::default()
}
}
}
/// Build a numeric-id `WireConsumer` for the offset-store/delete requests.
/// The partition plane resolves numeric consumer ids verbatim, so this
/// mirrors the real SDK wire shape (`[kind][WireIdentifier]`) rather than
/// the old fixed `[kind][u32]` prefix.
const fn namespace_consumer(kind: u8, consumer_id: u32) -> WireConsumer {
WireConsumer {
kind,
id: WireIdentifier::Numeric(consumer_id),
}
}
/// Decompose a namespace into the `(stream_id, topic_id, partition_id)` wire
/// identifiers the consumer-offset requests carry. Namespace ids are small
/// test values that always fit `u32`.
fn namespace_ids(ns: IggyNamespace) -> (WireIdentifier, WireIdentifier, Option<u32>) {
let to_u32 = |v: usize| u32::try_from(v).expect("namespace id fits u32");
(
WireIdentifier::Numeric(to_u32(ns.stream_id())),
WireIdentifier::Numeric(to_u32(ns.topic_id())),
Some(to_u32(ns.partition_id())),
)
}
#[cfg(test)]
mod tests {
use super::*;
// A partition send between two metadata ops must not consume a metadata
// request number: the server's `ClientTable` requires the metadata sequence
// gap-free (`committed + 1`), else a gap permanently wedges the client's
// metadata plane. Partition ops draw from a separate counter in a disjoint
// range (`PARTITION_ID_BASE`), so the metadata sequence stays `1, 2, 3`
// regardless of interleaved sends and a partition id never collides with a
// metadata id.
#[test]
fn partition_ops_do_not_gap_the_metadata_request_sequence() {
let client = SimClient::new(7);
// Metadata ops advance (1, 2, 3); interleaved sends draw their own
// disjoint sequence and leave the metadata counter untouched.
assert_eq!(client.request_id_for(Operation::CreateStream), 1);
assert_eq!(
client.request_id_for(Operation::SendMessages),
PARTITION_ID_BASE + 1
);
assert_eq!(client.request_id_for(Operation::CreateStream), 2);
assert_eq!(
client.request_id_for(Operation::SendMessages),
PARTITION_ID_BASE + 2
);
assert_eq!(client.request_id_for(Operation::CreateStream), 3);
}
}