Skip to content

Commit dad9879

Browse files
kdelayolegz
authored andcommitted
GH-3242: Key StreamBridge function cache by binding name
StreamBridge caches the FunctionInvocationWrapper used by send(..) under the producer properties. The binding name only took part in that key when partitionKeyExpression and ProducerProperties#getBindingName() were both set, and getBindingName() is never populated on the instance StreamBridge reads: BindingService#bindProducer populates it on the extended copy it creates for an ExtendedPropertiesBinder, not on the original returned by BindingServiceProperties#getProducerProperties. A partitioned binding therefore shared its cached function with another binding, and since the partition enhancer is left on the cached function after a send that produced a partition header, the next send on the other binding failed with IllegalArgumentException: Partition key cannot be null. Take the binding name from the send(..) argument, which is always available, and include it for partitioned bindings. Key the cache by a record of the five properties rather than by their computed int hash, so bindings are told apart by equality and no hash collision can make two of them share a function. Bindings that are not partitioned keep sharing a cached function as before. Signed-off-by: kdelay <kdelay20@gmail.com> Resolves #3244
1 parent 2f88288 commit dad9879

2 files changed

Lines changed: 94 additions & 17 deletions

File tree

  • core
    • spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/function
    • spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function

core/spring-cloud-stream-integration-tests/src/test/java/org/springframework/cloud/stream/function/StreamBridgeTests.java

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
import org.springframework.cloud.function.cloudevent.CloudEventMessageBuilder;
4848
import org.springframework.cloud.function.cloudevent.CloudEventMessageUtils;
4949
import org.springframework.cloud.function.context.catalog.SimpleFunctionRegistry.FunctionInvocationWrapper;
50+
import org.springframework.cloud.stream.binder.BinderHeaders;
51+
import org.springframework.cloud.stream.binder.PartitionKeyExtractorStrategy;
5052
import org.springframework.cloud.stream.binder.test.InputDestination;
5153
import org.springframework.cloud.stream.binder.test.OutputDestination;
5254
import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration;
@@ -487,6 +489,67 @@ void delayedSend() {
487489
}
488490
}
489491

