apoorvmittal10 commented on code in PR #23360: URL: https://github.com/apache/kafka/pull/23360#discussion_r3958014502
########## server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordHelper.java: ########## @@ -0,0 +1,262 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.server.share.dlq; + +import org.apache.kafka.common.TopicIdPartition; +import org.apache.kafka.common.Uuid; +import org.apache.kafka.common.compress.Compression; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.record.internal.DefaultRecord; +import org.apache.kafka.common.record.internal.DefaultRecordBatch; +import org.apache.kafka.common.record.internal.MemoryRecords; +import org.apache.kafka.common.record.internal.Record; +import org.apache.kafka.common.record.internal.SimpleRecord; +import org.apache.kafka.common.utils.Time; +import org.apache.kafka.server.share.LogReader; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + * Shared helper for DLQ record building and source-record fetching. + * Used by both the K1 ({@link ShareGroupDLQStateManager}) and K2 DLQ manager implementations. + */ +public class ShareGroupDLQRecordHelper { + + /** + * In most cases we expect the records getting DLQ'ed will be single offsets and + * not complete batches. Hence, using a large upper limit while reading from the log + * would be fruitless in most cases. Therefore, the value of 1 MB has been chosen + * for the DLQ-related log reads. + */ + public static final int DLQ_MAX_FETCH_BYTES = 1024 * 1024; Review Comment: Seems being used only in this class, shall we make it private? ########## clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerDLQTest.java: ########## @@ -583,6 +583,8 @@ public void testDlqRespectsDlqTopicMaxMessageBytesNotEqToSourceTopic() throws Ex }, DEFAULT_MAX_WAIT_MS, 100L, () -> "Raised max.message.bytes did not propagate on the DLQ topic"); } + waitForDlqTopicConfigRefresh(); Review Comment: Comment here would be good regarding the significance of same. ########## server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQValidator.java: ########## @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.server.share.dlq; + +import org.apache.kafka.common.config.ConfigException; + +import java.util.Optional; + +/** + * Shared validation logic for DLQ managers ({@link ShareGroupDLQStateManager} and + * {@code K2ShareGroupDLQManager}). + */ +public final class ShareGroupDLQValidator { + + private ShareGroupDLQValidator() {} + + /** + * Validates the fields of a {@link ShareGroupDLQRecordParameter}. + * + * @throws IllegalArgumentException if any field is invalid + */ + public static void validateParam(ShareGroupDLQRecordParameter param) { + String prefix = "DLQ records parameters"; + if (param == null) { + throw new IllegalArgumentException(prefix + " cannot be null."); + } + if (param.groupId() == null || param.groupId().isEmpty()) { + throw new IllegalArgumentException(prefix + " group cannot be null or empty."); + } + if (param.topicIdPartition() == null) { + throw new IllegalArgumentException(prefix + " topic/partition data cannot be null or empty."); + } + if (param.topicIdPartition().topicId() == null) { + throw new IllegalArgumentException(prefix + " topic id data cannot be null or empty."); + } + if (param.topicIdPartition().partition() < 0) { + throw new IllegalArgumentException(prefix + " partition cannot be negative."); + } + if (param.firstOffset() < 0) { + throw new IllegalArgumentException(prefix + " first offset cannot be negative."); + } + if (param.lastOffset() < 0) { + throw new IllegalArgumentException(prefix + " last offset cannot be negative."); + } + if (param.lastOffset() < param.firstOffset()) { + throw new IllegalArgumentException(prefix + " last offset cannot be less than first offset."); + } + } + + /** + * Validates DLQ topic configuration. Checks that the topic name does not start with {@code __}, + * that DLQ is enabled on the topic (if it exists), and that the topic name complies with the + * configured prefix. + * + * <p>Callers are responsible for checking that the topic name is present in the config (non-empty) + * before calling this method, and for any implementation-specific checks (e.g., K1 auto-create). + * + * @param groupId the share group ID, for error messages + * @param userTopicName the raw DLQ topic name from config (without tenant prefix) + * @param resolvedTopicName the topic name used for metadata cache lookups (may include tenant + * prefix in K2; same as {@code userTopicName} in K1) Review Comment: ```suggestion * @param resolvedTopicName the topic name used for metadata cache lookups ``` ########## server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordHelper.java: ########## @@ -0,0 +1,262 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.server.share.dlq; + +import org.apache.kafka.common.TopicIdPartition; +import org.apache.kafka.common.Uuid; +import org.apache.kafka.common.compress.Compression; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.record.internal.DefaultRecord; +import org.apache.kafka.common.record.internal.DefaultRecordBatch; +import org.apache.kafka.common.record.internal.MemoryRecords; +import org.apache.kafka.common.record.internal.Record; +import org.apache.kafka.common.record.internal.SimpleRecord; +import org.apache.kafka.common.utils.Time; +import org.apache.kafka.server.share.LogReader; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + * Shared helper for DLQ record building and source-record fetching. + * Used by both the K1 ({@link ShareGroupDLQStateManager}) and K2 DLQ manager implementations. + */ +public class ShareGroupDLQRecordHelper { + + /** + * In most cases we expect the records getting DLQ'ed will be single offsets and + * not complete batches. Hence, using a large upper limit while reading from the log + * would be fruitless in most cases. Therefore, the value of 1 MB has been chosen + * for the DLQ-related log reads. + */ + public static final int DLQ_MAX_FETCH_BYTES = 1024 * 1024; + + public static final String HEADER_DLQ_ERRORS_TOPIC = "__dlq.errors.topic"; + public static final String HEADER_DLQ_ERRORS_PARTITION = "__dlq.errors.partition"; + public static final String HEADER_DLQ_ERRORS_OFFSET = "__dlq.errors.offset"; + public static final String HEADER_DLQ_ERRORS_GROUP = "__dlq.errors.group"; + public static final String HEADER_DLQ_ERRORS_DELIVERY_COUNT = "__dlq.errors.delivery.count"; + public static final String HEADER_DLQ_ERRORS_MESSAGE = "__dlq.errors.message"; + + /** + * Result of building DLQ records for a range of offsets, respecting maxMessageBytes. + * + * @param records The built MemoryRecords containing DLQ records with headers + * @param lastOffsetIncluded The last source offset included in this batch + * @param recordCount The number of individual records in the batch Review Comment: nit: correct the indentation ``` * @param records The built MemoryRecords containing DLQ records with headers * @param lastOffsetIncluded The last source offset included in this batch * @param recordCount The number of individual records in the batch ########## server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordHelper.java: ########## @@ -0,0 +1,262 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.server.share.dlq; + +import org.apache.kafka.common.TopicIdPartition; +import org.apache.kafka.common.Uuid; +import org.apache.kafka.common.compress.Compression; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.record.internal.DefaultRecord; +import org.apache.kafka.common.record.internal.DefaultRecordBatch; +import org.apache.kafka.common.record.internal.MemoryRecords; +import org.apache.kafka.common.record.internal.Record; +import org.apache.kafka.common.record.internal.SimpleRecord; +import org.apache.kafka.common.utils.Time; +import org.apache.kafka.server.share.LogReader; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + * Shared helper for DLQ record building and source-record fetching. + * Used by both the K1 ({@link ShareGroupDLQStateManager}) and K2 DLQ manager implementations. + */ +public class ShareGroupDLQRecordHelper { + + /** + * In most cases we expect the records getting DLQ'ed will be single offsets and + * not complete batches. Hence, using a large upper limit while reading from the log + * would be fruitless in most cases. Therefore, the value of 1 MB has been chosen + * for the DLQ-related log reads. + */ + public static final int DLQ_MAX_FETCH_BYTES = 1024 * 1024; + + public static final String HEADER_DLQ_ERRORS_TOPIC = "__dlq.errors.topic"; + public static final String HEADER_DLQ_ERRORS_PARTITION = "__dlq.errors.partition"; + public static final String HEADER_DLQ_ERRORS_OFFSET = "__dlq.errors.offset"; + public static final String HEADER_DLQ_ERRORS_GROUP = "__dlq.errors.group"; + public static final String HEADER_DLQ_ERRORS_DELIVERY_COUNT = "__dlq.errors.delivery.count"; + public static final String HEADER_DLQ_ERRORS_MESSAGE = "__dlq.errors.message"; + + /** + * Result of building DLQ records for a range of offsets, respecting maxMessageBytes. + * + * @param records The built MemoryRecords containing DLQ records with headers + * @param lastOffsetIncluded The last source offset included in this batch + * @param recordCount The number of individual records in the batch + */ + public record BuildResult(MemoryRecords records, long lastOffsetIncluded, int recordCount) { + } + + /** + * Builds DLQ headers for a single offset. + * + * @param sourceTopic The resolved source topic name + * @param partition The source partition number + * @param offset The source offset + * @param groupId The share group ID + * @param deliveryCount Optional delivery count + * @param cause Optional cause/reason for DLQ + * @return Array of DLQ headers + */ + private static Header[] headers( + String sourceTopic, + int partition, + long offset, + String groupId, + Optional<Short> deliveryCount, + Optional<Throwable> cause + ) { + List<Header> headers = new ArrayList<>(); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_TOPIC, sourceTopic.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_PARTITION, Integer.toString(partition).getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_OFFSET, Long.toString(offset).getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_GROUP, groupId.getBytes(StandardCharsets.UTF_8))); + deliveryCount.ifPresent(dc -> headers.add( + new RecordHeader(HEADER_DLQ_ERRORS_DELIVERY_COUNT, Short.toString(dc).getBytes(StandardCharsets.UTF_8)))); + cause.ifPresent(c -> { + if (c.getMessage() != null) { + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_MESSAGE, c.getMessage().getBytes(StandardCharsets.UTF_8))); + } + }); + return headers.toArray(new Header[0]); Review Comment: Would it be better to avoid unnecessary ArrayList construction and Array copy? ``` String causeMessage = cause.map(Throwable::getMessage).orElse(null); int size = 4 + (deliveryCount.isPresent() ? 1 : 0) + (causeMessage != null ? 1 : 0); Header[] headers = new Header[size]; int i = 0; headers[i++] = new RecordHeader(HEADER_DLQ_ERRORS_TOPIC, sourceTopic.getBytes(StandardCharsets.UTF_8)); headers[i++] = new RecordHeader(HEADER_DLQ_ERRORS_PARTITION, Integer.toString(partition).getBytes(StandardCharsets.UTF_8)); headers[i++] = new RecordHeader(HEADER_DLQ_ERRORS_OFFSET, Long.toString(offset).getBytes(StandardCharsets.UTF_8)); headers[i++] = new RecordHeader(HEADER_DLQ_ERRORS_GROUP, groupId.getBytes(StandardCharsets.UTF_8)); if (deliveryCount.isPresent()) { headers[i++] = new RecordHeader(HEADER_DLQ_ERRORS_DELIVERY_COUNT, Short.toString(deliveryCount.get()).getBytes(StandardCharsets.UTF_8)); } if (causeMessage != null) { headers[i] = new RecordHeader(HEADER_DLQ_ERRORS_MESSAGE, causeMessage.getBytes(StandardCharsets.UTF_8)); } return headers; ########## server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordHelper.java: ########## @@ -0,0 +1,262 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.server.share.dlq; + +import org.apache.kafka.common.TopicIdPartition; +import org.apache.kafka.common.Uuid; +import org.apache.kafka.common.compress.Compression; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.record.internal.DefaultRecord; +import org.apache.kafka.common.record.internal.DefaultRecordBatch; +import org.apache.kafka.common.record.internal.MemoryRecords; +import org.apache.kafka.common.record.internal.Record; +import org.apache.kafka.common.record.internal.SimpleRecord; +import org.apache.kafka.common.utils.Time; +import org.apache.kafka.server.share.LogReader; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + * Shared helper for DLQ record building and source-record fetching. + * Used by both the K1 ({@link ShareGroupDLQStateManager}) and K2 DLQ manager implementations. + */ +public class ShareGroupDLQRecordHelper { + + /** + * In most cases we expect the records getting DLQ'ed will be single offsets and + * not complete batches. Hence, using a large upper limit while reading from the log + * would be fruitless in most cases. Therefore, the value of 1 MB has been chosen + * for the DLQ-related log reads. + */ + public static final int DLQ_MAX_FETCH_BYTES = 1024 * 1024; + + public static final String HEADER_DLQ_ERRORS_TOPIC = "__dlq.errors.topic"; + public static final String HEADER_DLQ_ERRORS_PARTITION = "__dlq.errors.partition"; + public static final String HEADER_DLQ_ERRORS_OFFSET = "__dlq.errors.offset"; + public static final String HEADER_DLQ_ERRORS_GROUP = "__dlq.errors.group"; + public static final String HEADER_DLQ_ERRORS_DELIVERY_COUNT = "__dlq.errors.delivery.count"; + public static final String HEADER_DLQ_ERRORS_MESSAGE = "__dlq.errors.message"; + + /** + * Result of building DLQ records for a range of offsets, respecting maxMessageBytes. + * + * @param records The built MemoryRecords containing DLQ records with headers + * @param lastOffsetIncluded The last source offset included in this batch + * @param recordCount The number of individual records in the batch + */ + public record BuildResult(MemoryRecords records, long lastOffsetIncluded, int recordCount) { + } + + /** + * Builds DLQ headers for a single offset. + * + * @param sourceTopic The resolved source topic name + * @param partition The source partition number + * @param offset The source offset + * @param groupId The share group ID + * @param deliveryCount Optional delivery count + * @param cause Optional cause/reason for DLQ + * @return Array of DLQ headers + */ + private static Header[] headers( + String sourceTopic, + int partition, + long offset, + String groupId, + Optional<Short> deliveryCount, + Optional<Throwable> cause + ) { + List<Header> headers = new ArrayList<>(); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_TOPIC, sourceTopic.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_PARTITION, Integer.toString(partition).getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_OFFSET, Long.toString(offset).getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_GROUP, groupId.getBytes(StandardCharsets.UTF_8))); + deliveryCount.ifPresent(dc -> headers.add( + new RecordHeader(HEADER_DLQ_ERRORS_DELIVERY_COUNT, Short.toString(dc).getBytes(StandardCharsets.UTF_8)))); + cause.ifPresent(c -> { + if (c.getMessage() != null) { + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_MESSAGE, c.getMessage().getBytes(StandardCharsets.UTF_8))); + } + }); + return headers.toArray(new Header[0]); + } + + /** + * Resolves the source topic name from a TopicIdPartition, falling back to the topic ID string. + * + * @param topicIdPartition The source topic-partition + * @param topicNameResolver Resolver that maps topic ID to name Review Comment: nit: align the indentation ########## server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQValidator.java: ########## @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.server.share.dlq; + +import org.apache.kafka.common.config.ConfigException; + +import java.util.Optional; + +/** + * Shared validation logic for DLQ managers ({@link ShareGroupDLQStateManager} and + * {@code K2ShareGroupDLQManager}). + */ +public final class ShareGroupDLQValidator { + + private ShareGroupDLQValidator() {} + + /** + * Validates the fields of a {@link ShareGroupDLQRecordParameter}. + * + * @throws IllegalArgumentException if any field is invalid + */ + public static void validateParam(ShareGroupDLQRecordParameter param) { + String prefix = "DLQ records parameters"; + if (param == null) { + throw new IllegalArgumentException(prefix + " cannot be null."); + } + if (param.groupId() == null || param.groupId().isEmpty()) { + throw new IllegalArgumentException(prefix + " group cannot be null or empty."); + } + if (param.topicIdPartition() == null) { + throw new IllegalArgumentException(prefix + " topic/partition data cannot be null or empty."); + } + if (param.topicIdPartition().topicId() == null) { + throw new IllegalArgumentException(prefix + " topic id data cannot be null or empty."); + } + if (param.topicIdPartition().partition() < 0) { + throw new IllegalArgumentException(prefix + " partition cannot be negative."); + } + if (param.firstOffset() < 0) { + throw new IllegalArgumentException(prefix + " first offset cannot be negative."); + } + if (param.lastOffset() < 0) { + throw new IllegalArgumentException(prefix + " last offset cannot be negative."); + } + if (param.lastOffset() < param.firstOffset()) { + throw new IllegalArgumentException(prefix + " last offset cannot be less than first offset."); + } + } + + /** + * Validates DLQ topic configuration. Checks that the topic name does not start with {@code __}, + * that DLQ is enabled on the topic (if it exists), and that the topic name complies with the + * configured prefix. + * + * <p>Callers are responsible for checking that the topic name is present in the config (non-empty) + * before calling this method, and for any implementation-specific checks (e.g., K1 auto-create). + * + * @param groupId the share group ID, for error messages + * @param userTopicName the raw DLQ topic name from config (without tenant prefix) Review Comment: ```suggestion * @param userTopicName the raw DLQ topic name from config ``` ########## server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQValidator.java: ########## @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.server.share.dlq; + +import org.apache.kafka.common.config.ConfigException; + +import java.util.Optional; + +/** + * Shared validation logic for DLQ managers ({@link ShareGroupDLQStateManager} and + * {@code K2ShareGroupDLQManager}). Review Comment: ```suggestion * Shared validation logic for DLQ manager. ``` ########## server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordHelper.java: ########## @@ -0,0 +1,262 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.server.share.dlq; + +import org.apache.kafka.common.TopicIdPartition; +import org.apache.kafka.common.Uuid; +import org.apache.kafka.common.compress.Compression; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.record.internal.DefaultRecord; +import org.apache.kafka.common.record.internal.DefaultRecordBatch; +import org.apache.kafka.common.record.internal.MemoryRecords; +import org.apache.kafka.common.record.internal.Record; +import org.apache.kafka.common.record.internal.SimpleRecord; +import org.apache.kafka.common.utils.Time; +import org.apache.kafka.server.share.LogReader; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + * Shared helper for DLQ record building and source-record fetching. + * Used by both the K1 ({@link ShareGroupDLQStateManager}) and K2 DLQ manager implementations. Review Comment: ```suggestion ``` ########## server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordHelper.java: ########## @@ -0,0 +1,262 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.server.share.dlq; + +import org.apache.kafka.common.TopicIdPartition; +import org.apache.kafka.common.Uuid; +import org.apache.kafka.common.compress.Compression; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.record.internal.DefaultRecord; +import org.apache.kafka.common.record.internal.DefaultRecordBatch; +import org.apache.kafka.common.record.internal.MemoryRecords; +import org.apache.kafka.common.record.internal.Record; +import org.apache.kafka.common.record.internal.SimpleRecord; +import org.apache.kafka.common.utils.Time; +import org.apache.kafka.server.share.LogReader; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + * Shared helper for DLQ record building and source-record fetching. + * Used by both the K1 ({@link ShareGroupDLQStateManager}) and K2 DLQ manager implementations. + */ +public class ShareGroupDLQRecordHelper { + + /** + * In most cases we expect the records getting DLQ'ed will be single offsets and + * not complete batches. Hence, using a large upper limit while reading from the log + * would be fruitless in most cases. Therefore, the value of 1 MB has been chosen + * for the DLQ-related log reads. + */ + public static final int DLQ_MAX_FETCH_BYTES = 1024 * 1024; + + public static final String HEADER_DLQ_ERRORS_TOPIC = "__dlq.errors.topic"; + public static final String HEADER_DLQ_ERRORS_PARTITION = "__dlq.errors.partition"; + public static final String HEADER_DLQ_ERRORS_OFFSET = "__dlq.errors.offset"; + public static final String HEADER_DLQ_ERRORS_GROUP = "__dlq.errors.group"; + public static final String HEADER_DLQ_ERRORS_DELIVERY_COUNT = "__dlq.errors.delivery.count"; + public static final String HEADER_DLQ_ERRORS_MESSAGE = "__dlq.errors.message"; + + /** + * Result of building DLQ records for a range of offsets, respecting maxMessageBytes. + * + * @param records The built MemoryRecords containing DLQ records with headers + * @param lastOffsetIncluded The last source offset included in this batch + * @param recordCount The number of individual records in the batch + */ + public record BuildResult(MemoryRecords records, long lastOffsetIncluded, int recordCount) { + } + + /** + * Builds DLQ headers for a single offset. + * + * @param sourceTopic The resolved source topic name + * @param partition The source partition number + * @param offset The source offset + * @param groupId The share group ID + * @param deliveryCount Optional delivery count + * @param cause Optional cause/reason for DLQ + * @return Array of DLQ headers + */ + private static Header[] headers( + String sourceTopic, + int partition, + long offset, + String groupId, + Optional<Short> deliveryCount, + Optional<Throwable> cause + ) { + List<Header> headers = new ArrayList<>(); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_TOPIC, sourceTopic.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_PARTITION, Integer.toString(partition).getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_OFFSET, Long.toString(offset).getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_GROUP, groupId.getBytes(StandardCharsets.UTF_8))); + deliveryCount.ifPresent(dc -> headers.add( + new RecordHeader(HEADER_DLQ_ERRORS_DELIVERY_COUNT, Short.toString(dc).getBytes(StandardCharsets.UTF_8)))); + cause.ifPresent(c -> { + if (c.getMessage() != null) { + headers.add(new RecordHeader(HEADER_DLQ_ERRORS_MESSAGE, c.getMessage().getBytes(StandardCharsets.UTF_8))); + } + }); + return headers.toArray(new Header[0]); + } + + /** + * Resolves the source topic name from a TopicIdPartition, falling back to the topic ID string. + * + * @param topicIdPartition The source topic-partition + * @param topicNameResolver Resolver that maps topic ID to name + * @return The resolved topic name + */ + public static String resolveSourceTopicName(TopicIdPartition topicIdPartition, java.util.function.Function<Uuid, Optional<String>> topicNameResolver) { + String recordTopicName = topicIdPartition.topic(); + if (recordTopicName == null || recordTopicName.isEmpty()) { + // If topic name lookup fails, use topic id as a String in the header. + recordTopicName = topicNameResolver.apply(topicIdPartition.topicId()).orElse(topicIdPartition.topicId().toString()); + } + return recordTopicName; + } + + /** + * Computes the destination DLQ partition from the source partition. + * + * @param sourcePartition The source partition number + * @param numDlqPartitions The number of partitions in the DLQ topic + * @return The destination DLQ partition number + */ + public static int dlqDestinationPartition(int sourcePartition, int numDlqPartitions) { + return sourcePartition % numDlqPartitions; + } + + /** + * Builds DLQ MemoryRecords for a range of offsets, respecting maxMessageBytes for batch splitting. + * + * @param param The DLQ record parameter with offset range and metadata + * @param resolvedRecordData Map of source offset to source Record (can be incomplete) + * @param nextOffsetToSend The first offset to include in this batch + * @param lastResolvedOffset The last offset that was resolved by the fetcher + * @param maxMessageBytes Maximum batch size in bytes + * @param time Time instance for wall-clock timestamps + * @param sourceTopic The resolved source topic name + * @return BuildResult containing the MemoryRecords and the last offset included + */ + public static BuildResult buildDLQRecords( + ShareGroupDLQRecordParameter param, + Map<Long, Record> resolvedRecordData, + long nextOffsetToSend, + long lastResolvedOffset, + int maxMessageBytes, + Time time, + String sourceTopic + ) { + // In most cases the offset range is a single offset (see DLQ_MAX_FETCH_BYTES). Track the + // running batch size incrementally via DefaultRecord.sizeInBytes() - the same formula + // MemoryRecords itself uses - instead of re-serializing the whole batch-so-far on every + // offset, which would make this loop quadratic in the number of offsets. + List<SimpleRecord> simpleRecords = new ArrayList<>(); + int batchSize = DefaultRecordBatch.RECORD_BATCH_OVERHEAD; + Long baseTimestamp = null; + // Capped at lastResolvedOffsetThisRound, not just param.lastOffset(): offsets beyond it were + // never attempted by this round's fetch and must stay untouched for a fresh round to retry, + // rather than being packed in here as headers-only just because they have no map entry yet. + // Floored at nextOffsetToSend itself (mirroring the single-record floor below for the + // size-exceeds-limit case): a fetch that resolved nothing at all for this round - e.g. a read + // that failed outright - would otherwise leave this loop with zero iterations, producing an + // empty record batch, which the broker rejects outright. Sending nextOffsetToSend alone, + // headers-only, guarantees forward progress even when nothing could be resolved. + long roundEnd = Math.max(nextOffsetToSend, Math.min(param.lastOffset(), lastResolvedOffset)); + + for (long offset = nextOffsetToSend; offset <= roundEnd; offset++) { + // Must be wall-clock (epoch) time: log retention decides whether to delete this + // record's segment by comparing its timestamp against the current wall-clock time. + long timestamp = time.milliseconds(); + ByteBuffer key = null; + ByteBuffer value = null; + Record record = resolvedRecordData.get(offset); + if (record != null) { + key = record.hasKey() ? record.key() : null; + value = record.hasValue() ? record.value() : null; + } + Header[] recordHeaders = headers(sourceTopic, param.topicIdPartition().partition(), + offset, param.groupId(), param.deliveryCount(), param.cause()); + if (baseTimestamp == null) { + baseTimestamp = timestamp; + } + int recordSize = DefaultRecord.sizeInBytes(simpleRecords.size(), timestamp - baseTimestamp, key, value, recordHeaders); + + if (batchSize + recordSize > maxMessageBytes && !simpleRecords.isEmpty()) { + // Adding this record would exceed the limit and the batch already has at least one + // record - stop here and send the rest in a follow-up request. + break; + } + simpleRecords.add(new SimpleRecord(timestamp, key, value, recordHeaders)); + batchSize += recordSize; + if (batchSize > maxMessageBytes) { + // A single record (with its DLQ headers) already exceeds the limit on its own; + // nothing to be gained by holding it back, so send it and let the broker + // enforce/report the ultimate limit for this one, rather than stalling forever. + break; + } + } + + long lastOffsetIncluded = nextOffsetToSend + simpleRecords.size() - 1; + MemoryRecords records = MemoryRecords.withRecords( + Compression.NONE, + simpleRecords.toArray(new SimpleRecord[]{}) + ); + return new BuildResult(records, lastOffsetIncluded, simpleRecords.size()); + } + + /** + * Optionally fetches source records for DLQ copy-record mode. If copy-record is disabled + * for the group, returns an empty result immediately. + * + * @param param The DLQ record parameter + * @param fromOffset The first offset to fetch from + * @param cacheHelper Metadata cache helper for config lookups + * @param logReader Log reader for fetching source records + * @param time Time instance + * @param topicNameResolver Resolves the raw DLQ topic name to the name used in the metadata + * cache. K2 prepends the tenant prefix; K1 passes {@link Function#identity()}. Review Comment: ```suggestion * @param topicNameResolver Resolves the raw DLQ topic name to the name used in the metadata cache. ``` ########## server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQValidator.java: ########## @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.server.share.dlq; + +import org.apache.kafka.common.config.ConfigException; + +import java.util.Optional; + +/** + * Shared validation logic for DLQ managers ({@link ShareGroupDLQStateManager} and + * {@code K2ShareGroupDLQManager}). + */ +public final class ShareGroupDLQValidator { + + private ShareGroupDLQValidator() {} + + /** + * Validates the fields of a {@link ShareGroupDLQRecordParameter}. + * + * @throws IllegalArgumentException if any field is invalid + */ + public static void validateParam(ShareGroupDLQRecordParameter param) { + String prefix = "DLQ records parameters"; + if (param == null) { + throw new IllegalArgumentException(prefix + " cannot be null."); + } + if (param.groupId() == null || param.groupId().isEmpty()) { + throw new IllegalArgumentException(prefix + " group cannot be null or empty."); + } + if (param.topicIdPartition() == null) { + throw new IllegalArgumentException(prefix + " topic/partition data cannot be null or empty."); + } + if (param.topicIdPartition().topicId() == null) { + throw new IllegalArgumentException(prefix + " topic id data cannot be null or empty."); + } + if (param.topicIdPartition().partition() < 0) { + throw new IllegalArgumentException(prefix + " partition cannot be negative."); + } + if (param.firstOffset() < 0) { + throw new IllegalArgumentException(prefix + " first offset cannot be negative."); + } + if (param.lastOffset() < 0) { + throw new IllegalArgumentException(prefix + " last offset cannot be negative."); + } + if (param.lastOffset() < param.firstOffset()) { + throw new IllegalArgumentException(prefix + " last offset cannot be less than first offset."); + } + } + + /** + * Validates DLQ topic configuration. Checks that the topic name does not start with {@code __}, + * that DLQ is enabled on the topic (if it exists), and that the topic name complies with the + * configured prefix. + * + * <p>Callers are responsible for checking that the topic name is present in the config (non-empty) + * before calling this method, and for any implementation-specific checks (e.g., K1 auto-create). Review Comment: ```suggestion * before calling this method, and for any implementation-specific checks (e.g., auto-create). ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
