Anchor prepare cache entry via PreparedStatement back-reference#893
Open
nikagra wants to merge 2 commits into
Open
Anchor prepare cache entry via PreparedStatement back-reference#893nikagra wants to merge 2 commits into
nikagra 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.
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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