blob: d4b77eb95445568d47ac45cf02de5bec71a3211a [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.
//! Consumer-group Join/Leave request enrichment.
//!
//! The wire `Join`/`Leave` requests carry only the stream/topic/group
//! identifiers. The replicated metadata `apply` additionally needs the
//! joining client's VSR id (which member) and, for Join, the topic's
//! partition count (to seed the group's partition list) -- and it cannot
//! read the Streams STM from inside the consumer-group apply. So the
//! primary enriches the op here before replication, mirroring the PAT mint
//! in [`crate::pat`] and the password hash in [`crate::users`].
use crate::responses::resolve_partition_namespace;
use crate::shell::{ShellBus, ShellShard};
use crate::wire::{request_body, rewrite_request_body};
use consensus::MetadataHandle;
use iggy_binary_protocol::PrepareHeader;
use iggy_binary_protocol::codec::{WireDecode, WireEncode};
use iggy_binary_protocol::primitives::consumer::WireConsumer;
use iggy_binary_protocol::requests::consumer_groups::{
JoinConsumerGroupRequest as WireJoinConsumerGroupRequest,
LeaveConsumerGroupRequest as WireLeaveConsumerGroupRequest,
};
use iggy_binary_protocol::requests::consumer_offsets::{
DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
};
use iggy_binary_protocol::{KIND_CONSUMER_GROUP, Operation, RoutedRequestHeader, WireIdentifier};
use iggy_common::IggyError;
use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use metadata::impls::metadata::StreamsFrontend;
use metadata::stm::consumer_group::{
JoinConsumerGroupRequest as ReplicatedJoinConsumerGroupRequest,
LeaveConsumerGroupRequest as ReplicatedLeaveConsumerGroupRequest,
};
use server_common::Message;
use shard::{PartitionRead, PartitionReadReply};
use std::rc::Rc;
/// Rewrite a `Join`/`Leave` request body into the replicated form carrying the
/// client's VSR id (which the apply can't read from the consensus header).
///
/// For `Join` the home shard also gathers `in_flight` -- the group's partitions
/// with uncommitted polled data -- by reading each partition's poll/commit state
/// (via the partition-read mesh), so the cooperative rebalance pending-revokes
/// only those and hands off never-polled/drained partitions synchronously at
/// join. Every other operation passes through.
pub async fn maybe_rewrite_consumer_group_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RoutedRequestHeader>,
) -> Result<Message<RoutedRequestHeader>, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let operation = request.header().operation;
let client_id = request.header().client;
let body = request_body(&request);
let rewritten = match operation {
Operation::JoinConsumerGroup => {
let wire = WireJoinConsumerGroupRequest::decode_from(body)
.map_err(|_| IggyError::InvalidCommand)?;
let in_flight =
gather_in_flight(shard, &wire.stream_id, &wire.topic_id, &wire.group_id).await;
ReplicatedJoinConsumerGroupRequest {
stream_id: wire.stream_id,
topic_id: wire.topic_id,
group_id: wire.group_id,
client_id,
in_flight,
}
.to_bytes()
}
Operation::LeaveConsumerGroup => {
let wire = WireLeaveConsumerGroupRequest::decode_from(body)
.map_err(|_| IggyError::InvalidCommand)?;
ReplicatedLeaveConsumerGroupRequest {
stream_id: wire.stream_id,
topic_id: wire.topic_id,
group_id: wire.group_id,
client_id,
}
.to_bytes()
}
_ => return Ok(request),
};
rewrite_request_body(&request, &rewritten)
}
/// Gather the group's in-flight partitions (`last_polled` present and
/// `committed < last_polled`) for the cooperative-rebalance classification. A
/// not-yet-created group, an unresolved topic, or a partition that does not
/// answer is treated as not-in-flight (eager handoff). An eager handoff leaves
/// no `PendingRevocation` record, so the reconciler never revisits it -- a
/// misclassification here just redelivers the uncommitted range to the new
/// owner, which is correct under at-least-once.
async fn gather_in_flight<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
group_id: &WireIdentifier,
) -> Vec<u32>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let streams = shard.plane.metadata().mux_stm.streams();
let Some(monotonic_group_id) = streams.resolve_consumer_group_id(stream_id, topic_id, group_id)
else {
// Fresh group (e.g. create-if-not-exists): nothing polled yet.
return Vec::new();
};
let Some(partition_ids) = streams.topic_partition_ids(stream_id, topic_id) else {
return Vec::new();
};
// Partitions a live member currently owns. A `last_polled` past the commit
// only means in-flight work when a live member still holds the partition;
// for an unowned one it is the residue of a member removed on disconnect
// (the reconnect case), which must be reassigned and re-read, not protected.
let assigned = streams
.consumer_group_assigned_partitions(stream_id, topic_id, group_id)
.unwrap_or_default();
// Resolve namespaces up front (sync), then fire every partition's
// `GroupOffsetState` read concurrently. The reads are independent, so a
// wide-topic join must not serialize N cross-shard round-trips before the
// join op can even be proposed.
let targets: Vec<(u32, _)> = partition_ids
.into_iter()
.filter_map(|partition_id| {
resolve_partition_namespace(shard, stream_id, topic_id, Some(partition_id))
.ok()
.map(|ns| (partition_id, ns))
})
.collect();
let results = futures::future::join_all(targets.iter().map(|&(partition_id, ns)| async move {
let reply = shard
.partition_read(
ns,
PartitionRead::GroupOffsetState {
group_id: monotonic_group_id,
},
)
.await;
(partition_id, ns, reply)
}))
.await;
let mut in_flight = Vec::new();
let mut stale_clears = Vec::new();
for (partition_id, ns, reply) in results {
let Some(PartitionReadReply::GroupOffsetState {
last_polled: Some(polled),
committed,
}) = reply
else {
continue;
};
if committed.is_some_and(|c| c >= polled) {
continue;
}
if assigned.contains(&partition_id) {
in_flight.push(partition_id);
} else {
// Stale mark from a removed member: drop it so a later join in this
// same restart does not misread it once the partition is reassigned.
stale_clears.push(shard.partition_read(
ns,
PartitionRead::ClearGroupLastPolled {
group_id: monotonic_group_id,
},
));
}
}
// Fire the stale-mark clears concurrently too; the result is unused.
futures::future::join_all(stale_clears).await;
in_flight
}
/// Rewrite a group consumer-offset op so its consumer id is the group's
/// monotonic id rather than the wire name. The partition plane keys group
/// offsets by that numeric id (decoded from `WireIdentifier::Numeric`), so the
/// read path -- which resolves the same id from metadata -- and the reconciler
/// purge agree, and a re-created group (new id) never inherits a stale offset.
/// Individual-consumer ops and every other operation pass through untouched.
#[allow(clippy::cast_possible_truncation)]
pub fn maybe_rewrite_consumer_offset_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RoutedRequestHeader>,
) -> Result<Message<RoutedRequestHeader>, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let operation = request.header().operation;
if !matches!(
operation,
Operation::StoreConsumerOffset | Operation::DeleteConsumerOffset
) {
return Ok(request);
}
let body = request_body(&request);
// The store/delete ops differ only in the decode type; this collapses
// their identical decode -> resolve group id -> rewrite consumer id ->
// re-encode bodies. A non-group consumer or unresolved group returns the
// request untouched (the apply/read path handles the miss).
macro_rules! rewrite_group_offset {
($ty:ty) => {{
let mut wire = <$ty>::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
let Some(group_id) =
resolve_group_offset_id(shard, &wire.consumer, (&wire.stream_id, &wire.topic_id))
else {
return Ok(request);
};
// The partition-plane group-offset key is u32 (see the documented
// ceiling on `Topic::next_consumer_group_id`). Clamp on the
// ~4-billion-creates overflow rather than panic this live
// client-driven path, matching `iggy_partition.rs`'s identical cast.
wire.consumer.id = WireIdentifier::Numeric(u32::try_from(group_id).unwrap_or(u32::MAX));
wire.to_bytes()
}};
}
let rewritten = match operation {
Operation::StoreConsumerOffset => rewrite_group_offset!(StoreConsumerOffsetRequest),
Operation::DeleteConsumerOffset => rewrite_group_offset!(DeleteConsumerOffsetRequest),
// The outer `matches!` already filtered to the 2 ops above, but the
// match is over the 37-variant `Operation`, so a catch-all is required.
_ => return Ok(request),
};
rewrite_request_body(&request, &rewritten)
}
/// Resolve the monotonic group id for a group consumer-offset op, or `None` for
/// an individual consumer (kind != 2) / unresolved group (leave the body as-is;
/// the apply / read path handle the miss).
fn resolve_group_offset_id<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
consumer: &WireConsumer,
namespace: (&WireIdentifier, &WireIdentifier),
) -> Option<u64>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
if consumer.kind != KIND_CONSUMER_GROUP {
return None;
}
shard
.plane
.metadata()
.mux_stm
.streams()
.resolve_consumer_group_id(namespace.0, namespace.1, &consumer.id)
}