This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 84d3b6766 feat(sdk): read offsets of consumer group members over the
HTTP API (#4348)
84d3b6766 is described below
commit 84d3b67660d8155bf282d1311f2c46004e5b7c42
Author: haubur <[email protected]>
AuthorDate: Mon Oct 12 19:35:06 2026 +0200
feat(sdk): read offsets of consumer group members over the HTTP API (#4348)
---
Cargo.lock | 1 +
Cargo.toml | 1 +
.../consumer_offsets/delete_consumer_offset.rs | 3 +
core/common/src/types/consumer/consumer_kind.rs | 7 +-
core/integration/tests/sdk/consumer_offset.rs | 297 ++++++++++++++++++++-
core/integration/tests/server/mod.rs | 2 +-
.../tests/server/scenarios/permissions_scenario.rs | 2 +-
core/sdk/Cargo.toml | 1 +
core/sdk/src/http/consumer_groups.rs | 9 +-
core/sdk/src/http/consumer_offsets.rs | 108 ++------
core/sdk/src/http/messages.rs | 14 +-
core/sdk/src/http/mod.rs | 1 +
core/sdk/src/http/partitions.rs | 5 +-
core/sdk/src/http/path.rs | 46 ++++
core/sdk/src/http/segments.rs | 5 +-
core/sdk/src/http/streams.rs | 3 +-
core/sdk/src/http/topics.rs | 5 +-
core/server/src/dispatch/failure.rs | 7 +-
core/server/src/http/handlers.rs | 86 +++---
core/server/src/http/wire.rs | 171 +++++++++---
20 files changed, 594 insertions(+), 180 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 785e2b02c..38bd403ca 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6934,6 +6934,7 @@ dependencies = [
"iggy_binary_protocol",
"iggy_common",
"mockall",
+ "percent-encoding",
"quinn",
"rand 0.10.2",
"rcgen",
diff --git a/Cargo.toml b/Cargo.toml
index e868a9421..6386a3dda 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -275,6 +275,7 @@ parquet = "58.4.0"
partitions = { path = "core/partitions" }
passterm = "2.0.6"
paste = "1.0"
+percent-encoding = "2.3.2"
pgwire = "0.40.7"
postcard = { version = "1.1.3", features = ["alloc"] }
predicates = "3.1.4"
diff --git a/core/common/src/http/consumer_offsets/delete_consumer_offset.rs
b/core/common/src/http/consumer_offsets/delete_consumer_offset.rs
index b97c9a7f4..b10eada36 100644
--- a/core/common/src/http/consumer_offsets/delete_consumer_offset.rs
+++ b/core/common/src/http/consumer_offsets/delete_consumer_offset.rs
@@ -15,10 +15,13 @@
// specific language governing permissions and limitations
// under the License.
+use crate::ConsumerKind;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteConsumerOffset {
#[serde(default)]
+ pub consumer_kind: ConsumerKind,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub partition_id: Option<u32>,
}
diff --git a/core/common/src/types/consumer/consumer_kind.rs
b/core/common/src/types/consumer/consumer_kind.rs
index 391211903..bcd70aa36 100644
--- a/core/common/src/types/consumer/consumer_kind.rs
+++ b/core/common/src/types/consumer/consumer_kind.rs
@@ -30,8 +30,8 @@ use std::str::FromStr;
/// - `id`: the unique identifier of the consumer.
#[derive(Debug, Serialize, Deserialize, PartialEq, Default, Clone)]
pub struct Consumer {
- /// The type of consumer.
- #[serde(skip)]
+ /// The type of consumer. It can be either `Consumer`, `ConsumerGroup` or
`ExternalGroup`.
+ #[serde(rename = "consumer_kind", default)]
pub kind: ConsumerKind,
/// The unique identifier of the consumer.
#[serde(rename = "consumer_id")]
@@ -96,8 +96,7 @@ impl Consumer {
///
/// For offset calls only: no membership check, no range check, never
polled, and no hold on
/// retention. Deleting the group deletes its offsets. A poll with it
returns
- /// [`IggyError::FeatureUnavailable`], and so does every call over HTTP,
because the REST API
- /// cannot name a consumer kind.
+ /// [`IggyError::FeatureUnavailable`].
pub fn external_group(id: Identifier) -> Self {
Self {
kind: ConsumerKind::ExternalGroup,
diff --git a/core/integration/tests/sdk/consumer_offset.rs
b/core/integration/tests/sdk/consumer_offset.rs
index b12d2b4a3..6b49f6365 100644
--- a/core/integration/tests/sdk/consumer_offset.rs
+++ b/core/integration/tests/sdk/consumer_offset.rs
@@ -20,14 +20,295 @@ use std::str::FromStr;
use futures::StreamExt;
use iggy::prelude::*;
use iggy::stream_builder::{IggyConsumerConfig, IggyStreamConsumer};
+use integration::harness::wait_for_consumer_group_assignment;
use integration::iggy_harness;
use tokio::time::{Duration, timeout};
-const STREAM_NAME: &str = "delete-offset-stream";
-const TOPIC_NAME: &str = "delete-offset-topic";
-const CONSUMER_NAME: &str = "delete-offset-consumer";
+const STREAM_NAME: &str = "test-offset-stream";
+const TOPIC_NAME: &str = "test-offset-topic";
+const CONSUMER_NAME: &str = "test-offset-consumer";
+const CONSUMER_GROUP_NAME: &str = "test-offset-group";
+const OFFSET_PARTITION_ID: u32 = 0;
const ASSIGNED_PARTITION_ID: u32 = 3;
const POLL_TIMEOUT: Duration = Duration::from_secs(10);
+const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10);
+
+#[iggy_harness(test_client_transport = [Http, Tcp, Quic, WebSocket])]
+async fn
given_consumer_and_group_offsets_when_managed_over_any_transport_should_store_read_and_delete(
+ harness: &TestHarness,
+) {
+ let client = harness
+ .root_client()
+ .await
+ .expect("Failed to get root client");
+
+ // A consumer-group member needs a persistent connection. Required
+ // for offset test cases using a consumer group.
+ let seed_client = harness
+ .tcp_root_client()
+ .await
+ .expect("Failed to get tcp seed client");
+
+ let stream_id = Identifier::named(STREAM_NAME).unwrap();
+ let topic_id = Identifier::named(TOPIC_NAME).unwrap();
+ let group_id = Identifier::named(CONSUMER_GROUP_NAME).unwrap();
+
+ client.create_stream(STREAM_NAME).await.unwrap();
+ client
+ .create_topic(&stream_id, TOPIC_NAME, &TopicCreateOptions::default())
+ .await
+ .unwrap();
+
+ let mut messages = vec![
+ IggyMessage::from_str("message_1").unwrap(),
+ IggyMessage::from_str("message_2").unwrap(),
+ IggyMessage::from_str("message_3").unwrap(),
+ IggyMessage::from_str("message_4").unwrap(),
+ IggyMessage::from_str("message_5").unwrap(),
+ ];
+
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(OFFSET_PARTITION_ID),
+ &mut messages,
+ )
+ .await
+ .unwrap();
+
+ // Setup a consumer that joins a group in order to store, read and delete
+ // offsets from a group member.
+ seed_client
+ .create_consumer_group(&stream_id, &topic_id, CONSUMER_GROUP_NAME)
+ .await
+ .unwrap();
+
+ seed_client
+ .join_consumer_group(&stream_id, &topic_id, &group_id)
+ .await
+ .unwrap();
+
+ wait_for_consumer_group_assignment(
+ &seed_client,
+ &stream_id,
+ &topic_id,
+ &group_id,
+ 1,
+ RESOLVE_TIMEOUT,
+ )
+ .await;
+
+ let standalone_consumer =
Consumer::new(Identifier::named(CONSUMER_NAME).unwrap());
+ let group_consumer = Consumer::group(group_id.clone());
+
+ // The group consumer must commit and delete through `seed_client`, the
+ // connection that actually joined the group: the server fences a
+ // group-offset write to the calling connection's own membership.
+ for (consumer, offset, owner) in [
+ (&standalone_consumer, 2_u64, &client),
+ (&group_consumer, 4_u64, &seed_client),
+ ] {
+ owner
+ .store_consumer_offset(
+ consumer,
+ &stream_id,
+ &topic_id,
+ Some(OFFSET_PARTITION_ID),
+ offset,
+ )
+ .await
+ .unwrap();
+
+ let stored_offset = client
+ .get_consumer_offset(consumer, &stream_id, &topic_id,
Some(OFFSET_PARTITION_ID))
+ .await
+ .unwrap()
+ .map(|info| info.stored_offset);
+
+ assert_eq!(
+ stored_offset,
+ Some(offset),
+ "{consumer:?} stored offset should be readable over every
transport"
+ );
+
+ owner
+ .delete_consumer_offset(consumer, &stream_id, &topic_id,
Some(OFFSET_PARTITION_ID))
+ .await
+ .unwrap();
+
+ let stored_offset_after_delete = client
+ .get_consumer_offset(consumer, &stream_id, &topic_id,
Some(OFFSET_PARTITION_ID))
+ .await
+ .unwrap()
+ .map(|info| info.stored_offset);
+
+ assert_eq!(
+ stored_offset_after_delete, None,
+ "{consumer:?} stored offset should be gone after delete over every
transport"
+ );
+
+ // HTTP has no persistent connection to fence a group-offset write to,
+ // so store/delete/poll should reject consumer_kind=consumer_group.
+ if consumer.kind == ConsumerKind::ConsumerGroup
+ && harness.transport().expect("harness transport") ==
TransportProtocol::Http
+ {
+ let store_error = client
+ .store_consumer_offset(
+ consumer,
+ &stream_id,
+ &topic_id,
+ Some(OFFSET_PARTITION_ID),
+ offset,
+ )
+ .await
+ .expect_err("HTTP store must reject a consumer-group kind");
+ assert!(
+ is_feature_unavailable(&store_error),
+ "expected a feature-unavailable rejection for HTTP store with
consumer_kind=consumer_group, got {store_error:?}"
+ );
+
+ let delete_error = client
+ .delete_consumer_offset(consumer, &stream_id, &topic_id,
Some(OFFSET_PARTITION_ID))
+ .await
+ .expect_err("HTTP delete must reject a consumer-group kind");
+ assert!(
+ is_feature_unavailable(&delete_error),
+ "expected a feature-unavailable rejection for HTTP delete with
consumer_kind=consumer_group, got {delete_error:?}"
+ );
+
+ let poll_error = client
+ .poll_messages(
+ &stream_id,
+ &topic_id,
+ Some(OFFSET_PARTITION_ID),
+ consumer,
+ &PollingStrategy::first(),
+ 1,
+ false,
+ )
+ .await
+ .expect_err("HTTP poll must reject a consumer-group kind");
+ assert!(
+ is_feature_unavailable(&poll_error),
+ "expected a feature-unavailable rejection for HTTP poll with
consumer_kind=consumer_group, got {poll_error:?}"
+ );
+ }
+ }
+}
+
+#[iggy_harness(test_client_transport = [Http])]
+async fn
given_external_group_offset_when_managed_over_http_should_store_read_delete_and_reject_poll(
+ harness: &TestHarness,
+) {
+ let client = harness
+ .root_client()
+ .await
+ .expect("Failed to get root client");
+
+ let stream_id = Identifier::named(STREAM_NAME).unwrap();
+ let topic_id = Identifier::named(TOPIC_NAME).unwrap();
+
+ client.create_stream(STREAM_NAME).await.unwrap();
+ client
+ .create_topic(&stream_id, TOPIC_NAME, &TopicCreateOptions::default())
+ .await
+ .unwrap();
+
+ let mut messages = vec![
+ IggyMessage::from_str("message_1").unwrap(),
+ IggyMessage::from_str("message_2").unwrap(),
+ IggyMessage::from_str("message_3").unwrap(),
+ IggyMessage::from_str("message_4").unwrap(),
+ IggyMessage::from_str("message_5").unwrap(),
+ ];
+
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(OFFSET_PARTITION_ID),
+ &mut messages,
+ )
+ .await
+ .unwrap();
+
+ let group = client
+ .create_consumer_group(&stream_id, &topic_id, CONSUMER_GROUP_NAME)
+ .await
+ .unwrap();
+ let external_group =
+ Consumer::external_group(Identifier::numeric(group.id).expect("valid
group id"));
+
+ client
+ .store_consumer_offset(
+ &external_group,
+ &stream_id,
+ &topic_id,
+ Some(OFFSET_PARTITION_ID),
+ 3,
+ )
+ .await
+ .unwrap();
+
+ let stored_offset = client
+ .get_consumer_offset(
+ &external_group,
+ &stream_id,
+ &topic_id,
+ Some(OFFSET_PARTITION_ID),
+ )
+ .await
+ .unwrap()
+ .map(|info| info.stored_offset);
+ assert_eq!(
+ stored_offset,
+ Some(3),
+ "external group stored offset should be readable over HTTP"
+ );
+
+ client
+ .delete_consumer_offset(
+ &external_group,
+ &stream_id,
+ &topic_id,
+ Some(OFFSET_PARTITION_ID),
+ )
+ .await
+ .unwrap();
+
+ let stored_offset_after_delete = client
+ .get_consumer_offset(
+ &external_group,
+ &stream_id,
+ &topic_id,
+ Some(OFFSET_PARTITION_ID),
+ )
+ .await
+ .unwrap()
+ .map(|info| info.stored_offset);
+ assert_eq!(
+ stored_offset_after_delete, None,
+ "external group stored offset should be gone after delete over HTTP"
+ );
+
+ let poll_error = client
+ .poll_messages(
+ &stream_id,
+ &topic_id,
+ Some(OFFSET_PARTITION_ID),
+ &external_group,
+ &PollingStrategy::first(),
+ 1,
+ false,
+ )
+ .await
+ .expect_err("HTTP poll must reject an external-group kind");
+ assert!(
+ matches!(poll_error, IggyError::FeatureUnavailable),
+ "expected FeatureUnavailable for HTTP poll with
consumer_kind=external_group, got {poll_error:?}"
+ );
+}
#[iggy_harness]
async fn standalone_consumer_deletes_partition_zero_on_none_in_delete_offset(
@@ -178,6 +459,16 @@ async fn
given_standalone_stream_consumer_when_creating_topic_should_select_part
}
}
+/// True when an HTTP error body wraps the `FeatureUnavailable` error code
into a 400.
+fn is_feature_unavailable(error: &IggyError) -> bool {
+ let IggyError::HttpResponseError(_, body) = error else {
+ return false;
+ };
+
+ crate::server::scenarios::permissions_scenario::http_error_id(body)
+ == Some(IggyError::FeatureUnavailable.as_code())
+}
+
/// Read the offset the server has stored for the consumer.
async fn fetch_stored_offset(
client: &IggyClient,
diff --git a/core/integration/tests/server/mod.rs
b/core/integration/tests/server/mod.rs
index 29f5bec54..ade46c258 100644
--- a/core/integration/tests/server/mod.rs
+++ b/core/integration/tests/server/mod.rs
@@ -92,6 +92,6 @@ mod message_retrieval;
// The full restart matrix (consumer variants included) runs under the server:
// a restarted replica rejoins via the view probe + journal repair.
mod delete_segments;
-mod scenarios;
+pub(crate) mod scenarios;
mod specific;
mod telemetry;
diff --git a/core/integration/tests/server/scenarios/permissions_scenario.rs
b/core/integration/tests/server/scenarios/permissions_scenario.rs
index 000b2aefd..8143f50fc 100644
--- a/core/integration/tests/server/scenarios/permissions_scenario.rs
+++ b/core/integration/tests/server/scenarios/permissions_scenario.rs
@@ -2894,7 +2894,7 @@ fn assert_error_code<T: std::fmt::Debug>(
/// The `id` field of an HTTP `ErrorResponse` body is the wire `IggyError`
code,
/// so a status-collapsed `HttpResponseError` can still be matched exactly.
-fn http_error_id(body: &str) -> Option<u32> {
+pub(crate) fn http_error_id(body: &str) -> Option<u32> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
value.get("id")?.as_u64().map(|id| id as u32)
}
diff --git a/core/sdk/Cargo.toml b/core/sdk/Cargo.toml
index daf208661..4361c7caf 100644
--- a/core/sdk/Cargo.toml
+++ b/core/sdk/Cargo.toml
@@ -42,6 +42,7 @@ futures = { workspace = true }
futures-util = { workspace = true }
iggy_binary_protocol = { workspace = true }
iggy_common = { workspace = true }
+percent-encoding = { workspace = true }
quinn = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true }
diff --git a/core/sdk/src/http/consumer_groups.rs
b/core/sdk/src/http/consumer_groups.rs
index ab7659505..dc3e8110a 100644
--- a/core/sdk/src/http/consumer_groups.rs
+++ b/core/sdk/src/http/consumer_groups.rs
@@ -17,6 +17,7 @@
use crate::http::http_client::HttpClient;
use crate::http::http_transport::HttpTransport;
+use crate::http::path::encode_segment;
use crate::prelude::IggyError;
use async_trait::async_trait;
use iggy_common::ConsumerGroupClient;
@@ -36,7 +37,7 @@ impl ConsumerGroupClient for HttpClient {
.get(&format!(
"{}/{}",
get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
- group_id
+ encode_segment(&group_id.as_cow_str())
))
.await;
if let Err(error) = response {
@@ -101,7 +102,7 @@ impl ConsumerGroupClient for HttpClient {
let path = format!(
"{}/{}",
get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
- group_id.as_cow_str()
+ encode_segment(&group_id.as_cow_str())
);
self.delete(&path).await?;
Ok(())
@@ -127,5 +128,7 @@ impl ConsumerGroupClient for HttpClient {
}
fn get_path(stream_id: &str, topic_id: &str) -> String {
- format!("streams/{stream_id}/topics/{topic_id}/consumer-groups")
+ let encoded_stream = encode_segment(stream_id);
+ let encoded_topic = encode_segment(topic_id);
+ format!("streams/{encoded_stream}/topics/{encoded_topic}/consumer-groups")
}
diff --git a/core/sdk/src/http/consumer_offsets.rs
b/core/sdk/src/http/consumer_offsets.rs
index f7a00625b..fbeb9dd56 100644
--- a/core/sdk/src/http/consumer_offsets.rs
+++ b/core/sdk/src/http/consumer_offsets.rs
@@ -17,13 +17,15 @@
use crate::http::http_client::HttpClient;
use crate::http::http_transport::HttpTransport;
+use crate::http::path::encode_segment;
use crate::prelude::Identifier;
use crate::prelude::IggyError;
use async_trait::async_trait;
use iggy_common::ConsumerOffsetClient;
+use iggy_common::delete_consumer_offset::DeleteConsumerOffset;
use iggy_common::get_consumer_offset::GetConsumerOffset;
use iggy_common::store_consumer_offset::StoreConsumerOffset;
-use iggy_common::{Consumer, ConsumerKind, ConsumerOffsetInfo,
ConsumerPosition};
+use iggy_common::{Consumer, ConsumerOffsetInfo, ConsumerPosition};
#[async_trait]
impl ConsumerOffsetClient for HttpClient {
@@ -34,7 +36,6 @@ impl ConsumerOffsetClient for HttpClient {
topic_id: &Identifier,
position: ConsumerPosition,
) -> Result<(), IggyError> {
- refuse_external_group(consumer)?;
self.put(
&get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
&StoreConsumerOffset {
@@ -56,7 +57,6 @@ impl ConsumerOffsetClient for HttpClient {
partition_id: Option<u32>,
offset: u64,
) -> Result<(), IggyError> {
- refuse_external_group(consumer)?;
self.put(
&get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
&StoreConsumerOffset {
@@ -77,7 +77,6 @@ impl ConsumerOffsetClient for HttpClient {
topic_id: &Identifier,
partition_id: Option<u32>,
) -> Result<Option<ConsumerOffsetInfo>, IggyError> {
- refuse_external_group(consumer)?;
let response = self
.get_with_query(
&get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
@@ -109,102 +108,47 @@ impl ConsumerOffsetClient for HttpClient {
topic_id: &Identifier,
partition_id: Option<u32>,
) -> Result<(), IggyError> {
- refuse_external_group(consumer)?;
- let partition_id = partition_id
- .map(|id| format!("?partition_id={id}"))
- .unwrap_or_default();
let path = format!(
- "{}/{}{partition_id}",
+ "{}/{}",
get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
- consumer.id
+ encode_segment(&consumer.id.as_cow_str())
);
- self.delete(&path).await?;
+ self.delete_with_query(
+ &path,
+ &DeleteConsumerOffset {
+ consumer_kind: consumer.kind,
+ partition_id,
+ },
+ )
+ .await?;
Ok(())
}
}
fn get_path(stream_id: &str, topic_id: &str) -> String {
- format!("streams/{stream_id}/topics/{topic_id}/consumer-offsets")
-}
-
-/// The REST API cannot name a consumer kind, so an external group would land
on a plain
-/// consumer's offset.
-pub(crate) fn refuse_external_group(consumer: &Consumer) -> Result<(),
IggyError> {
- if consumer.kind == ConsumerKind::ExternalGroup {
- return Err(IggyError::FeatureUnavailable);
- }
- Ok(())
+ let encoded_stream = encode_segment(stream_id);
+ let encoded_topic = encode_segment(topic_id);
+ format!("streams/{encoded_stream}/topics/{encoded_topic}/consumer-offsets")
}
#[cfg(test)]
mod tests {
use super::*;
- use iggy_common::{MessageClient, PartitionContext, PollingStrategy};
-
- /// Nothing listens on this port, so a call that reached the network would
fail with a
- /// transport error instead.
- const UNREACHABLE_API: &str = "http://127.0.0.1:1";
- #[tokio::test]
- async fn
given_external_group_when_calling_over_http_should_refuse_before_sending() {
- let client = HttpClient::new(UNREACHABLE_API).unwrap();
- let group = Consumer::external_group(Identifier::numeric(1).unwrap());
- let stream = Identifier::numeric(1).unwrap();
- let topic = Identifier::numeric(1).unwrap();
+ #[test]
+ fn given_plain_ids_when_building_path_should_join_segments() {
+ let path = get_path("1", "orders");
- assert!(matches!(
- client
- .store_consumer_offset(&group, &stream, &topic, Some(0), 5)
- .await,
- Err(IggyError::FeatureUnavailable)
- ));
- assert!(matches!(
- client
- .store_consumer_position(
- &group,
- &stream,
- &topic,
- ConsumerPosition {
- partition_id: 0,
- offset: 5,
- context: PartitionContext::default(),
- },
- )
- .await,
- Err(IggyError::FeatureUnavailable)
- ));
- assert!(matches!(
- client
- .get_consumer_offset(&group, &stream, &topic, Some(0))
- .await,
- Err(IggyError::FeatureUnavailable)
- ));
- assert!(matches!(
- client
- .delete_consumer_offset(&group, &stream, &topic, Some(0))
- .await,
- Err(IggyError::FeatureUnavailable)
- ));
- assert!(matches!(
- client
- .poll_messages(
- &stream,
- &topic,
- Some(0),
- &group,
- &PollingStrategy::next(),
- 1,
- false
- )
- .await,
- Err(IggyError::FeatureUnavailable)
- ));
+ assert_eq!(path, "streams/1/topics/orders/consumer-offsets");
}
#[test]
- fn given_plain_consumer_when_guarded_should_pass() {
- let consumer = Consumer::new(Identifier::numeric(1).unwrap());
+ fn given_reserved_characters_when_building_path_should_percent_encode() {
+ let path = get_path("my stream", "my/topic");
- assert!(refuse_external_group(&consumer).is_ok());
+ assert_eq!(
+ path,
+ "streams/my%20stream/topics/my%2Ftopic/consumer-offsets"
+ );
}
}
diff --git a/core/sdk/src/http/messages.rs b/core/sdk/src/http/messages.rs
index c33a685eb..3bf74d13e 100644
--- a/core/sdk/src/http/messages.rs
+++ b/core/sdk/src/http/messages.rs
@@ -17,9 +17,10 @@
use crate::http::http_client::HttpClient;
use crate::http::http_transport::HttpTransport;
+use crate::http::path::encode_segment;
use crate::prelude::{
- Consumer, Identifier, IggyError, IggyMessage, PartitionContext,
Partitioning, PollMessages,
- PolledMessages, PollingStrategy, SendMessages, SendMessagesResponse,
+ Consumer, ConsumerKind, Identifier, IggyError, IggyMessage,
PartitionContext, Partitioning,
+ PollMessages, PolledMessages, PollingStrategy, SendMessages,
SendMessagesResponse,
};
use async_trait::async_trait;
use iggy_common::IggyMessagesBatch;
@@ -39,7 +40,10 @@ impl MessageClient for HttpClient {
count: u32,
auto_commit: bool,
) -> Result<PolledMessages, IggyError> {
- crate::http::consumer_offsets::refuse_external_group(consumer)?;
+ if matches!(consumer.kind, ConsumerKind::ExternalGroup) {
+ return Err(IggyError::FeatureUnavailable);
+ }
+
let response = self
.get_with_query(
&get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
@@ -215,7 +219,9 @@ async fn decode_send_response(
}
fn get_path(stream_id: &str, topic_id: &str) -> String {
- format!("streams/{stream_id}/topics/{topic_id}/messages")
+ let encoded_stream = encode_segment(stream_id);
+ let encoded_topic = encode_segment(topic_id);
+ format!("streams/{encoded_stream}/topics/{encoded_topic}/messages")
}
#[cfg(test)]
diff --git a/core/sdk/src/http/mod.rs b/core/sdk/src/http/mod.rs
index 7f5dc1504..8ef3399e5 100644
--- a/core/sdk/src/http/mod.rs
+++ b/core/sdk/src/http/mod.rs
@@ -22,6 +22,7 @@ pub mod http_client;
pub(crate) mod http_transport;
pub mod messages;
pub mod partitions;
+pub(crate) mod path;
pub mod personal_access_tokens;
#[cfg(test)]
pub(crate) mod scripted_server;
diff --git a/core/sdk/src/http/partitions.rs b/core/sdk/src/http/partitions.rs
index 7a2bd7dd6..e216003f5 100644
--- a/core/sdk/src/http/partitions.rs
+++ b/core/sdk/src/http/partitions.rs
@@ -17,6 +17,7 @@
use crate::http::http_client::HttpClient;
use crate::http::http_transport::HttpTransport;
+use crate::http::path::encode_segment;
use crate::prelude::{Identifier, IggyError};
use async_trait::async_trait;
use iggy_common::PartitionClient;
@@ -65,5 +66,7 @@ impl PartitionClient for HttpClient {
}
fn get_path(stream_id: &str, topic_id: &str) -> String {
- format!("streams/{stream_id}/topics/{topic_id}/partitions")
+ let encoded_stream = encode_segment(stream_id);
+ let encoded_topic = encode_segment(topic_id);
+ format!("streams/{encoded_stream}/topics/{encoded_topic}/partitions")
}
diff --git a/core/sdk/src/http/path.rs b/core/sdk/src/http/path.rs
new file mode 100644
index 000000000..cf3209c70
--- /dev/null
+++ b/core/sdk/src/http/path.rs
@@ -0,0 +1,46 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, PercentEncode,
utf8_percent_encode};
+
+const PATH_SEGMENT: &AsciiSet = NON_ALPHANUMERIC;
+
+pub(crate) fn encode_segment(value: &str) -> PercentEncode<'_> {
+ utf8_percent_encode(value, PATH_SEGMENT)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn given_plain_id_when_encoding_should_pass_through() {
+ assert_eq!(encode_segment("1").to_string(), "1");
+ }
+
+ #[test]
+ fn given_reserved_characters_when_encoding_should_percent_encode() {
+ assert_eq!(encode_segment("my stream").to_string(), "my%20stream");
+ assert_eq!(encode_segment("my/topic").to_string(), "my%2Ftopic");
+ }
+
+ #[test]
+ fn given_dot_segments_when_encoding_should_never_pass_through_literally() {
+ assert_eq!(encode_segment(".").to_string(), "%2E");
+ assert_eq!(encode_segment("..").to_string(), "%2E%2E");
+ }
+}
diff --git a/core/sdk/src/http/segments.rs b/core/sdk/src/http/segments.rs
index 9a64c37db..4a2dfb40e 100644
--- a/core/sdk/src/http/segments.rs
+++ b/core/sdk/src/http/segments.rs
@@ -17,6 +17,7 @@
use crate::http::http_client::HttpClient;
use crate::http::http_transport::HttpTransport;
+use crate::http::path::encode_segment;
use crate::prelude::{Identifier, IggyError};
use async_trait::async_trait;
use iggy_common::SegmentClient;
@@ -50,5 +51,7 @@ impl SegmentClient for HttpClient {
}
fn get_path(stream_id: &str, topic_id: &str, partition_id: u32) -> String {
- format!("streams/{stream_id}/topics/{topic_id}/partitions/{partition_id}")
+ let encoded_stream = encode_segment(stream_id);
+ let encoded_topic = encode_segment(topic_id);
+
format!("streams/{encoded_stream}/topics/{encoded_topic}/partitions/{partition_id}")
}
diff --git a/core/sdk/src/http/streams.rs b/core/sdk/src/http/streams.rs
index 65c200a27..7c149640d 100644
--- a/core/sdk/src/http/streams.rs
+++ b/core/sdk/src/http/streams.rs
@@ -17,6 +17,7 @@
use crate::http::http_client::HttpClient;
use crate::http::http_transport::HttpTransport;
+use crate::http::path::encode_segment;
use crate::prelude::Identifier;
use crate::prelude::IggyError;
use async_trait::async_trait;
@@ -99,5 +100,5 @@ impl StreamClient for HttpClient {
}
fn get_details_path(stream_id: &str) -> String {
- format!("{PATH}/{stream_id}")
+ format!("{PATH}/{}", encode_segment(stream_id))
}
diff --git a/core/sdk/src/http/topics.rs b/core/sdk/src/http/topics.rs
index 62fba3924..f154903a2 100644
--- a/core/sdk/src/http/topics.rs
+++ b/core/sdk/src/http/topics.rs
@@ -17,6 +17,7 @@
use crate::http::http_client::HttpClient;
use crate::http::http_transport::HttpTransport;
+use crate::http::path::encode_segment;
use crate::prelude::{Identifier, IggyError, IggyExpiry, MaxTopicSize};
use async_trait::async_trait;
use iggy_common::TopicClient;
@@ -143,9 +144,9 @@ impl HttpClient {
}
fn get_path(stream_id: &str) -> String {
- format!("streams/{stream_id}/topics")
+ format!("streams/{}/topics", encode_segment(stream_id))
}
fn get_details_path(stream_id: &str, topic_id: &str) -> String {
- format!("{}/{topic_id}", get_path(stream_id))
+ format!("{}/{}", get_path(stream_id), encode_segment(topic_id))
}
diff --git a/core/server/src/dispatch/failure.rs
b/core/server/src/dispatch/failure.rs
index 1bff997e4..d2e70c6fa 100644
--- a/core/server/src/dispatch/failure.rs
+++ b/core/server/src/dispatch/failure.rs
@@ -827,10 +827,9 @@ mod tests {
);
}
- /// The HTTP mirror (`resync_required_polled_messages` in
- /// `crate::http::wire`) is a JSON DTO, not a wire frame, so the shared
- /// contract asserted here is the sentinel constant itself; the DTO's own
- /// test pins its `partition_id` to the same constant.
+ /// TCP is the only transport that still carries this sentinel. HTTP
+ /// rejects `consumer_kind=consumer_group` polls before a poll ever
+ /// reaches this path (`crate::http::wire::poll_wire_request`).
#[compio::test]
async fn
resync_sentinel_must_ride_a_status_zero_poll_body_led_by_the_sentinel() {
let (bus, shard) = shard_at_commit();
diff --git a/core/server/src/http/handlers.rs b/core/server/src/http/handlers.rs
index a4105cdcb..15adb538f 100644
--- a/core/server/src/http/handlers.rs
+++ b/core/server/src/http/handlers.rs
@@ -142,7 +142,7 @@ use crate::http::submit::{
};
use crate::http::wire::{
consumer_offset_wire_request, delete_offset_wire_request,
encode_send_messages,
- poll_wire_request, resync_required_polled_messages,
store_offset_wire_request,
+ poll_wire_request, store_offset_wire_request,
};
use crate::reply_frame::{build_polled_messages_body, build_raw_pat_reply};
use crate::responses::connected_client_to_response;
@@ -157,11 +157,11 @@ const PONG: &str = "pong";
/// VSR client id stamped on HTTP data-plane reads (poll / consumer-offset).
/// HTTP reads never Register a VSR client, and shard-0 client ids are minted
-/// from 1, so 0 can never name a live consumer-group member: a group-kind
-/// poll fences closed with `ConsumerGroupPartitionNotOwned` and answers the
-/// re-sync sentinel empty poll - the same failure a stale TCP member sees.
-/// Legacy HTTP polls with client id 0 for the same reason (no persistent
-/// sessions, so no group membership).
+/// from 1, so 0 can never collide with a live client. `resolve_poll_request`
+/// takes a client id only for its consumer-group fencing branch, and HTTP
+/// never reaches it: `poll_wire_request` rejects
`consumer_kind=consumer_group`
+/// up front. Legacy HTTP polls with client id 0 for the same reason (no
+/// persistent sessions, so no group membership).
const HTTP_READ_CLIENT_ID: u128 = 0;
/// Response header attesting what durability a produce response proves:
@@ -1198,7 +1198,10 @@ pub(in crate::http) async fn delete_segments(
/// messages as the same `PolledMessages` JSON the legacy server returns. The
/// query is the same flattened `PollMessages` shape the legacy server accepts
/// (`consumer_id`, `partition_id`, strategy `kind`+`value`, `count`,
-/// `auto_commit`); stream and topic come from the path.
+/// `auto_commit`); stream and topic come from the path. `consumer_kind`
+/// defaults to `consumer` and `consumer_group` is rejected (see
+/// [`poll_wire_request`]) since HTTP has no persistent connection and cannot
observe
+/// group's partition assignment (that can be subject to change).
///
/// A non-replicated read served in band: the same resolution the TCP dispatch
/// runs ([`resolve_poll_request`]), then a mesh read on the owning shard and a
@@ -1228,20 +1231,21 @@ pub(in crate::http) async fn poll_messages(
},
))
.await?;
- let wire = poll_wire_request(&stream_id, &topic_id,
&query).map_err(ReadError::Rejected)?;
+
+ let wire = match poll_wire_request(&stream_id, &topic_id, &query) {
+ Ok(request) => request,
+ Err(error) => return Err(ReadError::Rejected(error)),
+ };
+
let (namespace, partition_id, consumer, args) =
match resolve_poll_request(&state.shard, &wire, HTTP_READ_CLIENT_ID) {
Ok(decoded) => decoded,
- // TCP parity: a fenced group poll answers 200 with the re-sync
- // sentinel partition, not an error (see [`HTTP_READ_CLIENT_ID`]).
- Err(IggyError::ConsumerGroupPartitionNotOwned(..)) => {
- return Ok(Json(resync_required_polled_messages()));
- }
- // A partition id the topic does not have is a client addressing
- // error with its own code; collapsing it into the generic 404 body
- // told an SDK "no such stream/topic" for a request whose stream
and
- // topic both resolved. TCP parity: the dispatch denies typed here.
- Err(error @ IggyError::PartitionNotFound(..)) => {
+ // A partition id the topic does not have, or a consumer kind HTTP
+ // can't poll with, is a client addressing error with its own code;
+ // collapsing it into the generic 404 body told an SDK "no such
+ // stream/topic" for a request whose stream and topic both
+ // resolved. TCP parity: the dispatch denies typed here.
+ Err(error @ (IggyError::PartitionNotFound(..) |
IggyError::FeatureUnavailable)) => {
return Err(ReadError::Rejected(error));
}
// The remaining resolver failures are STM lookups that came up
@@ -1299,13 +1303,18 @@ pub(in crate::http) async fn poll_messages(
/// `GET /streams/{stream_id}/topics/{topic_id}/consumer-offsets`: fetch a
/// consumer's stored offset as the same `ConsumerOffsetInfo` JSON the legacy
-/// server returns. The query is the same flattened `GetConsumerOffset` shape
-/// the legacy server accepts (`consumer_id`, optional `partition_id`).
+/// server returns. The query is the flattened `GetConsumerOffset` shape
+/// (`consumer_id`, optional `consumer_kind` defaulting to `consumer`,
+/// optional `partition_id`).
///
/// A non-replicated read served in band, mirroring [`poll_messages`]. A
-/// missing offset is the legacy 404, which the SDK reads as `None`. An owner
-/// that does not hold the partition yet answers 503 `TransientNotAccepted`,
-/// as TCP does, so it never reads as `None`.
+/// missing offset (never stored, or the partition unknown to its owner) is
+/// the legacy 404: the TCP path replies an empty body the SDK decodes as
+/// `None`, and the legacy HTTP server renders that `None` as
+/// `CustomError::ResourceNotFound`. Unlike poll/store/delete,
+/// `consumer_kind=consumer_group` is allowed, because a group offset read
needs
+/// no held connection, so it is not member-fenced
+/// ([`resolve_consumer_offset_request`]).
pub(in crate::http) async fn get_consumer_offset(
State(state): State<HttpState>,
identity: Identity,
@@ -1455,9 +1464,11 @@ pub(in crate::http) async fn send_messages(
/// `PUT /streams/{stream_id}/topics/{topic_id}/consumer-offsets`: store a
/// consumer's offset. The JSON body is the same `StoreConsumerOffset` shape
the
-/// legacy server accepts (flattened `consumer_id`, optional `partition_id`,
-/// `offset`); stream and topic come from the path. Returns 204 on commit,
-/// matching the legacy server.
+/// legacy server accepts (flattened `consumer_id`, optional `consumer_kind`,
+/// `partition_id`, `offset`); stream and topic come from the path. Returns
+/// 204 on commit, matching the legacy server. `consumer_kind=consumer_group`
+/// is rejected ([`store_offset_wire_request`]) since HTTP has no persistent
+/// connection and group memberships can change.
///
/// Data plane like a produce: the offset write is a replicated op on the
/// partition group's own consensus, awaited through the session's in-process
@@ -1498,12 +1509,14 @@ pub(in crate::http) async fn store_consumer_offset(
}
/// `DELETE
/streams/{stream_id}/topics/{topic_id}/consumer-offsets/{consumer_id}`:
-/// delete a consumer's stored offset. The consumer comes from the path and the
-/// optional `partition_id` from the query, the same `DeleteConsumerOffset`
-/// shape the legacy server accepts. Returns 204 on commit, matching the legacy
-/// server; a delete of a never-stored offset is denied by the partition
-/// primary (`ReplyHeader.status`) and renders the legacy typed 404. Same
-/// replicated partition write as [`store_consumer_offset`].
+/// delete a consumer's stored offset. The consumer id comes from the path and
the
+/// optional `consumer_kind` (defaulting to `consumer`) and `partition_id` come
+/// from the query, the same `DeleteConsumerOffset` shape the legacy server
+/// accepts. Returns 204 on commit, matching the legacy server; a delete of a
+/// never-stored offset is denied by the partition primary
+/// (`ReplyHeader.status`) and renders the legacy typed 404. Same replicated
+/// partition write as [`store_consumer_offset`].
`consumer_kind=consumer_group`
+/// is rejected because HTTP does not persist a connection and group
memberships can change.
pub(in crate::http) async fn delete_consumer_offset(
State(state): State<HttpState>,
identity: Authenticated,
@@ -1522,11 +1535,10 @@ pub(in crate::http) async fn delete_consumer_offset(
Permissioner::delete_consumer_offset,
)
.map_err(PartitionWriteError::Rejected)?;
- // `Consumer::new` fixes the kind to `Consumer`, exactly as the legacy
- // handler does; HTTP cannot express a group-kind offset op.
- let consumer = Consumer::new(
-
Identifier::from_str_value(&consumer_id).map_err(PartitionWriteError::Rejected)?,
- );
+ let consumer = Consumer {
+ kind: query.consumer_kind,
+ id:
Identifier::from_str_value(&consumer_id).map_err(PartitionWriteError::Rejected)?,
+ };
let request = delete_offset_wire_request(&stream_id, &topic_id, &consumer,
query.partition_id)
.map_err(PartitionWriteError::Rejected)?;
let body = request.to_bytes();
diff --git a/core/server/src/http/wire.rs b/core/server/src/http/wire.rs
index 34b291759..83281dd6e 100644
--- a/core/server/src/http/wire.rs
+++ b/core/server/src/http/wire.rs
@@ -34,8 +34,7 @@ use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
use iggy_common::store_consumer_offset::StoreConsumerOffset;
use iggy_common::wire_conversions::{consumer_to_wire, identifier_to_wire,
partitioning_to_wire};
use iggy_common::{
- Consumer, Identifier, IggyError, IggyMessageView, PollMessages,
PolledMessages,
- RESYNC_REQUIRED_PARTITION_SENTINEL, SendMessages,
+ Consumer, ConsumerKind, Identifier, IggyError, IggyMessageView,
PollMessages, SendMessages,
};
use server_common::Message;
@@ -96,14 +95,14 @@ pub(in crate::http) fn encode_send_messages(
/// Map a validated HTTP poll query onto the wire `PollMessagesRequest` the
/// shared TCP resolver consumes, so both transports resolve one request shape.
-/// The query's consumer kind is structurally always `Consumer`
-/// (`Consumer::kind` is `#[serde(skip)]` - the flattened `kind` param names
-/// the polling strategy), matching the legacy HTTP server.
+/// Rejects `consumer_kind=consumer_group` since HTTP has no persistent
connection.
pub(in crate::http) fn poll_wire_request(
stream_id: &Identifier,
topic_id: &Identifier,
query: &PollMessages,
) -> Result<PollMessagesRequest, IggyError> {
+ refuse_consumer_group(query.consumer.kind)?;
+
Ok(PollMessagesRequest {
consumer: WireConsumer {
kind: query.consumer.kind.as_code(),
@@ -143,15 +142,17 @@ pub(in crate::http) fn consumer_offset_wire_request(
/// Map a validated HTTP store-offset body onto the wire request
/// (`StoreConsumerOffsetRequest`), `ack` pinned to `Quorum` so the route can
-/// await the committed reply. The body's consumer kind is structurally always
-/// `Consumer` (`Consumer::kind` is `#[serde(skip)]`), matching the legacy HTTP
-/// server; `partition_id` passes through as the wire `Option` (flag byte +
-/// u32) for the server-side resolvers to ground.
+/// await the committed reply. `partition_id` passes through as the wire
+/// `Option` (flag byte + u32) for the server-side resolvers to ground.
+/// Rejects `consumer_kind=consumer_group` since HTTP has no persistent
+/// connection to hold group membership, so the write would always be fenced.
pub(in crate::http) fn store_offset_wire_request(
stream_id: &Identifier,
topic_id: &Identifier,
command: &StoreConsumerOffset,
) -> Result<StoreConsumerOffsetRequest, IggyError> {
+ refuse_consumer_group(command.consumer.kind)?;
+
Ok(StoreConsumerOffsetRequest {
consumer: consumer_to_wire(&command.consumer)?,
stream_id: identifier_to_wire(stream_id)?,
@@ -164,13 +165,16 @@ pub(in crate::http) fn store_offset_wire_request(
/// Map a validated HTTP delete-offset request onto the wire request
/// (`DeleteConsumerOffsetRequest`), `ack` pinned to `Quorum` like
-/// [`store_offset_wire_request`].
+/// [`store_offset_wire_request`]. Rejects `consumer_kind=consumer_group` for
+/// the same reason [`store_offset_wire_request`] does.
pub(in crate::http) fn delete_offset_wire_request(
stream_id: &Identifier,
topic_id: &Identifier,
consumer: &Consumer,
partition_id: Option<u32>,
) -> Result<DeleteConsumerOffsetRequest, IggyError> {
+ refuse_consumer_group(consumer.kind)?;
+
Ok(DeleteConsumerOffsetRequest {
consumer: consumer_to_wire(consumer)?,
stream_id: identifier_to_wire(stream_id)?,
@@ -180,23 +184,6 @@ pub(in crate::http) fn delete_offset_wire_request(
})
}
-/// The empty `PolledMessages` a fenced consumer-group poll answers, carrying
-/// the re-sync sentinel in `partition_id` exactly as the TCP dispatch replies
-/// it, so an SDK re-syncs its assignment instead of reading end-of-partition.
-pub(in crate::http) const fn resync_required_polled_messages() ->
PolledMessages {
- PolledMessages {
- context:
iggy_binary_protocol::primitives::partition_history::PartitionContext {
- incarnation: 0,
- owner_generation: 0,
- metadata_op: 0,
- },
- partition_id: RESYNC_REQUIRED_PARTITION_SENTINEL,
- current_offset: 0,
- count: 0,
- messages: Vec::new(),
- }
-}
-
/// Build a `Message<RoutedRequestHeader>` for a control-plane write by
filling a zeroed
/// `#[repr(C)]` header, mirroring `wire::rewrite_request_body` and the
partition
/// reconciler's prepare builder. `body` is the already-encoded wire request,
@@ -224,6 +211,13 @@ pub(in crate::http) fn build_request_message(
message
}
+fn refuse_consumer_group(kind: ConsumerKind) -> Result<(), IggyError> {
+ if kind == ConsumerKind::ConsumerGroup {
+ return Err(IggyError::FeatureUnavailable);
+ }
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -234,7 +228,7 @@ mod tests {
use iggy_binary_protocol::WireEncode;
use iggy_common::delete_consumer_offset::DeleteConsumerOffset;
use iggy_common::{
- Consumer, ConsumerKind, IggyMessagesBatch, IggyTimestamp,
Partitioning, PartitioningKind,
+ IggyMessagesBatch, IggyTimestamp, Partitioning, PartitioningKind,
PolledMessages,
PollingKind, PollingStrategy, Validatable,
};
use partitions::{Fragment, PollFragments};
@@ -429,6 +423,18 @@ mod tests {
assert!(wire.auto_commit);
}
+ #[test]
+ fn poll_wire_request_rejects_consumer_group_kind() {
+ let stream_id = Identifier::from_str_value("orders").expect("valid
stream id");
+ let topic_id = Identifier::from_str_value("1").expect("valid topic
id");
+ let query = PollMessages {
+ consumer: Consumer::group(Identifier::numeric(9).expect("valid
id")),
+ ..Default::default()
+ };
+ let error = poll_wire_request(&stream_id, &topic_id,
&query).expect_err("must reject");
+ assert!(matches!(error, IggyError::FeatureUnavailable));
+ }
+
#[test]
fn consumer_offset_wire_request_defaults_omitted_partition_to_zero() {
let stream_id = Identifier::numeric(1).expect("valid stream id");
@@ -442,6 +448,54 @@ mod tests {
assert_eq!(wire.consumer.kind, 1);
}
+ #[test]
+ fn consumer_offset_wire_request_maps_consumer_group_kind() {
+ let stream_id = Identifier::numeric(1).expect("valid stream id");
+ let topic_id = Identifier::numeric(1).expect("valid topic id");
+ let query = GetConsumerOffset {
+ consumer: Consumer::group(Identifier::numeric(7).expect("valid
id")),
+ partition_id: Some(2),
+ };
+ let wire = consumer_offset_wire_request(&stream_id, &topic_id,
&query).expect("maps");
+ assert_eq!(wire.consumer.kind, 2);
+ assert_eq!(wire.partition_id, Some(2));
+ }
+
+ #[test]
+ fn get_consumer_offset_query_parses_consumer_kind_param() {
+ let uri: Uri =
"/streams/1/topics/1/consumer-offsets?consumer_id=my-group&consumer_kind=consumer_group&partition_id=0"
+ .parse()
+ .expect("valid uri");
+ let Query(query) =
Query::<GetConsumerOffset>::try_from_uri(&uri).expect("parses");
+ assert_eq!(query.consumer.kind, ConsumerKind::ConsumerGroup);
+ assert_eq!(
+ query.consumer.id,
+ Identifier::named("my-group").expect("valid id")
+ );
+ }
+
+ #[test]
+ fn get_consumer_offset_query_defaults_consumer_kind_when_omitted() {
+ let uri: Uri = "/streams/1/topics/1/consumer-offsets?consumer_id=42"
+ .parse()
+ .expect("valid uri");
+ let Query(query) =
Query::<GetConsumerOffset>::try_from_uri(&uri).expect("parses");
+ assert_eq!(query.consumer.kind, ConsumerKind::Consumer);
+ }
+
+ #[test]
+ fn store_consumer_offset_json_body_round_trips_consumer_group_kind() {
+ let json = serde_json::json!({
+ "consumer_id": "my-group",
+ "consumer_kind": "consumer_group",
+ "partition_id": 0,
+ "offset": 42,
+ });
+ let command: StoreConsumerOffset =
+ serde_json::from_value(json).expect("valid StoreConsumerOffset
body");
+ assert_eq!(command.consumer.kind, ConsumerKind::ConsumerGroup);
+ }
+
#[test]
fn store_offset_wire_request_round_trips_with_quorum_ack() {
let stream_id = Identifier::numeric(1).expect("valid stream id");
@@ -519,6 +573,59 @@ mod tests {
assert_eq!(query.partition_id, None);
}
+ #[test]
+ fn delete_offset_query_parses_consumer_kind_param() {
+ let uri: Uri = "/x?consumer_kind=consumer_group&partition_id=3"
+ .parse()
+ .expect("valid uri");
+ let Query(query) =
Query::<DeleteConsumerOffset>::try_from_uri(&uri).expect("parses");
+ assert_eq!(query.consumer_kind, ConsumerKind::ConsumerGroup);
+ let bare: Uri = "/x".parse().expect("valid uri");
+ let Query(query) =
Query::<DeleteConsumerOffset>::try_from_uri(&bare).expect("parses");
+ assert_eq!(query.consumer_kind, ConsumerKind::Consumer);
+ }
+
+ #[test]
+ fn store_offset_wire_request_rejects_consumer_group_kind() {
+ let stream_id = Identifier::numeric(1).expect("valid stream id");
+ let topic_id = Identifier::named("orders").expect("valid topic id");
+ let command = StoreConsumerOffset {
+ context: None,
+ consumer: Consumer::group(Identifier::numeric(9).expect("valid
id")),
+ partition_id: Some(1),
+ offset: 42,
+ };
+ let error =
+ store_offset_wire_request(&stream_id, &topic_id,
&command).expect_err("must reject");
+ assert!(matches!(error, IggyError::FeatureUnavailable));
+ }
+
+ #[test]
+ fn refuse_consumer_group_rejects_group_kind() {
+ let error =
refuse_consumer_group(ConsumerKind::ConsumerGroup).expect_err("must reject");
+ assert!(matches!(error, IggyError::FeatureUnavailable));
+ }
+
+ #[test]
+ fn refuse_consumer_group_passes_non_group_kinds() {
+ assert!(refuse_consumer_group(ConsumerKind::Consumer).is_ok());
+ assert!(refuse_consumer_group(ConsumerKind::ExternalGroup).is_ok());
+ }
+
+ #[test]
+ fn delete_offset_wire_request_rejects_consumer_group_kind() {
+ let stream_id = Identifier::named("stream-1").expect("valid stream
id");
+ let topic_id = Identifier::numeric(2).expect("valid topic id");
+ let error = delete_offset_wire_request(
+ &stream_id,
+ &topic_id,
+ &Consumer::group(Identifier::numeric(9).expect("valid id")),
+ Some(1),
+ )
+ .expect_err("must reject");
+ assert!(matches!(error, IggyError::FeatureUnavailable));
+ }
+
/// Wrap one stored `SendMessages` batch (`[256B header][blob]`) as the
/// poll fragment the owning shard replies, the shape
/// `build_polled_messages_body` consumes.
@@ -593,12 +700,4 @@ mod tests {
Some(b"raw-header-bytes".as_ref())
);
}
-
- #[test]
- fn resync_required_polled_messages_carries_sentinel_partition() {
- let polled = resync_required_polled_messages();
- assert_eq!(polled.partition_id, RESYNC_REQUIRED_PARTITION_SENTINEL);
- assert_eq!(polled.count, 0);
- assert_eq!(polled.messages, []);
- }
}