spring-cloud / spring-cloud/spring-cloud-stream
StreamBridge's hashProducerProperties produces hash collisions across different binding names, causing Partition key cannot be null
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 1.1k
- Forks
- 646
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 8
Description
Issue: StreamBridge's hashProducerProperties produces hash collisions across different binding names, causing Partition key cannot be null
Metadata
- Component:
spring-cloud-stream(core) —StreamBridge - Version verified:
4.3.1(also still present onmain/5.0.2) - Binder: RabbitMQ (and any binder implementing
ExtendedPropertiesBinder; see "Root cause") - Type: Bug
Summary
StreamBridge.hashProducerProperties(...) computes a cache key for the shared FunctionInvocationWrapper used by StreamBridge.send(...). The hash does not reliably include the binding name, so two different producer bindings can produce the same hash and therefore share (reuse) the same cached FunctionInvocationWrapper. When one of those bindings is partitioned and the other is not, the non-partitioned binding can receive the partition handler/PartitionHandler of the partitioned one, and since its message has no partition key, it fails with:
java.lang.IllegalArgumentException: Partition key cannot be null
at PartitionHandler.extractKey(PartitionHandler.java:124).
Root cause
// StreamBridge.hashProducerProperties (identical in 4.3.1 and main/5.0.2)
private int hashProducerProperties(ProducerProperties producerProperties, String outputContentType) {
int hash = outputContentType.hashCode()
+ Boolean.hashCode(producerProperties.isUseNativeEncoding())
+ Boolean.hashCode(producerProperties.isPartitioned())
+ producerProperties.getPartitionCount();
if (producerProperties.getPartitionKeyExpression() != null && producerProperties.getBindingName() != null) {
hash += producerProperties.getBindingName().hashCode();
}
return hash;
}
Two independent defects:
-
The binding name is only added when the binding is partitioned (guarded by
getPartitionKeyExpression() != null). A non-partitioned binding never contributes its binding name to the hash. So a non-partitioned binding can collide with a partitioned binding that shares the samecontentType,useNativeEncoding,isPartitioned-affecting fields, andpartitionCount. -
getBindingName()is actuallynullin theStreamBridge.sendpath even for partitioned bindings.populateBindingName(...)is only invoked insideBindingService.bindProducer(...)on the copy of the producer properties that is created when the binder is anExtendedPropertiesBinder(e.g. RabbitMQ).StreamBridge.sendcallsBindingServiceProperties.getProducerProperties(bindingName)which returns the originalProducerPropertieswhosebindingNamefield is never populated. So thegetBindingName() != nullguard effectively prevents the binding name from ever being added in thesendpath.
Concrete collision (verified arithmetically)
With the same outputContentType, Boolean.hashCode(false) == 1237 and Boolean.hashCode(true) == 1231, so a partitioned binding and a non-partitioned binding collide whenever:
1231 + partitionCount_partitioned == 1237 + partitionCount_nonPartitioned
i.e. partitionCount_partitioned == partitionCount_nonPartitioned + 6. In our demo configuration, partitionedBinding-out-0 used partitionCount: 7 and nonPartitionedBinding-out-0 used the default partitionCount: 1 → 1231 + 7 == 1237 + 1 == 1238. Collision confirmed.
The shared cached FunctionInvocationWrapper then received the partition enhancer (via PartitionAwareFunctionWrapper.setEnhancer(...)), and the non-partitioned binding's message (which has no partition key) was processed by PartitionHandler, throwing Partition key cannot be null.
Workaround
Setting a partitionCount that cannot collide with any other binding's effective hash (here partition-count: 15 → 1237 + 15 = 1252, isolated from the partitioned binding's 1238) avoids the collision:
spring:
cloud:
stream:
bindings:
nonPartitionedBinding-out-0:
producer:
partition-count: 15
This is fragile: it depends on the hard-coded Boolean.hashCode values and requires every new StreamBridge.send binding to manually avoid collisions — easy to regress.
Suggestion
The cache key for StreamBridge should uniquely identify the binding. There are two complementary fixes:
-
Fix
getBindingName()returningnullin the send path.populateBindingName(...)is only invoked on the copy ofProducerPropertiescreated insideBindingService.bindProducer(...)forExtendedPropertiesBinder(e.g. RabbitMQ), whileStreamBridge.send(...)reads the originalProducerPropertiesreturned byBindingServiceProperties.getProducerProperties(...), whosebindingNameis never populated. The framework should populatebindingNameon the original object too (or ensure both paths share the same instance), so the existinggetBindingName() != nullguard can actually work. -
Include the binding name unconditionally in the hash. Even with fix #1, because the guard is
getPartitionKeyExpression() != null && getBindingName() != null, a non-partitioned binding would still never contribute its binding name. The hash should be derived from thebindingNameargument passed toStreamBridge.send("...", ...), which is always available, rather than relying onProducerProperties#getBindingName(). For example:
private int hashProducerProperties(String bindingName, ProducerProperties producerProperties, String outputContentType) {
int hash = outputContentType.hashCode()
+ bindingName.hashCode()
+ Boolean.hashCode(producerProperties.isUseNativeEncoding())
+ Boolean.hashCode(producerProperties.isPartitioned())
+ producerProperties.getPartitionCount();
return hash;
}
With both fixes, each binding gets a distinct cache key — collisions (and the resulting Partition key cannot be null) are eliminated.
Reproduction
We can provide a minimal reproducer if needed. High-level steps:
- Configure two outbound bindings through
StreamBridge.send:- Binding A: partitioned (
partitionKeyExpressionset),partitionCount: 7. - Binding B: non-partitioned, default
partitionCount: 1, samecontentType.
- Binding A: partitioned (
- Send a message to Binding A first (populates the cache), then send a message to Binding B.
- Observe
java.lang.IllegalArgumentException: Partition key cannot be nullthrown fromPartitionHandler.extractKey.
Environment
- Spring Cloud Stream
4.3.1(Spring Cloud2025.0.1), Spring Boot3.5.x - Also reproducible on
main(5.0.2) — same code - RabbitMQ binder, but the defect is reproducible with any binder that implements
ExtendedPropertiesBinder
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with StreamBridge.hashProducerProperties and trace the StreamBridge.send path through BindingServiceProperties.getProducerProperties and BindingService.bindProducer, then inspect PartitionHandler.extractKey. Reproduce the collision with the two bindings described in the issue. Done means distinct cache keys for different binding names and no Partition key cannot be null failure when the non-partitioned binding sends.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- stream-processing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100