diff --git a/binders/kafka-binder/spring-cloud-stream-binder-kafka-core/pom.xml b/binders/kafka-binder/spring-cloud-stream-binder-kafka-core/pom.xml
index bb0b3a552..6091fcbd4 100644
--- a/binders/kafka-binder/spring-cloud-stream-binder-kafka-core/pom.xml
+++ b/binders/kafka-binder/spring-cloud-stream-binder-kafka-core/pom.xml
@@ -24,6 +24,11 @@
org.springframework.integration
spring-integration-kafka
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+ true
+
org.springframework.boot
spring-boot-configuration-processor
diff --git a/binders/kafka-binder/spring-cloud-stream-binder-kafka-core/src/main/java/org/springframework/cloud/stream/binder/kafka/common/AbstractKafkaBinderHealthIndicator.java b/binders/kafka-binder/spring-cloud-stream-binder-kafka-core/src/main/java/org/springframework/cloud/stream/binder/kafka/common/AbstractKafkaBinderHealthIndicator.java
new file mode 100644
index 000000000..e4cf87d8b
--- /dev/null
+++ b/binders/kafka-binder/spring-cloud-stream-binder-kafka-core/src/main/java/org/springframework/cloud/stream/binder/kafka/common/AbstractKafkaBinderHealthIndicator.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.cloud.stream.binder.kafka.common;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.kafka.clients.consumer.Consumer;
+import org.apache.kafka.common.PartitionInfo;
+
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.boot.actuate.health.AbstractHealthIndicator;
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.boot.actuate.health.Status;
+import org.springframework.boot.actuate.health.StatusAggregator;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.util.Assert;
+
+/**
+ * Base class that abstracts the common health indicator details for the various Kafka binder flavors.
+ *
+ * @author Soby Chacko
+ * @since 4.1.0
+ */
+public abstract class AbstractKafkaBinderHealthIndicator extends AbstractHealthIndicator implements DisposableBean {
+
+ private static final int DEFAULT_TIMEOUT = 60;
+
+ protected int timeout = DEFAULT_TIMEOUT;
+
+ private final ExecutorService executor;
+
+ protected Consumer, ?> metadataConsumer;
+
+ protected boolean considerDownWhenAnyPartitionHasNoLeader;
+
+ private final ConsumerFactory, ?> consumerFactory;
+
+ public AbstractKafkaBinderHealthIndicator(ConsumerFactory, ?> consumerFactory) {
+ this.consumerFactory = consumerFactory;
+ this.executor = createHealthBinderExecutorService();
+ Assert.notNull(this.executor, "The health indicator executor service must not be null");
+ }
+
+ protected abstract Map getTopicsInUse();
+
+ protected abstract Health buildBinderSpecificHealthDetails();
+
+ protected abstract ExecutorService createHealthBinderExecutorService();
+
+ private void initMetadataConsumer() {
+ if (this.metadataConsumer == null) {
+ this.metadataConsumer = this.consumerFactory.createConsumer();
+ }
+ }
+
+ @Override
+ public void destroy() {
+ executor.shutdown();
+ if (this.metadataConsumer != null) {
+ this.metadataConsumer.close();
+ }
+ }
+
+ @Override
+ protected void doHealthCheck(Health.Builder builder) throws Exception {
+ Health topicsHealth = safelyBuildTopicsHealth();
+ Health listenerContainersHealth = buildBinderSpecificHealthDetails();
+ merge(topicsHealth, listenerContainersHealth, builder);
+ }
+
+ protected Health safelyBuildTopicsHealth() {
+ Future future = executor.submit(this::buildTopicsHealth);
+ try {
+ return future.get(this.timeout, TimeUnit.SECONDS);
+ }
+ catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ return Health.down()
+ .withDetail("Interrupted while waiting for partition information in",
+ this.timeout + " seconds")
+ .build();
+ }
+ catch (ExecutionException ex) {
+ return Health.down(ex).build();
+ }
+ catch (TimeoutException ex) {
+ return Health.down().withDetail("Failed to retrieve partition information in",
+ this.timeout + " seconds").build();
+ }
+ }
+
+ private Health buildTopicsHealth() {
+ try {
+ initMetadataConsumer();
+ Set downMessages = new HashSet<>();
+ Set checkedTopics = new HashSet<>();
+ final Map topicsInUse = getTopicsInUse();
+ if (topicsInUse.isEmpty()) {
+ try {
+ this.metadataConsumer.listTopics(Duration.ofSeconds(this.timeout));
+ }
+ catch (Exception e) {
+ return Health.down().withDetail("No topic information available",
+ "Kafka broker is not reachable").build();
+ }
+ return Health.unknown().withDetail("No bindings found",
+ "Kafka binder may not be bound to destinations on the broker").build();
+ }
+ else {
+ for (String topic : topicsInUse.keySet()) {
+ TopicInformation topicInformation = topicsInUse
+ .get(topic);
+ if (!topicInformation.isTopicPattern()) {
+ List partitionInfos = this.metadataConsumer
+ .partitionsFor(topic);
+ for (PartitionInfo partitionInfo : partitionInfos) {
+ if (topicInformation.partitionInfos()
+ .contains(partitionInfo)
+ && partitionInfo.leader() == null ||
+ (partitionInfo.leader() != null && partitionInfo.leader().id() == -1)) {
+ downMessages.add(partitionInfo.toString());
+ }
+ else if (this.considerDownWhenAnyPartitionHasNoLeader &&
+ partitionInfo.leader() == null || (partitionInfo.leader() != null && partitionInfo.leader().id() == -1)) {
+ downMessages.add(partitionInfo.toString());
+ }
+ }
+ checkedTopics.add(topic);
+ }
+ else {
+ try {
+ // Since destination is a pattern, all we are doing is just to make sure that
+ // we can connect to the cluster and query the topics.
+ this.metadataConsumer.listTopics(Duration.ofSeconds(this.timeout));
+ }
+ catch (Exception ex) {
+ return Health.down()
+ .withDetail("Cluster not connected",
+ "Destination provided is a pattern, but cannot connect to the cluster for any verification")
+ .build();
+ }
+ }
+ }
+ }
+ if (downMessages.isEmpty()) {
+ return Health.up().withDetail("topicsInUse", checkedTopics).build();
+ }
+ else {
+ return Health.down()
+ .withDetail("Following partitions in use have no leaders: ",
+ downMessages.toString())
+ .build();
+ }
+ }
+ catch (Exception ex) {
+ return Health.down(ex).build();
+ }
+ }
+
+ private void merge(Health topicsHealth, Health listenerContainersHealth, Health.Builder builder) {
+ Status aggregatedStatus = StatusAggregator.getDefault()
+ .getAggregateStatus(topicsHealth.getStatus(), listenerContainersHealth.getStatus());
+ Map aggregatedDetails = new HashMap<>();
+ aggregatedDetails.putAll(topicsHealth.getDetails());
+ aggregatedDetails.putAll(listenerContainersHealth.getDetails());
+ builder.status(aggregatedStatus).withDetails(aggregatedDetails);
+ }
+
+ /**
+ * Set the timeout in seconds to retrieve health information.
+ *
+ * @param timeout the timeout - default 60.
+ */
+ public void setTimeout(int timeout) {
+ this.timeout = timeout;
+ }
+
+ public void setConsiderDownWhenAnyPartitionHasNoLeader(boolean considerDownWhenAnyPartitionHasNoLeader) {
+ this.considerDownWhenAnyPartitionHasNoLeader = considerDownWhenAnyPartitionHasNoLeader;
+ }
+}
diff --git a/binders/kafka-binder/spring-cloud-stream-binder-kafka-core/src/main/java/org/springframework/cloud/stream/binder/kafka/common/TopicInformation.java b/binders/kafka-binder/spring-cloud-stream-binder-kafka-core/src/main/java/org/springframework/cloud/stream/binder/kafka/common/TopicInformation.java
new file mode 100644
index 000000000..bac2b6901
--- /dev/null
+++ b/binders/kafka-binder/spring-cloud-stream-binder-kafka-core/src/main/java/org/springframework/cloud/stream/binder/kafka/common/TopicInformation.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.cloud.stream.binder.kafka.common;
+
+import java.util.Collection;
+
+import org.apache.kafka.common.PartitionInfo;
+
+/**
+ * Record to capture topic information for various binder related tasks.
+ *
+ * @param consumerGroup consumer group for the consumer
+ * @param partitionInfos collection of {@link PartitionInfo}
+ * @param isTopicPattern if the topic is specified as a pattern
+ *
+ * @author Soby Chacko (and previous authors before refactoring).
+ */
+public record TopicInformation(String consumerGroup, Collection partitionInfos, boolean isTopicPattern) {
+
+ public boolean isConsumerTopic() {
+ return this.consumerGroup != null;
+ }
+}
diff --git a/binders/kafka-binder/spring-cloud-stream-binder-kafka-reactive/src/main/java/org/springframework/cloud/stream/binder/reactorkafka/ReactorKafkaBinder.java b/binders/kafka-binder/spring-cloud-stream-binder-kafka-reactive/src/main/java/org/springframework/cloud/stream/binder/reactorkafka/ReactorKafkaBinder.java
index 371ac967f..91ec7fc41 100644
--- a/binders/kafka-binder/spring-cloud-stream-binder-kafka-reactive/src/main/java/org/springframework/cloud/stream/binder/reactorkafka/ReactorKafkaBinder.java
+++ b/binders/kafka-binder/spring-cloud-stream-binder-kafka-reactive/src/main/java/org/springframework/cloud/stream/binder/reactorkafka/ReactorKafkaBinder.java
@@ -18,15 +18,21 @@ package org.springframework.cloud.stream.binder.reactorkafka;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
+import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.PartitionInfo;
import reactor.core.publisher.Flux;
import reactor.kafka.receiver.KafkaReceiver;
import reactor.kafka.receiver.ReceiverOptions;
@@ -42,6 +48,7 @@ import org.springframework.cloud.stream.binder.BinderSpecificPropertiesProvider;
import org.springframework.cloud.stream.binder.ExtendedConsumerProperties;
import org.springframework.cloud.stream.binder.ExtendedProducerProperties;
import org.springframework.cloud.stream.binder.ExtendedPropertiesBinder;
+import org.springframework.cloud.stream.binder.kafka.common.TopicInformation;
import org.springframework.cloud.stream.binder.kafka.properties.KafkaBinderConfigurationProperties;
import org.springframework.cloud.stream.binder.kafka.properties.KafkaConsumerProperties;
import org.springframework.cloud.stream.binder.kafka.properties.KafkaExtendedBindingProperties;
@@ -60,6 +67,9 @@ import org.springframework.integration.core.MessageProducer;
import org.springframework.integration.endpoint.MessageProducerSupport;
import org.springframework.integration.handler.AbstractMessageHandler;
import org.springframework.integration.support.MessageBuilder;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.kafka.support.converter.KafkaMessageHeaders;
import org.springframework.kafka.support.converter.MessageConverter;
@@ -99,6 +109,10 @@ public class ReactorKafkaBinder
private SenderOptionsCustomizer