From 3fa2c6bcc8429b92f95ba0bed6400e235b227d4d Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Sat, 15 Oct 2022 11:56:21 -0500 Subject: [PATCH] GH-2523: Port KafkaStreamsVersionAgnosticTopologyInfoFacade from 3.2.x See #2523 --- .../streams/InteractiveQueryService.java | 21 +- ...eamsVersionAgnosticTopologyInfoFacade.java | 130 ++++++++++ ...ersionAgnosticTopologyInfoFacadeTests.java | 242 ++++++++++++++++++ 3 files changed, 375 insertions(+), 18 deletions(-) create mode 100644 binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/main/java/org/springframework/cloud/stream/binder/kafka/streams/KafkaStreamsVersionAgnosticTopologyInfoFacade.java create mode 100644 binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/test/java/org/springframework/cloud/stream/binder/kafka/streams/KafkaStreamsVersionAgnosticTopologyInfoFacadeTests.java diff --git a/binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/main/java/org/springframework/cloud/stream/binder/kafka/streams/InteractiveQueryService.java b/binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/main/java/org/springframework/cloud/stream/binder/kafka/streams/InteractiveQueryService.java index 322fda359..330a1fef9 100644 --- a/binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/main/java/org/springframework/cloud/stream/binder/kafka/streams/InteractiveQueryService.java +++ b/binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/main/java/org/springframework/cloud/stream/binder/kafka/streams/InteractiveQueryService.java @@ -16,7 +16,6 @@ package org.springframework.cloud.stream.binder.kafka.streams; -import java.lang.reflect.Field; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,7 +33,6 @@ import org.apache.kafka.streams.StoreQueryParameters; import org.apache.kafka.streams.StreamsConfig; import org.apache.kafka.streams.errors.InvalidStateStoreException; import org.apache.kafka.streams.errors.UnknownStateStoreException; -import org.apache.kafka.streams.processor.internals.TopologyMetadata; import org.apache.kafka.streams.state.HostInfo; import org.apache.kafka.streams.state.QueryableStoreType; import org.apache.kafka.streams.state.StreamsMetadata; @@ -44,7 +42,6 @@ import org.springframework.retry.RetryPolicy; import org.springframework.retry.backoff.FixedBackOffPolicy; import org.springframework.retry.policy.SimpleRetryPolicy; import org.springframework.retry.support.RetryTemplate; -import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; /** @@ -68,7 +65,7 @@ public class InteractiveQueryService { private final KafkaStreamsBinderConfigurationProperties binderConfigurationProperties; - private final Field topologyMetadataField; + private final KafkaStreamsVersionAgnosticTopologyInfoFacade topologyInfoFacade; /** * Constructor for InteractiveQueryService. @@ -79,8 +76,7 @@ public class InteractiveQueryService { KafkaStreamsBinderConfigurationProperties binderConfigurationProperties) { this.kafkaStreamsRegistry = kafkaStreamsRegistry; this.binderConfigurationProperties = binderConfigurationProperties; - this.topologyMetadataField = ReflectionUtils.findField(KafkaStreams.class, "topologyMetadata"); - this.topologyMetadataField.setAccessible(true); + this.topologyInfoFacade = new KafkaStreamsVersionAgnosticTopologyInfoFacade(); } /** @@ -134,7 +130,7 @@ public class InteractiveQueryService { if (candidateStores.size() > 1) { candidateStores = candidateStores.entrySet().stream() - .filter((e) -> storeActuallyAvailable(e.getKey(), storeName)) + .filter((e) -> this.topologyInfoFacade.streamsAppActuallyHasStore(e.getKey(), storeName)) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); if (candidateStores.size() == 1) { @@ -150,17 +146,6 @@ public class InteractiveQueryService { }); } - private boolean storeActuallyAvailable(KafkaStreams kafkaStreams, String storeName) { - try { - TopologyMetadata topologyMetadata = (TopologyMetadata) ReflectionUtils.getField(topologyMetadataField, kafkaStreams); - return !topologyMetadata.sourceTopicsForStore(storeName, null).isEmpty(); - } - catch (Exception ex) { - LOG.error("Unable to determine if store (" + storeName + ") is available to the app due to: " + ex.getMessage(), ex); - } - return false; - } - /** * Retrieves the current {@link KafkaStreams} context if executing Thread is created by a Streams App (contains a matching application id in Thread's name). * diff --git a/binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/main/java/org/springframework/cloud/stream/binder/kafka/streams/KafkaStreamsVersionAgnosticTopologyInfoFacade.java b/binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/main/java/org/springframework/cloud/stream/binder/kafka/streams/KafkaStreamsVersionAgnosticTopologyInfoFacade.java new file mode 100644 index 000000000..763030bc5 --- /dev/null +++ b/binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/main/java/org/springframework/cloud/stream/binder/kafka/streams/KafkaStreamsVersionAgnosticTopologyInfoFacade.java @@ -0,0 +1,130 @@ +/* + * Copyright 2022-2022 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.streams; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.StringJoiner; + +import org.apache.kafka.streams.KafkaStreams; + +import org.springframework.core.log.LogAccessor; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ReflectionUtils; + +/** + * A facade to access topology info for a Kafka Streams application in a version agnostic + * manner. + *

