spring-projects / spring-projects/spring-data-commons

TypedPropertyPaths cache grows linearly for Kotlin property references

Open
#3,521 2 comments 3 reactions 1 assignee View on GitHub

@mp911de is already working on this.

Since Aug 5, 2026.

type: bug
Dominant language
Java
Stars
838
Forks
730
PR merge metrics
No merged PRs in 30d

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.