This is an automated email from the ASF dual-hosted git repository.
krishvishal 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 b669d9669 fix(gateways): stop reporting cleanup.policy and accept -1
defaults (#4534)
b669d9669 is described below
commit b669d9669b165eed6900f8ed78ddfcdc1f6f3b10
Author: Krishna Vishal <[email protected]>
AuthorDate: Wed Oct 14 14:43:23 2026 +0530
fix(gateways): stop reporting cleanup.policy and accept -1 defaults (#4534)
---
gateways/kafka/docs/CONFIGS.md | 31 +++--
gateways/kafka/docs/MANUAL_TESTING.md | 4 +-
gateways/kafka/docs/SCOPE.md | 4 +-
.../kafka/src/protocol/handlers/create_topics.rs | 125 +++++++--------------
.../src/protocol/handlers/describe_configs.rs | 42 +++----
.../kafka/src/protocol/handlers/topic_config.rs | 55 +++++----
gateways/kafka/tests/configs_java_client_tests.rs | 5 +-
gateways/kafka/tests/configs_real_bridge_tests.rs | 18 +--
.../kafka/tests/create_topics_real_bridge_tests.rs | 45 ++++++++
gateways/kafka/tests/java/ConfigsProbe.java | 17 +--
gateways/kafka/tests/response_negative_tests.rs | 11 +-
11 files changed, 175 insertions(+), 182 deletions(-)
diff --git a/gateways/kafka/docs/CONFIGS.md b/gateways/kafka/docs/CONFIGS.md
index 19fa2e570..8dd1a3f4d 100644
--- a/gateways/kafka/docs/CONFIGS.md
+++ b/gateways/kafka/docs/CONFIGS.md
@@ -23,20 +23,26 @@ authorization check of its own.
## What a describe returns
-A null or empty `configuration_keys` list returns both keys below. A non-empty
+A null or empty `configuration_keys` list returns `retention.ms`. A non-empty
list returns only the requested names this gateway knows, in request order,
with a name repeated in the list collapsed to one entry. A name it does not
-know, including `retention.minutes` and `retention.hours`, is omitted. The
-resource's error code stays `NONE` (0), and the known keys in that request are
-still returned.
+know, including `cleanup.policy`, `retention.minutes` and `retention.hours`, is
+omitted. The resource's error code stays `NONE` (0), and the known keys in that
+request are still returned.
| Key | Value | Read only | Source |
| --- | ----- | --------- | ------ |
| `retention.ms` | Sealed-segment lifetime from Iggy `message_expiry`, or `-1`
when those segments never expire | no | `DEFAULT_CONFIG` (5) for the unset
never-expire default; `DYNAMIC_TOPIC_CONFIG` (1) when a client set it or a
stored duration cannot be shown as milliseconds |
-| `cleanup.policy` | `delete` | yes | `DEFAULT_CONFIG` (5) |
-`is_sensitive` is false for both. From v3, `include_documentation` is honored:
-true attaches one sentence per known key, false sends no documentation.
+The gateway does not report `cleanup.policy` until it stores a policy per
+topic. Iggy deletes expired messages and does not compact, so the true value is
+`delete`. Kafka Connect refuses an internal topic that reports any policy other
+than `compact`. When the entry is missing, Connect starts
+(`TopicAdmin.verifyTopicCleanupPolicyOnlyCompact`). A reported `delete`
+therefore stops Connect, even on topics that an operator created for it.
+
+`is_sensitive` is false. From v3, `include_documentation` is honored: true
+attaches one sentence per known key, false sends no documentation.
`include_synonyms` has no effect: this gateway models no config-inheritance
chain beyond `retention.ms` itself, so the synonym list on every entry is
always empty.
@@ -86,11 +92,14 @@ call `update_topic`.
## Limitations
-- `IncrementalAlterConfigs` (API 44) is not implemented here. It is planned
- after this work merges. It is not advertised, so a client falls back to
- `AlterConfigs` (33); see "Authorization" above for what an unadvertised key
- does to the client.
+- `IncrementalAlterConfigs` (API 44) is not implemented and is not
+ advertised. No client falls back to `AlterConfigs` (33) on its own. The Java
+ client and librdkafka fail locally, as the top of this page says. Kafka 4.0
+ removed `Admin.alterConfigs`, and `kafka-configs --alter` sends only API 44.
+ A Java 4.x client or tool therefore cannot change `retention.ms` through this
+ gateway.
- `retention.ms` remains the only value Iggy stores.
+- `cleanup.policy` is not reported. See "What a describe returns".
## Other resource errors
diff --git a/gateways/kafka/docs/MANUAL_TESTING.md
b/gateways/kafka/docs/MANUAL_TESTING.md
index 36a6602cc..21c88dd13 100644
--- a/gateways/kafka/docs/MANUAL_TESTING.md
+++ b/gateways/kafka/docs/MANUAL_TESTING.md
@@ -387,8 +387,8 @@ criteria).
| 21 | INVALID_REQUIRED_ACKS | Produce with a bridge: `acks` is not 0, 1 or -1
|
| 29 | TOPIC_AUTHORIZATION_FAILED | Produce with a bridge: Iggy user lacks
permission |
| 35 | UNSUPPORTED_VERSION | Out of range: **ApiVersions only** (KIP-511
exception); every other API key's out-of-range version closes the connection
instead - see Category B. In range: InitProducerId with a `transactional_id`;
Produce with a `transactional_id`, on every partition with or without a bridge
(an `acks=0` one closes instead); and, with a bridge, a transactional or
control batch - transactions are not supported, see `SCOPE.md` |
-| 37 | INVALID_PARTITIONS | CreateTopics: partition count `0` or `< -1` (or
any non-positive on v2–v3) |
-| 38 | INVALID_REPLICATION_FACTOR | CreateTopics: replication factor `0` or `<
-1` (or any non-positive on v2–v3) |
+| 37 | INVALID_PARTITIONS | CreateTopics: partition count `0` or `< -1` |
+| 38 | INVALID_REPLICATION_FACTOR | CreateTopics: replication factor `0` or `<
-1` |
| 41 | NOT_CONTROLLER | CreateTopics stub (topic not created) |
| 42 | INVALID_REQUEST | Fetch/ListOffsets/CreateTopics/ApiVersions decode
failure. Not Produce: it closes the connection (H1) |
| 76 | UNSUPPORTED_COMPRESSION_TYPE | Produce with a bridge: zstd before v7 |
diff --git a/gateways/kafka/docs/SCOPE.md b/gateways/kafka/docs/SCOPE.md
index b8cd2d962..5a3ef4695 100644
--- a/gateways/kafka/docs/SCOPE.md
+++ b/gateways/kafka/docs/SCOPE.md
@@ -54,7 +54,7 @@ it knows the server supports flexible encoding.
| 0 | Produce | 3 | 9 | 3, 4, 5, 6, 7, 8, 9 | With a bridge: one
`send_messages` per partition. Without one: stub returns
`NOT_LEADER_OR_FOLLOWER` (6) |
| 1 | Fetch | 4 | 12 | 4, 5, 6, 7, 8, 9, 10, 11, 12 | With a bridge: shared
topic probes, paged `poll_messages` per partition with records, wait up to
`max_wait_ms` (max 18 s). Without one: stub returns `NOT_LEADER_OR_FOLLOWER`
(6) |
| 2 | ListOffsets | 1 | 6 | 1, 2, 3, 4, 5, 6 | Decode request; stub response |
-| 19 | CreateTopics | 2 | 5 | 2, 3, 4, 5 | Decode request; stub returns
`NOT_CONTROLLER` (41); `-1` partitions/RF = broker default on v4+ |
+| 19 | CreateTopics | 2 | 5 | 2, 3, 4, 5 | Decode request; stub returns
`NOT_CONTROLLER` (41); `-1` partitions/RF = broker default at every version |
| 20 | DeleteTopics | 1 | 5 | 1, 2, 3, 4, 5 | With a bridge: deletes the Iggy
topic, never the backing stream (see `BRIDGE_MAPPING.md`). Without one: stub
returns `NOT_CONTROLLER` (41). v6 (topic-id-based) not advertised - this bridge
has no topic-id concept |
| 8 | OffsetCommit | 2 | 9 | 2 … 9 | With a bridge: stores each offset as an
Iggy external group offset, and a negative offset deletes it
([`OFFSET_STORAGE.md`](OFFSET_STORAGE.md)). Without one:
`COORDINATOR_LOAD_IN_PROGRESS` (14). Flexible encoding at v8+ |
| 9 | OffsetFetch | 1 | 9 | 1 … 9 | With a bridge: reads each offset, -1 when
none is stored, and a null topic list reads every topic that holds the group.
Without one: 14. Flexible encoding at v6+ |
@@ -66,7 +66,7 @@ it knows the server supports flexible encoding.
| 15 | DescribeGroups | 0 | 6 | 0, 1, 2, 3, 4, 5, 6 | Members, assignment and
state of groups on this coordinator; a missing group is `Dead`, and v6 also
returns `GROUP_ID_NOT_FOUND` (69); flexible encoding at v5+ |
| 16 | ListGroups | 0 | 5 | 0, 1, 2, 3, 4, 5 | Groups on this coordinator; v4+
state filter and v5 type filter; flexible encoding at v3+ |
| 22 | InitProducerId | 0 | 5 | 0, 1, 2, 3, 4, 5 | Allocate a producer id
(epoch 0); a `transactional_id` gets `UNSUPPORTED_VERSION` (35); flexible
encoding at v2+ |
-| 32 | DescribeConfigs | 1 | 4 | 1, 2, 3, 4 | Topic resources only. Returns
`retention.ms` and `cleanup.policy`. Flexible encoding at v4. See
[`CONFIGS.md`](CONFIGS.md) |
+| 32 | DescribeConfigs | 1 | 4 | 1, 2, 3, 4 | Topic resources only. Returns
`retention.ms` and leaves out `cleanup.policy`. Flexible encoding at v4. See
[`CONFIGS.md`](CONFIGS.md) |
| 33 | AlterConfigs | 0 | 2 | 0, 1, 2 | Persists `retention.ms` only. Flexible
encoding at v2. See [`CONFIGS.md`](CONFIGS.md) |
A request is accepted when `min_version ≤ api_version ≤ max_version` for that
API key. Any other version for a listed key closes the connection (ApiVersions
excepted - see Governance model above).
diff --git a/gateways/kafka/src/protocol/handlers/create_topics.rs
b/gateways/kafka/src/protocol/handlers/create_topics.rs
index d43c09d10..83ce99395 100644
--- a/gateways/kafka/src/protocol/handlers/create_topics.rs
+++ b/gateways/kafka/src/protocol/handlers/create_topics.rs
@@ -136,7 +136,6 @@ pub async fn handle(state: &GatewayState, api_version: i16,
body: Bytes) -> Hand
let deadline = Instant::now() + clamp_request_timeout(req.timeout_ms);
let results = create_all_topics(
bridge,
- api_version,
&req.topics,
&duplicate_names,
req.validate_only,
@@ -166,7 +165,6 @@ pub async fn handle(state: &GatewayState, api_version: i16,
body: Bytes) -> Hand
/// 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,
topics: &[CreatableTopic],
duplicate_names: &HashSet<&str>,
validate_only: bool,
@@ -201,12 +199,8 @@ async fn create_all_topics(
continue;
}
- let result = match tokio::time::timeout_at(
- deadline,
- create_one_topic(bridge, api_version, topic, validate_only),
- )
- .await
- {
+ let create = create_one_topic(bridge, topic, validate_only);
+ let result = match tokio::time::timeout_at(deadline, create).await {
Ok(result) => result,
Err(_elapsed) => {
deadline_exceeded = true;
@@ -247,7 +241,6 @@ async fn create_all_topics(
/// one caller does.
async fn create_one_topic(
bridge: &IggyBridge,
- version: i16,
topic: &CreatableTopic,
validate_only: bool,
) -> CreatableTopicResult {
@@ -256,7 +249,7 @@ async fn create_one_topic(
.with_error_message(None);
let kafka_topic = topic.name.as_str();
- let partition_count = match local_shape_error(version, topic) {
+ let partition_count = match local_shape_error(topic) {
Ok(count) => count,
Err((error_code, error_message)) => {
return match bridge.get_kafka_topic(kafka_topic).await {
@@ -308,10 +301,7 @@ async fn create_one_topic(
/// once, only on the rejection path. Returns `(error_code, error_message)`
rather than a whole
/// [`CreatableTopicResult`] to keep this `Result`'s `Err` arm small
(`clippy::result_large_err`);
/// the caller already holds the shared `name`/base fields to rebuild the full
result from.
-fn local_shape_error(
- version: i16,
- topic: &CreatableTopic,
-) -> core::result::Result<u32, (i16, Option<StrBytes>)> {
+fn local_shape_error(topic: &CreatableTopic) -> core::result::Result<u32,
(i16, Option<StrBytes>)> {
if !topic.configs.is_empty() {
return Err((
ERROR_INVALID_CONFIG,
@@ -321,7 +311,7 @@ fn local_shape_error(
)),
));
}
- validate_create_topic_shape(version, topic).map_err(|code| (code, None))
+ validate_create_topic_shape(topic).map_err(|code| (code, None))
}
/// Maps a bridge failure to a Kafka result, logging the real cause
server-side.
@@ -405,12 +395,10 @@ fn bridge_error_result(
/// binding this way, not just the index.
///
/// With no assignments, `num_partitions = -1` / `replication_factor = -1`
mean "use the broker
-/// default" from v4+ (pre-v4 requires an explicit positive value for both,
since v2/v3 have no
-/// broker-default sentinel absent a manual assignment).
-fn validate_create_topic_shape(
- version: i16,
- topic: &CreatableTopic,
-) -> core::result::Result<u32, i16> {
+/// default" at every version. The Java client refuses to send them below v4,
but Kafka's
+/// controller never checks the version
(`ReplicationControlManager.createTopic`), and
+/// `KafkaJS` `createTopics()` sends both at v3.
+fn validate_create_topic_shape(topic: &CreatableTopic) ->
core::result::Result<u32, i16> {
if !topic.assignments.is_empty() {
if topic.num_partitions != -1 || topic.replication_factor != -1 {
return Err(ERROR_INVALID_REQUEST);
@@ -423,9 +411,7 @@ fn validate_create_topic_shape(
return
Ok(u32::try_from(topic.assignments.len()).unwrap_or(DEFAULT_PARTITION_COUNT));
}
- let broker_default_ok = version >= 4;
-
- // Checked ahead of (and separately from) `partitions_ok` below: that
check's own
+ // Checked ahead of (and separately from) the partition-count check below:
that check's own
// `INVALID_PARTITIONS` (37) means "count is below 1" on the wire
(`bridge/error.rs`'s own
// comment on `IggyError::TooManyPartitions`), the opposite condition from
"too many" - reusing
// it here would send a client-visible message that contradicts the
request it just sent. This
@@ -436,12 +422,7 @@ fn validate_create_topic_shape(
return Err(ERROR_INVALID_REQUEST);
}
- let partitions_ok = if broker_default_ok {
- topic.num_partitions == -1 || topic.num_partitions > 0
- } else {
- topic.num_partitions > 0
- };
- if !partitions_ok {
+ if topic.num_partitions != -1 && topic.num_partitions <= 0 {
return Err(ERROR_INVALID_PARTITIONS);
}
@@ -451,12 +432,7 @@ fn validate_create_topic_shape(
// controller rejects `replication_factor > 1` the same way
(`ReplicationControlManager`) -
// accepting it here and silently reporting back `1` (`success()`, below)
would tell the
// client its request succeeded as sent when it didn't.
- let replication_ok = if broker_default_ok {
- topic.replication_factor == -1 || topic.replication_factor == 1
- } else {
- topic.replication_factor == 1
- };
- if !replication_ok {
+ if topic.replication_factor != -1 && topic.replication_factor != 1 {
return Err(ERROR_INVALID_REPLICATION_FACTOR);
}
@@ -523,36 +499,20 @@ pub fn encode_response(version: i16, req:
&CreateTopicsRequest) -> Result<Bytes>
/// Resolve per-topic `CreateTopics` error for the stub (no-bridge) path.
///
-/// KIP-464: `num_partitions = -1` / `replication_factor = -1` mean broker
default when either
-/// (a) the version is v4+, or (b) the topic carries a manual partition
assignment (valid on
-/// v2/v3 as well). Otherwise non-positive values are
[`ERROR_INVALID_PARTITIONS`] /
-/// [`ERROR_INVALID_REPLICATION_FACTOR`]. When validation passes, the stub
returns
-/// [`ERROR_NOT_CONTROLLER`] so clients do not believe the topic was created.
-const fn topic_error(version: i16, topic: &CreatableTopic, forced_error: i16)
-> i16 {
+/// KIP-464: `num_partitions = -1` / `replication_factor = -1` mean broker
default at every
+/// version, as in [`validate_create_topic_shape`]. Other non-positive values
are
+/// [`ERROR_INVALID_PARTITIONS`] / [`ERROR_INVALID_REPLICATION_FACTOR`]. When
validation passes,
+/// the stub returns [`ERROR_NOT_CONTROLLER`] so clients do not believe the
topic was created.
+const fn topic_error(topic: &CreatableTopic, forced_error: i16) -> i16 {
if forced_error != ERROR_NONE {
return forced_error;
}
-
- let broker_default_ok = version >= 4 || !topic.assignments.is_empty();
-
- let partitions_ok = if broker_default_ok {
- topic.num_partitions == -1 || topic.num_partitions > 0
- } else {
- topic.num_partitions > 0
- };
- if !partitions_ok {
+ if topic.num_partitions != -1 && topic.num_partitions <= 0 {
return ERROR_INVALID_PARTITIONS;
}
-
- let replication_ok = if broker_default_ok {
- topic.replication_factor == -1 || topic.replication_factor > 0
- } else {
- topic.replication_factor > 0
- };
- if !replication_ok {
+ if topic.replication_factor != -1 && topic.replication_factor <= 0 {
return ERROR_INVALID_REPLICATION_FACTOR;
}
-
ERROR_NOT_CONTROLLER
}
@@ -562,7 +522,7 @@ fn encode_inner(version: i16, topics: &[CreatableTopic],
forced_error: i16) -> R
.map(|topic| {
CreatableTopicResult::default()
.with_name(topic.name.clone())
- .with_error_code(topic_error(version, topic, forced_error))
+ .with_error_code(topic_error(topic, forced_error))
.with_error_message(None)
.with_num_partitions(topic.num_partitions)
.with_replication_factor(topic.replication_factor)
@@ -607,31 +567,26 @@ mod tests {
#[test]
fn explicit_partitions_and_replication_resolve_as_given() {
let topic = creatable_topic(3, 1);
- assert_eq!(validate_create_topic_shape(2, &topic), Ok(3));
+ assert_eq!(validate_create_topic_shape(&topic), Ok(3));
}
#[test]
- fn v4_plus_accepts_broker_default_sentinel_with_no_assignments() {
- let topic = creatable_topic(-1, -1);
+ fn
broker_default_sentinels_with_no_assignments_resolve_to_the_default_count() {
assert_eq!(
- validate_create_topic_shape(4, &topic),
+ validate_create_topic_shape(&creatable_topic(-1, -1)),
Ok(DEFAULT_PARTITION_COUNT)
);
- }
-
- #[test]
- fn pre_v4_rejects_broker_default_sentinel_with_no_assignments() {
- let topic = creatable_topic(-1, -1);
assert_eq!(
- validate_create_topic_shape(2, &topic),
- Err(ERROR_INVALID_PARTITIONS)
+ validate_create_topic_shape(&creatable_topic(-1, 1)),
+ Ok(DEFAULT_PARTITION_COUNT)
);
+ assert_eq!(validate_create_topic_shape(&creatable_topic(4, -1)),
Ok(4));
}
#[test]
- fn pre_v4_accepts_broker_default_sentinel_with_a_manual_assignment() {
+ fn broker_default_sentinels_with_a_manual_assignment_take_its_length() {
let topic = creatable_topic(-1,
-1).with_assignments(vec![assignment(0), assignment(1)]);
- assert_eq!(validate_create_topic_shape(2, &topic), Ok(2));
+ assert_eq!(validate_create_topic_shape(&topic), Ok(2));
}
#[test]
@@ -639,7 +594,7 @@ mod tests {
let topic =
creatable_topic(-1,
-1).with_assignments(vec![assignment_with_replicas(0, vec![])]);
assert_eq!(
- validate_create_topic_shape(2, &topic),
+ validate_create_topic_shape(&topic),
Err(ERROR_INVALID_REPLICA_ASSIGNMENT)
);
}
@@ -649,7 +604,7 @@ mod tests {
let topic =
creatable_topic(-1,
-1).with_assignments(vec![assignment_with_replicas(0, vec![1, 1])]);
assert_eq!(
- validate_create_topic_shape(2, &topic),
+ validate_create_topic_shape(&topic),
Err(ERROR_INVALID_REPLICA_ASSIGNMENT)
);
}
@@ -661,7 +616,7 @@ mod tests {
let topic =
creatable_topic(-1,
-1).with_assignments(vec![assignment_with_replicas(0, vec![7])]);
assert_eq!(
- validate_create_topic_shape(2, &topic),
+ validate_create_topic_shape(&topic),
Err(ERROR_INVALID_REPLICA_ASSIGNMENT)
);
}
@@ -670,7 +625,7 @@ mod tests {
fn zero_partitions_is_rejected_regardless_of_version() {
let topic = creatable_topic(0, 1);
assert_eq!(
- validate_create_topic_shape(5, &topic),
+ validate_create_topic_shape(&topic),
Err(ERROR_INVALID_PARTITIONS)
);
}
@@ -679,7 +634,7 @@ mod tests {
fn zero_replication_factor_is_rejected_regardless_of_version() {
let topic = creatable_topic(1, 0);
assert_eq!(
- validate_create_topic_shape(5, &topic),
+ validate_create_topic_shape(&topic),
Err(ERROR_INVALID_REPLICATION_FACTOR)
);
}
@@ -692,7 +647,7 @@ mod tests {
// accepted-and-silently-downgraded.
let topic = creatable_topic(1, 3);
assert_eq!(
- validate_create_topic_shape(5, &topic),
+ validate_create_topic_shape(&topic),
Err(ERROR_INVALID_REPLICATION_FACTOR)
);
}
@@ -700,14 +655,14 @@ mod tests {
#[test]
fn replication_factor_of_exactly_one_is_accepted() {
let topic = creatable_topic(1, 1);
- assert_eq!(validate_create_topic_shape(5, &topic), Ok(1));
+ assert_eq!(validate_create_topic_shape(&topic), Ok(1));
}
#[test]
fn partition_count_at_the_cap_is_accepted() {
let topic =
creatable_topic(i32::try_from(MAX_PARTITIONS_COUNT).unwrap(), 1);
assert_eq!(
- validate_create_topic_shape(5, &topic),
+ validate_create_topic_shape(&topic),
Ok(MAX_PARTITIONS_COUNT)
);
}
@@ -720,7 +675,7 @@ mod tests {
//
(`bridge/error.rs::too_many_partitions_maps_to_invalid_request_not_invalid_partitions`).
let topic =
creatable_topic(i32::try_from(MAX_PARTITIONS_COUNT).unwrap() + 1, 1);
assert_eq!(
- validate_create_topic_shape(5, &topic),
+ validate_create_topic_shape(&topic),
Err(ERROR_INVALID_REQUEST)
);
}
@@ -734,7 +689,7 @@ mod tests {
// which one would win if it did.
let topic = creatable_topic(2, 1).with_assignments(vec![assignment(0),
assignment(1)]);
assert_eq!(
- validate_create_topic_shape(5, &topic),
+ validate_create_topic_shape(&topic),
Err(ERROR_INVALID_REQUEST)
);
}
@@ -743,7 +698,7 @@ mod tests {
fn
explicit_num_partitions_disagreeing_with_assignments_length_is_also_rejected() {
let topic = creatable_topic(3, 1).with_assignments(vec![assignment(0),
assignment(1)]);
assert_eq!(
- validate_create_topic_shape(5, &topic),
+ validate_create_topic_shape(&topic),
Err(ERROR_INVALID_REQUEST)
);
}
@@ -753,7 +708,7 @@ mod tests {
{
let topic = creatable_topic(-1,
1).with_assignments(vec![assignment(0), assignment(1)]);
assert_eq!(
- validate_create_topic_shape(5, &topic),
+ validate_create_topic_shape(&topic),
Err(ERROR_INVALID_REQUEST)
);
}
diff --git a/gateways/kafka/src/protocol/handlers/describe_configs.rs
b/gateways/kafka/src/protocol/handlers/describe_configs.rs
index 54017c66e..90ac2dc92 100644
--- a/gateways/kafka/src/protocol/handlers/describe_configs.rs
+++ b/gateways/kafka/src/protocol/handlers/describe_configs.rs
@@ -39,8 +39,7 @@ 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,
+ CONFIG_SOURCE_TOPIC, CONFIG_TYPE_LONG, ListedKey, ONLY_TOPIC_RESOURCES,
RESOURCE_TYPE_TOPIC,
RETENTION_DOC, RETENTION_MS, bridge_failure, listed_keys, name_reason,
retention_ms,
topic_cap_and_duplicates, topic_cap_message,
};
@@ -289,14 +288,6 @@ fn described_entries(
include_documentation.then_some(RETENTION_DOC),
));
}
- ListedKey::Cleanup => configs.push(config_entry(
- CLEANUP_POLICY,
- Some(CLEANUP_POLICY_VALUE),
- true,
- CONFIG_SOURCE_DEFAULT,
- CONFIG_TYPE_STRING,
- include_documentation.then_some(CLEANUP_DOC),
- )),
// A resource error makes clients drop every returned config,
including
// retention.ms. An unmodeled name is omitted and the resource
stays successful.
ListedKey::Unknown => {}
@@ -346,9 +337,7 @@ mod tests {
CompressionAlgorithm, HeaderKey, HeaderValue, IggyByteSize,
IggyExpiry, IggyTimestamp,
MaxTopicSize, OptionValue, ResourceOptions, TopicDetails,
topic_option_keys,
};
- use kafka_protocol::messages::describe_configs_response::{
- DescribeConfigsResourceResult, DescribeConfigsSynonym,
- };
+ use
kafka_protocol::messages::describe_configs_response::DescribeConfigsResourceResult;
use kafka_protocol::protocol::StrBytes;
use super::described_entries;
@@ -401,7 +390,7 @@ mod tests {
}
#[test]
- fn default_describe_returns_retention_and_cleanup() {
+ fn default_describe_returns_retention_only() {
let expiry = parse_retention_ms("120000").expect("2 minutes in ms");
let details = topic_details(expiry, true);
let (configs, bad) = entries(&details, None, false);
@@ -411,14 +400,27 @@ mod tests {
.iter()
.map(|config| config.name.as_str())
.collect::<Vec<_>>(),
- vec![RETENTION_MS, CLEANUP_POLICY]
+ vec![RETENTION_MS]
);
assert_eq!(value_of(&configs, RETENTION_MS), Some("120000"));
- let cleanup = configs
- .iter()
- .find(|config| config.name.as_str() == CLEANUP_POLICY)
- .expect("cleanup");
- assert_eq!(cleanup.synonyms, [] as [DescribeConfigsSynonym; 0]);
+ }
+
+ #[test]
+ fn cleanup_policy_is_omitted_even_when_asked_for() {
+ let details = topic_details(IggyExpiry::NeverExpire, false);
+ let only_cleanup = [StrBytes::from_static_str(CLEANUP_POLICY)];
+ let (configs, bad) = entries(&details, Some(&only_cleanup), true);
+ assert!(!bad);
+ assert_eq!(configs, [] as [DescribeConfigsResourceResult; 0]);
+
+ let both = [
+ StrBytes::from_static_str(CLEANUP_POLICY),
+ StrBytes::from_static_str(RETENTION_MS),
+ ];
+ let (configs, bad) = entries(&details, Some(&both), true);
+ assert!(!bad);
+ assert_eq!(configs.len(), 1);
+ assert_eq!(configs[0].name.as_str(), RETENTION_MS);
}
#[test]
diff --git a/gateways/kafka/src/protocol/handlers/topic_config.rs
b/gateways/kafka/src/protocol/handlers/topic_config.rs
index 9da428bc5..1c1afc243 100644
--- a/gateways/kafka/src/protocol/handlers/topic_config.rs
+++ b/gateways/kafka/src/protocol/handlers/topic_config.rs
@@ -44,20 +44,15 @@ pub const CONFIG_SOURCE_TOPIC: i8 = 1;
/// Kafka `ConfigSource.DEFAULT_CONFIG`: the value is the gateway default.
pub const CONFIG_SOURCE_DEFAULT: i8 = 5;
-/// Kafka `ConfigType.STRING`.
-pub const CONFIG_TYPE_STRING: i8 = 2;
-
/// Kafka `ConfigType.LONG`.
pub const CONFIG_TYPE_LONG: i8 = 5;
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 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.";
/// Distinct topic names one `CreateTopics`, `DescribeConfigs`, or
`AlterConfigs` request
/// may address through the bridge.
@@ -73,11 +68,10 @@ pub const MAX_CONFIG_TOPICS: usize = 100;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListedKey {
Retention,
- Cleanup,
- /// An unmodeled name, including `retention.minutes` and
`retention.hours`. The name
- /// itself is never read back out: `DescribeConfigs` omits every `Unknown`
entry from
- /// its response, so carrying the name here would only pay a `String`
allocation for a
- /// value no caller inspects.
+ /// An unmodeled name, including `cleanup.policy`, `retention.minutes` and
+ /// `retention.hours`. The name itself is never read back out:
`DescribeConfigs` omits
+ /// every `Unknown` entry from its response, so carrying the name here
would only pay a
+ /// `String` allocation for a value no caller inspects.
Unknown,
}
@@ -319,21 +313,26 @@ pub fn bridge_failure(error: &BridgeError, handler: &str,
action: &str) -> (i16,
/// Keys to return for one describe resource.
///
-/// `None` and an empty list both mean `retention.ms` and `cleanup.policy`. A
name repeated
-/// in `configuration_keys` is collapsed to one entry, in its first position.
Any other name,
-/// including `retention.minutes` and `retention.hours`, is
[`ListedKey::Unknown`].
-/// `DescribeConfigs` omits those names from the response and does not fail
the resource.
+/// `None` and an empty list both mean `retention.ms`. A name repeated in
+/// `configuration_keys` is collapsed to one entry, in its first position. Any
other name,
+/// including `cleanup.policy`, `retention.minutes` and `retention.hours`, is
+/// [`ListedKey::Unknown`]. `DescribeConfigs` omits those names from the
response and does not
+/// fail the resource.
+///
+/// `cleanup.policy` stays unknown until the gateway stores a policy per
topic. Kafka Connect
+/// refuses an internal topic that reports any policy other than `compact`,
but it starts when
+/// the entry is missing (`TopicAdmin.verifyTopicCleanupPolicyOnlyCompact`). A
fixed `delete`
+/// therefore stops Connect even on topics an operator created for it.
#[must_use]
pub fn listed_keys(configuration_keys: Option<&[StrBytes]>) -> Vec<ListedKey> {
let Some(keys) = configuration_keys.filter(|keys| !keys.is_empty()) else {
- return vec![ListedKey::Retention, ListedKey::Cleanup];
+ return vec![ListedKey::Retention];
};
let mut seen = HashSet::new();
keys.iter()
.filter(|key| seen.insert(key.as_str()))
.map(|key| match key.as_str() {
RETENTION_MS => ListedKey::Retention,
- CLEANUP_POLICY => ListedKey::Cleanup,
_ => ListedKey::Unknown,
})
.collect()
@@ -421,25 +420,25 @@ mod tests {
}
#[test]
- fn listed_keys_defaults_to_both_and_keeps_an_unknown_name() {
- assert_eq!(
- listed_keys(None),
- vec![ListedKey::Retention, ListedKey::Cleanup]
- );
- assert_eq!(
- listed_keys(Some(&[])),
- vec![ListedKey::Retention, ListedKey::Cleanup]
- );
+ fn listed_keys_defaults_to_retention_and_keeps_an_unknown_name() {
+ assert_eq!(listed_keys(None), vec![ListedKey::Retention]);
+ assert_eq!(listed_keys(Some(&[])), vec![ListedKey::Retention]);
let asked = [
- StrBytes::from_static_str(CLEANUP_POLICY),
+ StrBytes::from_static_str(RETENTION_MS),
StrBytes::from_static_str("no.such"),
];
assert_eq!(
listed_keys(Some(&asked)),
- vec![ListedKey::Cleanup, ListedKey::Unknown]
+ vec![ListedKey::Retention, ListedKey::Unknown]
);
}
+ #[test]
+ fn cleanup_policy_is_an_unknown_name_even_when_asked_for() {
+ let asked = [StrBytes::from_static_str(CLEANUP_POLICY)];
+ assert_eq!(listed_keys(Some(&asked)), vec![ListedKey::Unknown]);
+ }
+
#[test]
fn listed_keys_collapses_a_name_repeated_in_the_request() {
let asked = [
@@ -449,7 +448,7 @@ mod tests {
];
assert_eq!(
listed_keys(Some(&asked)),
- vec![ListedKey::Retention, ListedKey::Cleanup]
+ vec![ListedKey::Retention, ListedKey::Unknown]
);
}
diff --git a/gateways/kafka/tests/configs_java_client_tests.rs
b/gateways/kafka/tests/configs_java_client_tests.rs
index 76ce7bd34..5bd3161a8 100644
--- a/gateways/kafka/tests/configs_java_client_tests.rs
+++ b/gateways/kafka/tests/configs_java_client_tests.rs
@@ -294,10 +294,7 @@ async fn
kafka_39_admin_client_describes_and_alters_topic_configs() {
text.contains("retention.ms=-1 source=DEFAULT_CONFIG"),
"{text}"
);
- assert!(
- text.contains("cleanup.policy=delete read_only=true
source=DEFAULT_CONFIG"),
- "{text}"
- );
+ assert!(text.contains("cleanup.policy=absent"), "{text}");
assert!(
text.contains("unknown_key_rejected=InvalidConfigurationException"),
"{text}"
diff --git a/gateways/kafka/tests/configs_real_bridge_tests.rs
b/gateways/kafka/tests/configs_real_bridge_tests.rs
index 7fa62837f..8dbf37a75 100644
--- a/gateways/kafka/tests/configs_real_bridge_tests.rs
+++ b/gateways/kafka/tests/configs_real_bridge_tests.rs
@@ -152,7 +152,7 @@ fn entry<'a>(
#[tokio::test]
#[serial]
-async fn
describe_configs_returns_retention_and_cleanup_and_alter_persists_retention() {
+async fn
describe_configs_returns_retention_without_cleanup_and_alter_persists_retention()
{
let data_dir = tempfile::tempdir().expect("tempdir");
let server = TestServer::spawn(data_dir.path()).await;
let state = connected_state(&server).await;
@@ -168,7 +168,7 @@ async fn
describe_configs_returns_retention_and_cleanup_and_alter_persists_reten
.await;
let result = &described.results[0];
assert_eq!(result.error_code, ERROR_NONE);
- assert_eq!(result.configs.len(), 2);
+ assert_eq!(result.configs.len(), 1);
let retention = entry(&result.configs, "retention.ms");
assert_eq!(retention.value.as_ref().map(StrBytes::as_str), Some("-1"));
assert!(!retention.read_only);
@@ -176,12 +176,6 @@ async fn
describe_configs_returns_retention_and_cleanup_and_alter_persists_reten
assert_eq!(retention.config_source, SOURCE_DEFAULT);
assert_eq!(retention.synonyms, [] as [DescribeConfigsSynonym; 0]);
assert!(retention.documentation.is_some());
- let cleanup = entry(&result.configs, "cleanup.policy");
- assert_eq!(cleanup.value.as_ref().map(StrBytes::as_str), Some("delete"));
- assert!(cleanup.read_only);
- assert_eq!(cleanup.config_source, SOURCE_DEFAULT);
- assert_eq!(cleanup.synonyms, [] as [DescribeConfigsSynonym; 0]);
- assert!(cleanup.documentation.is_some());
let without_docs = describe(
&state,
@@ -206,11 +200,11 @@ async fn
describe_configs_returns_retention_and_cleanup_and_alter_persists_reten
true,
)
.await;
+ // Kafka Connect starts when the entry is missing and refuses a topic that
reports `delete`.
assert_eq!(filtered.results[0].error_code, ERROR_NONE);
- assert_eq!(filtered.results[0].configs.len(), 1);
assert_eq!(
- filtered.results[0].configs[0].name.as_str(),
- "cleanup.policy"
+ filtered.results[0].configs,
+ [] as [DescribeConfigsResourceResult; 0]
);
let v1 = describe(
@@ -222,7 +216,7 @@ async fn
describe_configs_returns_retention_and_cleanup_and_alter_persists_reten
)
.await;
assert_eq!(v1.results[0].error_code, ERROR_NONE);
- assert_eq!(v1.results[0].configs.len(), 2);
+ assert_eq!(v1.results[0].configs.len(), 1);
let validated = alter(
&state,
diff --git a/gateways/kafka/tests/create_topics_real_bridge_tests.rs
b/gateways/kafka/tests/create_topics_real_bridge_tests.rs
index b2470a55e..8f1f97463 100644
--- a/gateways/kafka/tests/create_topics_real_bridge_tests.rs
+++ b/gateways/kafka/tests/create_topics_real_bridge_tests.rs
@@ -331,6 +331,51 @@ async fn
create_topics_with_a_config_against_an_existing_topic_answers_already_e
assert_eq!(error_code, ERROR_TOPIC_ALREADY_EXISTS);
}
+/// Builds a v3 `CreateTopics` request for one topic with no assignments and
no configs. v3 is
+/// not flexible, so strings and arrays carry fixed-width lengths.
+fn build_v3_request(name: &str, num_partitions: i32, replication_factor: i16)
-> Bytes {
+ let mut enc = Encoder::with_capacity(64);
+ enc.write_i32(1); // one topic
+ enc.write_nullable_string(Some(name))
+ .expect("topic name fits");
+ enc.write_i32(num_partitions);
+ enc.write_i16(replication_factor);
+ enc.write_i32(0); // no assignments
+ enc.write_i32(0); // no configs
+ enc.write_i32(5_000); // timeout_ms
+ enc.write_bool(false); // validate_only
+ enc.freeze()
+}
+
+/// `KafkaJS` `createTopics()` sends `-1` partitions and replication factor at
v3. Kafka's
+/// controller accepts them at every version, so the topic is created with the
default partition
+/// count.
+#[tokio::test]
+#[serial]
+async fn create_topics_v3_accepts_broker_default_sentinels() {
+ let data_dir = tempfile::tempdir().expect("tempdir");
+ let server = TestServer::spawn(data_dir.path()).await;
+ let state = connected_state(&server).await;
+
+ let outcome = create_topics::handle(&state, 3, build_v3_request("orders",
-1, -1)).await;
+ let mut d = Decoder::new(outcome.expect_response("CreateTopics request
always answers"));
+ let _throttle_time_ms = d.read_i32().expect("throttle_time_ms");
+ assert_eq!(d.read_i32().expect("topics array count"), 1);
+ assert_eq!(
+ d.read_nullable_string().expect("topic name").as_deref(),
+ Some("orders")
+ );
+ assert_eq!(d.read_i16().expect("error_code"), ERROR_NONE);
+
+ let raw = raw_client(&server).await;
+ let topics = raw
+ .get_topics(&Identifier::named("kafka").expect("valid stream name"))
+ .await
+ .expect("get_topics call");
+ assert_eq!(topics.len(), 1, "the handler must have actually created it");
+ assert_eq!(topics[0].partitions_count, 1);
+}
+
/// A manual replica assignment resolves the created partition count from its
own length, not
/// from `num_partitions` (`-1` here, per KIP-464).
#[tokio::test]
diff --git a/gateways/kafka/tests/java/ConfigsProbe.java
b/gateways/kafka/tests/java/ConfigsProbe.java
index 6c757f404..454c666f2 100644
--- a/gateways/kafka/tests/java/ConfigsProbe.java
+++ b/gateways/kafka/tests/java/ConfigsProbe.java
@@ -53,26 +53,17 @@ public final class ConfigsProbe {
try (AdminClient admin = AdminClient.create(props)) {
Config before = describe(admin, resource);
ConfigEntry retention = before.get("retention.ms");
- ConfigEntry cleanup = before.get("cleanup.policy");
require(retention != null && "-1".equals(retention.value()),
"initial retention.ms");
require(
retention.source() ==
ConfigEntry.ConfigSource.DEFAULT_CONFIG,
"initial retention source");
require(!retention.isReadOnly(), "retention.ms is writable");
- require(cleanup != null && "delete".equals(cleanup.value()),
"cleanup.policy");
- require(cleanup.isReadOnly(), "cleanup.policy is read only");
- require(
- cleanup.source() ==
ConfigEntry.ConfigSource.DEFAULT_CONFIG,
- "cleanup source");
+ // Kafka Connect refuses an internal topic that reports any
cleanup.policy other
+ // than compact, and starts when the entry is missing.
+ require(before.get("cleanup.policy") == null, "cleanup.policy is
not reported");
System.out.println(
"retention.ms=" + retention.value() + " source=" +
retention.source());
- System.out.println(
- "cleanup.policy="
- + cleanup.value()
- + " read_only="
- + cleanup.isReadOnly()
- + " source="
- + cleanup.source());
+ System.out.println("cleanup.policy=absent");
try {
admin.alterConfigs(Map.of(resource, config("no.such",
"1"))).all().get();
diff --git a/gateways/kafka/tests/response_negative_tests.rs
b/gateways/kafka/tests/response_negative_tests.rs
index 14750559a..02d9cf801 100644
--- a/gateways/kafka/tests/response_negative_tests.rs
+++ b/gateways/kafka/tests/response_negative_tests.rs
@@ -129,9 +129,10 @@ fn create_topics_v5_flags_invalid_replication_factor() {
}
#[test]
-fn create_topics_v2_rejects_broker_default_sentinel() {
- // KIP-464 defaults apply from v4; on v2 without assignments, -1 is
INVALID_PARTITIONS.
- let req =
CreateTopicsRequest::default().with_topics(vec![creatable_topic("legacy", -1,
1)]);
+fn create_topics_v2_accepts_broker_default_sentinels() {
+ // Kafka's controller takes -1 at every version, and KafkaJS sends it at
v3. The stub still
+ // answers NOT_CONTROLLER, not INVALID_PARTITIONS or
INVALID_REPLICATION_FACTOR.
+ let req =
CreateTopicsRequest::default().with_topics(vec![creatable_topic("legacy", -1,
-1)]);
let body = create_topics::encode_response(2, &req).unwrap();
let mut d = Decoder::new(body);
assert_eq!(d.read_i32().unwrap(), 0);
@@ -140,12 +141,12 @@ fn create_topics_v2_rejects_broker_default_sentinel() {
d.read_nullable_string().unwrap(),
Some("legacy".to_string())
);
- assert_eq!(d.read_i16().unwrap(), ERROR_INVALID_PARTITIONS);
+ assert_eq!(d.read_i16().unwrap(), ERROR_NOT_CONTROLLER);
}
#[test]
fn create_topics_v2_with_assignments_allows_broker_default_sentinels() {
- // KIP-464: on v2/v3, -1 partitions/replication are valid when assignments
are present.
+ // KIP-464: -1 partitions/replication stay valid on v2 when assignments
are present too.
use
kafka_protocol::messages::create_topics_request::CreatableReplicaAssignment;
let assignment = CreatableReplicaAssignment::default()