You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Version verified: 4.3.1 (also still present on main / 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).
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 same contentType, useNativeEncoding, isPartitioned-affecting fields, and partitionCount.
getBindingName() is actually null in the StreamBridge.send path even for partitioned bindings. populateBindingName(...) is only invoked inside BindingService.bindProducer(...) on the copy of the producer properties that is created when the binder is an ExtendedPropertiesBinder (e.g. RabbitMQ). StreamBridge.send calls BindingServiceProperties.getProducerProperties(bindingName) which returns the originalProducerProperties whose bindingName field is never populated. So the getBindingName() != null guard effectively prevents the binding name from ever being added in the send path.
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:
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:
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() returning null in the send path.populateBindingName(...) is only invoked on the copy of ProducerProperties created inside BindingService.bindProducer(...) for ExtendedPropertiesBinder (e.g. RabbitMQ), while StreamBridge.send(...) reads the originalProducerProperties returned by BindingServiceProperties.getProducerProperties(...), whose bindingName is never populated. The framework should populate bindingName on the original object too (or ensure both paths share the same instance), so the existing getBindingName() != null guard can actually work.
Include the binding name unconditionally in the hash. Even with fix removed spring-xd-dirt dependencies #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 the bindingName argument passed to StreamBridge.send("...", ...), which is always available, rather than relying on ProducerProperties#getBindingName(). For example:
Issue: StreamBridge's
hashProducerPropertiesproduces hash collisions across different binding names, causingPartition key cannot be nullMetadata
spring-cloud-stream(core) —StreamBridge4.3.1(also still present onmain/5.0.2)ExtendedPropertiesBinder; see "Root cause")Summary
StreamBridge.hashProducerProperties(...)computes a cache key for the sharedFunctionInvocationWrapperused byStreamBridge.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 cachedFunctionInvocationWrapper. When one of those bindings is partitioned and the other is not, the non-partitioned binding can receive the partition handler/PartitionHandlerof the partitioned one, and since its message has no partition key, it fails with:at
PartitionHandler.extractKey(PartitionHandler.java:124).Root cause
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) == 1237andBoolean.hashCode(true) == 1231, so a partitioned binding and a non-partitioned binding collide whenever:i.e.
partitionCount_partitioned == partitionCount_nonPartitioned + 6. In our demo configuration,partitionedBinding-out-0usedpartitionCount: 7andnonPartitionedBinding-out-0used the defaultpartitionCount: 1→1231 + 7 == 1237 + 1 == 1238. Collision confirmed.The shared cached
FunctionInvocationWrapperthen received the partitionenhancer(viaPartitionAwareFunctionWrapper.setEnhancer(...)), and the non-partitioned binding's message (which has no partition key) was processed byPartitionHandler, throwingPartition key cannot be null.Workaround
Setting a
partitionCountthat cannot collide with any other binding's effective hash (herepartition-count: 15→1237 + 15 = 1252, isolated from the partitioned binding's1238) avoids the collision:This is fragile: it depends on the hard-coded
Boolean.hashCodevalues and requires every newStreamBridge.sendbinding to manually avoid collisions — easy to regress.Suggestion
The cache key for
StreamBridgeshould 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 removed spring-xd-dirt dependencies #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: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:
StreamBridge.send:partitionKeyExpressionset),partitionCount: 7.partitionCount: 1, samecontentType.java.lang.IllegalArgumentException: Partition key cannot be nullthrown fromPartitionHandler.extractKey.Environment
4.3.1(Spring Cloud2025.0.1), Spring Boot3.5.xmain(5.0.2) — same codeExtendedPropertiesBinder