This is an automated email from the ASF dual-hosted git repository.
ryerraguntla pushed a commit to branch feat/gateways-3546-describe-alter-configs
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to
refs/heads/feat/gateways-3546-describe-alter-configs by this push:
new bb7446772 Implementing review commands.
bb7446772 is described below
commit bb74467721e721a3b1ef6aa41c63be053ad47307
Author: ryerraguntla <[email protected]>
AuthorDate: Sun Oct 4 06:48:44 2026 -0400
Implementing review commands.
gateways/kafka/docs/CONFIGS.md
gateways/kafka/src/protocol/handlers/alter_configs.rs
gateways/kafka/src/protocol/handlers/describe_configs.rs
gateways/kafka/src/protocol/handlers/topic_config.rs
gateways/kafka/tests/common/wire.rs
gateways/kafka/tests/configs_handler_tests.rs
gateways/kafka/tests/configs_java_client_tests.rs
gateways/kafka/tests/configs_real_bridge_tests.rs
---
gateways/kafka/docs/CONFIGS.md | 9 +-
.../kafka/src/protocol/handlers/alter_configs.rs | 154 +++++----------
.../src/protocol/handlers/describe_configs.rs | 215 ++++++++++-----------
.../kafka/src/protocol/handlers/topic_config.rs | 96 +++++++--
gateways/kafka/tests/common/wire.rs | 16 +-
gateways/kafka/tests/configs_handler_tests.rs | 21 +-
gateways/kafka/tests/configs_java_client_tests.rs | 37 +++-
gateways/kafka/tests/configs_real_bridge_tests.rs | 141 ++++++++++++--
8 files changed, 424 insertions(+), 265 deletions(-)
diff --git a/gateways/kafka/docs/CONFIGS.md b/gateways/kafka/docs/CONFIGS.md
index b734ca0a1..4acf0a942 100644
--- a/gateways/kafka/docs/CONFIGS.md
+++ b/gateways/kafka/docs/CONFIGS.md
@@ -34,7 +34,9 @@ the never-expire default. The gateway does not round.
## What an alter stores
-Only `retention.ms` is written, through `update_topic`'s `message_expiry`.
+Only `retention.ms` is written, through `update_topic`'s `message_expiry`. A
resource
+whose `configs` list does not name `retention.ms` is left unchanged. Omitting
the key
+does not clear a previously set expiry.
- `-1` stores `IggyExpiry::NeverExpire`. The server treats `0`
(`IggyExpiry::ServerDefault`) as "leave the current expiry", so `-1` is not
@@ -63,5 +65,6 @@ call `update_topic`.
| Resource type other than topic | `INVALID_REQUEST` (42) | Message is `only
topic resources are supported`. Other resources in the batch still run |
| Topic name fails the same rules as CreateTopics | `INVALID_TOPIC_EXCEPTION`
(17) | The message is the validation reason and does not repeat the topic name |
| Topic does not exist | `UNKNOWN_TOPIC_OR_PARTITION` (3) | Checked before
config keys, so a missing topic is not `INVALID_CONFIG` |
-| More than 100 distinct topic names | `POLICY_VIOLATION` (44) | Every
resource in the request. Duplicate names count once and are not an error by
themselves |
-| Non-empty tagged fields | `INVALID_REQUEST` (42) | Request-level tags fail
every resource. Per-resource or per-entry tags fail that resource |
+| More than 100 distinct topic names | `POLICY_VIOLATION` (44) | Every
resource in the request |
+| A topic name repeated across resources | `INVALID_REQUEST` (42) | Every
occurrence of the duplicate, not just the second one. Same choice CreateTopics
makes for a repeated topic name |
+| Non-empty unknown tagged fields | `INVALID_REQUEST` (42) | Request-level
tags fail every resource. A resource or config entry's own tags fail that
resource. Empty tagged fields are the normal flexible encoding |
diff --git a/gateways/kafka/src/protocol/handlers/alter_configs.rs
b/gateways/kafka/src/protocol/handlers/alter_configs.rs
index 26154fb75..8da1671b3 100644
--- a/gateways/kafka/src/protocol/handlers/alter_configs.rs
+++ b/gateways/kafka/src/protocol/handlers/alter_configs.rs
@@ -17,8 +17,6 @@
//! `AlterConfigs` (API key 33).
-use std::collections::HashMap;
-
use bytes::Bytes;
use iggy::prelude::IggyError;
use kafka_protocol::messages::AlterConfigsRequest;
@@ -39,8 +37,8 @@ use crate::protocol::api::{
};
use crate::protocol::bounds_guard::validate_alter_configs_shape;
use crate::protocol::handlers::topic_config::{
- ONLY_TOPIC_RESOURCES, RESOURCE_TYPE_TOPIC, UNKNOWN_TAGGED_FIELDS,
exceeds_topic_cap,
- plan_retention_update, topic_cap_message,
+ ONLY_TOPIC_RESOURCES, RESOURCE_TYPE_TOPIC, bridge_failure,
exceeds_topic_cap,
+ find_duplicate_names, name_reason, plan_retention_update, static_text,
topic_cap_message,
};
use crate::protocol::handlers::{
decode_guarded, encode_message, is_supported_version, respond_or_close,
@@ -73,17 +71,6 @@ pub async fn handle(state: &GatewayState, api_version: i16,
body: Bytes) -> Hand
}
};
- if !req.unknown_tagged_fields.is_empty() {
- return respond_or_close(
- encode_message(
- &response(reject_tagged_resources(&req.resources)),
- api_version,
- 256,
- ),
- "AlterConfigs",
- );
- }
-
let Some(bridge) = &state.bridge else {
let responses = req
.resources
@@ -129,12 +116,33 @@ async fn alter_all(
req: &AlterConfigsRequest,
deadline: Instant,
) -> Vec<AlterConfigsResourceResponse> {
- let topic_names = req
+ if !req.unknown_tagged_fields.is_empty() {
+ let message = static_text("unknown tagged fields are not supported");
+ return if req.resources.is_empty() {
+ vec![
+ AlterConfigsResourceResponse::default()
+ .with_error_code(ERROR_INVALID_REQUEST)
+ .with_error_message(Some(message))
+ .with_resource_type(RESOURCE_TYPE_TOPIC)
+ .with_resource_name(StrBytes::from_static_str("")),
+ ]
+ } else {
+ req.resources
+ .iter()
+ .map(|resource| {
+ resource_error(resource, ERROR_INVALID_REQUEST,
Some(message.clone()))
+ })
+ .collect()
+ };
+ }
+
+ let topic_names: Vec<&str> = req
.resources
.iter()
.filter(|resource| resource.resource_type == RESOURCE_TYPE_TOPIC)
- .map(|resource| resource.resource_name.as_str());
- if exceeds_topic_cap(topic_names) {
+ .map(|resource| resource.resource_name.as_str())
+ .collect();
+ if exceeds_topic_cap(topic_names.iter().copied()) {
let message = StrBytes::from(topic_cap_message("AlterConfigs"));
tracing::warn!("AlterConfigs request addresses too many distinct
topics; rejecting");
return req
@@ -144,14 +152,17 @@ async fn alter_all(
.collect();
}
- let mut seen: HashMap<String, AlterConfigsResourceResponse> =
HashMap::new();
+ // Real Kafka refuses every occurrence of a duplicate resource name
outright rather than
+ // picking a winner - the same choice CreateTopics makes for a repeated
topic name.
+ let duplicate_names = find_duplicate_names(topic_names.iter().copied());
+
let mut responses = Vec::with_capacity(req.resources.len());
let mut deadline_exceeded = false;
for resource in &req.resources {
if resource.resource_type == RESOURCE_TYPE_TOPIC
- && let Some(prior) = seen.get(resource.resource_name.as_str())
+ && duplicate_names.contains(resource.resource_name.as_str())
{
- responses.push(prior.clone());
+ responses.push(resource_error(resource, ERROR_INVALID_REQUEST,
None));
continue;
}
if deadline_exceeded || Instant::now() >= deadline {
@@ -162,9 +173,7 @@ async fn alter_all(
instead of starting new Iggy calls"
);
}
- let timed_out = resource_error(resource, ERROR_REQUEST_TIMED_OUT,
None);
- remember_topic(&mut seen, resource, &timed_out);
- responses.push(timed_out);
+ responses.push(resource_error(resource, ERROR_REQUEST_TIMED_OUT,
None));
continue;
}
@@ -172,22 +181,11 @@ async fn alter_all(
if Instant::now() >= deadline {
deadline_exceeded = true;
}
- remember_topic(&mut seen, resource, &result);
responses.push(result);
}
responses
}
-fn remember_topic(
- seen: &mut HashMap<String, AlterConfigsResourceResponse>,
- resource: &AlterConfigsResource,
- result: &AlterConfigsResourceResponse,
-) {
- if resource.resource_type == RESOURCE_TYPE_TOPIC {
- seen.insert(resource.resource_name.as_str().to_string(),
result.clone());
- }
-}
-
async fn alter_one(
bridge: &IggyBridge,
resource: &AlterConfigsResource,
@@ -203,7 +201,7 @@ async fn alter_one(
return resource_error(
resource,
ERROR_INVALID_REQUEST,
- Some(static_text(UNKNOWN_TAGGED_FIELDS)),
+ Some(static_text("unknown tagged fields are not supported")),
);
}
if resource.resource_type != RESOURCE_TYPE_TOPIC {
@@ -232,15 +230,23 @@ async fn alter_one(
);
return resource_error(resource, ERROR_REQUEST_TIMED_OUT, None);
}
- match bridge.get_kafka_topic(name).await {
- Ok(Some(_topic)) => {}
- Ok(None) => {
+ match tokio::time::timeout_at(deadline,
bridge.get_kafka_topic(name)).await {
+ Ok(Ok(Some(_topic))) => {}
+ Ok(Ok(None)) => {
return resource_error(resource, ERROR_UNKNOWN_TOPIC_OR_PARTITION,
None);
}
- Err(error) => {
- let (code, message) = bridge_failure(&error);
+ Ok(Err(error)) => {
+ let (code, message) = bridge_failure(&error, "AlterConfigs",
"altering");
return resource_error(resource, code, message);
}
+ Err(_elapsed) => {
+ tracing::warn!(
+ resource = name,
+ "AlterConfigs: this resource's bridge work exceeded the
request deadline; \
+ answering retriable instead of blocking further"
+ );
+ return resource_error(resource, ERROR_REQUEST_TIMED_OUT, None);
+ }
}
let expiry = match
plan_retention_update(resource.configs.iter().map(|config| {
@@ -258,12 +264,14 @@ async fn alter_one(
);
}
};
-
- // `validate_only` already performed the existence read and the same key
checks.
- // It does not call `update_topic`.
+ // A resource that does not name retention.ms has nothing to store.
Omitting the key
+ // does not clear a previously set expiry.
let Some(expiry) = expiry else {
return resource_error(resource, ERROR_NONE, None);
};
+
+ // `validate_only` already performed the existence read and the same key
checks.
+ // It does not call `update_topic`.
if validate_only {
return resource_error(resource, ERROR_NONE, None);
}
@@ -284,36 +292,12 @@ async fn alter_one(
resource_error(resource, ERROR_NONE, None)
}
Err(error) => {
- let (code, message) = bridge_failure(&error);
+ let (code, message) = bridge_failure(&error, "AlterConfigs",
"altering");
resource_error(resource, code, message)
}
}
}
-fn reject_tagged_resources(
- resources: &[AlterConfigsResource],
-) -> Vec<AlterConfigsResourceResponse> {
- if resources.is_empty() {
- return vec![
- AlterConfigsResourceResponse::default()
- .with_error_code(ERROR_INVALID_REQUEST)
- .with_error_message(Some(static_text(UNKNOWN_TAGGED_FIELDS)))
- .with_resource_type(0)
- .with_resource_name(StrBytes::from_static_str("")),
- ];
- }
- resources
- .iter()
- .map(|resource| {
- resource_error(
- resource,
- ERROR_INVALID_REQUEST,
- Some(static_text(UNKNOWN_TAGGED_FIELDS)),
- )
- })
- .collect()
-}
-
fn resource_error(
resource: &AlterConfigsResource,
error_code: i16,
@@ -325,35 +309,3 @@ fn resource_error(
.with_resource_type(resource.resource_type)
.with_resource_name(resource.resource_name.clone())
}
-
-fn name_reason(error: &BridgeError) -> String {
- match error {
- BridgeError::InvalidKafkaTopicName { reason, .. } => reason.clone(),
- _ => "invalid topic name".to_string(),
- }
-}
-
-fn bridge_failure(error: &BridgeError) -> (i16, Option<StrBytes>) {
- let code = error.to_kafka_error_code();
- match error {
- BridgeError::InvalidKafkaTopicName { reason, .. } => {
- tracing::debug!(reason, "AlterConfigs rejected an invalid topic
name");
- (code, Some(StrBytes::from(reason.clone())))
- }
- BridgeError::Timeout | BridgeError::SendLost(_) => {
- tracing::warn!(%error, "AlterConfigs exceeded the bridge
deadline");
- (code, None)
- }
- other => {
- tracing::error!(%other, "AlterConfigs failed while updating topic
configuration");
- (
- code,
- Some(static_text("internal error updating topic
configuration")),
- )
- }
- }
-}
-
-const fn static_text(message: &'static str) -> StrBytes {
- StrBytes::from_static_str(message)
-}
diff --git a/gateways/kafka/src/protocol/handlers/describe_configs.rs
b/gateways/kafka/src/protocol/handlers/describe_configs.rs
index 530b305a4..200261443 100644
--- a/gateways/kafka/src/protocol/handlers/describe_configs.rs
+++ b/gateways/kafka/src/protocol/handlers/describe_configs.rs
@@ -17,8 +17,6 @@
//! `DescribeConfigs` (API key 32).
-use std::collections::HashMap;
-
use bytes::Bytes;
use iggy::prelude::TopicDetails;
use kafka_protocol::messages::DescribeConfigsRequest;
@@ -30,8 +28,8 @@ use kafka_protocol::messages::describe_configs_response::{
use kafka_protocol::protocol::StrBytes;
use tokio::time::Instant;
+use crate::bridge::IggyBridge;
use crate::bridge::topic_map::validate_kafka_topic_name;
-use crate::bridge::{BridgeError, IggyBridge};
use crate::protocol::api::{
API_KEY_DESCRIBE_CONFIGS, ApiVersionRange, ERROR_INVALID_CONFIG,
ERROR_INVALID_REQUEST,
ERROR_INVALID_TOPIC_EXCEPTION, ERROR_NONE, ERROR_NOT_CONTROLLER,
ERROR_POLICY_VIOLATION,
@@ -41,9 +39,10 @@ use crate::protocol::api::{
use crate::protocol::bounds_guard::validate_describe_configs_shape;
use crate::protocol::handlers::topic_config::{
CLEANUP_DOC, CLEANUP_POLICY, CLEANUP_POLICY_VALUE, CONFIG_SOURCE_DEFAULT,
CONFIG_SOURCE_TOPIC,
- CONFIG_TYPE_LONG, CONFIG_TYPE_STRING, ListedKey, ONLY_TOPIC_RESOURCES,
RESOURCE_TYPE_TOPIC,
- RETENTION_DOC, RETENTION_MS, UNKNOWN_TAGGED_FIELDS, exceeds_topic_cap,
listed_keys,
- message_expiry_is_explicit, retention_ms, topic_cap_message,
+ CONFIG_TYPE_LONG, CONFIG_TYPE_STRING, CONFIG_TYPE_UNKNOWN, ListedKey,
ONLY_TOPIC_RESOURCES,
+ RESOURCE_TYPE_TOPIC, RETENTION_DOC, RETENTION_MS, bridge_failure,
exceeds_topic_cap,
+ find_duplicate_names, listed_keys, message_expiry_is_explicit,
name_reason, retention_ms,
+ static_text, topic_cap_message,
};
use crate::protocol::handlers::{
decode_guarded, encode_message, is_supported_version, respond_or_close,
@@ -76,17 +75,6 @@ pub async fn handle(state: &GatewayState, api_version: i16,
body: Bytes) -> Hand
}
};
- if !req.unknown_tagged_fields.is_empty() {
- return respond_or_close(
- encode_message(
- &response(reject_tagged_resources(&req.resources)),
- api_version,
- 256,
- ),
- "DescribeConfigs",
- );
- }
-
let Some(bridge) = &state.bridge else {
let results = req
.resources
@@ -133,12 +121,39 @@ async fn describe_all(
req: &DescribeConfigsRequest,
deadline: Instant,
) -> Vec<DescribeConfigsResult> {
- let topic_names = req
+ if !req.unknown_tagged_fields.is_empty() {
+ let message = static_text("unknown tagged fields are not supported");
+ return if req.resources.is_empty() {
+ vec![
+ DescribeConfigsResult::default()
+ .with_error_code(ERROR_INVALID_REQUEST)
+ .with_error_message(Some(message))
+ .with_resource_type(RESOURCE_TYPE_TOPIC)
+ .with_resource_name(StrBytes::from_static_str(""))
+ .with_configs(Vec::new()),
+ ]
+ } else {
+ req.resources
+ .iter()
+ .map(|resource| {
+ resource_error(
+ resource,
+ ERROR_INVALID_REQUEST,
+ Some(message.clone()),
+ Vec::new(),
+ )
+ })
+ .collect()
+ };
+ }
+
+ let topic_names: Vec<&str> = req
.resources
.iter()
.filter(|resource| resource.resource_type == RESOURCE_TYPE_TOPIC)
- .map(|resource| resource.resource_name.as_str());
- if exceeds_topic_cap(topic_names) {
+ .map(|resource| resource.resource_name.as_str())
+ .collect();
+ if exceeds_topic_cap(topic_names.iter().copied()) {
let message = StrBytes::from(topic_cap_message("DescribeConfigs"));
tracing::warn!("DescribeConfigs request addresses too many distinct
topics; rejecting");
return req
@@ -155,16 +170,23 @@ async fn describe_all(
.collect();
}
- // `get_kafka_topics` lists partition counts, not `message_expiry`, so
each distinct
- // topic is one `get_kafka_topic`. Duplicates reuse the first result and
do not call again.
- let mut seen: HashMap<String, DescribeConfigsResult> = HashMap::new();
+ // Real Kafka refuses every occurrence of a duplicate resource name
outright rather than
+ // answering it from a cached result that may have been filtered by a
different
+ // `configuration_keys` list - the same choice CreateTopics makes for a
repeated topic name.
+ let duplicate_names = find_duplicate_names(topic_names.iter().copied());
+
let mut results = Vec::with_capacity(req.resources.len());
let mut deadline_exceeded = false;
for resource in &req.resources {
if resource.resource_type == RESOURCE_TYPE_TOPIC
- && let Some(prior) = seen.get(resource.resource_name.as_str())
+ && duplicate_names.contains(resource.resource_name.as_str())
{
- results.push(prior.clone());
+ results.push(resource_error(
+ resource,
+ ERROR_INVALID_REQUEST,
+ None,
+ Vec::new(),
+ ));
continue;
}
if deadline_exceeded || Instant::now() >= deadline {
@@ -175,9 +197,12 @@ async fn describe_all(
instead of starting new Iggy calls"
);
}
- let timed_out = resource_error(resource, ERROR_REQUEST_TIMED_OUT,
None, Vec::new());
- remember_topic(&mut seen, resource, &timed_out);
- results.push(timed_out);
+ results.push(resource_error(
+ resource,
+ ERROR_REQUEST_TIMED_OUT,
+ None,
+ Vec::new(),
+ ));
continue;
}
@@ -192,22 +217,11 @@ async fn describe_all(
if Instant::now() >= deadline {
deadline_exceeded = true;
}
- remember_topic(&mut seen, resource, &result);
results.push(result);
}
results
}
-fn remember_topic(
- seen: &mut HashMap<String, DescribeConfigsResult>,
- resource: &DescribeConfigsResource,
- result: &DescribeConfigsResult,
-) {
- if resource.resource_type == RESOURCE_TYPE_TOPIC {
- seen.insert(resource.resource_name.as_str().to_string(),
result.clone());
- }
-}
-
async fn describe_one(
bridge: &IggyBridge,
resource: &DescribeConfigsResource,
@@ -219,7 +233,7 @@ async fn describe_one(
return resource_error(
resource,
ERROR_INVALID_REQUEST,
- Some(static_text(UNKNOWN_TAGGED_FIELDS)),
+ Some(static_text("unknown tagged fields are not supported")),
Vec::new(),
);
}
@@ -248,34 +262,49 @@ async fn describe_one(
);
return resource_error(resource, ERROR_REQUEST_TIMED_OUT, None,
Vec::new());
}
- let topic = match bridge.get_kafka_topic(name).await {
- Ok(Some(topic)) => topic,
- Ok(None) => {
+ let topic = match tokio::time::timeout_at(deadline,
bridge.get_kafka_topic(name)).await {
+ Ok(Ok(Some(topic))) => topic,
+ Ok(Ok(None)) => {
return resource_error(resource, ERROR_UNKNOWN_TOPIC_OR_PARTITION,
None, Vec::new());
}
- Err(error) => {
- let (code, message) = bridge_failure(&error, "reading");
+ Ok(Err(error)) => {
+ let (code, message) = bridge_failure(&error, "DescribeConfigs",
"reading");
return resource_error(resource, code, message, Vec::new());
}
+ Err(_elapsed) => {
+ tracing::warn!(
+ resource = name,
+ "DescribeConfigs: this resource's bridge work exceeded the
request deadline; \
+ answering retriable instead of blocking further"
+ );
+ return resource_error(resource, ERROR_REQUEST_TIMED_OUT, None,
Vec::new());
+ }
};
- let (configs, unknown, unrepresentable) = described_entries(
+ let (configs, unknown_key, unrepresentable) = described_entries(
&topic,
resource.configuration_keys.as_deref(),
include_synonyms,
include_documentation,
);
- if unknown || unrepresentable {
- let message = if unrepresentable {
- "stored message expiry is not a whole number of milliseconds"
- } else {
- "unknown config key"
- };
+ if let Some(unknown_key) = unknown_key {
return resource_error(
resource,
ERROR_INVALID_CONFIG,
- Some(static_text(message)),
+ Some(StrBytes::from(format!(
+ "unknown config key '{unknown_key}'"
+ ))),
+ configs,
+ );
+ }
+ if unrepresentable {
+ return resource_error(
+ resource,
+ ERROR_INVALID_CONFIG,
+ Some(static_text(
+ "stored message expiry is not a whole number of milliseconds",
+ )),
configs,
);
}
@@ -288,11 +317,11 @@ fn described_entries(
configuration_keys: Option<&[StrBytes]>,
include_synonyms: bool,
include_documentation: bool,
-) -> (Vec<DescribeConfigsResourceResult>, bool, bool) {
+) -> (Vec<DescribeConfigsResourceResult>, Option<String>, bool) {
let explicit = message_expiry_is_explicit(&topic.options);
let retention = retention_ms(topic.message_expiry, explicit);
let keys = listed_keys(configuration_keys);
- let mut unknown = false;
+ let mut unknown_key = None;
let mut unrepresentable = false;
let mut configs = Vec::with_capacity(keys.len());
for key in keys {
@@ -327,20 +356,22 @@ fn described_entries(
include_documentation.then_some(CLEANUP_DOC),
)),
ListedKey::Unknown(name) => {
- unknown = true;
+ if unknown_key.is_none() {
+ unknown_key = Some(name.clone());
+ }
configs.push(config_entry(
&name,
None,
false,
CONFIG_SOURCE_DEFAULT,
- 0,
+ CONFIG_TYPE_UNKNOWN,
include_synonyms,
None,
));
}
}
}
- (configs, unknown, unrepresentable)
+ (configs, unknown_key, unrepresentable)
}
fn config_entry(
@@ -363,33 +394,17 @@ fn config_entry(
.with_documentation(documentation.map(|text|
StrBytes::from(text.to_string())))
}
-/// Neither known key has a synonym. `include_synonyms` asks for the list and
receives none.
-const fn synonyms(_include_synonyms: bool) -> Vec<DescribeConfigsSynonym> {
- Vec::new()
-}
-
-fn reject_tagged_resources(resources: &[DescribeConfigsResource]) ->
Vec<DescribeConfigsResult> {
- if resources.is_empty() {
- return vec![
- DescribeConfigsResult::default()
- .with_error_code(ERROR_INVALID_REQUEST)
- .with_error_message(Some(static_text(UNKNOWN_TAGGED_FIELDS)))
- .with_resource_type(0)
- .with_resource_name(StrBytes::from_static_str(""))
- .with_configs(Vec::new()),
- ];
+/// Neither known key has a synonym. `true` asks for the list and receives
none.
+#[allow(
+ clippy::if_same_then_else,
+ reason = "true asks for synonyms and there are none"
+)]
+fn synonyms(include_synonyms: bool) -> Vec<DescribeConfigsSynonym> {
+ if include_synonyms {
+ Vec::new()
+ } else {
+ Vec::new()
}
- resources
- .iter()
- .map(|resource| {
- resource_error(
- resource,
- ERROR_INVALID_REQUEST,
- Some(static_text(UNKNOWN_TAGGED_FIELDS)),
- Vec::new(),
- )
- })
- .collect()
}
fn resource_error(
@@ -405,35 +420,3 @@ fn resource_error(
.with_resource_name(resource.resource_name.clone())
.with_configs(configs)
}
-
-fn name_reason(error: &BridgeError) -> String {
- match error {
- BridgeError::InvalidKafkaTopicName { reason, .. } => reason.clone(),
- _ => "invalid topic name".to_string(),
- }
-}
-
-fn bridge_failure(error: &BridgeError, action: &str) -> (i16,
Option<StrBytes>) {
- let code = error.to_kafka_error_code();
- match error {
- BridgeError::InvalidKafkaTopicName { reason, .. } => {
- tracing::debug!(reason, "DescribeConfigs rejected an invalid topic
name");
- (code, Some(StrBytes::from(reason.clone())))
- }
- BridgeError::Timeout | BridgeError::SendLost(_) => {
- tracing::warn!(%error, "DescribeConfigs {action} exceeded the
bridge deadline");
- (code, None)
- }
- other => {
- tracing::error!(%other, "DescribeConfigs failed while {action}
topic configuration");
- (
- code,
- Some(static_text("internal error reading topic
configuration")),
- )
- }
- }
-}
-
-const fn static_text(message: &'static str) -> StrBytes {
- StrBytes::from_static_str(message)
-}
diff --git a/gateways/kafka/src/protocol/handlers/topic_config.rs
b/gateways/kafka/src/protocol/handlers/topic_config.rs
index 876092282..1b6ccb56c 100644
--- a/gateways/kafka/src/protocol/handlers/topic_config.rs
+++ b/gateways/kafka/src/protocol/handlers/topic_config.rs
@@ -30,6 +30,8 @@ use std::str::FromStr;
use iggy::prelude::{HeaderKey, IggyDuration, IggyExpiry, ResourceOptions,
topic_option_keys};
use kafka_protocol::protocol::StrBytes;
+use crate::bridge::BridgeError;
+
/// Kafka `ConfigResource.Type.TOPIC`.
pub const RESOURCE_TYPE_TOPIC: i8 = 2;
@@ -45,12 +47,14 @@ pub const CONFIG_TYPE_STRING: i8 = 2;
/// Kafka `ConfigType.LONG`.
pub const CONFIG_TYPE_LONG: i8 = 5;
+/// Kafka `ConfigType.UNKNOWN`.
+pub const CONFIG_TYPE_UNKNOWN: i8 = 0;
+
pub const RETENTION_MS: &str = "retention.ms";
pub const CLEANUP_POLICY: &str = "cleanup.policy";
pub const CLEANUP_POLICY_VALUE: &str = "delete";
pub const ONLY_TOPIC_RESOURCES: &str = "only topic resources are supported";
-pub const UNKNOWN_TAGGED_FIELDS: &str = "unknown tagged fields are not
supported";
pub const RETENTION_DOC: &str = "How long a sealed segment is kept, in
milliseconds. The active segment does not expire. -1 means sealed segments
never expire.";
pub const CLEANUP_DOC: &str = "Iggy deletes expired messages and does not
compact a topic.";
@@ -58,8 +62,10 @@ pub const CLEANUP_DOC: &str = "Iggy deletes expired messages
and does not compac
/// Distinct Kafka topic names one `DescribeConfigs` or `AlterConfigs` request
may send to Iggy.
///
/// Same ceiling as `CreateTopics`: each distinct name is a lockstep call on
the shared client.
-/// Duplicate names in one request count once. A larger batch is
`POLICY_VIOLATION` on every
-/// resource. The decode-time element ceiling is a separate, larger limit and
is not this cap.
+/// Duplicate names in one request count once for this cap, but a repeated
name is itself
+/// rejected outright - see [`find_duplicate_names`]. A larger batch is
`POLICY_VIOLATION` on
+/// every resource. The decode-time element ceiling is a separate, larger
limit and is not this
+/// cap.
pub const MAX_CONFIG_TOPICS: usize = 100;
/// One config name a describe request asked for, in request order.
@@ -139,12 +145,8 @@ pub fn retention_ms(expiry: IggyExpiry, explicit: bool) ->
Result<RetentionMs, (
if micros == 0 || !micros.is_multiple_of(1_000) {
return Err(());
}
- let millis = i64::try_from(micros / 1_000).map_err(|_| ())?;
- if millis <= 0 {
- return Err(());
- }
Ok(RetentionMs {
- value: millis.to_string(),
+ value: (micros / 1_000).to_string(),
source: CONFIG_SOURCE_TOPIC,
})
}
@@ -177,9 +179,9 @@ pub fn parse_retention_ms(value: &str) ->
Result<IggyExpiry, ConfigFault> {
return Err(ConfigFault::InvalidRetention);
}
let millis = u64::try_from(millis).map_err(|_|
ConfigFault::InvalidRetention)?;
- // `IggyExpiry::from_str` rejects `as_secs() > u32::MAX`. A longer
duration makes
- // `Segment::is_expired` wrap `max_timestamp + micros` (and panic in
debug), so a
- // sealed segment looks expired and is deleted.
+ // `IggyExpiry::from_str` rejects `as_secs() > u32::MAX`. The typed update
path
+ // does not, but a value the server's own parser refuses is not one this
gateway
+ // should store.
if millis / 1_000 > u64::from(u32::MAX) {
return Err(ConfigFault::InvalidRetention);
}
@@ -203,6 +205,67 @@ pub fn topic_cap_message(api_name: &str) -> String {
)
}
+/// Names in `names` that occur more than once.
+///
+/// Real Kafka refuses every occurrence of a duplicate resource name with
`INVALID_REQUEST`
+/// (42) rather than silently picking a winner - the same choice
`CreateTopics`' own
+/// `find_duplicate_names` makes for a repeated topic name in one batch.
+#[must_use]
+pub fn find_duplicate_names<'a>(names: impl IntoIterator<Item = &'a str>) ->
HashSet<&'a str> {
+ let mut seen = HashSet::new();
+ let mut duplicates = HashSet::new();
+ for name in names {
+ if !seen.insert(name) {
+ duplicates.insert(name);
+ }
+ }
+ duplicates
+}
+
+/// The reason a topic name failed Kafka's own naming rules, or a fixed
fallback.
+#[must_use]
+pub fn name_reason(error: &BridgeError) -> String {
+ match error {
+ BridgeError::InvalidKafkaTopicName { reason, .. } => reason.clone(),
+ _ => "invalid topic name".to_string(),
+ }
+}
+
+#[must_use]
+pub const fn static_text(message: &'static str) -> StrBytes {
+ StrBytes::from_static_str(message)
+}
+
+/// Maps a bridge failure to a Kafka error code and client-facing message,
logging the real
+/// cause server-side.
+///
+/// `handler` names the API in the log line
(`"DescribeConfigs"`/`"AlterConfigs"`); `action` is
+/// the present participle of what the bridge call was doing
(`"reading"`/`"altering"`), reused
+/// in both the timeout log and the generic internal-error message.
+#[must_use]
+pub fn bridge_failure(error: &BridgeError, handler: &str, action: &str) ->
(i16, Option<StrBytes>) {
+ let code = error.to_kafka_error_code();
+ match error {
+ BridgeError::InvalidKafkaTopicName { reason, .. } => {
+ tracing::debug!(reason, "{handler} rejected an invalid topic
name");
+ (code, Some(StrBytes::from(reason.clone())))
+ }
+ BridgeError::Timeout | BridgeError::SendLost(_) => {
+ tracing::warn!(%error, "{handler} {action} exceeded the bridge
deadline");
+ (code, None)
+ }
+ other => {
+ tracing::error!(%other, "{handler} failed while {action} topic
configuration");
+ (
+ code,
+ Some(StrBytes::from(format!(
+ "internal error {action} topic configuration"
+ ))),
+ )
+ }
+ }
+}
+
/// Keys to return for one describe resource.
///
/// `None` and an empty list both mean both known keys. Any other name is
@@ -327,6 +390,17 @@ mod tests {
);
}
+ #[test]
+ fn find_duplicate_names_finds_a_name_repeated_across_two_resources() {
+ let duplicates = find_duplicate_names(["orders", "payments",
"orders"]);
+ assert_eq!(duplicates, HashSet::from(["orders"]));
+ }
+
+ #[test]
+ fn find_duplicate_names_is_empty_when_every_name_is_unique() {
+ assert!(find_duplicate_names(["orders", "payments"]).is_empty());
+ }
+
#[test]
fn one_bad_key_rejects_the_whole_alter_plan() {
let err = plan_retention_update([(RETENTION_MS, Some("5000")),
("no.such", Some("1"))])
diff --git a/gateways/kafka/tests/common/wire.rs
b/gateways/kafka/tests/common/wire.rs
index 1605f1885..b9dd3c6bb 100644
--- a/gateways/kafka/tests/common/wire.rs
+++ b/gateways/kafka/tests/common/wire.rs
@@ -26,10 +26,24 @@
//! every function some *other* binary uses as unused here.
#![allow(dead_code)]
-use bytes::Bytes;
+use bytes::{Bytes, BytesMut};
+use kafka_protocol::protocol::{Decodable, Encodable};
use super::codec::Encoder;
+/// Encodes any `kafka_protocol` request or response type at `version`.
+pub fn encode<M: Encodable>(message: &M, version: i16) -> Bytes {
+ let mut buf = BytesMut::new();
+ message.encode(&mut buf, version).expect("encode request");
+ buf.freeze()
+}
+
+/// Decodes any `kafka_protocol` request or response type at `version`.
+pub fn decode<M: Decodable>(body: Bytes, version: i16) -> M {
+ let mut buf = body;
+ M::decode(&mut buf, version).expect("decode response")
+}
+
/// Consumer-group and admin keys explicitly out of scope in SCOPE.md.
pub const OUT_OF_SCOPE_API_KEYS: &[(i16, &str)] = &[
(8, "OffsetCommit"),
diff --git a/gateways/kafka/tests/configs_handler_tests.rs
b/gateways/kafka/tests/configs_handler_tests.rs
index ede44539b..4bf8c9346 100644
--- a/gateways/kafka/tests/configs_handler_tests.rs
+++ b/gateways/kafka/tests/configs_handler_tests.rs
@@ -17,7 +17,6 @@
//! `DescribeConfigs` and `AlterConfigs` with the bridge off.
-use bytes::{Bytes, BytesMut};
use kafka_protocol::messages::alter_configs_request::{
AlterConfigsRequest, AlterConfigsResource, AlterableConfig,
};
@@ -26,13 +25,20 @@ use kafka_protocol::messages::describe_configs_request::{
DescribeConfigsRequest, DescribeConfigsResource,
};
use
kafka_protocol::messages::describe_configs_response::DescribeConfigsResponse;
-use kafka_protocol::protocol::{Decodable, Encodable, StrBytes};
+use kafka_protocol::protocol::StrBytes;
use tokio_util::sync::CancellationToken;
use iggy_gateway_kafka::group::{GroupCoordinator, GroupCoordinatorConfig};
use iggy_gateway_kafka::protocol::api::{BrokerAdvertise, ERROR_NOT_CONTROLLER,
GatewayState};
use iggy_gateway_kafka::protocol::handlers::{alter_configs, describe_configs};
+#[path = "common/codec.rs"]
+mod codec;
+#[path = "common/wire.rs"]
+mod wire;
+
+use wire::{decode, encode};
+
const DESCRIBE_VERSION: i16 = 4;
const ALTER_VERSION: i16 = 2;
@@ -47,17 +53,6 @@ fn state_without_bridge() -> GatewayState {
)
}
-fn encode<M: Encodable>(message: &M, version: i16) -> Bytes {
- let mut buf = BytesMut::new();
- message.encode(&mut buf, version).expect("encode request");
- buf.freeze()
-}
-
-fn decode<M: Decodable>(body: Bytes, version: i16) -> M {
- let mut buf = body;
- M::decode(&mut buf, version).expect("decode response")
-}
-
#[tokio::test]
async fn describe_configs_without_a_bridge_returns_not_controller() {
let request = DescribeConfigsRequest::default()
diff --git a/gateways/kafka/tests/configs_java_client_tests.rs
b/gateways/kafka/tests/configs_java_client_tests.rs
index 4dc590508..1fde0a4ec 100644
--- a/gateways/kafka/tests/configs_java_client_tests.rs
+++ b/gateways/kafka/tests/configs_java_client_tests.rs
@@ -46,6 +46,34 @@ const TOPIC: &str = "orders";
/// process until the runner itself was killed.
const CHILD_LIMIT: Duration = Duration::from_secs(120);
+/// Reports why the suite cannot run, and whether that is fatal.
+///
+/// Returns `true` when the caller should skip. `JAVA_E2E_REQUIRED=1` makes it
panic instead, so a
+/// CI job that means to run this fails loudly rather than reporting a pass
over zero assertions -
+/// same convention as `kafka_client_e2e_tests.rs`'s own
`skip`/`KAFKA_E2E_REQUIRED`.
+fn skip(reason: &str) -> bool {
+ assert!(
+ std::env::var("JAVA_E2E_REQUIRED").as_deref() != Ok("1"),
+ "JAVA_E2E_REQUIRED=1 but the suite cannot run: {reason}"
+ );
+ eprintln!("skipping Java admin-client end-to-end test: {reason}");
+ true
+}
+
+fn java_missing() -> bool {
+ let available = Command::new("java")
+ .arg("-version")
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .is_ok_and(|status| status.success());
+ if available {
+ false
+ } else {
+ skip("java is unavailable")
+ }
+}
+
fn output_bounded(command: &mut Command, what: &str) -> Output {
let mut child = command
.stdout(Stdio::piped())
@@ -188,12 +216,9 @@ async fn spawn_gateway(server: &TestServer) -> SocketAddr {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kafka_39_admin_client_describes_and_alters_topic_configs() {
- let java = output_bounded(Command::new("java").arg("-version"), "java
-version");
- assert!(
- java.status.success(),
- "java -version failed: {}",
- String::from_utf8_lossy(&java.stderr)
- );
+ if java_missing() {
+ return;
+ }
let jars = jar_dir();
ensure_jars(&jars);
diff --git a/gateways/kafka/tests/configs_real_bridge_tests.rs
b/gateways/kafka/tests/configs_real_bridge_tests.rs
index 933a85a9f..8893c6ee7 100644
--- a/gateways/kafka/tests/configs_real_bridge_tests.rs
+++ b/gateways/kafka/tests/configs_real_bridge_tests.rs
@@ -19,7 +19,6 @@
use std::sync::Arc;
-use bytes::{Bytes, BytesMut};
use kafka_protocol::messages::alter_configs_request::{
AlterConfigsRequest, AlterConfigsResource, AlterableConfig,
};
@@ -30,7 +29,7 @@ use kafka_protocol::messages::describe_configs_request::{
use kafka_protocol::messages::describe_configs_response::{
DescribeConfigsResourceResult, DescribeConfigsResponse,
};
-use kafka_protocol::protocol::{Decodable, Encodable, StrBytes};
+use kafka_protocol::protocol::StrBytes;
use serial_test::serial;
use tokio_util::sync::CancellationToken;
@@ -42,10 +41,15 @@ use iggy_gateway_kafka::protocol::api::{
};
use iggy_gateway_kafka::protocol::handlers::{alter_configs, describe_configs};
+#[path = "common/codec.rs"]
+mod codec;
#[path = "common/iggy_server.rs"]
mod iggy_server;
+#[path = "common/wire.rs"]
+mod wire;
use iggy_server::TestServer;
+use wire::{decode, encode};
const DESCRIBE_V4: i16 = 4;
const DESCRIBE_V1: i16 = 1;
@@ -59,17 +63,6 @@ const fn text(value: &'static str) -> StrBytes {
StrBytes::from_static_str(value)
}
-fn encode<M: Encodable>(message: &M, version: i16) -> Bytes {
- let mut buf = BytesMut::new();
- message.encode(&mut buf, version).expect("encode request");
- buf.freeze()
-}
-
-fn decode<M: Decodable>(body: Bytes, version: i16) -> M {
- let mut buf = body;
- M::decode(&mut buf, version).expect("decode response")
-}
-
async fn connected_state(server: &TestServer) -> GatewayState {
let bridge = IggyBridge::connect(server.test_config())
.await
@@ -318,7 +311,10 @@ async fn
describe_and_alter_configs_reject_unknown_nontopic_missing_and_invalid_
.error_message
.as_ref()
.map_or("", StrBytes::as_str);
- assert!(!message.contains("no.such"));
+ assert!(
+ message.contains("no.such"),
+ "unknown-key message must name the key, matching AlterConfigs' own
wording: {message}"
+ );
let broker = describe(
&state,
@@ -519,3 +515,120 @@ async fn
alter_configs_one_bad_key_skips_only_that_resource() {
);
assert_eq!(described.results[1].configs[0].config_source, SOURCE_TOPIC);
}
+
+/// Regression test for the duplicate-resource memoization bug: a second
`AlterConfigs`
+/// resource entry for an already-seen topic name must not be silently
answered from a cached
+/// result - its own, different `retention.ms` must never be reported as
applied when the write
+/// for it never ran.
+#[tokio::test]
+#[serial]
+async fn alter_configs_rejects_every_occurrence_of_a_duplicate_resource_name()
{
+ let data_dir = tempfile::tempdir().expect("tempdir");
+ let server = TestServer::spawn(data_dir.path()).await;
+ let state = connected_state(&server).await;
+ create_topic(&state, "orders").await;
+
+ let altered = alter(
+ &state,
+ ALTER_V2,
+ vec![
+ alter_resource("orders", TOPIC, &[("retention.ms", "4000")]),
+ alter_resource("orders", TOPIC, &[("retention.ms", "5000")]),
+ ],
+ false,
+ )
+ .await;
+ assert_eq!(altered.responses.len(), 2);
+ assert_eq!(altered.responses[0].error_code, ERROR_INVALID_REQUEST);
+ assert_eq!(altered.responses[1].error_code, ERROR_INVALID_REQUEST);
+
+ // Neither occurrence's value was applied - the stored retention is still
the default.
+ let described = describe(
+ &state,
+ DESCRIBE_V4,
+ vec![describe_resource("orders", Some(vec!["retention.ms"]))],
+ false,
+ false,
+ )
+ .await;
+ assert_eq!(
+ described.results[0].configs[0]
+ .value
+ .as_ref()
+ .map(StrBytes::as_str),
+ Some("-1")
+ );
+}
+
+/// The `DescribeConfigs` sibling of the same memoization pattern: a duplicate
resource name
+/// with a *different* `configuration_keys` filter must not be answered from
the first entry's
+/// cached, differently-filtered result.
+#[tokio::test]
+#[serial]
+async fn
describe_configs_rejects_every_occurrence_of_a_duplicate_resource_name() {
+ let data_dir = tempfile::tempdir().expect("tempdir");
+ let server = TestServer::spawn(data_dir.path()).await;
+ let state = connected_state(&server).await;
+ create_topic(&state, "orders").await;
+
+ let described = describe(
+ &state,
+ DESCRIBE_V4,
+ vec![
+ describe_resource("orders", Some(vec!["retention.ms"])),
+ describe_resource("orders", Some(vec!["cleanup.policy"])),
+ ],
+ false,
+ false,
+ )
+ .await;
+ assert_eq!(described.results.len(), 2);
+ assert_eq!(described.results[0].error_code, ERROR_INVALID_REQUEST);
+ assert_eq!(described.results[1].error_code, ERROR_INVALID_REQUEST);
+}
+
+/// An alter that does not name `retention.ms` does not clear a previously set
expiry.
+#[tokio::test]
+#[serial]
+async fn alter_configs_with_no_configs_leaves_retention_unchanged() {
+ let data_dir = tempfile::tempdir().expect("tempdir");
+ let server = TestServer::spawn(data_dir.path()).await;
+ let state = connected_state(&server).await;
+ create_topic(&state, "orders").await;
+
+ let first = alter(
+ &state,
+ ALTER_V2,
+ vec![alter_resource("orders", TOPIC, &[("retention.ms", "4000")])],
+ false,
+ )
+ .await;
+ assert_eq!(first.responses[0].error_code, ERROR_NONE);
+
+ let reset = alter(
+ &state,
+ ALTER_V2,
+ vec![alter_resource("orders", TOPIC, &[])],
+ false,
+ )
+ .await;
+ assert_eq!(reset.responses[0].error_code, ERROR_NONE);
+
+ let described = describe(
+ &state,
+ DESCRIBE_V4,
+ vec![describe_resource("orders", Some(vec!["retention.ms"]))],
+ false,
+ false,
+ )
+ .await;
+ assert_eq!(
+ described.results[0].configs[0]
+ .value
+ .as_ref()
+ .map(StrBytes::as_str),
+ Some("4000"),
+ "an empty configs list must not clear a previously set retention.ms"
+ );
+ assert_eq!(described.results[0].configs[0].config_source, SOURCE_TOPIC);
+}