blob: b2e0789bfa244588e937f4832a01c426766a0cab [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.
//! The partition data plane, both sides of the mesh on one page.
//!
//! Server side: [`make_partition_read_handler`] answers `PartitionRead`
//! frames on the owning shard (poll snapshots, consumer offsets, segment
//! deletes). Client side: the funnel routes partition writes through
//! [`dispatch_partition_request`], and the non-replicated read arms call
//! [`handle_poll_messages`] / [`handle_get_consumer_offset`], which read via
//! the shard mesh.
//!
//! Deliberate asymmetry (the plane-split reply trio): the partitions engine
//! replies to committed writes itself, straight from the owning shard, while
//! everything the host builds here -- read bodies, denies, empty-poll shapes
//! -- goes out on the connection's home shard. The reply path is therefore
//! split by plane, not unified, and the deny helpers in `authz` are the
//! third leg (typed status replies for requests that never reach a plane).
use crate::consumer_group::maybe_rewrite_consumer_offset_request;
use crate::dispatch::authz::{
authorize_partition_op, authorize_partition_read, send_deny_reply, send_non_replicated_deny,
};
use crate::dispatch::submit::submit_client_request_on_owner;
use crate::dispatch::{send_non_replicated_bytes, send_reply_frame, upgrade_shard_handle};
use crate::responses::{
build_consumer_offset_body, build_empty_reply, build_polled_messages_reply,
current_metadata_commit, resolve_partition_namespace, resolve_partition_request_namespace,
};
use crate::shell::{ShellBus, ShellShard, ShellShardHandle};
use crate::wire::{request_body, usize_to_u32};
use bytes::Bytes;
use consensus::{Consensus, MetadataHandle, PartitionsHandle, build_result_rejection_reply};
use iggy_binary_protocol::PrepareHeader;
use iggy_binary_protocol::primitives::consumer::WireConsumer;
use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
use iggy_binary_protocol::requests::consumer_offsets::{
GetConsumerOffsetRequest, StoreConsumerOffsetRequest,
};
use iggy_binary_protocol::requests::messages::PollMessagesRequest;
use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
use iggy_binary_protocol::{
AckLevel, Command, KIND_CONSUMER_GROUP, Operation, RoutedRequestHeader, WireDecode, WireEncode,
WireIdentifier,
};
use iggy_common::{IggyError, PollingStrategy};
use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use message_bus::AUTO_COMMIT_CLIENT_ID;
use metadata::impls::metadata::{
StreamsFrontend, build_truncate_partition_client_message,
build_truncate_partition_client_message_with_identifiers,
};
use partitions::{AutoCommitApplied, PollPlan, PollingArgs, PollingConsumer};
use server_common::Message;
use server_common::sharding::IggyNamespace;
use shard::shards_table::ShardsTable;
use shard::{PartitionRead, PartitionReadHandler, PartitionReadReply};
use std::rc::Rc;
use tracing::{debug, warn};
/// Build the per-shard [`PartitionReadHandler`]: on a `PartitionRead` frame
/// (this shard owns the namespace), run the poll / consumer-offset lookup
/// against the local partitions plane and push the result back over the
/// carried reply sender. The requesting shard bounds the wait with a
/// timeout, so a dropped reply degrades to a client-visible read failure.
pub fn make_partition_read_handler<B, MJ, S, SB>(
shard_handle: &ShellShardHandle<B, MJ, S, SB>,
) -> PartitionReadHandler
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let shard_handle = Rc::clone(shard_handle);
// Runs synchronously on the shard pump (see `process_lifecycle` ->
// `on_partition_read`). `build_poll_snapshot` takes a pump-only `&mut`
// partition borrow (synchronous, so no sibling task can realloc under it) and
// returns an owned `PollPlan`; only owned data crosses into `spawn_poll_io`. A
// fully-resident poll replies here without spawning. See the `poll_plan` module docs.
Rc::new(move |namespace, read, reply| {
let Some(shard) = upgrade_shard_handle(&shard_handle) else {
return;
};
let partitions = shard.plane.partitions();
match read {
PartitionRead::Poll { consumer, args } => {
match partitions.build_poll_snapshot(&namespace, consumer, &args) {
None => {
let _ = reply.try_send(PartitionReadReply::NotFound);
}
Some(plan) if plan.needs_off_pump_io() => {
spawn_poll_io(Rc::clone(&shard), namespace, plan, reply);
}
Some(plan) => {
let (fragments, current_offset, auto_commit) = plan.execute_resident();
if let Some(applied) = auto_commit {
submit_auto_commit(&shard, namespace, &applied);
}
let _ = reply.try_send(PartitionReadReply::Poll {
fragments,
current_offset,
});
}
}
}
PartitionRead::ConsumerOffset { consumer } => {
let result = match partitions.consumer_offset_read(&namespace, consumer) {
Some((stored, current_offset)) => PartitionReadReply::ConsumerOffset {
stored,
current_offset,
},
None => PartitionReadReply::NotFound,
};
let _ = reply.try_send(result);
}
PartitionRead::GroupOffsetState { group_id } => {
let result = match partitions.group_offset_state(&namespace, group_id) {
Some((last_polled, committed)) => PartitionReadReply::GroupOffsetState {
last_polled,
committed,
},
None => PartitionReadReply::NotFound,
};
let _ = reply.try_send(result);
}
PartitionRead::ClearGroupLastPolled { group_id } => {
let result = match partitions.clear_group_last_polled(&namespace, group_id) {
Some(()) => PartitionReadReply::Ack,
None => PartitionReadReply::NotFound,
};
let _ = reply.try_send(result);
}
PartitionRead::ResolveSegmentDeleteOffset { count } => {
let result = partitions
.segment_delete_resolution(&namespace, count)
.map_or_else(
|| PartitionReadReply::NotFound,
|(up_to_offset, lagging)| PartitionReadReply::SegmentDeleteOffset {
up_to_offset,
lagging,
},
);
let _ = reply.try_send(result);
}
}
})
}
/// Spawn the off-pump leg of a partition poll: disk read + auto-commit apply on
/// the OWNED plan (disk descriptors, resident-tail `Frozen` clones, `Arc` offset
/// map), then replicate the auto-committed offset and send the reply. Holds no
/// partition reference across the IO, so it is sound concurrently with the
/// pump's `&mut` writes; the auto-commit submit re-borrows synchronously after.
fn spawn_poll_io<B, MJ, S, SB>(
shard: Rc<ShellShard<B, MJ, S, SB>>,
namespace: IggyNamespace,
plan: PollPlan,
reply: shard::Sender<PartitionReadReply>,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let bus = shard.bus.clone();
bus.spawn(async move {
// Diagnostic-only wall clock: `elapsed` gates the slow-poll `warn!`
// below and is never folded into a reply or the deterministic schedule,
// so it stays sound under the simulator's virtual clock (there it just
// measures near-zero real time and never fires). Do not derive any
// replicated or reply value from it, or replay determinism breaks.
let poll_started = std::time::Instant::now();
let (fragments, current_offset, auto_commit) = plan.execute().await;
let elapsed = poll_started.elapsed();
if elapsed > std::time::Duration::from_secs(1) {
warn!(
namespace_raw = namespace.inner(),
elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
"slow partition poll; gather side may have timed out"
);
}
// Fire-and-forget: the poll reply is not gated on the offset commit.
if let Some(applied) = auto_commit {
submit_auto_commit(&shard, namespace, &applied);
}
let _ = reply.try_send(PartitionReadReply::Poll {
fragments,
current_offset,
});
});
}
/// Replicate a poll's auto-committed offset through the partition consensus so
/// it survives failover, mirroring the explicit `StoreConsumerOffset` path: the
/// same op code, submitted onto the owning shard's own pipeline. Best-effort and
/// fire-and-forget -- the poll reply never waits on it, and a full inbox drops
/// the op at WARN rather than backpressuring the reply.
///
/// The partition plane admits writes on the primary only (it asserts so), and a
/// poll is served on whichever node owns the namespace locally, which may be a
/// backup. So gate on primary status here and drop at WARN otherwise; auto-commit
/// is server-managed best-effort (at-least-once delivery), so a follower-served
/// poll simply does not advance the durable offset.
///
/// Coalescing: an offset the partition's committed high-water already covers is
/// dropped without a consensus op (the steady state for a re-poll of committed
/// data, hence no log). The gate reads committed state only, so an offset that
/// merely sits in flight keeps resubmitting until its covering op commits -- a
/// dropped op self-heals on the next poll instead of being suppressed forever.
fn submit_auto_commit<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
namespace: IggyNamespace,
applied: &AutoCommitApplied,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
enum AutoCommitGate {
Submit,
Covered,
NotPrimary,
}
let gate = shard
.plane
.partitions()
.with_partition(&namespace, |partition| {
let consensus = partition.consensus();
if !(consensus.is_primary() && consensus.is_normal() && !consensus.is_transferring()) {
AutoCommitGate::NotPrimary
} else if partition.is_auto_commit_offset_covered(
applied.kind,
applied.consumer_id,
applied.offset,
) {
AutoCommitGate::Covered
} else {
AutoCommitGate::Submit
}
});
match gate {
Some(AutoCommitGate::Submit) => {}
Some(AutoCommitGate::Covered) => return,
Some(AutoCommitGate::NotPrimary) | None => {
warn!(
namespace_raw = namespace.inner(),
"auto-commit offset not replicated: partition not primary on this node (best-effort)"
);
return;
}
}
let message = match build_auto_commit_request(namespace, applied) {
Ok(message) => message,
Err(error) => {
warn!(
namespace_raw = namespace.inner(),
error = %error,
"failed to build auto-commit store-offset request"
);
return;
}
};
// Routes by namespace to this same (owning, primary) shard's inbox; the pump
// admits it next turn exactly like a client store. `dispatch` never blocks.
shard.dispatch(message.into_generic());
}
/// Build the synthetic `StoreConsumerOffset` request for an auto-commit, keyed
/// to the resolved numeric consumer/group id and stamped with the reserved
/// [`AUTO_COMMIT_CLIENT_ID`] so the commit path skips the (unwaited) reply. The
/// wire stream/topic ids are cosmetic here -- admission and apply key off the
/// header namespace and the consumer id -- but are set from the namespace for a
/// well-formed body. `ack` is `Quorum` so the offset actually replicates.
fn build_auto_commit_request(
namespace: IggyNamespace,
applied: &AutoCommitApplied,
) -> Result<Message<RoutedRequestHeader>, IggyError> {
let request = StoreConsumerOffsetRequest {
consumer: WireConsumer {
kind: applied.kind.as_code(),
id: WireIdentifier::Numeric(applied.consumer_id),
},
stream_id: WireIdentifier::Numeric(usize_to_u32(namespace.stream_id())?),
topic_id: WireIdentifier::Numeric(usize_to_u32(namespace.topic_id())?),
partition_id: Some(usize_to_u32(namespace.partition_id())?),
offset: applied.offset,
ack: AckLevel::Quorum,
};
let body = request.to_bytes();
let header_size = std::mem::size_of::<RoutedRequestHeader>();
let total_size = header_size + body.len();
let size = u32::try_from(total_size).map_err(|_| IggyError::InvalidConfiguration)?;
let mut message = Message::<RoutedRequestHeader>::new(total_size);
message.as_mut_slice()[header_size..].copy_from_slice(&body);
Ok(
message.transmute_header(|_, header: &mut RoutedRequestHeader| {
*header = RoutedRequestHeader {
command: Command::Request,
operation: Operation::StoreConsumerOffset,
size,
client: AUTO_COMMIT_CLIENT_ID,
// The reserved sentinel client is never deduped and never
// replied to; a nonzero session + request just satisfy the wire
// header validation.
session: 1,
request: 1,
group: namespace.inner(),
..Default::default()
};
}),
)
}
/// Route a partition data-plane op (`SendMessages` / consumer-offset writes)
/// through the shard mesh by namespace: the op belongs to the partition's
/// own consensus group, not the metadata group. The owning shard's
/// partitions plane dedups the request against its group's slice, so
/// `header.client` carries the VSR consensus id (the dedup key) rather than
/// the transport id.
///
/// How the committed reply gets back depends on whether the bus can route that
/// id. HTTP registers each session under its own shard-0 transport id, so the
/// two are equal and the plane's `send_to_client` fires the session's
/// in-process reply slot directly; the request is dispatched and forgotten
/// here (`?ack=none` relies on exactly that: nothing listening, reply shed at
/// the bus). Every other transport registers under a client-chosen id the bus
/// cannot route, so the request is submitted with an in-process channel and
/// the reply is relayed to the socket this shard holds.
///
/// Callers must have authenticated the transport already: `vsr_client_id` /
/// `bound_session` come from its bound VSR session. Every failure before
/// dispatch replies with a nonzero status -- unresolvable namespace,
/// authorization denial, exhausted routable wait -- so the client fails fast
/// instead of wedging on a silent drop or reading a status-0 frame as a
/// committed write.
///
/// `vsr_client_id` keys the consumer-group offset fence (the member id),
/// not the transport id stamped into the partition-op header.
#[allow(clippy::future_not_send)]
pub async fn dispatch_partition_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RoutedRequestHeader>,
vsr_client_id: u128,
bound_session: u64,
transport_client_id: u128,
acting_user_id: Option<u32>,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let header = *request.header();
let namespace = match resolve_partition_request_namespace(
shard,
header.operation,
request_body(&request),
vsr_client_id,
) {
Ok(namespace) => namespace,
Err(error) => {
// A partition op against a stream/topic that no longer resolves
// (e.g. a consumer's trailing auto-commit racing a `delete_stream`,
// or an explicit partition id that skipped the client-side
// resolve). The op never reached the partition plane, so a status-0
// reply would read as a committed ack for work that never happened.
// A silent drop is no better: the SDK connection processes replies
// in lockstep and would wedge forever.
warn!(
transport_client_id,
error = %error,
operation = ?header.operation,
"partition request with unresolved namespace; replying denied"
);
send_deny_reply(
shard,
transport_client_id,
&header,
IggyError::ResourceNotFound(String::new()).as_code(),
)
.await;
return;
}
};
// Dispatch-time RBAC. The partition plane is not replicated through the
// metadata STM, so the in-apply gate cannot cover it; authorize here, on
// the connection's own shard, before burning the routable wait or touching
// the plane. The namespace resolved above, so its stream/topic are the
// committed slab ids the permissioner keys on directly. A denial replies
// the op's frame with an empty body and a nonzero `status` the SDK peeks.
//
// Consistency: this reads THIS shard's local committed permissioner. On a
// peer shard that is a replicated read-mirror, so a permission revocation
// takes effect on the partition plane only once this shard applies the
// revoking commit -- an apply-lag window bounded by replication lag.
// Control-plane ops are exact (gated in-apply, in the same committed order
// on every replica); this local-read relaxation on the data plane is the
// accepted trade for keeping partition ops off the metadata consensus.
let scope = IggyNamespace::from_raw(namespace);
if let Some(status) = authorize_partition_op(
shard,
header.operation,
acting_user_id,
scope.stream_id(),
scope.topic_id(),
) {
warn!(
transport_client_id,
status,
operation = ?header.operation,
"partition request denied by authorization; replying with status"
);
send_deny_reply(shard, transport_client_id, &header, status).await;
return;
}
// Convergence wait: a CreateTopic commit returns to the client before the
// per-shard reconcilers seed routing rows and materialise the partition
// (next wake/periodic tick). An op arriving inside that window is not lost
// if it skips this wait -- `router::route_typed` falls back to the hash
// assignment, and the owning shard parks it -- so this is an admission
// courtesy that keeps the steady state off that park buffer, not a
// correctness gate. See `wait_for_partition_routable`, which spells out why
// there is no owner-readiness probe here any more.
if !wait_for_partition_routable(shard, IggyNamespace::from_raw(namespace)).await {
// The op never reached the partition plane, so it is safe to re-issue
// anywhere -- the same contract the plane itself answers for a
// non-primary routing artifact. A status-0 empty reply here would
// fabricate a success ack for a write that hit no partition at all.
warn!(
transport_client_id,
namespace,
operation = ?header.operation,
"partition request not routable within budget; replying transient"
);
send_deny_reply(
shard,
transport_client_id,
&header,
IggyError::TransientNotAccepted.as_code(),
)
.await;
return;
}
// A group consumer-offset op carries the group NAME on the wire; the
// partition plane keys the offset by the group's monotonic id (the same
// key the poll path auto-commits under and the read path resolves), so
// rewrite the consumer id before replication -- the apply layer has no
// metadata access to resolve it.
let request = match maybe_rewrite_consumer_offset_request(shard, request) {
Ok(rewritten) => rewritten,
Err(error) => {
warn!(
transport_client_id,
error = %error,
operation = ?header.operation,
"failed to rewrite consumer-offset request; replying empty"
);
send_empty_partition_reply(shard, transport_client_id, &header).await;
return;
}
};
let request = request.transmute_header(|header, new_header: &mut RoutedRequestHeader| {
*new_header = header;
new_header.group = namespace;
// The VSR consensus id, exactly as metadata ops are stamped: it is the
// dedup key every replica keys its slice by, and unlike the transport
// id it stays valid across nodes. Replies therefore cannot be routed
// by this field -- they ride the submit's channel back to this shard,
// which owns the socket.
new_header.client = vsr_client_id;
// Header validation requires `session > 0` for non-register ops. The
// slices mint no epoch of their own, so the bound VSR session only
// satisfies validation here.
new_header.session = bound_session;
// The session's owner as this shard resolved it, never the
// client-supplied value: the dedup slice keys on it to tell a re-minted
// id's next holder apart from its previous one, so it has to be
// trustworthy on every replica. Same rule as the metadata plane. The
// gate above failed closed on `None`, so `0` (unattributed) is only a
// type-level fallback here.
new_header.user_id = acting_user_id.unwrap_or(0);
});
if vsr_client_id == transport_client_id {
shard.dispatch(request.into_generic());
return;
}
relay_partition_reply(
shard,
IggyNamespace::from_raw(namespace),
request,
transport_client_id,
&header,
)
.await;
}
/// Submit a partition write whose reply the bus cannot route (the routed
/// header's `client` is the VSR consensus id, whose bits encode no home shard)
/// and relay the committed reply to the socket this shard holds.
///
/// Only the submit runs on the caller's drain loop: it is what fixes the order
/// two writes from one connection reach the owning shard in. The wait for the
/// commit is spawned, so a connection keeps draining its queued polls and
/// metadata ops instead of holding them behind one replication round trip.
#[allow(clippy::future_not_send)]
async fn relay_partition_reply<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
namespace: IggyNamespace,
request: Message<RoutedRequestHeader>,
transport_client_id: u128,
header: &RoutedRequestHeader,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let Ok(ticket) = shard.partition_submit(namespace, request) else {
// `PartitionSubmitRefused`: the frame never reached the owning shard,
// so this is a known outcome and the client can be told now rather
// than after its read-timeout. Same transient the plane itself answers
// for a request it could not admit.
send_deny_reply(
shard,
transport_client_id,
header,
IggyError::TransientNotAccepted.as_code(),
)
.await;
return;
};
let operation = header.operation;
let shard = Rc::clone(shard);
// Through the bus, not the runtime directly: the simulator supplies its
// own executor and virtual clock.
shard.bus.clone().spawn(async move {
let Some(reply) = shard.await_partition_submit(ticket).await else {
// Abandoned or expired. Deliberately silent: the outcome is
// unknown, and a synthesized failure could contradict a write that
// commits moments later. The client's read-timeout is the recovery.
return;
};
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_frozen())
.await
{
warn!(
transport_client_id,
operation = ?operation,
error = %error,
"failed to forward committed partition reply to its socket"
);
}
});
}
/// Serve `poll_messages`: resolve the partition namespace, run the read on
/// the owning shard ([`shard::IggyShard::partition_read`]), and re-encode
/// the stored batches into the legacy wire `PolledMessages` body.
///
/// Failures reply with an empty body so the SDK fails fast on decode
/// instead of hanging until its read timeout.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn handle_poll_messages<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
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,
{
let Ok(wire) = PollMessagesRequest::decode_from(request_body(request)) else {
// Undecodable poll: keep the fail-fast empty-poll shape.
send_non_replicated_bytes(
shard,
request,
transport_client_id,
empty_polled_messages_body(0),
"poll_messages",
)
.await;
return;
};
// Gate on (stream, topic) before touching the partition plane. A resolution
// miss falls through to the resolve path below (empty-poll / not-found); a
// denial replies status!=0 with an empty body, distinct from the empty-poll
// "0 messages" shape.
if let Some(status) = authorize_partition_read(
shard,
&wire.stream_id,
&wire.topic_id,
user_id,
|permissioner, uid, stream_id, topic_id| {
permissioner.poll_messages(uid, stream_id, topic_id)
},
) {
send_non_replicated_deny(shard, request, transport_client_id, status).await;
return;
}
let body = match resolve_poll_request(shard, &wire, request.header().client) {
Ok((namespace, partition_id, consumer, args)) => {
match shard
.partition_read(namespace, PartitionRead::Poll { consumer, args })
.await
{
Some(PartitionReadReply::Poll {
fragments,
current_offset,
}) => match build_polled_messages_reply(
request.header(),
current_metadata_commit(shard),
partition_id,
current_offset,
fragments,
shard.plane.partitions().config().encryptor.as_deref(),
) {
Ok(reply) => {
send_reply_frame(shard, transport_client_id, reply, "poll_messages").await;
return;
}
Err(error) => {
warn!(
transport_client_id,
error = %error,
"failed to re-encode polled batches; replying empty poll"
);
empty_polled_messages_body(partition_id)
}
},
other => {
warn!(
transport_client_id,
namespace = namespace.inner(),
reply_was_none = other.is_none(),
"partition read failed; replying empty poll"
);
empty_polled_messages_body(partition_id)
}
}
}
Err(error) => {
// A stream, topic, or partition id that does not resolve is a
// client addressing error and must surface as a typed rejection,
// not an empty poll a consumer would read as end-of-partition.
if matches!(
error,
IggyError::PartitionNotFound(..)
| IggyError::StreamIdNotFound(_)
| IggyError::TopicIdNotFound(..)
) {
warn!(
transport_client_id,
error = %error,
"poll_messages rejected: target not found"
);
send_non_replicated_deny(shard, request, transport_client_id, error.as_code())
.await;
return;
}
// A zero-byte body would panic the SDK's `PolledMessages`
// decoder; reply the 16-byte empty-poll shape instead. A generation
// fence (the client's cached assignment is stale after a rebalance)
// carries the re-sync sentinel so the SDK re-syncs and retries
// rather than treating the empty poll as end-of-partition.
warn!(
transport_client_id,
error = %error,
"poll_messages request rejected; replying empty poll"
);
let partition_id = if matches!(error, IggyError::ConsumerGroupPartitionNotOwned(..)) {
iggy_common::RESYNC_REQUIRED_PARTITION_SENTINEL
} else {
0
};
empty_polled_messages_body(partition_id)
}
};
send_non_replicated_bytes(shard, request, transport_client_id, body, "poll_messages").await;
}
/// Serve `get_consumer_offset`. An empty body decodes as `None` on the SDK
/// side (no offset stored / partition unknown).
// TODO(hubcio): plain local partition_read with no primary gate, so a
// follower answers from its own (possibly lagging) offset state. Needs the
// same is-caught-up-primary gate the auto-commit path has, or an explicit
// read-from-follower contract.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn handle_get_consumer_offset<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
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,
{
let Ok(wire) = GetConsumerOffsetRequest::decode_from(request_body(request)) else {
// Undecodable: an empty body decodes as None (no offset) on the SDK.
send_non_replicated_bytes(
shard,
request,
transport_client_id,
Bytes::new(),
"get_consumer_offset",
)
.await;
return;
};
if let Some(status) = authorize_partition_read(
shard,
&wire.stream_id,
&wire.topic_id,
user_id,
|permissioner, uid, stream_id, topic_id| {
permissioner.get_consumer_offset(uid, stream_id, topic_id)
},
) {
send_non_replicated_deny(shard, request, transport_client_id, status).await;
return;
}
let body = match resolve_consumer_offset_request(shard, &wire) {
Ok((namespace, partition_id, consumer)) => {
match shard
.partition_read(namespace, PartitionRead::ConsumerOffset { consumer })
.await
{
Some(PartitionReadReply::ConsumerOffset {
stored: Some(stored_offset),
current_offset,
}) => build_consumer_offset_body(partition_id, current_offset, stored_offset),
_ => Bytes::new(),
}
}
// A partition id that does not exist in a resolvable topic is a client
// addressing error, the same one the poll path denies typed. An empty
// body decodes as `None` -- indistinguishable from "this consumer has
// no stored offset yet" -- so the caller cannot tell a typo from a
// fresh consumer.
Err(error @ IggyError::PartitionNotFound(..)) => {
warn!(
transport_client_id,
error = %error,
"get_consumer_offset rejected: partition not found"
);
send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
return;
}
Err(error) => {
warn!(
transport_client_id,
error = %error,
"get_consumer_offset request rejected; replying empty"
);
Bytes::new()
}
};
send_non_replicated_bytes(
shard,
request,
transport_client_id,
body,
"get_consumer_offset",
)
.await;
}
/// Ack a consumer-offset op whose body could not be rewritten for the
/// partition plane with an empty Reply. The SDK connection processes replies
/// in lockstep, so a silent drop wedges every subsequent request on that
/// connection.
#[allow(clippy::future_not_send)]
async fn send_empty_partition_reply<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RoutedRequestHeader,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let commit = current_metadata_commit(shard);
let reply = build_empty_reply(request_header, transport_client_id, 0, commit);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
operation = ?request_header.operation,
"failed to surface empty partition reply"
);
}
}
/// Wait (bounded) until this shard holds a routing row for `namespace`. Fast
/// path: row already present -> no wait.
///
/// Covers the post-`CreateTopic` convergence window where the metadata commit
/// has returned to the client but the per-shard reconcilers have not yet seeded
/// routing rows. This is an admission courtesy, not a correctness gate: the row
/// is a cache of the deterministic hash assignment and may exist before the
/// owner has materialised anything, so its presence proves only where the
/// partition belongs. What makes an early arrival safe is the owning shard
/// itself - `park_if_unmaterialised` holds the frame until its partition lands,
/// and `serves_committed_incarnation` refuses to serve a mismatched
/// incarnation. Waiting here simply keeps the steady state off that park
/// buffer, whose overflow is the one path that still sheds a request without
/// replying (`frame_drops_total{variant=partition,reason=park_overflow}`).
///
/// Deliberately no owner-readiness probe. One used to run here, on the theory
/// that the table could not be trusted; it could not close the window either,
/// because the fast path above skipped it in exactly the case it was meant to
/// cover - a row seeded from the hash by a shard that owns nothing. Readiness
/// belongs to the owner, which is where it is now enforced.
#[allow(clippy::future_not_send)]
async fn wait_for_partition_routable<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
namespace: IggyNamespace,
) -> bool
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
const ATTEMPT_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
// 3s budget at 50ms per attempt. Counting attempts, not reading a
// wall-clock deadline, keeps the wait virtual under the simulator: the
// bus sleep advances virtual time, whereas `Instant::now` would not.
const MAX_ATTEMPTS: u32 = 60;
let mut attempts = 0u32;
while shard.shards_table().shard_for(namespace).is_none() {
if attempts >= MAX_ATTEMPTS {
return false;
}
attempts += 1;
shard.bus.sleep(ATTEMPT_DELAY).await;
}
true
}
/// The 16-byte `PolledMessages` body with zero messages
/// (`[partition_id:4][current_offset:8][count:4]`). The SDK decoder
/// requires at least this header, so failure paths must never reply a
/// zero-byte body.
fn empty_polled_messages_body(partition_id: u32) -> Bytes {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&partition_id.to_le_bytes());
body.extend_from_slice(&0u64.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
Bytes::from(body)
}
type DecodedPollRequest = (IggyNamespace, u32, PollingConsumer, PollingArgs);
/// Resolve a decoded poll request into its owning-shard read: namespace,
/// partition, polling consumer, and args. Shared by the TCP dispatch (client
/// id = the connection's bound VSR client) and the HTTP route (client id 0,
/// which fences group polls closed).
#[allow(clippy::cast_possible_truncation)]
pub fn resolve_poll_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
wire: &PollMessagesRequest,
client_id: u128,
) -> Result<DecodedPollRequest, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let strategy = polling_strategy_from_wire(&wire.strategy)?;
let args = PollingArgs::new(strategy, wire.count, wire.auto_commit);
// Consumer-group poll: the client selects which of its assigned partitions
// to read and sends it explicitly. The coordinator FENCES ownership (a stale
// client whose partition was reassigned is rejected with
// `ConsumerGroupPartitionNotOwned`, prompting a re-sync) and resolves the
// group's monotonic id -- the offset key the store rewrite and read path
// both use, so `next()` reads back the offset it just committed.
if wire.consumer.kind == KIND_CONSUMER_GROUP {
let partition_id = wire.partition_id.ok_or(IggyError::InvalidIdentifier)?;
let group_id = shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_fence(
&wire.stream_id,
&wire.topic_id,
&wire.consumer.id,
client_id,
partition_id,
// Poll fence: reject a pending-revoked partition so the source
// re-syncs and skips it (it still commits it via the offset fence).
true,
)
.ok_or(IggyError::ConsumerGroupPartitionNotOwned(
client_id as u32,
partition_id,
))?;
let namespace = resolve_partition_namespace(
shard,
&wire.stream_id,
&wire.topic_id,
Some(partition_id),
)?;
#[allow(clippy::cast_possible_truncation)]
let consumer = PollingConsumer::ConsumerGroup(group_id as usize, partition_id as usize);
return Ok((namespace, partition_id, consumer, args));
}
// Plain-consumer poll: an omitted partition selects partition 0, matching
// the legacy resolver (`resolve_consumer_with_partition_id` uses
// `unwrap_or(0)` for `ConsumerKind::Consumer`).
let partition_id = wire.partition_id.unwrap_or(0);
let namespace =
resolve_partition_namespace(shard, &wire.stream_id, &wire.topic_id, Some(partition_id))?;
let consumer = polling_consumer_from_wire(&wire.consumer, partition_id)?;
Ok((namespace, partition_id, consumer, args))
}
/// Resolve a decoded consumer-offset read into its owning-shard read:
/// namespace, partition, and polling consumer. Shared by the TCP dispatch and
/// the HTTP route; needs no client id because offset reads are not fenced
/// (any client may read a group's offset, member or not).
pub fn resolve_consumer_offset_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
wire: &GetConsumerOffsetRequest,
) -> Result<(IggyNamespace, u32, PollingConsumer), IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
// Omitted partition reads partition 0, matching the legacy resolver for
// both consumer kinds (`unwrap_or(0)`).
let partition_id = wire.partition_id.unwrap_or(0);
let namespace =
resolve_partition_namespace(shard, &wire.stream_id, &wire.topic_id, Some(partition_id))?;
// A group offset is keyed by the group's monotonic id (any client may read
// it, member or not), the same key the write path is rewritten to. An
// unresolved group (e.g. deleted) has no offset, so the read reports None.
let consumer = if wire.consumer.kind == KIND_CONSUMER_GROUP {
let group_id = shard
.plane
.metadata()
.mux_stm
.streams()
.resolve_consumer_group_id(&wire.stream_id, &wire.topic_id, &wire.consumer.id)
.ok_or(IggyError::InvalidIdentifier)?;
#[allow(clippy::cast_possible_truncation)]
PollingConsumer::ConsumerGroup(group_id as usize, partition_id as usize)
} else {
polling_consumer_from_wire(&wire.consumer, partition_id)?
};
Ok((namespace, partition_id, consumer))
}
fn polling_consumer_from_wire(
consumer: &WireConsumer,
partition_id: u32,
) -> Result<PollingConsumer, IggyError> {
// Mirrors the legacy server's `PollingConsumer::resolve_consumer_id`:
// numeric ids pass through, named consumers hash to a stable u32 so
// reads derive the same offset-table key the write path stores under.
let consumer_id = match &consumer.id {
iggy_binary_protocol::WireIdentifier::Numeric(id) => *id,
iggy_binary_protocol::WireIdentifier::String(name) => {
iggy_common::calculate_32(name.as_str().as_bytes())
}
} as usize;
match consumer.kind {
1 => Ok(PollingConsumer::Consumer(
consumer_id,
partition_id as usize,
)),
KIND_CONSUMER_GROUP => Ok(PollingConsumer::ConsumerGroup(
consumer_id,
partition_id as usize,
)),
_ => Err(IggyError::InvalidCommand),
}
}
fn polling_strategy_from_wire(
strategy: &WirePollingStrategy,
) -> Result<PollingStrategy, IggyError> {
let mut mapped = match strategy.kind {
1 => PollingStrategy::offset(0),
2 => PollingStrategy::timestamp(iggy_common::IggyTimestamp::from(strategy.value)),
3 => PollingStrategy::first(),
4 => PollingStrategy::last(),
5 => PollingStrategy::next(),
_ => return Err(IggyError::InvalidCommand),
};
mapped.set_value(strategy.value);
Ok(mapped)
}
/// Handle a client `DeleteSegments`: resolve the requested count to an offset
/// on the owning shard, replicate a `TruncatePartition` through metadata so
/// every replica trims to the same watermark, then ack the client. The local
/// deletion happens later, when each replica's reconciler observes the commit.
///
/// The consensus reply is forwarded verbatim: nothing-to-delete commits a
/// no-op `TruncatePartition(0)` and acks, while a not-primary rejection
/// reaches the client as `TransientNotCommitted` so the SDK replays instead
/// of mistaking a dropped delete for success. Only a malformed / unresolvable
/// request is acked empty without a commit.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn handle_delete_segments_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
bound: Option<(u128, u64)>,
request: &Message<RoutedRequestHeader>,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let header = *request.header();
let body = request_body(request);
// An unbound transport cannot be attributed a VSR request sequence; the
// outer handler already short-circuits these, so this is defensive.
let Some((vsr_client_id, session)) = bound else {
return;
};
// The client numbers DeleteSegments in the same monotonic request sequence
// as every other metadata op. So resolve the requested count to a concrete
// offset on the owning shard, then replicate a `TruncatePartition(offset)`
// AS the client's own request through the standard owner path: the commit
// records (client, session, request) in the `ClientTable` on every replica,
// advancing the watermark. Skipping the commit (or attributing it to an
// internal id) leaves this request id unrecorded, so the SDK's own retry
// of it would re-execute instead of deduping. A no-op delete still
// commits `up_to_offset = 0` (monotonic apply) for the same reason.
let truncate = match resolve_delete_segments_truncate(
shard,
&header,
vsr_client_id,
session,
body,
)
.await
{
Ok(truncate) => Some(truncate),
// The owning partition has not converged on the committed log yet, so
// the delete cannot be resolved to a watermark. Reply with the
// result-framed transient rejection (under the TruncatePartition
// operation, which the SDK decodes) so the client replays the same
// request once the partition catches up. Nothing was submitted, hence
// the re-issuable-anywhere flavor.
Err(IggyError::TransientNotAccepted) => {
let template = build_truncate_partition_client_message(
&header,
vsr_client_id,
session,
0,
0,
0,
0,
);
let reply = build_result_rejection_reply(
template.header(),
current_metadata_commit(shard),
IggyError::TransientNotAccepted.as_code(),
);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
"delete_segments: failed to send transient rejection"
);
}
return;
}
Err(_) => None,
};
let reply = if let Some(truncate) = truncate {
// Forward the consensus reply verbatim, exactly like the generic
// metadata path: a committed success acks the delete, and a
// result-framed `TransientNotCommitted` rejection makes the SDK
// replay the request. Acking unconditionally here would swallow a
// not-primary rejection and drop the delete on the floor while the
// client believes it succeeded.
let Some(reply) = submit_client_request_on_owner(shard, truncate).await else {
// Transient submit failure (not primary / view change). Stay
// silent; the SDK read-timeout replays the same request id,
// which re-resolves and commits. Acking here would advance the
// client past an unrecorded request and gap the next metadata
// op.
warn!(
transport_client_id,
"delete_segments: transient submit; client will replay"
);
return;
};
reply
} else {
// Undecodable body (never produced by the SDK): ack empty so the
// lockstep stream stays framed; the typed decoder surfaces the
// failure client-side. Unresolvable-but-well-formed targets commit a
// typed rejection instead (see the resolve), so only a wire-corrupt
// request can gap the sequence here.
let commit = current_metadata_commit(shard);
build_empty_reply(&header, transport_client_id, session, commit).into_generic()
};
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
"delete_segments: failed to send reply"
);
}
}
/// Resolve a client `DeleteSegments` to the `TruncatePartition` that commits the
/// trim. Shared by the TCP dispatch and the HTTP listener so both resolve the
/// requested segment count to a concrete watermark identically.
///
/// `template` supplies the wire `cluster` / `view` / `release` and the client's
/// `request` number; `client_id` / `session` are the bound VSR identity the
/// truncate commits under. A resolvable namespace with nothing sealed to delete
/// still yields a `TruncatePartition(up_to_offset = 0)` so the metadata request
/// sequence stays contiguous. `Err` on a malformed body or an unresolved
/// namespace: the TCP caller drops it to a silent replay, the HTTP caller renders
/// the error.
#[allow(clippy::future_not_send)]
#[allow(clippy::cast_possible_truncation)]
pub async fn resolve_delete_segments_truncate<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
template: &RoutedRequestHeader,
client_id: u128,
session: u64,
body: &[u8],
) -> Result<Message<RoutedRequestHeader>, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let parsed = DeleteSegmentsRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
let namespace_raw = match resolve_partition_request_namespace(
shard,
Operation::DeleteSegments,
body,
client_id,
) {
Ok(namespace_raw) => namespace_raw,
// Unresolvable stream/topic: still commit the truncate, against the
// client's raw identifiers -- the apply rejects it as a committed
// result, so the failure is recorded against the client's request id
// and its retry dedups, while the client gets the typed error an
// empty ack would swallow.
Err(error) => {
debug!(
client_id,
%error,
"delete_segments: unresolved target; committing typed rejection"
);
return Ok(build_truncate_partition_client_message_with_identifiers(
template,
client_id,
session,
parsed.stream_id,
parsed.topic_id,
parsed.partition_id,
0,
));
}
};
let namespace = IggyNamespace::from_raw(namespace_raw);
let up_to_offset = match shard
.partition_read(
namespace,
PartitionRead::ResolveSegmentDeleteOffset {
count: parsed.segments_count,
},
)
.await
{
Some(PartitionReadReply::SegmentDeleteOffset {
up_to_offset: Some(offset),
..
}) => offset,
// Nothing sealed to delete on a replica that has not converged on the
// replicated log (a backup behind the commit frontier may be missing
// whole sealed segments). Answering now would commit a no-op truncate
// and silently drop the delete, so surface a transient and let the
// client replay once the partition catches up. A converged primary
// whose resident tail is merely unflushed settles as a no-op below.
Some(PartitionReadReply::SegmentDeleteOffset {
up_to_offset: None,
lagging: true,
}) => {
debug!(
client_id,
namespace_raw, "delete_segments: partition not converged; transient"
);
return Err(IggyError::TransientNotAccepted);
}
other => {
debug!(
client_id,
namespace_raw,
reply = ?other,
"delete_segments: nothing to delete; committing no-op truncate"
);
0
}
};
Ok(build_truncate_partition_client_message(
template,
client_id,
session,
namespace.stream_id() as u32,
namespace.topic_id() as u32,
namespace.partition_id() as u32,
up_to_offset,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dispatch::test_support::{
SpyBus, TestMux, TestShard, prepare_message, request_message,
};
use iggy_binary_protocol::ReplyHeader;
use iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
use iggy_binary_protocol::requests::messages::SendMessagesHeader;
use iggy_binary_protocol::requests::streams::CreateStreamRequest;
use iggy_binary_protocol::requests::topics::{
CreateTopicRequest, CreateTopicWithAssignmentsRequest,
};
use iggy_binary_protocol::{WireName, WireOptions, WirePartitioning};
use iggy_common::defaults::DEFAULT_ROOT_USER_ID;
use metadata::IggyMetadata;
use metadata::stm::StateMachine as _;
use partitions::{IggyPartitions, PartitionPathLayout, PartitionsConfig};
use server_common::MessageBag;
use server_common::sharding::ShardId;
use shard::metrics::ShardMetrics;
use shard::shards_table::PapayaShardsTable;
use shard::{
LifecycleFrame, PartitionConsensusConfig, ReconcileOp, ReplicaTopology, ShardFrame,
ShardIdentity, shard_channel,
};
/// A partition write whose routable wait exhausts (namespace committed,
/// but no reconciler ever seeds this shard's routing row -- the state a
/// teardown/rematerialise churn leaves behind) must answer a nonzero
/// retriable status. A status-0 empty reply is a fabricated success: the
/// SDK grades the send as acknowledged while zero bytes reached any
/// partition.
#[compio::test]
async fn unroutable_partition_send_must_reply_transient_error_not_success() {
const VSR_CLIENT: u128 = 1;
const SESSION: u64 = 1;
const TRANSPORT: u128 = 91;
const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
let bus = SpyBus::default();
let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
let partitions = IggyPartitions::new(
ShardId::new(0),
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
enforce_fsync: false,
validate_checksum: true,
segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
preallocate_segments: false,
encryptor: None,
path_layout: PartitionPathLayout::default(),
},
);
let shard = Rc::new(TestShard::without_inbox(
ShardIdentity::new(0, "unroutable-send-test".to_string()),
bus.clone(),
metadata,
partitions,
PapayaShardsTable::new(),
PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
));
let md = shard.plane.metadata();
// Committed stream 0 / topic 0 / partition 0, applied straight into
// the STM: the namespace resolves and root authorizes, but no
// reconciler runs, so the shards table never gains a routing row and
// the routable wait exhausts its budget.
md.mux_stm.users().ensure_root_user("iggy", "hash");
let create_stream = CreateStreamRequest {
name: WireName::new("stream").unwrap(),
options: WireOptions::empty(),
};
md.mux_stm
.update(prepare_message(
Operation::CreateStream,
VSR_CLIENT,
1,
&create_stream.to_bytes(),
))
.unwrap();
let create_topic = CreateTopicWithAssignmentsRequest {
request: CreateTopicRequest {
stream_id: WireIdentifier::numeric(0),
partitions_count: 1,
name: WireName::new("topic").unwrap(),
options: WireOptions::empty(),
},
derived_options: WireOptions::empty(),
partitions: vec![CreatedPartitionAssignment {
partition_id: 0,
consensus_group_id: 1,
}],
created_view: 0,
};
md.mux_stm
.update(prepare_message(
Operation::CreateTopicWithAssignments,
VSR_CLIENT,
2,
&create_topic.to_bytes(),
))
.unwrap();
assert!(
md.mux_stm
.streams()
.namespace_from_partition(
&WireIdentifier::numeric(0),
&WireIdentifier::numeric(0),
0
)
.is_some(),
"seeded namespace must resolve, or the unresolved-namespace path \
would reply instead of the exhausted routable wait"
);
let send_header = SendMessagesHeader {
stream_id: WireIdentifier::numeric(0),
topic_id: WireIdentifier::numeric(0),
partitioning: WirePartitioning::PartitionId(0),
messages_count: 1,
};
let send_metadata = send_header.to_bytes();
let mut send_body = Vec::with_capacity(4 + send_metadata.len());
send_body.extend_from_slice(&u32::try_from(send_metadata.len()).unwrap().to_le_bytes());
send_body.extend_from_slice(&send_metadata);
let request = request_message(Operation::SendMessages, VSR_CLIENT, SESSION, 1, &send_body);
dispatch_partition_request(
&shard,
request,
VSR_CLIENT,
SESSION,
TRANSPORT,
Some(DEFAULT_ROOT_USER_ID),
)
.await;
let replies = bus.client_replies.borrow();
assert_eq!(replies.len(), 1, "one reply frame for the failed send");
let (client, frame) = &replies[0];
assert_eq!(*client, TRANSPORT, "reply must target the transport id");
let status =
u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
assert_eq!(
status,
IggyError::TransientNotAccepted.as_code(),
"an unroutable partition write must surface the retriable \
transient status; status 0 with an empty body grades as a \
successfully acknowledged send"
);
}
/// A send that reaches the owning shard while its namespace is
/// tombstoned (the teardown fence a delete/recreate churn sets before
/// the disk delete) must answer the retriable transient status. The
/// partition plane's own tombstone guard drops the frame without any
/// reply; the transports decode replies in lockstep, so that silence
/// wedges the connection until the SDK's response read-timeout.
#[compio::test]
async fn tombstoned_partition_send_must_reply_transient_error_not_silence() {
const TRANSPORT: u128 = 91;
const SESSION: u64 = 1;
const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
let bus = SpyBus::default();
let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
let partitions = IggyPartitions::new(
ShardId::new(0),
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
enforce_fsync: false,
validate_checksum: true,
segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
preallocate_segments: false,
encryptor: None,
path_layout: PartitionPathLayout::default(),
},
);
let shard = Rc::new(TestShard::without_inbox(
ShardIdentity::new(0, "tombstoned-send-test".to_string()),
bus.clone(),
metadata,
partitions,
PapayaShardsTable::new(),
PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
));
let namespace = IggyNamespace::new(0, 0, 0);
shard.plane.partitions().tombstone(namespace);
let request = request_message(Operation::SendMessages, TRANSPORT, SESSION, 1, &[])
.transmute_header(|header, new_header: &mut RoutedRequestHeader| {
*new_header = header;
new_header.group = namespace.inner();
});
shard.on_message(MessageBag::Request(request)).await;
let replies = bus.client_replies.borrow();
assert_eq!(
replies.len(),
1,
"a send into a tombstoned namespace must produce a reply frame; \
silence wedges the connection's lockstep decode"
);
let (client, frame) = &replies[0];
assert_eq!(*client, TRANSPORT, "reply must target the request's client");
let status =
u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
assert_eq!(
status,
IggyError::TransientNotAccepted.as_code(),
"a tombstoned-namespace send must surface the retriable transient \
status so the SDK replays it after the partition rematerialises"
);
}
/// A send parked for a namespace that is torn down before materialising
/// (create -> delete before the reconciler's `InsertOwned`) is discarded
/// on `ConfirmRemove`. The discard must stage the same retriable
/// transient deny toward the client -- through the shard's own pump as a
/// `ForwardClientSend` -- instead of dropping the request without any
/// reply.
#[compio::test]
async fn discarded_parked_partition_send_must_reply_transient_error_not_silence() {
const TRANSPORT: u128 = 91;
const SESSION: u64 = 1;
const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
let bus = SpyBus::default();
let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
let partitions = IggyPartitions::new(
ShardId::new(0),
PartitionsConfig {
messages_required_to_save: 1,
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
enforce_fsync: false,
validate_checksum: true,
segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
preallocate_segments: false,
encryptor: None,
path_layout: PartitionPathLayout::default(),
},
);
// Real sender ring so the staged deny is observable: the test holds
// the receiving ends of this shard's own lanes. The deny is a client
// Reply forward, so it lands on the REPLY lane.
let (sender, _pump_rx, reply_rx) = shard_channel(0, 16, 16);
let (_inbox_tx, inbox_rx, reply_inbox_rx) = shard_channel(0, 1, 1);
let shard = TestShard::new(
ShardIdentity::new(0, "discarded-parked-send-test".to_string()),
bus.clone(),
Rc::new(|_, _| {}),
Rc::new(|_, _| {}),
Rc::new(|_| {}),
Rc::new(|_| {}),
Rc::new(|_, _, _| {}),
metadata,
partitions,
vec![sender],
inbox_rx,
reply_inbox_rx,
PapayaShardsTable::new(),
PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
None,
ShardMetrics::for_shard(),
)
.expect("single-sender ring is canonically ordered");
let namespace = IggyNamespace::new(0, 0, 0);
let request = request_message(Operation::SendMessages, TRANSPORT, SESSION, 1, &[])
.transmute_header(|header, new_header: &mut RoutedRequestHeader| {
*new_header = header;
new_header.group = namespace.inner();
});
// Namespace neither materialised nor tombstoned: the frame parks.
shard.on_message(MessageBag::Request(request)).await;
shard.enqueue_reconcile_op(ReconcileOp::ConfirmRemove { namespace });
shard.apply_reconcile_ops();
let mut denies = Vec::new();
while let Ok(frame) = reply_rx.try_recv() {
if let ShardFrame::Lifecycle(LifecycleFrame::ForwardClientSend { client_id, msg }) =
frame
{
denies.push((client_id, msg.into_contiguous().as_slice().to_vec()));
}
}
assert_eq!(
denies.len(),
1,
"discarding a parked client request must stage exactly one deny \
reply; silence wedges the connection's lockstep decode"
);
let (client, frame) = &denies[0];
assert_eq!(*client, TRANSPORT, "deny must target the request's client");
let status =
u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
assert_eq!(
status,
IggyError::TransientNotAccepted.as_code(),
"a discarded parked send must surface the retriable transient \
status so the SDK replays it instead of timing out"
);
}
}