492+
/*
493+
* Two bindings whose properties hash alike must still get their own function, which is why the
494+
* cache is keyed by value rather than by a hash of those properties. This pair collides under
495+
* Objects.hash(contentType, nativeEncoding, partitioned, partitionCount, bindingName): with a
496+
* cache keyed by that hash, the non-partitioned send picks up the function left partition-aware
497+
* by the previous send and fails with "Partition key cannot be null" as in GH-3242.
498+
*/
499+
@SuppressWarnings("rawtypes")
500+
@Test
501+
void partitionedBindingIsNotSharedWithHashCollidingBinding() throws Exception {
502+
try (ConfigurableApplicationContext context = new SpringApplicationBuilder(
503+
TestChannelBinderConfiguration.getCompleteConfiguration(
504+
PartitionKeyExtractorConfiguration.class)).web(WebApplicationType.NONE).run(
505+
"--spring.cloud.stream.source=nonPartitioned",
506+
"--spring.cloud.stream.bindings[A>].producer.partition-count=120",
507+
"--spring.cloud.stream.bindings[A>].producer.partition-key-extractor-name=partitionKeyExtractor",
508+
"--spring.cloud.stream.bindings.nonPartitioned-out-0.producer.partition-count=1",
509+
"--spring.jmx.enabled=false")) {
510+
StreamBridge streamBridge = context.getBean(StreamBridge.class);
511+
Field field = ReflectionUtils.findField(StreamBridge.class, "streamBridgeFunctionCache");
512+
Objects.requireNonNull(field).setAccessible(true);
513+
Map functionCache = (Map) field.get(streamBridge);
514+
515+
streamBridge.send("A>", MessageBuilder.withPayload("partitioned").setHeader("partitionKey", "key").build());
516+
streamBridge.send("nonPartitioned-out-0", MessageBuilder.withPayload("nonPartitioned").build());
517+
518+
assertThat(functionCache.size()).isEqualTo(2);
519+
520+
OutputDestination output = context.getBean(OutputDestination.class);
521+
assertThat(output.receive(1000, "A>").getHeaders()
522+
.containsKey(BinderHeaders.PARTITION_HEADER)).isTrue();
523+
assertThat(output.receive(1000, "nonPartitioned-out-0").getHeaders()
524+
.containsKey(BinderHeaders.PARTITION_HEADER)).isFalse();
525+
}
526+
}
527+
528+
// See https://github.com/spring-cloud/spring-cloud-stream/issues/3242
529+
@Test
530+
void test_3242() {
531+
try (ConfigurableApplicationContext context = new SpringApplicationBuilder(
532+
TestChannelBinderConfiguration.getCompleteConfiguration(
533+
PartitionKeyExtractorConfiguration.class)).web(WebApplicationType.NONE).run(
534+
"--spring.cloud.stream.source=partitioned;nonPartitioned",
535+
"--spring.cloud.stream.bindings.partitioned-out-0.producer.partition-count=7",
536+
"--spring.cloud.stream.bindings.partitioned-out-0.producer.partition-key-extractor-name=partitionKeyExtractor",
537+
"--spring.cloud.stream.bindings.nonPartitioned-out-0.producer.partition-count=1",
538+
"--spring.jmx.enabled=false")) {
539+
StreamBridge streamBridge = context.getBean(StreamBridge.class);
540+
541+
streamBridge.send("partitioned-out-0",
542+
MessageBuilder.withPayload("partitioned").setHeader("partitionKey", "key").build());
543+
streamBridge.send("nonPartitioned-out-0", MessageBuilder.withPayload("nonPartitioned").build());
544+
545+
OutputDestination output = context.getBean(OutputDestination.class);
546+
assertThat(output.receive(1000, "partitioned-out-0").getHeaders()
547+
.containsKey(BinderHeaders.PARTITION_HEADER)).isTrue();
548+
assertThat(output.receive(1000, "nonPartitioned-out-0").getHeaders()
549+
.containsKey(BinderHeaders.PARTITION_HEADER)).isFalse();
550+
}
551+
}
552+
490553
@Test
491554
void withInterceptorsMatchedAgainstAllPatterns() {
492555
try (ConfigurableApplicationContext context = new SpringApplicationBuilder(TestChannelBinderConfiguration
@@ -933,6 +996,16 @@ public static class EmptyConfiguration {
933996

934997
}
935998

999+
@EnableAutoConfiguration
1000+
public static class PartitionKeyExtractorConfiguration {
1001+
1002+
@Bean
1003+
public PartitionKeyExtractorStrategy partitionKeyExtractor() {
1004+
return message -> message.getHeaders().get("partitionKey");
1005+
}
1006+
1007+
}
1008+
9361009
@EnableAutoConfiguration
9371010
public static class EmptyConfigurationWithCustomConverters {
9381011

core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/function/StreamBridge.java

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ public final class StreamBridge implements StreamOperations, SmartInitializingSi
117117

118118
private final BindingService bindingService;
119119

120-
private final Map<Integer, FunctionInvocationWrapper> streamBridgeFunctionCache;
120+
private final Map<StreamBridgeFunctionKey, FunctionInvocationWrapper> streamBridgeFunctionCache;
121121

122122
private final FunctionInvocationHelper<?> functionInvocationHelper;
123123

@@ -197,7 +197,7 @@ public boolean send(String bindingName, @Nullable String binderName, Object data
197197
ProducerProperties producerProperties = this.bindingServiceProperties.getProducerProperties(bindingName);
198198
MessageChannel messageChannel = this.resolveDestination(bindingName, producerProperties, binderName);
199199

200-
Function functionToInvoke = this.getStreamBridgeFunction(outputContentType.toString(), producerProperties);
200+
Function functionToInvoke = this.getStreamBridgeFunction(bindingName, outputContentType.toString(), producerProperties);
201201

202202
if (producerProperties != null && producerProperties.isPartitioned()) {
203203
functionToInvoke = new PartitionAwareFunctionWrapper(functionToInvoke, this.applicationContext, producerProperties);
@@ -233,21 +233,12 @@ public boolean send(String bindingName, @Nullable String binderName, Object data
233233
return messageChannel.send(resultMessage);
234234
}
235235

236-
private int hashProducerProperties(ProducerProperties producerProperties, String outputContentType) {
237-
int hash = outputContentType.hashCode()
238-
+ Boolean.hashCode(producerProperties.isUseNativeEncoding())
239-
+ Boolean.hashCode(producerProperties.isPartitioned())
240-
+ producerProperties.getPartitionCount();
241-
242-
if (producerProperties.getPartitionKeyExpression() != null && producerProperties.getBindingName() != null) {
243-
hash += producerProperties.getBindingName().hashCode();
244-
}
245-
246-
return hash;
247-
}
248-
249-
private FunctionInvocationWrapper getStreamBridgeFunction(String outputContentType, ProducerProperties producerProperties) {
250-
int streamBridgeFunctionKey = this.hashProducerProperties(producerProperties, outputContentType);
236+
private FunctionInvocationWrapper getStreamBridgeFunction(String bindingName, String outputContentType, ProducerProperties producerProperties) {
237+
StreamBridgeFunctionKey streamBridgeFunctionKey = new StreamBridgeFunctionKey(outputContentType,
238+
producerProperties.isUseNativeEncoding(),
239+
producerProperties.isPartitioned(),
240+
producerProperties.getPartitionCount(),
241+
producerProperties.isPartitioned() ? bindingName : null);
251242

252243
return this.streamBridgeFunctionCache.computeIfAbsent(streamBridgeFunctionKey, key -> {
253244
FunctionInvocationWrapper functionToInvoke = this.functionCatalog.lookup(STREAM_BRIDGE_FUNC_NAME, outputContentType.toString());
@@ -394,6 +385,19 @@ private void closeChannelsGracefully() {
394385
});
395386
}
396387

388+
/*
389+
* Identifies the function cached for a send(..). A partitioned binding mutates the cached
390+
* function by setting the partition enhancer on it, so it must not share that function with
391+
* another binding; its binding name is therefore part of the key. The name is taken from the
392+
* send(..) argument, since ProducerProperties#getBindingName() is only populated for binders
393+
* that are not an ExtendedPropertiesBinder (see GH-3242). Non-partitioned bindings leave it
394+
* null and keep sharing a single function. Equality rather than a computed hash decides cache
395+
* hits, so two distinct bindings can never be conflated by a hash collision.
396+
*/
397+
private record StreamBridgeFunctionKey(String outputContentType, boolean useNativeEncoding,
398+
boolean partitioned, int partitionCount, String bindingName) {
399+
}
400+
397401
private static final class ContextPropagationHelper {
398402
static ExecutorService wrap(ExecutorService executorService) {
399403
return ContextExecutorService.wrap(executorService, () -> ContextSnapshotFactory.builder().build().captureAll());

0 commit comments

Comments
 (0)