This is an automated email from the ASF dual-hosted git repository.
ryerraguntla pushed a commit to branch feat/gateways-3546-delete-topics-only
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to
refs/heads/feat/gateways-3546-delete-topics-only by this push:
new 657228341 Fixing review comments
657228341 is described below
commit 657228341ea6394ddc083ab545083ccc1307fd34
Author: ryerraguntla <[email protected]>
AuthorDate: Wed Oct 7 06:27:44 2026 -0400
Fixing review comments
---
gateways/kafka/README.md | 5 +-
gateways/kafka/docs/SCOPE.md | 18 +++-
gateways/kafka/src/protocol/api.rs | 9 +-
.../kafka/src/protocol/handlers/create_topics.rs | 79 +++++++++-----
.../kafka/src/protocol/handlers/delete_topics.rs | 115 +++++++++++++++++++--
gateways/kafka/src/protocol/handlers/mod.rs | 4 +-
gateways/kafka/src/server.rs | 3 +
gateways/kafka/tests/api_handler_tests.rs | 58 +++++++++--
gateways/kafka/tests/common/wire.rs | 23 +++++
.../kafka/tests/delete_topics_real_bridge_tests.rs | 32 ++++--
gateways/kafka/tests/fetch_real_bridge_tests.rs | 10 +-
11 files changed, 293 insertions(+), 63 deletions(-)
diff --git a/gateways/kafka/README.md b/gateways/kafka/README.md
index 0145462cc..f95f599da 100644
--- a/gateways/kafka/README.md
+++ b/gateways/kafka/README.md
@@ -9,7 +9,10 @@ Foundation layer for
[apache/iggy#3421](https://github.com/apache/iggy/issues/34
> **CreateTopics and DeleteTopics run as the bridge's own Iggy user**: with
> the bridge on and
> `IGGY_KAFKA_SASL_ENABLED` off (the default), any client that can reach this
> port can create
> topics (up to 1000 partitions each) and can delete topics and their data.
> Every client reads and
-> writes as the bridge's Iggy user. See [docs/SCOPE.md](docs/SCOPE.md).
+> writes as the bridge's Iggy user. With SASL on, `DeleteTopics` additionally
requires the
+> authenticated principal to hold `manage_topics`; without it every name in
the request fails
+> `TOPIC_AUTHORIZATION_FAILED` (29) and nothing is deleted. `CreateTopics` has
no such gate yet -
+> SASL on does not protect it. See [docs/SCOPE.md](docs/SCOPE.md).
>
> InitProducerId does real work too, with or without the bridge: it allocates
> a producer id, so a stock idempotent producer starts instead of failing at
> startup.
>
diff --git a/gateways/kafka/docs/SCOPE.md b/gateways/kafka/docs/SCOPE.md
index 09d5deafe..234a7525f 100644
--- a/gateways/kafka/docs/SCOPE.md
+++ b/gateways/kafka/docs/SCOPE.md
@@ -243,13 +243,21 @@ below it are still open for the issues that build on top
of it.
[`BRIDGE_MAPPING.md`](BRIDGE_MAPPING.md).
- Per-topic results are independent: one missing or invalid name in a batch
does not block an
existing, validly-named topic elsewhere in the same request from being
deleted.
- - A name repeated within one request is not specially rejected the way
`CreateTopics` rejects a
- duplicate - deleting the same topic twice has no race to protect against
the way creating it
- twice does, so the first occurrence deletes it and the second then
correctly answers
- `UNKNOWN_TOPIC_OR_PARTITION` (3) because the topic genuinely no longer
exists.
+ - A name repeated within one request is rejected outright for every
occurrence
+ (`INVALID_REQUEST`, 42, no bridge call for any occurrence), the same
choice `CreateTopics`
+ makes for a repeated topic name: real Kafka's
`ControllerApis.deleteTopics` answers
+ `INVALID_REQUEST` "Duplicate topic name" for every occurrence and deletes
nothing, rather
+ than a first-occurrence-wins split that would let an `AdminClient` caller
observe a delete it
+ never got a clean per-name answer for.
- Same bridge-fan-out bounds as CreateTopics: a request naming more than 100
topic names is
rejected outright (`POLICY_VIOLATION`, no bridge call for any of them),
and the wire
- `timeout_ms` (clamped to `[1s, 30s]`) bounds the whole handler's aggregate
bridge work.
+ `timeout_ms` (clamped to `[1s, 30s]`) bounds the whole handler's aggregate
bridge work. The
+ deadline is checked before each name's own bridge call starts, not only
inside the
+ `timeout_at` wrapping it - `timeout_at` polls its inner future before it
checks the delay, so
+ a deadline that already passed would otherwise still start a real delete.
+ - With SASL on, the authenticated principal must hold `manage_topics` -
without it every name
+ in the request fails `TOPIC_AUTHORIZATION_FAILED` (29) and
`delete_kafka_topic` is never
+ called. With SASL off there is no principal to check, and the request is
not gated here.
- v6 (the `topics: Vec<DeleteTopicState>`, topic-id-based shape) is not
advertised - this bridge
has no concept of a Kafka topic id, only the Kafka-side name
`TopicMapping` resolves.
- [x] Document partition mapping in [`BRIDGE_MAPPING.md`](BRIDGE_MAPPING.md):
diff --git a/gateways/kafka/src/protocol/api.rs
b/gateways/kafka/src/protocol/api.rs
index f8965a22b..b65916a44 100644
--- a/gateways/kafka/src/protocol/api.rs
+++ b/gateways/kafka/src/protocol/api.rs
@@ -26,6 +26,7 @@ use kafka_protocol::messages::{
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
+use crate::auth::AuthenticatedPrincipal;
use crate::bridge::IggyBridge;
use crate::error::Result;
use crate::group::{GroupCoordinator, GroupCoordinatorConfig};
@@ -407,18 +408,22 @@ pub async fn handle_request_bounded(
body: Bytes,
) -> HandleOutcome {
let connection = ConnectionState::default();
- handle_connection_request(state, &connection, api_key, api_version,
body).await
+ handle_connection_request(state, &connection, None, api_key, api_version,
body).await
}
/// [`handle_request_bounded`] for one request of `connection`.
+///
+/// `principal` is `None` when SASL is off, or for a key the connection
reached before
+/// authenticating - only [`crate::server::route_frame`] ever has a principal
to pass.
pub async fn handle_connection_request(
state: &GatewayState,
connection: &ConnectionState,
+ principal: Option<&AuthenticatedPrincipal>,
api_key: i16,
api_version: i16,
body: Bytes,
) -> HandleOutcome {
- dispatch(state, connection, api_key, api_version, body).await
+ dispatch(state, connection, principal, api_key, api_version, body).await
}
#[must_use]
diff --git a/gateways/kafka/src/protocol/handlers/create_topics.rs
b/gateways/kafka/src/protocol/handlers/create_topics.rs
index b411d5f4e..69a38fbf1 100644
--- a/gateways/kafka/src/protocol/handlers/create_topics.rs
+++ b/gateways/kafka/src/protocol/handlers/create_topics.rs
@@ -168,9 +168,13 @@ pub async fn handle(state: &GatewayState, api_version:
i16, body: Bytes) -> Hand
/// `deadline` bounds each topic's own bridge work individually
(`timeout_at`), not the whole
/// loop: a single `timeout` around the entire call would discard every
already-resolved result
/// the moment one topic's call ran long, answering `REQUEST_TIMED_OUT` even
for topics that had
-/// already committed. Once `deadline` passes, every remaining topic's own
`timeout_at` elapses
-/// immediately rather than making a fresh bridge call, so a stuck topic near
the front of a large
-/// batch does not turn into one slow round trip per topic behind it.
+/// already committed.
+///
+/// `tokio::time::timeout_at` polls the inner future before it ever checks the
deadline (see
+/// `Timeout::poll`), so once `deadline` has already passed, wrapping a fresh
bridge call in it
+/// still starts that call - a topic past the deadline would both answer
`REQUEST_TIMED_OUT` *and*
+/// actually get created. The `Instant::now() >= deadline` check below must
run and must skip the
+/// call itself, the same pattern `list_offsets.rs` uses, not rely on
`timeout_at` to skip it.
async fn create_all_topics(
bridge: &IggyBridge,
api_version: i16,
@@ -180,31 +184,52 @@ async fn create_all_topics(
deadline: Instant,
) -> Vec<CreatableTopicResult> {
let mut results = Vec::with_capacity(topics.len());
+ let mut deadline_exceeded = false;
for topic in topics {
- let result = if duplicate_names.contains(&topic.name) {
- CreatableTopicResult::default()
- .with_name(topic.name.clone())
- .with_error_code(ERROR_INVALID_REQUEST)
- .with_error_message(None)
- } else {
- match tokio::time::timeout_at(
- deadline,
- create_one_topic(bridge, api_version, topic, validate_only),
- )
- .await
- {
- Ok(result) => result,
- Err(_elapsed) => {
- tracing::warn!(
- kafka_topic = topic.name.as_str(),
- "CreateTopics: this topic's bridge work exceeded the
request deadline; \
- answering retriable instead of blocking further"
- );
- CreatableTopicResult::default()
- .with_name(topic.name.clone())
- .with_error_code(ERROR_REQUEST_TIMED_OUT)
- .with_error_message(None)
- }
+ if duplicate_names.contains(&topic.name) {
+ results.push(
+ CreatableTopicResult::default()
+ .with_name(topic.name.clone())
+ .with_error_code(ERROR_INVALID_REQUEST)
+ .with_error_message(None),
+ );
+ continue;
+ }
+ if deadline_exceeded || Instant::now() >= deadline {
+ if !deadline_exceeded {
+ deadline_exceeded = true;
+ tracing::warn!(
+ "CreateTopics deadline passed; answering remaining topics
retriable \
+ instead of starting new Iggy calls"
+ );
+ }
+ results.push(
+ CreatableTopicResult::default()
+ .with_name(topic.name.clone())
+ .with_error_code(ERROR_REQUEST_TIMED_OUT)
+ .with_error_message(None),
+ );
+ continue;
+ }
+
+ let result = match tokio::time::timeout_at(
+ deadline,
+ create_one_topic(bridge, api_version, topic, validate_only),
+ )
+ .await
+ {
+ Ok(result) => result,
+ Err(_elapsed) => {
+ deadline_exceeded = true;
+ tracing::warn!(
+ kafka_topic = topic.name.as_str(),
+ "CreateTopics: this topic's bridge work exceeded the
request deadline; \
+ answering retriable instead of blocking further"
+ );
+ CreatableTopicResult::default()
+ .with_name(topic.name.clone())
+ .with_error_code(ERROR_REQUEST_TIMED_OUT)
+ .with_error_message(None)
}
};
results.push(result);
diff --git a/gateways/kafka/src/protocol/handlers/delete_topics.rs
b/gateways/kafka/src/protocol/handlers/delete_topics.rs
index 319361591..ef776ae4b 100644
--- a/gateways/kafka/src/protocol/handlers/delete_topics.rs
+++ b/gateways/kafka/src/protocol/handlers/delete_topics.rs
@@ -27,6 +27,8 @@
//! silently dropped. Unlike `CreateTopics`, whose equivalent field is encoded
at every version it
//! supports.
+use std::collections::HashSet;
+
use bytes::Bytes;
use iggy::prelude::IggyError;
use kafka_protocol::messages::delete_topics_response::DeletableTopicResult;
@@ -35,12 +37,13 @@ use kafka_protocol::protocol::StrBytes;
use tokio::time::Instant;
+use crate::auth::AuthenticatedPrincipal;
use crate::bridge::{BridgeError, IggyBridge};
use crate::error::Result;
use crate::protocol::api::{
API_KEY_DELETE_TOPICS, ApiVersionRange, ERROR_INVALID_REQUEST, ERROR_NONE,
- ERROR_NOT_CONTROLLER, ERROR_POLICY_VIOLATION, ERROR_REQUEST_TIMED_OUT,
GatewayState,
- HandleOutcome,
+ ERROR_NOT_CONTROLLER, ERROR_POLICY_VIOLATION, ERROR_REQUEST_TIMED_OUT,
+ ERROR_TOPIC_AUTHORIZATION_FAILED, GatewayState, HandleOutcome,
};
use crate::protocol::bounds_guard::validate_delete_topics_shape;
use crate::protocol::handlers::{
@@ -66,7 +69,12 @@ pub const RANGE: ApiVersionRange = ApiVersionRange {
/// rather than the distinct one `CreateTopics` charges.
const MAX_BRIDGE_BACKED_TOPICS: usize = 100;
-pub async fn handle(state: &GatewayState, api_version: i16, body: Bytes) ->
HandleOutcome {
+pub async fn handle(
+ state: &GatewayState,
+ principal: Option<&AuthenticatedPrincipal>,
+ api_version: i16,
+ body: Bytes,
+) -> HandleOutcome {
let Some(bridge) = &state.bridge else {
return handle_versioned_request(
API_KEY_DELETE_TOPICS,
@@ -103,6 +111,11 @@ pub async fn handle(state: &GatewayState, api_version:
i16, body: Bytes) -> Hand
}
};
+ if let Some(results) = authorize(principal, &req.topic_names) {
+ let resp = DeleteTopicsResponse::default().with_responses(results);
+ return respond_or_close(encode_message(&resp, api_version, 256),
"DeleteTopics");
+ }
+
// RANGE caps at v5, so req.topics (the v6 topic-id shape) is always empty
- topic_names is
// the only populated field at any version this bridge advertises.
if req.topic_names.len() > MAX_BRIDGE_BACKED_TOPICS {
@@ -128,12 +141,66 @@ pub async fn handle(state: &GatewayState, api_version:
i16, body: Bytes) -> Hand
return respond_or_close(encode_message(&resp, api_version, 256),
"DeleteTopics");
}
+ // Real Kafka (`ControllerApis.deleteTopics`) refuses every occurrence of
a duplicate name
+ // with `INVALID_REQUEST` (42) and deletes nothing for it, the same choice
this gateway's own
+ // `CreateTopics` already makes for a repeated topic name.
+ let duplicate_names = find_duplicate_names(&req.topic_names);
+
let deadline = Instant::now() + clamp_request_timeout(req.timeout_ms);
- let results = delete_all_topics(bridge, &req.topic_names, deadline).await;
+ let results = delete_all_topics(bridge, &req.topic_names,
&duplicate_names, deadline).await;
let resp = DeleteTopicsResponse::default().with_responses(results);
respond_or_close(encode_message(&resp, api_version, 256), "DeleteTopics")
}
+/// Denies every requested topic when `principal` is known but is not allowed
to delete topic
+/// data, or when its permissions could not be read. `None` means SASL is off,
which this
+/// gateway treats as no principal to enforce against - the same choice the
rest of this
+/// gateway makes when authentication itself is not configured.
+fn authorize(
+ principal: Option<&AuthenticatedPrincipal>,
+ topic_names: &[TopicName],
+) -> Option<Vec<DeletableTopicResult>> {
+ let principal = principal?;
+ if principal.permissions_known && principal.permissions.manage_topics {
+ return None;
+ }
+ if !principal.permissions_known {
+ // The permission read failed after a successful login, so this
connection holds no real
+ // answer. Denying is the fail-closed choice: granting would authorize
an irreversible
+ // delete off a value nothing ever actually read.
+ tracing::warn!(
+ principal = %principal.username,
+ "DeleteTopics asked on a connection whose permissions were never
read; denying"
+ );
+ }
+ let message =
+ StrBytes::from_static_str("the authenticated principal is not
authorized to delete topics");
+ Some(
+ topic_names
+ .iter()
+ .map(|name| {
+ DeletableTopicResult::default()
+ .with_name(Some(name.clone()))
+ .with_error_code(ERROR_TOPIC_AUTHORIZATION_FAILED)
+ .with_error_message(Some(message.clone()))
+ })
+ .collect(),
+ )
+}
+
+/// Every topic name that appears more than once in `topic_names` - mirrors
+/// `create_topics::find_duplicate_names`.
+fn find_duplicate_names(topic_names: &[TopicName]) -> HashSet<TopicName> {
+ let mut seen = HashSet::with_capacity(topic_names.len());
+ let mut duplicates = HashSet::new();
+ for name in topic_names {
+ if !seen.insert(name.clone()) {
+ duplicates.insert(name.clone());
+ }
+ }
+ duplicates
+}
+
/// Deletes every requested topic, independently of the others.
///
/// `deadline` bounds each topic's own bridge work individually
(`timeout_at`), not the whole
@@ -141,21 +208,51 @@ pub async fn handle(state: &GatewayState, api_version:
i16, body: Bytes) -> Hand
/// entire call would discard every already-resolved result the moment one
topic's call ran
/// long, answering a retriable code even for topics that had already deleted
cleanly.
///
-/// A name repeated within the same request is not specially rejected the way
`CreateTopics`
-/// rejects a duplicate: deleting the same topic twice has no race to protect
against the way
-/// creating it twice does - the first occurrence deletes it, the second then
answers
-/// `UNKNOWN_TOPIC_OR_PARTITION` because by the time it runs, the topic
genuinely doesn't exist
-/// any more. That's the correct answer for a duplicate, not a special case.
+/// `tokio::time::timeout_at` polls the inner future before it ever checks the
deadline (see
+/// `Timeout::poll`), so once `deadline` has already passed, wrapping a fresh
bridge call in it
+/// still starts that call - a name past the deadline would both answer
`REQUEST_TIMED_OUT` *and*
+/// actually delete the topic. The `Instant::now() >= deadline` check below
must run and must
+/// skip the call itself, the same pattern `list_offsets.rs` uses, not rely on
`timeout_at` to
+/// skip it.
async fn delete_all_topics(
bridge: &IggyBridge,
topic_names: &[TopicName],
+ duplicate_names: &HashSet<TopicName>,
deadline: Instant,
) -> Vec<DeletableTopicResult> {
let mut results = Vec::with_capacity(topic_names.len());
+ let mut deadline_exceeded = false;
for name in topic_names {
+ if duplicate_names.contains(name) {
+ results.push(
+ DeletableTopicResult::default()
+ .with_name(Some(name.clone()))
+ .with_error_code(ERROR_INVALID_REQUEST)
+ .with_error_message(None),
+ );
+ continue;
+ }
+ if deadline_exceeded || Instant::now() >= deadline {
+ if !deadline_exceeded {
+ deadline_exceeded = true;
+ tracing::warn!(
+ "DeleteTopics deadline passed; answering remaining topics
retriable \
+ instead of starting new Iggy calls"
+ );
+ }
+ results.push(
+ DeletableTopicResult::default()
+ .with_name(Some(name.clone()))
+ .with_error_code(ERROR_REQUEST_TIMED_OUT)
+ .with_error_message(None),
+ );
+ continue;
+ }
+
let result = match tokio::time::timeout_at(deadline,
delete_one_topic(bridge, name)).await {
Ok(result) => result,
Err(_elapsed) => {
+ deadline_exceeded = true;
tracing::warn!(
kafka_topic = name.as_str(),
"DeleteTopics: this topic's bridge work exceeded the
request deadline; \
diff --git a/gateways/kafka/src/protocol/handlers/mod.rs
b/gateways/kafka/src/protocol/handlers/mod.rs
index 89bfab1ef..dbc371975 100644
--- a/gateways/kafka/src/protocol/handlers/mod.rs
+++ b/gateways/kafka/src/protocol/handlers/mod.rs
@@ -45,6 +45,7 @@ use kafka_protocol::messages::TransactionalId;
use kafka_protocol::protocol::{Decodable, Encodable};
use tokio::runtime::{Handle, RuntimeFlavor};
+use crate::auth::AuthenticatedPrincipal;
use crate::error::{KafkaProtocolError, Result};
use crate::protocol::api::{
API_KEY_API_VERSIONS, API_KEY_CREATE_TOPICS, API_KEY_DELETE_TOPICS,
API_KEY_FETCH,
@@ -92,6 +93,7 @@ pub(crate) fn off_worker<T>(heavy: bool, work: impl FnOnce()
-> T) -> T {
pub async fn dispatch(
state: &GatewayState,
connection: &ConnectionState,
+ principal: Option<&AuthenticatedPrincipal>,
api_key: i16,
api_version: i16,
body: Bytes,
@@ -103,7 +105,7 @@ pub async fn dispatch(
API_KEY_METADATA => metadata::handle(state, api_version, body).await,
API_KEY_API_VERSIONS => api_versions::handle(state, api_version,
body).await,
API_KEY_CREATE_TOPICS => create_topics::handle(state, api_version,
body).await,
- API_KEY_DELETE_TOPICS => delete_topics::handle(state, api_version,
body).await,
+ API_KEY_DELETE_TOPICS => delete_topics::handle(state, principal,
api_version, body).await,
API_KEY_FIND_COORDINATOR => find_coordinator::handle(state,
api_version, body).await,
API_KEY_JOIN_GROUP => join_group::handle(state, api_version,
body).await,
API_KEY_HEARTBEAT => heartbeat::handle(state, api_version, body).await,
diff --git a/gateways/kafka/src/server.rs b/gateways/kafka/src/server.rs
index 8315e73fa..70ee83e2e 100644
--- a/gateways/kafka/src/server.rs
+++ b/gateways/kafka/src/server.rs
@@ -550,6 +550,7 @@ async fn route_frame(
handle_connection_request(
ctx.state,
ctx.connection,
+ principal.as_ref(),
req.request_api_key,
req.request_api_version,
body,
@@ -563,9 +564,11 @@ async fn route_frame(
// peer repeat a rejected version forever, resetting the
pre-authentication read budget
// on every frame and holding a `max_connections` permit with it.
sasl_state.count_api_versions_answer();
+ // Pre-authentication by construction: no principal exists yet at
this point.
handle_connection_request(
ctx.state,
ctx.connection,
+ None,
req.request_api_key,
req.request_api_version,
body,
diff --git a/gateways/kafka/tests/api_handler_tests.rs
b/gateways/kafka/tests/api_handler_tests.rs
index d007221bb..eff5ff440 100644
--- a/gateways/kafka/tests/api_handler_tests.rs
+++ b/gateways/kafka/tests/api_handler_tests.rs
@@ -29,10 +29,10 @@ mod wire;
use bytes::Bytes;
use iggy_gateway_kafka::protocol::api::{
- API_KEY_API_VERSIONS, API_KEY_CREATE_TOPICS, API_KEY_FETCH,
API_KEY_LIST_OFFSETS,
- API_KEY_METADATA, API_KEY_PRODUCE, ERROR_INVALID_REQUEST, ERROR_NONE,
ERROR_NOT_CONTROLLER,
- ERROR_NOT_LEADER_OR_FOLLOWER, ERROR_UNKNOWN_TOPIC_OR_PARTITION,
ERROR_UNSUPPORTED_VERSION,
- handle_request, is_supported_version, supported_api_ranges,
+ API_KEY_API_VERSIONS, API_KEY_CREATE_TOPICS, API_KEY_DELETE_TOPICS,
API_KEY_FETCH,
+ API_KEY_LIST_OFFSETS, API_KEY_METADATA, API_KEY_PRODUCE,
ERROR_INVALID_REQUEST, ERROR_NONE,
+ ERROR_NOT_CONTROLLER, ERROR_NOT_LEADER_OR_FOLLOWER,
ERROR_UNKNOWN_TOPIC_OR_PARTITION,
+ ERROR_UNSUPPORTED_VERSION, handle_request, is_supported_version,
supported_api_ranges,
};
use codec::Decoder;
@@ -40,9 +40,10 @@ use fixtures::load_fixture_body_or_skip;
use scope::default_broker;
use tcp::{build_metadata_legacy_request, build_produce_v3_body};
use wire::{
- build_api_versions_flexible_request, build_metadata_all_topics_legacy,
- build_metadata_flexible_request, build_metadata_flexible_request_v10,
- build_metadata_legacy_request_for_version, build_produce_legacy_request,
+ build_api_versions_flexible_request, build_delete_topics_request_with_name,
+ build_metadata_all_topics_legacy, build_metadata_flexible_request,
+ build_metadata_flexible_request_v10,
build_metadata_legacy_request_for_version,
+ build_produce_legacy_request,
};
// ── ApiVersions ─────────────────────────────────────────────────────────────
@@ -808,6 +809,49 @@ async fn
create_topics_decodes_request_with_real_topic_and_assignment() {
assert_eq!(d.read_i16().unwrap(), ERROR_NOT_CONTROLLER);
}
+/// Pins the `flexible = version >= 4` boundary `validate_delete_topics_shape`
and
+/// `encode_delete_topics_response` both rely on, with a real (non-empty)
topic name so the
+/// per-topic error code is actually observable in the response - an empty
request's response
+/// has zero entries, so no error code in it is ever asserted anywhere else.
v1 and v3 are
+/// legacy; v4 and v5 are flexible (v4 has no `error_message` field, v5 does).
+#[tokio::test]
+async fn
delete_topics_stub_response_returns_not_controller_per_version_shape() {
+ for version in [1i16, 3, 4, 5] {
+ let body = build_delete_topics_request_with_name(version, "orders");
+ let resp = handle_request(API_KEY_DELETE_TOPICS, version, body,
&default_broker())
+ .await
+ .expect_response("test request has acks != 0 and expects a
response");
+ let flexible = version >= 4;
+ let mut d = Decoder::new(resp);
+ let _throttle = d.read_i32().unwrap();
+ if flexible {
+ let responses_plus_one = d.read_varint().unwrap();
+ assert_eq!(
+ responses_plus_one, 2,
+ "DeleteTopics v{version}: one response"
+ );
+ assert_eq!(
+ d.read_compact_nullable_string().unwrap(),
+ Some("orders".to_string()),
+ "DeleteTopics v{version}: name must echo the request"
+ );
+ } else {
+ let responses = d.read_i32().unwrap();
+ assert_eq!(responses, 1, "DeleteTopics v{version}: one response");
+ assert_eq!(
+ d.read_nullable_string().unwrap(),
+ Some("orders".to_string()),
+ "DeleteTopics v{version}: name must echo the request"
+ );
+ }
+ assert_eq!(
+ d.read_i16().unwrap(),
+ ERROR_NOT_CONTROLLER,
+ "DeleteTopics v{version}"
+ );
+ }
+}
+
// ── Produce acks=0 with a malformed body closes ──────
#[tokio::test]
diff --git a/gateways/kafka/tests/common/wire.rs
b/gateways/kafka/tests/common/wire.rs
index e85d17553..bf16efaaa 100644
--- a/gateways/kafka/tests/common/wire.rs
+++ b/gateways/kafka/tests/common/wire.rs
@@ -195,6 +195,29 @@ pub fn build_delete_topics_empty_request(version: i16) ->
Bytes {
enc.freeze()
}
+/// `DeleteTopics` request naming one topic, for supported versions (v1-v5).
+///
+/// Unlike [`build_delete_topics_empty_request`], this actually produces a
per-topic result -
+/// an empty request's response has zero entries, so no error code in it is
ever observed.
+pub fn build_delete_topics_request_with_name(version: i16, name: &str) ->
Bytes {
+ let flexible = version >= 4;
+ let mut enc = Encoder::with_capacity(32);
+
+ if flexible {
+ enc.write_varint(2); // one topic_names entry (N+1 = 2)
+ enc.write_compact_nullable_string(Some(name));
+ } else {
+ enc.write_i32(1);
+ enc.write_nullable_string(Some(name)).expect("name fits");
+ }
+ enc.write_i32(5_000); // timeout_ms
+ if flexible {
+ enc.write_empty_tagged_fields();
+ }
+
+ enc.freeze()
+}
+
/// Produce v2–v8 legacy request with optional transactional id and topic.
pub fn build_produce_legacy_request(
version: i16,
diff --git a/gateways/kafka/tests/delete_topics_real_bridge_tests.rs
b/gateways/kafka/tests/delete_topics_real_bridge_tests.rs
index c6c592cbb..f125d3f2a 100644
--- a/gateways/kafka/tests/delete_topics_real_bridge_tests.rs
+++ b/gateways/kafka/tests/delete_topics_real_bridge_tests.rs
@@ -36,8 +36,8 @@ use tokio_util::sync::CancellationToken;
use iggy_gateway_kafka::bridge::IggyBridge;
use iggy_gateway_kafka::group::{GroupCoordinator, GroupCoordinatorConfig};
use iggy_gateway_kafka::protocol::api::{
- BrokerAdvertise, ERROR_NONE, ERROR_POLICY_VIOLATION,
ERROR_UNKNOWN_TOPIC_OR_PARTITION,
- GatewayState,
+ BrokerAdvertise, ERROR_INVALID_REQUEST, ERROR_NONE, ERROR_POLICY_VIOLATION,
+ ERROR_UNKNOWN_TOPIC_OR_PARTITION, GatewayState,
};
use iggy_gateway_kafka::protocol::handlers::delete_topics;
@@ -94,7 +94,7 @@ fn decode_first_result(body: Bytes) -> (i16, Option<String>) {
async fn send(state: &GatewayState, names: &[&str]) -> Vec<(Option<String>,
i16)> {
let body = build_request(names);
- let outcome = delete_topics::handle(state, REQUEST_VERSION, body).await;
+ let outcome = delete_topics::handle(state, None, REQUEST_VERSION,
body).await;
let resp_body = outcome.expect_response("DeleteTopics request always
answers");
decode_all_results(resp_body)
}
@@ -201,11 +201,15 @@ async fn
delete_topics_on_a_nonexistent_topic_returns_unknown_topic_or_partition
);
}
-/// A name repeated in the same request is not specially rejected the way
`CreateTopics` rejects
-/// a duplicate - the first occurrence deletes the topic, so the second
genuinely finds nothing.
+/// A name repeated in the same request is rejected outright for every
occurrence, the same
+/// choice `CreateTopics` makes for a repeated topic name (real Kafka's
`ControllerApis.deleteTopics`
+/// answers `INVALID_REQUEST`(42) "Duplicate topic name" for every occurrence
and deletes nothing -
+/// a first-occurrence-wins split would let an `AdminClient` caller observe a
delete it never got
+/// a clean answer for, since the client keys its futures by name and silently
discards the second
+/// per-name result regardless of which occurrence this bridge picked).
#[tokio::test]
#[serial]
-async fn
delete_topics_second_occurrence_of_a_duplicate_name_is_unknown_topic_or_partition()
{
+async fn delete_topics_every_occurrence_of_a_duplicate_name_is_rejected() {
let data_dir = tempfile::tempdir().expect("tempdir");
let server = TestServer::spawn(data_dir.path()).await;
let state = connected_state(&server).await;
@@ -221,10 +225,20 @@ async fn
delete_topics_second_occurrence_of_a_duplicate_name_is_unknown_topic_or
assert_eq!(
results,
vec![
- (Some("orders".to_string()), ERROR_NONE),
- (Some("orders".to_string()), ERROR_UNKNOWN_TOPIC_OR_PARTITION),
+ (Some("orders".to_string()), ERROR_INVALID_REQUEST),
+ (Some("orders".to_string()), ERROR_INVALID_REQUEST),
]
);
+
+ // Neither occurrence deleted anything - the topic must still exist.
+ let topic = bridge
+ .get_kafka_topic("orders")
+ .await
+ .expect("lookup should not fail");
+ assert!(
+ topic.is_some(),
+ "a duplicate name must not delete the topic it names"
+ );
}
/// One missing topic in a batch must not block an unrelated, existing topic
from being deleted.
@@ -311,7 +325,7 @@ async fn
delete_topics_over_cap_error_message_names_the_limit() {
let names: Vec<String> = (0..101).map(|i| format!("topic-{i}")).collect();
let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
let body = build_request(&name_refs);
- let outcome = delete_topics::handle(&state, REQUEST_VERSION, body).await;
+ let outcome = delete_topics::handle(&state, None, REQUEST_VERSION,
body).await;
let resp_body = outcome.expect_response("DeleteTopics request always
answers");
let (error_code, error_message) = decode_first_result(resp_body);
assert_eq!(error_code, ERROR_POLICY_VIOLATION);
diff --git a/gateways/kafka/tests/fetch_real_bridge_tests.rs
b/gateways/kafka/tests/fetch_real_bridge_tests.rs
index 1340aedad..ecf480ff0 100644
--- a/gateways/kafka/tests/fetch_real_bridge_tests.rs
+++ b/gateways/kafka/tests/fetch_real_bridge_tests.rs
@@ -249,8 +249,14 @@ async fn fetch_on(
request
.encode(&mut body, VERSION)
.expect("the request encodes at this version");
- let handled =
- handle_connection_request(state, connection, API_KEY_FETCH, VERSION,
body.freeze());
+ let handled = handle_connection_request(
+ state,
+ connection,
+ None,
+ API_KEY_FETCH,
+ VERSION,
+ body.freeze(),
+ );
let mut response = tokio::time::timeout(PROMPT, handled)
.await
.expect("records or an error answer at once")