Anchor prepare cache entry via PreparedStatement back-reference#893
Anchor prepare cache entry via PreparedStatement back-reference#893nikagra wants to merge 2 commits into
Conversation
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
There was a problem hiding this comment.
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 toDefaultPreparedStatementto 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
DefaultPreparedStatementconstruction relies on manynullarguments 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.
| /** | ||
| * 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; | ||
| } |
| 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); |
| // 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, |
| 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. |
| if (preparedStatement instanceof DefaultPreparedStatement) { | ||
| ((DefaultPreparedStatement) preparedStatement).setCacheRetainer(mine); | ||
| } |
There was a problem hiding this comment.
i feel like you don't need whole cache thing if you store this in the DefaultPreparedStatement, but then you need to make this method exportable, whole thing look hacky, what if they have their own implementation of the PreparedStatement, then it won't work.
Can you please check if then it makes sense to make this part of public API fo the PreparedStatement and have a default function that stores cache retainer anchor?
Summary
Adds a strong back-reference from
DefaultPreparedStatementto the cachedCompletableFuture, 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
volatile Object cacheRetainerfield toDefaultPreparedStatementCqlPrepareAsyncProcessor, after the prepare handler completes successfully, callps.setCacheRetainer(mine)before completing the futurecache --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 applicationLifecycle:
Builds on
Testing
Added 2 new tests to
CqlPrepareAsyncProcessorTest:should_keep_cache_entry_alive_via_prepared_statement_retainer— PS holds retainer → GC cannot evictshould_evict_cache_entry_when_prepared_statement_is_unreachable— PS released → GC evicts (no leak)All existing tests pass.
Related