Before kafka-streams 3.1 the topology exists at 'KafkaStreams.internalTopologyBuilder'. + * Starting in kafka-streams 3.1 the topology exists at 'KafkaStreams.topologyMetadata'. + * + * @author Chris Bono + * @since 3.2.6 + */ +class KafkaStreamsVersionAgnosticTopologyInfoFacade { + + private final LogAccessor logger = new LogAccessor(KafkaStreamsVersionAgnosticTopologyInfoFacade.class); + + @Nullable + private Field topologyInfoField; + + @Nullable + private Method sourceTopicsForStoreMethod; + + private boolean sourceTopicsForStoreMethodHasTwoArgs; + + KafkaStreamsVersionAgnosticTopologyInfoFacade() { + this(KafkaStreams.class); + } + + KafkaStreamsVersionAgnosticTopologyInfoFacade(Class rootClass) { + + // First look for KafkaStreams.internalTopologyBuilder (exists in kafka-streams <= 3.0) + Field internalTopologyBuilderField = ReflectionUtils.findField(rootClass, "internalTopologyBuilder"); + if (internalTopologyBuilderField != null) { + internalTopologyBuilderField.setAccessible(true); + this.topologyInfoField = internalTopologyBuilderField; + this.sourceTopicsForStoreMethod = ReflectionUtils.findMethod(internalTopologyBuilderField.getType(), "sourceTopicsForStore", String.class); + } + + // Otherwise look for KafkaStreams.topologyMetadata (exists in kafka-streams >= 3.1) + if (this.sourceTopicsForStoreMethod == null) { + Field topologyMetadataField = ReflectionUtils.findField(rootClass, "topologyMetadata"); + if (topologyMetadataField != null) { + topologyMetadataField.setAccessible(true); + this.topologyInfoField = topologyMetadataField; + this.sourceTopicsForStoreMethod = ReflectionUtils.findMethod(topologyMetadataField.getType(), "sourceTopicsForStore", String.class); + if (this.sourceTopicsForStoreMethod == null) { + // The sourceTopicsForStore method has extra arg in kafka-streams >= 3.3 + this.sourceTopicsForStoreMethod = ReflectionUtils.findMethod(topologyMetadataField.getType(), "sourceTopicsForStore", String.class, String.class); + this.sourceTopicsForStoreMethodHasTwoArgs = true; + } + } + } + + if (this.sourceTopicsForStoreMethod != null) { + this.sourceTopicsForStoreMethod.setAccessible(true); + logger.info(() -> "Using " + methodDescription(this.sourceTopicsForStoreMethod)); + } + else { + logger.warn("Could not find 'topologyMetadata.sourceTopicsForStore' or 'internalTopologyBuilder.sourceTopicsForStore' " + + "from KafkaStreams class - will be unable to reason about state stores."); + } + } + + /** + * Determines if a state store is actually available to a KafkaStreams instance by + * querying the topology info source topics for the requested store. + * + * @param kafkaStreams the streams app + * @param storeName the name of the state store + * @return {@code true} if state store is available or {@code false} if the state store is + * not available or there was a problem reflecting on the topology info + */ + boolean streamsAppActuallyHasStore(KafkaStreams kafkaStreams, String storeName) { + if (this.sourceTopicsForStoreMethod == null) { + logger.warn("Unable to reason about state store because sourceTopicsForStore method was not found - returning false"); + return false; + } + try { + Object topologyInfo = ReflectionUtils.getField(this.topologyInfoField, kafkaStreams); + if (topologyInfo == null) { + logger.warn("Unable to reason about state store because topologyInfo field was null - returning false"); + return false; + } + Object[] args = this.sourceTopicsForStoreMethodHasTwoArgs ? + new Object[] { storeName, null } : new Object[] { storeName }; + Collection sourceTopicsForStore = (Collection) + ReflectionUtils.invokeMethod(this.sourceTopicsForStoreMethod, topologyInfo, args); + return !CollectionUtils.isEmpty(sourceTopicsForStore); + } + catch (Exception ex) { + logger.error(ex, () -> "Unable to reason about state store due to error: " + ex.getMessage() + " - returning false"); + } + return false; + } + + private String methodDescription(Method method) { + StringJoiner sj = new StringJoiner(",", method.getName() + "(", ")"); + for (Class parameterType : method.getParameterTypes()) { + sj.add(parameterType.getTypeName()); + } + return "method " + method.getDeclaringClass().getTypeName() + '.' + sj.toString(); + } +} diff --git a/binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/test/java/org/springframework/cloud/stream/binder/kafka/streams/KafkaStreamsVersionAgnosticTopologyInfoFacadeTests.java b/binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/test/java/org/springframework/cloud/stream/binder/kafka/streams/KafkaStreamsVersionAgnosticTopologyInfoFacadeTests.java new file mode 100644 index 000000000..0a859d577 --- /dev/null +++ b/binders/kafka-binder/spring-cloud-stream-binder-kafka-streams/src/test/java/org/springframework/cloud/stream/binder/kafka/streams/KafkaStreamsVersionAgnosticTopologyInfoFacadeTests.java @@ -0,0 +1,242 @@ +/* + * Copyright 2022-2022 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.streams; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Properties; + +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.StreamsBuilder; +import org.apache.kafka.streams.StreamsConfig; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link KafkaStreamsVersionAgnosticTopologyInfoFacade}. + * + * @author Chris Bono + */ +class KafkaStreamsVersionAgnosticTopologyInfoFacadeTests { + + private static final Properties STREAM_PROPS = new Properties(); + + private static final StreamsBuilder STREAM_BUILDER = new StreamsBuilder(); + + static { + STREAM_PROPS.put(StreamsConfig.APPLICATION_ID_CONFIG, "topology-facade-tests-app"); + STREAM_PROPS.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); + STREAM_BUILDER.stream("foo").to("bar"); + } + + @Nested + class KafkaStreams30 { + + @Test + void sourceTopicsForStoreWithTopics() { + KafkaStreamsVersionAgnosticTopologyInfoFacade facade = + new KafkaStreamsVersionAgnosticTopologyInfoFacade(TestKafkaStreams30.class); + assertThat(facade.streamsAppActuallyHasStore(new TestKafkaStreams30("topic1"), "store1")).isTrue(); + } + + @Test + void sourceTopicsForStoreWithNoTopics() { + KafkaStreamsVersionAgnosticTopologyInfoFacade facade = + new KafkaStreamsVersionAgnosticTopologyInfoFacade(TestKafkaStreams30.class); + assertThat(facade.streamsAppActuallyHasStore(new TestKafkaStreams30(), "store1")).isFalse(); + } + + } + + @Nested + class KafkaStreams31_32 { + + @Test + void sourceTopicsForStoreWithTopics() { + KafkaStreamsVersionAgnosticTopologyInfoFacade facade = + new KafkaStreamsVersionAgnosticTopologyInfoFacade(TestKafkaStreams31_32.class); + assertThat(facade.streamsAppActuallyHasStore(new TestKafkaStreams31_32("topic1"), "store1")).isTrue(); + } + + @Test + void sourceTopicsForStoreWithNoTopics() { + KafkaStreamsVersionAgnosticTopologyInfoFacade facade = + new KafkaStreamsVersionAgnosticTopologyInfoFacade(TestKafkaStreams31_32.class); + assertThat(facade.streamsAppActuallyHasStore(new TestKafkaStreams31_32(), "store1")).isFalse(); + } + + } + + @Nested + class KafkaStreams33 { + + @Test + void sourceTopicsForStoreWithTopics() { + KafkaStreamsVersionAgnosticTopologyInfoFacade facade = + new KafkaStreamsVersionAgnosticTopologyInfoFacade(TestKafkaStreams33.class); + assertThat(facade.streamsAppActuallyHasStore(new TestKafkaStreams33("topic1"), "store1")).isTrue(); + } + + @Test + void sourceTopicsForStoreWithNoTopics() { + KafkaStreamsVersionAgnosticTopologyInfoFacade facade = + new KafkaStreamsVersionAgnosticTopologyInfoFacade(TestKafkaStreams33.class); + assertThat(facade.streamsAppActuallyHasStore(new TestKafkaStreams33(), "store1")).isFalse(); + } + } + + @Nested + class NegativeCases { + @Test + void nullTopologyInfo() { + KafkaStreamsVersionAgnosticTopologyInfoFacade facade = + new KafkaStreamsVersionAgnosticTopologyInfoFacade(TestKafkaStreams30.class); + TestToplogyInfoOneArg internalTopologyBuilder = null; + assertThat(facade.streamsAppActuallyHasStore(new TestKafkaStreams30(internalTopologyBuilder), "store1")).isFalse(); + } + + @Test + void sourceTopicsForStoreMethodNotFound() { + KafkaStreamsVersionAgnosticTopologyInfoFacade facade = + new KafkaStreamsVersionAgnosticTopologyInfoFacade(TestKafkaStreamsNoSourceTopicsMethod.class); + assertThat(facade.streamsAppActuallyHasStore(new TestKafkaStreamsNoSourceTopicsMethod(), "store1")).isFalse(); + } + + + @Test + void sourceTopicsForStoreThrowsException() { + KafkaStreamsVersionAgnosticTopologyInfoFacade facade = + new KafkaStreamsVersionAgnosticTopologyInfoFacade(TestKafkaStreamsThrowsError.class); + assertThat(facade.streamsAppActuallyHasStore(new TestKafkaStreamsThrowsError(), "store1")).isFalse(); + } + } + + /** + * A test version of KafkaStreams as it exists in kafka-streams 3.0 w/ a topology + * info field named 'internalTopologyBuilder' that in turn has a single-arg version + * of sourceTopicsForStore(String)' that holds the info we need. + */ + static class TestKafkaStreams30 extends KafkaStreams { + + private TestToplogyInfoOneArg internalTopologyBuilder; + + TestKafkaStreams30(String... topics) { + this(new TestToplogyInfoOneArg(topics)); + } + + TestKafkaStreams30(TestToplogyInfoOneArg internalTopologyBuilder) { + super(STREAM_BUILDER.build(), STREAM_PROPS); + this.internalTopologyBuilder = internalTopologyBuilder; + } + } + + /** + * A test version of KafkaStreams as it exists in kafka-streams [3.1,3.2] w/ a topology + * info field named 'topologyMetadata' that in turn has a single-arg version + * of sourceTopicsForStore(String)' that holds the info we need. + */ + static class TestKafkaStreams31_32 extends KafkaStreams { + + private TestToplogyInfoOneArg topologyMetadata; + + TestKafkaStreams31_32(String... topics) { + super(STREAM_BUILDER.build(), STREAM_PROPS); + this.topologyMetadata = new TestToplogyInfoOneArg(topics); + } + } + + /** + * A test version of KafkaStreams as it exists in kafka-streams 3.3+ w/ a topology + * info field named 'topologyMetadata' that in turn has a two-arg version + * of sourceTopicsForStore(String,String)' that holds the info we need. + */ + static class TestKafkaStreams33 extends KafkaStreams { + + private TestToplogyInfoTwoArgs topologyMetadata; + + TestKafkaStreams33(String... topics) { + super(STREAM_BUILDER.build(), STREAM_PROPS); + this.topologyMetadata = new TestToplogyInfoTwoArgs(topics); + } + } + + static class TestToplogyInfoOneArg { + private String[] sourceTopics; + + TestToplogyInfoOneArg(String... sourceTopics) { + this.sourceTopics = sourceTopics; + } + + public Collection sourceTopicsForStore(String storeName) { + return this.sourceTopics == null ? Collections.emptyList() : Arrays.asList(this.sourceTopics); + } + } + + static class TestToplogyInfoTwoArgs { + private String[] sourceTopics; + + TestToplogyInfoTwoArgs(String... sourceTopics) { + this.sourceTopics = sourceTopics; + } + + public Collection sourceTopicsForStore(String storeName, String topologyName) { + return this.sourceTopics == null ? Collections.emptyList() : Arrays.asList(this.sourceTopics); + } + } + + /** + * A test version of KafkaStreams w/ a topology info field named 'internalTopologyBuilder' + * that in turn has NO 'sourceTopicsForStore' method to use. + */ + static class TestKafkaStreamsNoSourceTopicsMethod extends KafkaStreams { + + private TestTopologyInfoNoSourceTopicsMethod internalTopologyBuilder = new TestTopologyInfoNoSourceTopicsMethod(); + + TestKafkaStreamsNoSourceTopicsMethod() { + super(STREAM_BUILDER.build(), STREAM_PROPS); + } + + static class TestTopologyInfoNoSourceTopicsMethod { + + } + + } + + /** + * A test version of KafkaStreams w/ a topology info field named 'internalTopologyBuilder' + * that in turn has a single-arg version of sourceTopicsForStore(String)' that throws + * an exception when invoked. + */ + static class TestKafkaStreamsThrowsError extends KafkaStreams { + + private TestToplogyInfoOneArg internalTopologyBuilder = new TestToplogyInfoOneArg() { + @Override + public Collection sourceTopicsForStore(String storeName) { + throw new RuntimeException("BOOM: " + storeName); + } + }; + + TestKafkaStreamsThrowsError() { + super(STREAM_BUILDER.build(), STREAM_PROPS); + } + + } + +}