Skip to content

Anchor prepare cache entry via PreparedStatement back-reference#893

Open
nikagra wants to merge 2 commits into
scylladb:scylla-4.xfrom
nikagra:fix/prepare-cache-anchor-liveness
Open

Anchor prepare cache entry via PreparedStatement back-reference#893
nikagra wants to merge 2 commits into
scylladb:scylla-4.xfrom
nikagra:fix/prepare-cache-anchor-liveness

Conversation

@nikagra
Copy link
Copy Markdown

@nikagra nikagra commented May 18, 2026

Summary

Adds a strong back-reference from DefaultPreparedStatement to the cached CompletableFuture, preventing weak-value eviction for the entire lifetime of the PreparedStatement.

Problem

Even with the defensive-copy fix (PR #892), there is still a window where the cache entry can be evicted: if no caller is currently holding the returned CompletionStage (e.g., between prepare() calls in a long-running application), the weak-value cache entry has no strong references and can be GC'd.

Fix

  • Add a volatile Object cacheRetainer field to DefaultPreparedStatement
  • In CqlPrepareAsyncProcessor, after the prepare handler completes successfully, call ps.setCacheRetainer(mine) before completing the future
  • This creates a reference cycle: cache --weak--> CF --> PS --> CF, but since the weak reference is from cache to CF, the entry stays alive as long as PS is reachable from the application

Lifecycle:

  • PS reachable → CF reachable (via retainer) → cache entry alive ✓
  • PS unreachable → CF unreachable → weak ref cleared → cache entry evicted ✓ (no memory leak)

Builds on

Testing

Added 2 new tests to CqlPrepareAsyncProcessorTest:

  • should_keep_cache_entry_alive_via_prepared_statement_retainer — PS holds retainer → GC cannot evict
  • should_evict_cache_entry_when_prepared_statement_is_unreachable — PS released → GC evicts (no leak)

All existing tests pass.

Related

nikagra added 2 commits May 18, 2026 13:47
When the cached CompletableFuture is already done, return it directly
instead of creating a defensive copy via thenApply(x -> x). Completed
futures are immutable (cancel/complete are no-ops), so the copy only
served to release the caller's strong reference to the cached value,
causing premature weak-value eviction under GC pressure.

This led to repeated PREPARE requests being sent to all nodes on every
execution, as the cache entry would be garbage-collected between calls.
With prepare-on-all-nodes=true (default), each eviction multiplied the
re-prepare cost by the cluster node count.

The defensive copy is still used for in-flight futures to protect the
shared cache entry from cancellation by concurrent waiters.

Ref: CUSTOMER-372
Add a 'cacheRetainer' field to DefaultPreparedStatement that holds a
strong reference to the CompletableFuture stored in the weak-value
prepare cache. This ensures that as long as the application holds a
reference to the PreparedStatement, the cache entry won't be GC'd.

Combined with the previous fix (skipping defensive copies for completed
futures), this provides a complete solution: the cache entry remains
alive for the entire lifetime of the PreparedStatement object, not just
for the duration of a single prepare() call.

When the PreparedStatement becomes unreachable, both it and the cached
future become eligible for GC, preserving the memory-bounded behavior
of weak-value caching.

Ref: CUSTOMER-372
@nikagra nikagra requested review from Copilot and dkropachev and removed request for Copilot May 18, 2026 11:59
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses premature eviction of entries in the weak-values prepare cache by anchoring the cached CompletableFuture to the resulting DefaultPreparedStatement, ensuring the cache entry stays alive for as long as the application holds the PreparedStatement.

Changes:

  • Add a strong back-reference field (cacheRetainer) and setter to DefaultPreparedStatement to retain the cached future.
  • In CqlPrepareAsyncProcessor, attach the cached future to the prepared statement upon successful prepare completion.
  • Add unit tests covering weak-value retention/eviction behavior, including the new prepared-statement retainer behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
core/src/main/java/com/datastax/oss/driver/internal/core/cql/CqlPrepareAsyncProcessor.java Anchors the cached future via the prepared statement, and returns cached future directly for completed entries.
core/src/main/java/com/datastax/oss/driver/internal/core/cql/DefaultPreparedStatement.java Adds cacheRetainer field + setter used to strongly retain the cached future.
core/src/test/java/com/datastax/oss/driver/internal/core/cql/CqlPrepareAsyncProcessorTest.java Adds tests for defensive-copy behavior and weak-value cache retention/eviction, including PS-retainer scenarios.
Comments suppressed due to low confidence (1)

core/src/test/java/com/datastax/oss/driver/internal/core/cql/CqlPrepareAsyncProcessorTest.java:235

  • Same brittleness concern as above: this direct DefaultPreparedStatement construction relies on many null arguments and could easily break with unrelated production changes. A helper that builds a minimally-valid instance (or a dedicated test fixture) would make the GC/retainer test more stable and easier to maintain.

    DefaultPreparedStatement ps =
        new DefaultPreparedStatement(
            java.nio.ByteBuffer.wrap(new byte[] {1, 2, 3, 4}),
            "SELECT 1",
            com.datastax.oss.driver.internal.core.cql.EmptyColumnDefinitions.INSTANCE,
            java.util.Collections.emptyList(),
            null,

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +157 to +163
/**
* Attaches a strong reference to the prepare cache entry, preventing its weak-value eviction as
* long as this PreparedStatement is reachable.
*/
public void setCacheRetainer(Object retainer) {
this.cacheRetainer = retainer;
}
Comment on lines +143 to +155
for (int i = 0; i < 10; i++) {
System.gc();
Thread.sleep(50);
cache.cleanUp();
if (cache.getIfPresent(request) == null) {
break;
}
}

// Cache entry may have been evicted (weak values)
// This is expected behavior - the fix ensures callers who DO hold a reference keep it alive
// We just verify the cache doesn't throw
assertThat(cache.size()).isGreaterThanOrEqualTo(0);
Comment on lines +169 to +176
// Simulate what the processor does: create a real DefaultPreparedStatement and set retainer
DefaultPreparedStatement ps =
new DefaultPreparedStatement(
java.nio.ByteBuffer.wrap(new byte[] {1, 2, 3, 4}),
"SELECT 1",
com.datastax.oss.driver.internal.core.cql.EmptyColumnDefinitions.INSTANCE,
java.util.Collections.emptyList(),
null,
Comment on lines +170 to +178
if (result.isDone()) {
// Completed futures are immutable (cancel/complete/completeExceptionally are no-ops),
// so returning the cached instance directly is safe. This also keeps the cache entry
// alive via the caller's strong reference, preventing premature weak-value eviction
// under GC pressure.
return result;
}
// Defensive copy for in-flight preparations only: protects the shared cached future
// from cancellation by one of multiple concurrent waiters.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants