blob: 6db759553432944aa350af9be22ed8fdbfa86027 [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.
//! Non-replicated read router and its per-code arms.
//!
//! Every `Operation::NonReplicated` request lands in
//! [`handle_non_replicated_request`] after the funnel's pre-auth gate; the
//! poll and consumer-offset arms live in `partition` (they read through the
//! shard mesh), everything else is served here from local shard state. The
//! catch-all arm delegates to the shared `responses` builder, which is
//! byte-shared with the HTTP read path -- authorization happens HERE (and in
//! the HTTP layer), never in the builder.
use crate::cluster_meta::ClusterRoster;
use crate::dispatch::authz::{authorize_default_read, authorize_partition_read, authorize_uid};
use crate::dispatch::complete_session_binding;
use crate::dispatch::failure::{
FrameChannel, send_host_frame, send_non_replicated_bytes, send_non_replicated_deny,
};
use crate::dispatch::partition::{
handle_get_consumer_offset, handle_poll_messages, resolve_poll_request,
};
use crate::namespace::fence_and_resolve_offset_namespace;
use crate::reply_frame::{build_empty_reply, current_metadata_commit};
use crate::responses::{
build_get_me_response, build_get_personal_access_tokens_response,
build_non_replicated_response, connected_client_to_response,
};
use crate::session_manager::{ConnectionContext, SessionManager};
use crate::shell::{ShellBus, ShellShard};
use crate::snapshot;
use crate::wire::request_body;
use bytes::Bytes;
use configs::server::ServerConfig;
use consensus::MetadataHandle;
use futures::future::{Either, select};
use iggy_binary_protocol::PrepareHeader;
use iggy_binary_protocol::codes::{
BIND_SESSION_CODE, DESCRIBE_OPTIONS_CODE, GET_CLIENT_CODE, GET_CLIENTS_CODE,
GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE, GET_CONSUMER_OFFSET_ROUTING_CODE,
GET_ME_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE, GET_POLL_ROUTING_CODE, GET_SNAPSHOT_FILE_CODE,
GET_STATS_CODE, PING_CODE, POLL_MESSAGES_CODE, POLL_MESSAGES_ON_PRIMARY_CODE,
SYNC_CONSUMER_GROUP_CODE,
};
use iggy_binary_protocol::dispatch::lookup_command;
use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
use iggy_binary_protocol::requests::consumer_offsets::GetConsumerOffsetRequest;
use iggy_binary_protocol::requests::messages::PollMessagesRequest;
use iggy_binary_protocol::requests::system::get_client::GetClientRequest;
use iggy_binary_protocol::requests::system::get_snapshot::GetSnapshotRequest;
use iggy_binary_protocol::requests::system::{BindSessionRequest, SessionIdentity};
use iggy_binary_protocol::responses::clients::client_response::ConsumerGroupInfoResponse;
use iggy_binary_protocol::responses::clients::get_client::ClientDetailsResponse;
use iggy_binary_protocol::responses::clients::get_clients::GetClientsResponse;
use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
use iggy_binary_protocol::responses::messages::PollRoutingResponse;
use iggy_binary_protocol::responses::system::get_snapshot::GetSnapshotResponse;
use iggy_binary_protocol::responses::users::LoginRegisterResponse;
use iggy_binary_protocol::{HEADER_SIZE, RoutedRequestHeader, WireDecode, WireEncode};
use iggy_common::{ClusterNodeRole, IggyError, SnapshotCompression, SystemSnapshotType};
use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use message_bus::framing::MAX_MESSAGE_SIZE;
use metadata::AppliedFrontier;
use metadata::impls::metadata::StreamsFrontend;
use metadata::permissioner::Permissioner;
use server_common::Message;
use shard::{PartitionRead, PartitionReadReply};
use std::cell::RefCell;
use std::future::Future;
use std::net::{IpAddr, SocketAddr};
use std::pin::pin;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, warn};
/// Per-user PATs, resolved from this shard's session (like `get_me`) and read
/// out of the Users STM. Built here rather than in `build_non_replicated_response`
/// which has no session context.
#[allow(clippy::future_not_send)]
async fn handle_get_personal_access_tokens<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: &Message<RoutedRequestHeader>,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let response = build_get_personal_access_tokens_response(shard, sessions, transport_client_id);
send_non_replicated_bytes(
shard,
request,
transport_client_id,
response.to_bytes(),
FrameChannel::Reply,
"get_personal_access_tokens",
)
.await;
}
/// The requesting connection's own identity, sourced from this shard's
/// `SessionManager` (not `IggyMetadata`), so built here rather than in
/// `build_non_replicated_response`.
#[allow(clippy::future_not_send)]
async fn handle_get_me<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: &Message<RoutedRequestHeader>,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let response = build_get_me_response(shard, sessions, transport_client_id);
send_non_replicated_bytes(
shard,
request,
transport_client_id,
response.to_bytes(),
FrameChannel::Reply,
"get_me",
)
.await;
}
/// Commit broadcasts a held read waits out before it fails retryable.
///
/// Sized for a node merely behind on its commit walk, NOT for a view change --
/// detecting one costs `heartbeat_timeout` and escalating it another
/// `view_change_status_timeout`, and `recovery_barrier_deadline` budgets at
/// least 15s for the same event, so a read that waits out an election is a read
/// the caller should retry elsewhere.
const READ_FRONTIER_BROADCASTS: u32 = 6;
/// How long a held metadata read may wait for this node's applied frontier,
/// sized from `[cluster] commit_broadcast_interval`: the thing the read is
/// short of is a commit broadcast, so the budget has to move with the
/// configured cadence rather than with the compile-time default that
/// `TimeoutManager::COMMIT_MESSAGE_TICKS` names (the runtime overrides it
/// through `set_commit_message_ticks`).
///
/// Minted once per process into the shared [`AppliedFrontier`], because a peer
/// shard's read gate has neither consensus nor the cluster config.
#[must_use]
pub fn read_frontier_budget(config: &ServerConfig) -> Duration {
config
.cluster
.commit_broadcast_interval
.get_duration()
// No config ceiling on the interval, so plain `*` can overflow.
.saturating_mul(READ_FRONTIER_BROADCASTS)
}
/// Whether `code`'s answer comes from the metadata state machine, and so must
/// not be served below the caller's watermark.
///
/// The decision for both planes, consulted by every read path that CAN hold:
/// the binary spine's gated arms run it through [`authorize_and_hold_read`] and
/// the REST spine through `http::reads::gate_local_read`. The arms that never
/// consult it are exactly the codes named below, so this is the single list of
/// what is not gated:
///
/// - `Ping` is the pre-auth liveness probe and reads nothing.
/// - `DescribeOptions` decodes a static catalog.
/// - `GetClusterMetadata` answers from the configured roster plus the
/// consensus view, and sits on the SDK's leader-discovery path, where the
/// wait would be real.
/// - `PollMessages` and `GetConsumerOffset` are partition-plane reads: their
/// answer comes from a partition group's own log, not the metadata STM, and
/// holding them would put metadata lag on the data path.
/// - `GetSnapshotFile` shells out to system tools off-thread; there is no
/// metadata answer to hold.
/// - A code this build does not know: its only outcome is `InvalidCommand`,
/// and parking a terminal error for the whole budget serves nobody.
///
/// A deny-list otherwise, so a read code added later is gated by default: the
/// failure mode of forgetting to name one here is a wait, while forgetting to
/// add it to an allow-list is a silent stale read.
pub const fn read_needs_metadata_frontier(code: u32) -> bool {
!matches!(
code,
PING_CODE
| DESCRIBE_OPTIONS_CODE
| GET_CLUSTER_METADATA_CODE
| POLL_MESSAGES_CODE
| POLL_MESSAGES_ON_PRIMARY_CODE
| GET_CONSUMER_OFFSET_CODE
| GET_SNAPSHOT_FILE_CODE
) && lookup_command(code).is_some()
}
/// Whether a frontier wait actually parked.
///
/// The caller's authorization resolved its scope off the pre-wait state
/// machine, and a wait that parked is one where that state machine moved, so
/// only the parked outcome forces the gate to run again.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrontierWait {
/// The frontier already covered the watermark: no await ran.
Ready,
/// The read parked, and the frontier caught up while it waited.
CaughtUp,
}
/// The frontier never reached the watermark inside the budget. Each plane
/// renders it in its own error currency.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FrontierUnreached;
/// Hold a read until `frontier` covers `watermark`, or until `budget` expires.
///
/// Event-driven: the wait is woken by the commit that advances the frontier
/// (see [`AppliedFrontier::advance`]), so a read resumes on the commit it was
/// short of rather than on a poll that happens to land after it. The caller
/// supplies the budget as a future because the two read planes measure time
/// differently -- the shard bus timer, which is virtual under the simulator,
/// against `compio::time` on the HTTP listener.
///
/// Shared by both planes because one wait with two copies is one wait with a
/// drift vector, and split from its callers so the fast path, the park, the
/// wake and the expiry are all testable without a live shard.
///
/// A caller with nothing to read back has `watermark == 0`, which the first
/// comparison satisfies: one `Acquire` load, no registration, no await. Expiry
/// is loud and carries both numbers, so a frontier that stopped moving is
/// visible instead of showing up as latency.
pub async fn hold_for_frontier(
frontier: &AppliedFrontier,
watermark: u64,
budget: impl Future<Output = ()>,
) -> Result<FrontierWait, FrontierUnreached> {
if frontier.get() >= watermark {
return Ok(FrontierWait::Ready);
}
let reached = pin!(frontier.reached(watermark));
let budget = pin!(budget);
match select(reached, budget).await {
Either::Left(((), _)) => Ok(FrontierWait::CaughtUp),
// Reported by the caller, not here: a durably lagging node refuses
// every held read of every client for as long as it lags, so this is
// a counter plus a `debug!`, never a line per refusal.
Either::Right(((), _)) => Err(FrontierUnreached),
}
}
/// Hold a local metadata read until this node has applied everything the
/// connection was told committed.
///
/// A committed reply hands the client an op number; answering its next read
/// from a state machine below that op contradicts the frame the client is
/// holding. The lag is real on a node whose commit walk trails the client's
/// epoch -- a backup that forwarded the client's register binds a committed
/// session while its own `commit_journal` is still behind it (see
/// [`crate::dispatch::session_ops`]) -- so the gate is not about peer shards:
/// every shard of a node reads one shared frontier and gates identically.
///
/// Fast path is a single `Acquire` load and no await, which is what keeps an
/// uncontended read shared-nothing. A park costs this connection more than the
/// read itself: the per-connection drain loop serves one frame at a time, so
/// the client's queued `SendMessages`, `PollMessages` and `PING` wait behind
/// the held read, and a client that keeps pipelining through the hold is
/// answered `TransientNotAccepted` once its queue hits
/// [`MAX_QUEUED_CLIENT_REQUESTS`](crate::dispatch::MAX_QUEUED_CLIENT_REQUESTS)
/// rather than growing it unbounded. No OTHER connection waits, and the budget
/// bounds the hold itself. Expiry fails loud and retryable rather than serving
/// state the client already saw replaced.
///
/// The wait ends on the commit that closes the gap, not on a poll: the budget
/// timer is the only timer armed, so a read that resumes costs one wake.
#[allow(clippy::future_not_send)]
pub(super) async fn await_metadata_read_frontier<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
watermark: u64,
) -> Result<FrontierWait, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let frontier = shard.plane.metadata().applied_frontier();
let budget = frontier.read_budget();
hold_for_frontier(frontier, watermark, shard.bus.sleep(budget))
.await
// `TransientNotAccepted`, not `NotCommitted`: a read never entered a
// pipeline, so it is safe to re-issue anywhere, and it is the code that
// drives the SDK's roster walk rather than a replay against the same
// durably lagging replica.
.map_err(|FrontierUnreached| {
shard.metrics().record_metadata_read_frontier_refusal();
debug!(
frontier = frontier.get(),
watermark,
?budget,
"metadata read frontier unreached inside the budget; failing the read retryable"
);
IggyError::TransientNotAccepted
})
}
/// Authorize a metadata read, then hold it for this node's applied frontier.
///
/// Authorization first: a denial is terminal, and parking the connection for
/// the whole budget before answering one buys nothing. Then again on a wait
/// that parked -- the rule resolves its scope and the caller's grants off the
/// state machine, and a park is exactly the case where both moved under it.
#[allow(clippy::future_not_send)]
async fn authorize_and_hold_read<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
code: u32,
watermark: u64,
authorize: impl Fn() -> Result<(), IggyError>,
) -> Result<(), IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
authorize()?;
if !read_needs_metadata_frontier(code) {
return Ok(());
}
if await_metadata_read_frontier(shard, watermark).await? == FrontierWait::CaughtUp {
authorize()?;
}
Ok(())
}
#[allow(clippy::future_not_send, clippy::too_many_lines)]
pub(in crate::dispatch) async fn handle_non_replicated_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
server_config: &Arc<ServerConfig>,
transport_client_id: u128,
request: Message<RoutedRequestHeader>,
// Acting user, peer address and read-your-writes floor for the read gates
// below, resolved by the funnel in the same connection lookup as the
// heartbeat. `user_id` is `None` only on the pre-auth path (PING), which
// serves ungated codes; the gated arms fail closed on it. An unknown
// connection arrives with a floor of `0`: it was promised nothing, so its
// reads wait for nothing.
ConnectionContext {
bound,
user_id,
address: client_address,
metadata_watermark: watermark,
}: ConnectionContext,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
const CODE_RANGE: std::ops::Range<usize> = 0..4;
let code = u32::from_le_bytes(request.header().reserved[CODE_RANGE].try_into().unwrap());
match code {
PING_CODE => {
// No `record_heartbeat` here: the funnel records one for EVERY
// frame before classification, so a ping is already covered.
let commit = current_metadata_commit(shard);
let reply = build_empty_reply(
request.header(),
request.header().client,
request.header().session,
commit,
);
send_host_frame(
&shard.bus,
transport_client_id,
reply.into_generic().into_frozen(),
FrameChannel::Reply,
"ping_reply",
)
.await;
}
GET_ME_CODE => {
// Self-scoped, so no permissioner rule -- but the consumer-group
// list it carries is read off the streams STM, so it is gated like
// any other metadata read.
if let Err(error) = authorize_and_hold_read(shard, code, watermark, || Ok(())).await {
send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
.await;
return;
}
handle_get_me(shard, sessions, transport_client_id, &request).await;
}
GET_PERSONAL_ACCESS_TOKENS_CODE => {
if let Err(error) = authorize_and_hold_read(shard, code, watermark, || Ok(())).await {
send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
.await;
return;
}
handle_get_personal_access_tokens(shard, sessions, transport_client_id, &request).await;
}
GET_CLIENTS_CODE => {
if let Err(error) = authorize_and_hold_read(shard, code, watermark, || {
authorize_uid(shard, user_id, Permissioner::get_clients)
})
.await
{
send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
.await;
return;
}
// Shared-nothing: each shard knows only its own connections, so
// gather across all shards (scatter-gather over the mesh).
let infos = shard.list_all_clients().await;
let response = GetClientsResponse {
clients: infos
.iter()
.map(|info| connected_client_to_response(shard, info))
.collect(),
};
send_non_replicated_bytes(
shard,
&request,
transport_client_id,
response.to_bytes(),
FrameChannel::Reply,
"get_clients",
)
.await;
}
GET_CLIENT_CODE => {
if let Err(error) = authorize_and_hold_read(shard, code, watermark, || {
authorize_uid(shard, user_id, Permissioner::get_client)
})
.await
{
send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
.await;
return;
}
// No reverse map from the wire u32 id to a u128 transport id /
// home shard (the u32 is just the seq tail), so gather all and
// filter -- same fan-out as `get_clients`.
let target = GetClientRequest::decode_from(request_body(&request))
.ok()
.map(|req| req.client_id);
let infos = shard.list_all_clients().await;
#[allow(clippy::cast_possible_truncation)]
let found = target.and_then(|id| infos.iter().find(|info| info.client_id as u32 == id));
// The SDK decodes an empty body as `None` (client not found).
let bytes = found.map_or_else(Bytes::new, |info| {
let consumer_groups = info.vsr_client_id.map_or_else(Vec::new, |vsr_client_id| {
shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_memberships(vsr_client_id)
.into_iter()
.map(
|(stream_id, topic_id, group_id)| ConsumerGroupInfoResponse {
stream_id,
topic_id,
group_id,
},
)
.collect()
});
ClientDetailsResponse {
client: connected_client_to_response(shard, info),
consumer_groups,
}
.to_bytes()
});
send_non_replicated_bytes(
shard,
&request,
transport_client_id,
bytes,
FrameChannel::Reply,
"get_client",
)
.await;
}
GET_SNAPSHOT_FILE_CODE => {
handle_get_snapshot(shard, server_config, transport_client_id, &request, user_id).await;
}
GET_POLL_ROUTING_CODE | GET_CONSUMER_OFFSET_ROUTING_CODE | BIND_SESSION_CODE => {
let result = if code == BIND_SESSION_CODE {
bind_session(shard, sessions, transport_client_id, &request).await
} else {
consumer_routing(
shard,
sessions,
transport_client_id,
&request,
user_id,
client_address,
code,
)
.await
};
match result {
Ok(bytes) => {
send_non_replicated_bytes(
shard,
&request,
transport_client_id,
bytes,
FrameChannel::Reply,
"consumer_routing",
)
.await;
}
Err(error) => {
send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
.await;
}
}
}
POLL_MESSAGES_CODE | POLL_MESSAGES_ON_PRIMARY_CODE => {
let consumer_client = if code == POLL_MESSAGES_ON_PRIMARY_CODE {
sessions
.borrow()
.consumer_session(transport_client_id)
.map(|(client_id, attachment)| (client_id, Some(attachment)))
} else {
bound
.map(|(client_id, _)| (client_id, None))
.ok_or(IggyError::Unauthenticated)
};
match consumer_client {
Ok((consumer_client_id, attachment)) => {
handle_poll_messages(
shard,
transport_client_id,
&request,
user_id,
consumer_client_id,
attachment,
)
.await;
}
Err(error) => {
send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
.await;
}
}
}
GET_CONSUMER_OFFSET_CODE => {
handle_get_consumer_offset(shard, transport_client_id, &request, user_id).await;
}
SYNC_CONSUMER_GROUP_CODE => {
// Self-scoped: serves the caller's own assignment keyed by the
// header client id, so it carries no permissioner rule. The
// assignment itself is metadata-STM state, hence the gate.
if let Err(error) = authorize_and_hold_read(shard, code, watermark, || Ok(())).await {
send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
.await;
return;
}
handle_sync_consumer_group(shard, transport_client_id, &request).await;
}
_ => {
let roster = sessions.borrow().cluster_roster();
let client_ip = client_address.map(|address| address.ip());
if client_ip.is_none() {
debug!(
transport_client_id,
code,
"no peer address recorded; advertised-address resolution degrades to the catch-all"
);
}
handle_default_non_replicated(
shard,
transport_client_id,
code,
&request,
user_id,
watermark,
&roster,
client_ip,
)
.await;
}
}
}
#[allow(clippy::future_not_send)]
async fn bind_session<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: &Message<RoutedRequestHeader>,
) -> Result<Bytes, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let (version_info, _) = iggy_binary_protocol::ClientVersionInfo::decode(request_body(request))
.map_err(|_| IggyError::InvalidCommand)?;
if !iggy_binary_protocol::is_protocol_compatible(version_info.protocol_version) {
return Err(IggyError::IncompatibleProtocolVersion(
version_info.protocol_version,
iggy_binary_protocol::IGGY_PROTOCOL_VERSION_MIN,
iggy_binary_protocol::IGGY_PROTOCOL_VERSION,
));
}
let wire = BindSessionRequest::decode_from(request_body(request))
.map_err(|_| IggyError::InvalidCommand)?;
let (user_id, session) = complete_session_binding(
shard,
sessions,
transport_client_id,
wire.identity,
wire.bind_secret,
)
.await?;
Ok(LoginRegisterResponse {
user_id,
session,
server_protocol_version: iggy_binary_protocol::IGGY_PROTOCOL_VERSION,
server_version: iggy_binary_protocol::WireName::new(env!("CARGO_PKG_VERSION"))
.map_err(|_| IggyError::InvalidConfiguration)?,
}
.to_bytes())
}
#[allow(clippy::future_not_send, clippy::too_many_arguments)]
async fn consumer_routing<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: &Message<RoutedRequestHeader>,
user_id: Option<u32>,
client_address: Option<SocketAddr>,
code: u32,
) -> Result<Bytes, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let poll = if code == GET_POLL_ROUTING_CODE {
let poll = PollMessagesRequest::decode_from(request_body(request))
.map_err(|_| IggyError::InvalidCommand)?;
if !poll.auto_commit {
return Err(IggyError::InvalidCommand);
}
Some(poll)
} else {
None
};
let (wire, _) = GetConsumerOffsetRequest::decode(request_body(request))
.map_err(|_| IggyError::InvalidCommand)?;
let (client_id, session) = sessions
.borrow()
.get_session(transport_client_id)
.ok_or(IggyError::Unauthenticated)?;
let watermark = sessions.borrow().metadata_watermark(transport_client_id);
authorize_and_hold_read(shard, code, watermark, || {
authorize_partition_read(
shard,
&wire.stream_id,
&wire.topic_id,
user_id,
|permissioner, user_id, stream_id, topic_id| {
permissioner.poll_messages(user_id, stream_id, topic_id)
},
)
.map_or(Ok(()), |code| Err(IggyError::from_code(code)))
})
.await?;
let namespace = if let Some(poll) = poll {
resolve_poll_request(shard, &poll, client_id)?.0
} else {
fence_and_resolve_offset_namespace(
shard,
&wire.consumer,
&wire.stream_id,
&wire.topic_id,
wire.partition_id,
client_id,
)?
};
let primary = match shard
.partition_read(namespace, PartitionRead::Primary)
.await
{
Some(PartitionReadReply::Primary(primary)) => primary,
Some(PartitionReadReply::Rejected(error)) => return Err(error),
_ => return Err(IggyError::TransientNotAccepted),
};
let roster = sessions.borrow().cluster_roster();
let primary = roster
.cluster_metadata(Some(primary), client_address.map(|address| address.ip()))
.nodes
.into_iter()
.find(|node| node.role == ClusterNodeRole::Leader)
.ok_or(IggyError::TransientNotAccepted)?;
Ok(PollRoutingResponse {
consumer_session: SessionIdentity {
client_id,
session,
metadata_watermark: watermark.max(shard.plane.metadata().applied_frontier().get()),
},
primary: primary.into(),
}
.to_bytes())
}
#[allow(clippy::future_not_send, clippy::too_many_arguments)]
async fn handle_default_non_replicated<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
code: u32,
request: &Message<RoutedRequestHeader>,
user_id: Option<u32>,
watermark: u64,
roster: &ClusterRoster,
client_ip: Option<IpAddr>,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
// Gate by command code before the shared builder runs. The builder stays
// authz-free (it is byte-shared with the HTTP read path, which gates
// separately); a denial replies status!=0 with an empty body. The
// read-your-writes hold sits INSIDE the same call, behind that denial: an
// unauthorized read must fail now, not after the whole poll budget.
if let Err(error) = authorize_and_hold_read(shard, code, watermark, || {
authorize_default_read(shard, code, request_body(request), user_id)
})
.await
{
// Same line as the builder-`Err` branch below: `send_non_replicated_deny`
// logs only on send FAILURE, so a refusal that reaches the client would
// otherwise leave nothing server-side - including the refusal this gate
// now issues for every armless or unknown non-replicated code.
// The frontier wait reaches here through the same `Err` and has
// already counted and logged itself, so it stays at `debug!`: a durably
// lagging node refuses every held read of every client for as long as
// it lags, and warning here would be a line per refusal.
if matches!(error, IggyError::TransientNotAccepted) {
debug!(
transport_client_id,
code, "denying non-replicated VSR request; read frontier unreached"
);
} else {
warn!(
transport_client_id,
code,
error = %error,
"denying non-replicated VSR request"
);
}
send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
return;
}
// Stats is the one default read with an async input: the cross-shard
// connected-client gather. Run it here so the shared builder stays sync.
let clients_count = if code == GET_STATS_CODE {
u32::try_from(shard.count_all_clients().await).unwrap_or(u32::MAX)
} else {
0
};
match build_non_replicated_response(
shard,
code,
request_body(request),
user_id,
roster,
client_ip,
clients_count,
) {
Ok(response) => {
let commit = current_metadata_commit(shard);
let reply = response.into_reply(
request.header(),
request.header().client,
request.header().session,
commit,
);
send_host_frame(
&shard.bus,
transport_client_id,
reply.into_generic().into_frozen(),
FrameChannel::Reply,
"non_replicated_reply",
)
.await;
}
Err(error) => {
// Surface the builder's typed error (unsupported op, undecodable
// body, or a not-found parity read) on the same deny channel the
// authz gate uses; a silent drop would wedge the client until its
// read timeout.
warn!(
transport_client_id,
code,
error = %error,
"denying non-replicated VSR request"
);
send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
}
}
}
/// Serve `GET_SNAPSHOT_FILE`: gate on the snapshot rule (`read_servers ||
/// manage_servers`, the legacy gate - the archive dumps host diagnostics, so
/// plain authentication must not suffice), then await the off-thread
/// collection (see `snapshot::collect`) and reply with the raw ZIP bytes.
#[allow(clippy::future_not_send)]
async fn handle_get_snapshot<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
server_config: &Arc<ServerConfig>,
transport_client_id: u128,
request: &Message<RoutedRequestHeader>,
user_id: Option<u32>,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_snapshot) {
send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
return;
}
let result = match decode_get_snapshot(request_body(request)) {
Ok((compression, snapshot_types)) => {
snapshot::collect(Arc::clone(server_config), compression, snapshot_types).await
}
Err(error) => Err(error),
};
match result {
Ok(archive) => {
// The reply frames as `[256-byte header][archive]`. The client's
// `message_bus::read_message` rejects any frame past `MAX_MESSAGE_SIZE`
// (64 MiB) by tearing the connection down untyped, and a frame past
// `u32::MAX` would panic `build_reply_with_body`. The archive is the
// only unbounded non-replicated body, so refuse an oversized one with a
// typed error the SDK decodes. The HTTP path streams via `Body` (not
// this framing), so it stays uncapped.
let frame_size = HEADER_SIZE + archive.len();
if frame_size > MAX_MESSAGE_SIZE {
warn!(
transport_client_id,
frame_size,
max = MAX_MESSAGE_SIZE,
"snapshot archive exceeds the client frame limit; refusing to send"
);
send_non_replicated_deny(
shard,
request,
transport_client_id,
IggyError::SnapshotFileCompletionFailed.as_code(),
)
.await;
return;
}
send_non_replicated_bytes(
shard,
request,
transport_client_id,
GetSnapshotResponse { data: archive }.to_bytes(),
FrameChannel::Reply,
"get_snapshot",
)
.await;
}
Err(error) => {
warn!(transport_client_id, error = %error, "denying snapshot request");
send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
}
}
}
fn decode_get_snapshot(
body: &[u8],
) -> Result<(SnapshotCompression, Vec<SystemSnapshotType>), IggyError> {
let request = GetSnapshotRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
let compression = SnapshotCompression::from_code(request.compression)?;
let snapshot_types = request
.snapshot_types
.iter()
.map(|&code| SystemSnapshotType::from_code(code))
.collect::<Result<Vec<_>, _>>()?;
Ok((compression, snapshot_types))
}
/// Serve `SyncConsumerGroup`: return the requesting member's current partition
/// assignment + group generation so the client can select partitions locally.
/// The member is keyed by the connection's bound VSR client id
/// (`header().client`). An empty body decodes as "no assignment" on the SDK.
#[allow(clippy::future_not_send)]
async fn handle_sync_consumer_group<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request: &Message<RoutedRequestHeader>,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let body = match SyncConsumerGroupRequest::decode_from(request_body(request)) {
Ok(wire) => shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_member_assignment(
&wire.stream_id,
&wire.topic_id,
&wire.group_id,
request.header().client,
)
.map_or_else(Bytes::new, |(generation, partitions)| {
SyncConsumerGroupResponse {
generation,
partitions,
}
.to_bytes()
}),
Err(error) => {
warn!(
transport_client_id,
error = %error,
"sync_consumer_group request rejected; replying empty"
);
Bytes::new()
}
};
send_non_replicated_bytes(
shard,
request,
transport_client_id,
body,
FrameChannel::Reply,
"sync_consumer_group",
)
.await;
}
#[cfg(test)]
mod tests {
use super::{
FrontierUnreached, FrontierWait, hold_for_frontier, read_frontier_budget,
read_needs_metadata_frontier,
};
use configs::server::ServerConfig;
use iggy_binary_protocol::codes::{
DESCRIBE_OPTIONS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE,
GET_SNAPSHOT_FILE_CODE, GET_STREAM_CODE, PING_CODE, POLL_MESSAGES_CODE,
SYNC_CONSUMER_GROUP_CODE,
};
use iggy_common::IggyDuration;
use metadata::AppliedFrontier;
use std::future::pending;
use std::sync::Arc;
use std::time::Duration;
/// The budget has to move with the CONFIGURED commit cadence, not with the
/// compile-time default: `[cluster] commit_broadcast_interval` is what
/// sizes the timer the read is waiting on, and a cluster that widens it to
/// 2s would otherwise get a budget of one and a half broadcasts and refuse
/// reads on a backup that is merely a commit behind.
///
/// The default arm also pins the fallback the simulator and the unit
/// fixtures run on, so the two cannot drift apart silently.
#[test]
fn given_a_configured_commit_cadence_when_sizing_the_budget_should_scale_with_it() {
let mut config = ServerConfig::default();
assert_eq!(
read_frontier_budget(&config),
AppliedFrontier::DEFAULT_READ_BUDGET,
"the config default must agree with the frontier's built-in fallback"
);
config.cluster.commit_broadcast_interval = IggyDuration::from(Duration::from_secs(2));
assert_eq!(
read_frontier_budget(&config),
Duration::from_secs(12),
"six broadcasts of the configured interval"
);
}
/// A caller with nothing to read back (`watermark == 0`) and one whose
/// watermark this node has already applied are the whole steady state, and
/// neither may cost a park: no registration, no await. The budget here is
/// a future that never completes, so a gate that parked would hang instead
/// of quietly costing a tick.
#[compio::test]
async fn given_a_frontier_at_the_watermark_when_gating_should_serve_without_parking() {
let frontier = AppliedFrontier::default();
frontier.advance(9);
for watermark in [0, 7, 9] {
assert_eq!(
hold_for_frontier(&frontier, watermark, pending()).await,
Ok(FrontierWait::Ready),
"frontier 9 covers {watermark}, so the read must not park"
);
}
assert_eq!(frontier.waiting(), 0, "a served read registers no wait");
}
/// The gate's whole point, and the reason the wait is event-driven: a read
/// whose caller was told op 9 committed is held while this node is at 4,
/// and the COMMIT that advances the frontier is what answers it - here a
/// detached task standing in for the commit path, with no timer in the
/// budget at all. The parked outcome is what tells the caller to re-run
/// the authorization it resolved off the pre-wait state machine.
#[compio::test]
async fn given_a_frontier_behind_the_watermark_when_it_advances_should_answer_the_held_read() {
let frontier = Arc::new(AppliedFrontier::default());
frontier.advance(4);
let committer = Arc::clone(&frontier);
compio::runtime::spawn(async move {
// Yields first, so the read is provably parked before the advance:
// a gate that answered off the lagging frontier would already have
// returned by the time this runs.
compio::runtime::time::sleep(std::time::Duration::ZERO).await;
committer.advance(9);
})
.detach();
assert_eq!(
hold_for_frontier(&frontier, 9, pending()).await,
Ok(FrontierWait::CaughtUp),
"the commit that closed the gap must answer the held read"
);
assert_eq!(frontier.waiting(), 0, "the answered wait deregisters");
}
/// A node can legitimately never catch up (a durably lagging replica), so
/// the wait is bounded - and the exit is a refusal, never the stale answer.
/// Each plane renders it retryable: `TransientNotAccepted` on the binary
/// transports, the shared 503 over HTTP.
#[compio::test]
async fn given_a_frontier_that_never_catches_up_when_the_budget_expires_should_fail_retryable()
{
let frontier = AppliedFrontier::default();
frontier.advance(4);
assert_eq!(
hold_for_frontier(&frontier, 9, std::future::ready(())).await,
Err(FrontierUnreached),
"an unreached frontier must refuse the read, not serve it"
);
assert_eq!(
frontier.waiting(),
0,
"the expired wait must not leave its waker behind"
);
}
/// The deny-list's whole point is that a read answered from the metadata
/// STM is gated even when nobody remembered to name it, so the arms that
/// are NOT gated are the ones worth pinning: the static catalog, the
/// roster read on the leader-discovery path, and a code this build cannot
/// serve at all (whose only outcome is `InvalidCommand`, which must not
/// wait out the budget first).
#[test]
fn given_a_read_code_when_classified_should_gate_all_but_the_named_exclusions() {
for code in [GET_STREAM_CODE, GET_ME_CODE, SYNC_CONSUMER_GROUP_CODE] {
assert!(
read_needs_metadata_frontier(code),
"code {code} answers from the metadata STM and must be gated"
);
}
for code in [
PING_CODE,
DESCRIBE_OPTIONS_CODE,
GET_CLUSTER_METADATA_CODE,
POLL_MESSAGES_CODE,
GET_CONSUMER_OFFSET_CODE,
GET_SNAPSHOT_FILE_CODE,
] {
assert!(
!read_needs_metadata_frontier(code),
"code {code} has no metadata-STM answer to hold; the arms that skip \
the gate are exactly these"
);
}
assert!(
!read_needs_metadata_frontier(u32::MAX),
"an unknown code has no answer to hold, so it must not park"
);
}
}