Affected versions
- Spring Data Commons: 4.1.0
- Spring Data MongoDB: 5.1.0
- Spring Boot: 4.1.0
- Kotlin: 2.4.10
- Java: 25
Summary
Repeatedly passing a Kotlin property reference to the new Update.set(TypedPropertyPath, value) overload causes the static TypedPropertyPaths.resolved cache to grow by one entry per invocation.
repeat(10_000) {
Update().set(CounterDocument::counter, it)
}
The same logical property is used every time, but the cache grows to 10,000 entries.
This can cause significant retained heap in applications with frequently executed MongoDB update paths.
Reproducer
A minimal reproducer is available here:
https://github.com/damianmalczewski/spring-data-memory-leak
Its output is:
Cache size before: 0
Cache size after 10 calls: 10
Cache size after 10000 total calls: 10000
Delta from before to after: 10000
The reproducer does not require a running MongoDB instance.
Why this happens
Spring Data MongoDB 5.1 introduced the following member overload:
public <T, P> Update set(TypedPropertyPath<T, P> property, @Nullable Object value) {
return set(TypedPropertyPath.of(property).toDotPath(), value);
}
Source:
https://github.com/spring-projects/spring-data-mongodb/blob/5.1.0/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Update.java#L136-L150
Before 5.1, the same Kotlin source resolved to the Update.set(KProperty, value) extension, which converted the property to a dot path and invoked the string overload.
The new member overload takes precedence over the Kotlin extension. Kotlin keeps CounterDocument::counter itself as a singleton KProperty1, but adapts it to the Java TypedPropertyPath SAM through a
capturing adapter. A new adapter instance is created on every evaluation of the call site.
TypedPropertyPaths then uses this adapter instance directly as the cache key:
return (TypedPropertyPath) cache.computeIfAbsent(
lambda,
TypedPropertyPaths::doResolvePropertyPathReference
);
Source:
https://github.com/spring-projects/spring-data-commons/blob/4.1.0/src/main/java/org/springframework/data/core/TypedPropertyPaths.java#L137-L168
Because the generated adapter uses identity equality, every invocation is a cache miss and creates another resolved path.
Expected behavior
Repeated use of the same logical property reference or call site should reuse a bounded number of cache entries.
Actual behavior
The cache grows linearly with the number of calls.
In an affected service, a heap histogram contained:
115,546 TypedPropertyPaths$KPropertyPathMetadata
115,546 TypedPropertyPaths$ResolvedTypedPropertyPath
118,270 ConcurrentReferenceHashMap$SoftEntryReference
115,408 ResolvableType
This correlated with container memory growing towards its limit.
Workaround
Explicitly invoking the string overload prevents the cache growth:
Update().set(CounterDocument::counter.name, value)
For nested Kotlin property paths, an application-level adapter can convert the complete property to a dot path before invoking set(String, value).
After applying this workaround, the problematic TypedPropertyPath allocations disappeared from the allocation profile and the initial prod deployment no longer showed an upward memory trend.
Affected versions
Summary
Repeatedly passing a Kotlin property reference to the new
Update.set(TypedPropertyPath, value)overload causes the staticTypedPropertyPaths.resolvedcache to grow by one entry per invocation.The same logical property is used every time, but the cache grows to 10,000 entries.
This can cause significant retained heap in applications with frequently executed MongoDB update paths.
Reproducer
A minimal reproducer is available here:
https://github.com/damianmalczewski/spring-data-memory-leak
Its output is:
The reproducer does not require a running MongoDB instance.
Why this happens
Spring Data MongoDB 5.1 introduced the following member overload:
Source:
https://github.com/spring-projects/spring-data-mongodb/blob/5.1.0/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Update.java#L136-L150
Before 5.1, the same Kotlin source resolved to the
Update.set(KProperty, value)extension, which converted the property to a dot path and invoked the string overload.The new member overload takes precedence over the Kotlin extension. Kotlin keeps
CounterDocument::counteritself as a singletonKProperty1, but adapts it to the JavaTypedPropertyPathSAM through acapturing adapter. A new adapter instance is created on every evaluation of the call site.
TypedPropertyPathsthen uses this adapter instance directly as the cache key:Source:
https://github.com/spring-projects/spring-data-commons/blob/4.1.0/src/main/java/org/springframework/data/core/TypedPropertyPaths.java#L137-L168
Because the generated adapter uses identity equality, every invocation is a cache miss and creates another resolved path.
Expected behavior
Repeated use of the same logical property reference or call site should reuse a bounded number of cache entries.
Actual behavior
The cache grows linearly with the number of calls.
In an affected service, a heap histogram contained:
This correlated with container memory growing towards its limit.
Workaround
Explicitly invoking the string overload prevents the cache growth:
For nested Kotlin property paths, an application-level adapter can convert the complete property to a dot path before invoking
set(String, value).After applying this workaround, the problematic
TypedPropertyPathallocations disappeared from the allocation profile and the initial prod deployment no longer showed an upward memory trend.