| // 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::{ |
| DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest, |
| }; |
| use iggy_binary_protocol::requests::messages::{ |
| PollMessagesRequest, RawMessage, SendMessagesEncoder, |
| }; |
| use iggy_binary_protocol::requests::partitions::{ |
| CreatePartitionsRequest, DeletePartitionsRequest, |
| }; |
| use iggy_binary_protocol::requests::personal_access_tokens::{ |
| CreatePersonalAccessTokenRequest as WireCreatePersonalAccessTokenRequest, |
| DeletePersonalAccessTokenRequest as WireDeletePersonalAccessTokenRequest, |
| }; |
| 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, RoutedRequestHeader, WireEncode, |
| WireIdentifier, WireName, WireOptions, WirePartitioning, WirePollingStrategy, |
| }; |
| use metadata::stm::user::{CreatePersonalAccessTokenRequest, DeletePersonalAccessTokenRequest}; |
| use secrecy::SecretString; |
| use server_common::sharding::{IggyNamespace, METADATA_GROUP}; |
| use server_common::{Message, iobuf::Owned}; |
| use std::cell::Cell; |
| |
| // TODO: Proper client which implements the full client SDK API |
| pub struct SimClient { |
| client_id: u128, |
| /// Monotonic `1, 2, 3, …` request ids shared by every replicated op, |
| /// metadata and partition alike, which is what the SDKs send. One sequence |
| /// also issues each id exactly once per client, so a delayed or duplicated |
| /// partition reply can never carry the `(client, request)` key of a live |
| /// metadata entry in the auditor's map. See [`SimClient::next_request_id`]. |
| request_counter: Cell<u64>, |
| /// Deterministic per-message id source for produced messages. The real SDK |
| /// mints a random UUID for a zero message id before encoding; 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>, |
| /// Whether this client talks to the server's real dispatch layer, which |
| /// changes what a PAT request must contain. |
| /// |
| /// A real client sends `[name][expiry]` and the server mints the token and its |
| /// hash in `maybe_rewrite_pat_request`, rewriting the request into the |
| /// replicated form before consensus sees it. The raw path has no dispatch layer |
| /// and so no rewrite, so a request submitted there must arrive already |
| /// replicated. Sessions split the same way (`register` raw, `login` shell); |
| /// this is the one op family whose BODY differs rather than its envelope. Set |
| /// by `Simulator::shell_login_via`, so it follows the path the client took. |
| shell_wire: Cell<bool>, |
| } |
| |
| impl SimClient { |
| #[must_use] |
| pub const fn new(client_id: u128) -> Self { |
| Self { |
| client_id, |
| request_counter: Cell::new(0), |
| message_counter: Cell::new(0), |
| session: Cell::new(0), |
| shell_wire: Cell::new(false), |
| } |
| } |
| |
| /// Mark this client as talking to the real dispatch layer, so PAT requests |
| /// carry the client wire shape rather than the replicated one. See |
| /// [`SimClient::shell_wire`]. |
| pub fn set_shell_wire(&self) { |
| self.shell_wire.set(true); |
| } |
| |
| /// Put this client back on the replicated wire shape. |
| /// |
| /// The inverse exists because the flip is otherwise permanent and silent: a |
| /// client moved to the client wire shape by mistake stops covering the |
| /// replicated PAT path for the rest of the run, with nothing failing. |
| pub fn clear_shell_wire(&self) { |
| self.shell_wire.set(false); |
| } |
| |
| /// Whether this client talks the client wire shape (see |
| /// [`SimClient::set_shell_wire`]). |
| #[must_use] |
| pub const fn shell_wire(&self) -> bool { |
| self.shell_wire.get() |
| } |
| |
| #[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 the next replicated op. |
| /// |
| /// Every replicated op advances one counter, metadata and partition alike, |
| /// which is what the SDKs send: a partition op needs its own number for a |
| /// retry to be recognisable, and the ids it spends cost the metadata plane |
| /// nothing, because `ClientTable` admits anything above the watermark |
| /// (`client_table.rs`: "There is no `RequestGap`"). |
| /// |
| /// `NonReplicated` reads never reach here — the poll path builds its own |
| /// header and reads the counter without advancing it, matching the SDK, |
| /// since the server ignores the id for ops the table never sees. |
| fn next_request_id(&self) -> u64 { |
| 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<RoutedRequestHeader> { |
| let header_size = std::mem::size_of::<RoutedRequestHeader>(); |
| let header = RoutedRequestHeader { |
| command: iggy_binary_protocol::Command::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 |
| // group, not by op class. |
| group: METADATA_GROUP, |
| ..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<RoutedRequestHeader> { |
| 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::<RoutedRequestHeader>(); |
| let total_size = header_size + body.len(); |
| let header = RoutedRequestHeader { |
| command: iggy_binary_protocol::Command::Request, |
| operation: Operation::Register, |
| size: total_size as u32, |
| client: self.client_id, |
| session: 0, |
| request: 0, |
| group: METADATA_GROUP, |
| ..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") |
| } |
| |
| /// Tear down this client's bound session. |
| /// |
| /// Replicates through the metadata plane like any other session op, so it |
| /// carries the bound session and a metadata request id and needs no body. A |
| /// logout to a BACKUP is what produces `ForwardLogout`: the backup owns the |
| /// connection but not the log, so it asks the primary to commit the teardown |
| /// and answers once `ForwardLogoutResult` returns. |
| #[must_use] |
| pub fn logout(&self) -> Message<RoutedRequestHeader> { |
| self.build_request(Operation::Logout, &[]) |
| } |
| |
| /// # Panics |
| /// Panics if the stream name is not a valid wire name. |
| pub fn create_stream(&self, name: &str) -> Message<RoutedRequestHeader> { |
| let wire = CreateStreamRequest { |
| name: WireName::new(name).expect("stream name must be valid"), |
| options: WireOptions::empty(), |
| }; |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| 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"), |
| options: WireOptions::empty(), |
| }; |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| let wire = CreateTopicRequest { |
| stream_id: WireIdentifier::named(stream).expect("stream name must be valid"), |
| partitions_count, |
| name: WireName::new(name).expect("topic name must be valid"), |
| options: WireOptions::empty(), |
| }; |
| 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<RoutedRequestHeader> { |
| 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"), |
| name: WireName::new(new_name).expect("topic name must be valid"), |
| options: WireOptions::empty(), |
| }; |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| let wire = CreateUserRequest { |
| username: WireName::new(username).expect("username must be valid"), |
| password: password.to_string(), |
| status, |
| permissions: None, |
| options: WireOptions::empty(), |
| }; |
| 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<RoutedRequestHeader> { |
| 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, |
| options: WireOptions::empty(), |
| }; |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| 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<RoutedRequestHeader> { |
| let name = WireName::new(name).expect("PAT name must be valid"); |
| // Through dispatch, send what a real client sends: the server resolves the |
| // acting user from the session and mints the token and its hash in |
| // `maybe_rewrite_pat_request`, rewriting this into the replicated form |
| // before consensus sees it. A client cannot produce that form, not knowing |
| // the hash, so sending it here made every PAT request fail to decode. |
| if self.shell_wire.get() { |
| let wire = WireCreatePersonalAccessTokenRequest { name, expiry }; |
| return self.build_request(Operation::CreatePersonalAccessToken, &wire.to_bytes()); |
| } |
| // Raw path: no dispatch layer, so no rewrite ever happens and the request |
| // has to arrive already replicated. |
| let wire = CreatePersonalAccessTokenRequest { |
| user_id: 0, |
| name, |
| expiry, |
| 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<RoutedRequestHeader> { |
| let name = WireName::new(name).expect("PAT name must be valid"); |
| // See `create_personal_access_token` for why the shape depends on the path. |
| if self.shell_wire.get() { |
| let wire = WireDeletePersonalAccessTokenRequest { name }; |
| return self.build_request(Operation::DeletePersonalAccessToken, &wire.to_bytes()); |
| } |
| let wire = DeletePersonalAccessTokenRequest { |
| user_id: 0, |
| name, |
| only_if_expired: false, |
| }; |
| self.build_request(Operation::DeletePersonalAccessToken, &wire.to_bytes()) |
| } |
| |
| /// Build a `SendMessages` request in the `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 before admission |
| /// strips the metadata and stamps the batch. |
| /// |
| /// # Panics |
| /// Panics if a group id exceeds `u32` or the request buffer is invalid. |
| pub fn send_messages( |
| &self, |
| group: IggyNamespace, |
| messages: &[Bytes], |
| ) -> Message<RoutedRequestHeader> { |
| let to_u32 = |v: usize| u32::try_from(v).expect("group id fits u32"); |
| let stream_id = WireIdentifier::Numeric(to_u32(group.stream_id())); |
| let topic_id = WireIdentifier::Numeric(to_u32(group.topic_id())); |
| let partitioning = WirePartitioning::PartitionId(to_u32(group.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) |
| .expect("simulator send batch encodes"); |
| |
| self.build_request_with_namespace(Operation::SendMessages, &buf, group) |
| } |
| |
| /// Build a `POLL_MESSAGES` request for an individual consumer, reading |
| /// `count` messages from offset 0 of `group`'s partition. |
| /// |
| /// A `NonReplicated` read: the command code sits in the header's |
| /// `reserved` prefix, and the request id ECHOES the current counter |
| /// without advancing it (matching the SDK). The server ignores the id for |
| /// ops its `ClientTable` never sees, so burning one would buy nothing. |
| /// 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, group: IggyNamespace, count: u32) -> Message<RoutedRequestHeader> { |
| let (stream_id, topic_id, partition_id) = namespace_ids(group); |
| 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::<RoutedRequestHeader>(); |
| 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 = RoutedRequestHeader { |
| command: iggy_binary_protocol::Command::Request, |
| operation: Operation::NonReplicated, |
| size: total_size as u32, |
| client: self.client_id, |
| session: self.session_id(), |
| request: self.request_counter.get(), |
| reserved, |
| group: group.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") |
| } |
| |
| /// 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<RoutedRequestHeader>` parse; both are simulator misconfig. |
| pub fn store_consumer_offset( |
| &self, |
| group: IggyNamespace, |
| consumer_kind: u8, |
| consumer_id: u32, |
| offset: u64, |
| ack: AckLevel, |
| ) -> Message<RoutedRequestHeader> { |
| let (stream_id, topic_id, partition_id) = namespace_ids(group); |
| let request = StoreConsumerOffsetRequest { |
| consumer: namespace_consumer(consumer_kind, consumer_id), |
| stream_id, |
| topic_id, |
| partition_id, |
| offset, |
| ack, |
| }; |
| self.build_request_with_namespace( |
| Operation::StoreConsumerOffset, |
| &request.to_bytes(), |
| group, |
| ) |
| } |
| |
| /// Delete offset with explicit `AckLevel`. |
| /// |
| /// # Panics |
| /// Panics on payload too large for `Owned::<4096>` or invalid |
| /// `Message<RoutedRequestHeader>` parse; both are simulator misconfig. |
| pub fn delete_consumer_offset( |
| &self, |
| group: IggyNamespace, |
| consumer_kind: u8, |
| consumer_id: u32, |
| ack: AckLevel, |
| ) -> Message<RoutedRequestHeader> { |
| let (stream_id, topic_id, partition_id) = namespace_ids(group); |
| let request = DeleteConsumerOffsetRequest { |
| consumer: namespace_consumer(consumer_kind, consumer_id), |
| stream_id, |
| topic_id, |
| partition_id, |
| ack, |
| }; |
| self.build_request_with_namespace( |
| Operation::DeleteConsumerOffset, |
| &request.to_bytes(), |
| group, |
| ) |
| } |
| |
| fn build_request_with_namespace( |
| &self, |
| operation: Operation, |
| payload: &[u8], |
| group: IggyNamespace, |
| ) -> Message<RoutedRequestHeader> { |
| let header_size = std::mem::size_of::<RoutedRequestHeader>(); |
| let total_size = header_size + payload.len(); |
| |
| let header = self.header(operation, group.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<RoutedRequestHeader> { |
| let header_size = std::mem::size_of::<RoutedRequestHeader>(); |
| 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 group on the wire. |
| let header = self.header(operation, METADATA_GROUP, 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, group: u64, total_size: usize) -> RoutedRequestHeader { |
| RoutedRequestHeader { |
| command: iggy_binary_protocol::Command::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.next_request_id(), |
| group, |
| ..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 group 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("group 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 consumes a request id like any other replicated op, so |
| // an interleaved run numbers both planes from one sequence and the metadata |
| // ids arrive with gaps under them. That is the pattern the SDKs produce, and |
| // the server admits it: `ClientTable` compares against a watermark rather |
| // than requiring contiguity (`client_table.rs`: "There is no `RequestGap`"), |
| // so a metadata op landing above the watermark executes and moves it. |
| #[test] |
| fn every_replicated_op_advances_the_one_request_sequence() { |
| let client = SimClient::new(7); |
| client.bind_session(1); |
| |
| let interleaved = [ |
| Operation::CreateStream, |
| Operation::SendMessages, |
| Operation::CreateStream, |
| Operation::SendMessages, |
| Operation::CreateStream, |
| ]; |
| let ids: Vec<u64> = interleaved |
| .into_iter() |
| .map(|operation| client.header(operation, METADATA_GROUP, 0).request) |
| .collect(); |
| |
| assert_eq!(ids, vec![1, 2, 3, 4, 5], "plane must not fork the sequence"); |
| } |
| } |