Skip to content

TypedPropertyPaths cache grows linearly for Kotlin property references #3521

Description

@Patryk-Piechaczek

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.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions