Skip to content

Invalidate exact bitset filter cache keys on reader close - #22498

Open
jwils wants to merge 4 commits into
opensearch-project:mainfrom
jwils:fix-bitset-stale-retention
Open

Invalidate exact bitset filter cache keys on reader close#22498
jwils wants to merge 4 commits into
opensearch-project:mainfrom
jwils:fix-bitset-stale-retention

Conversation

@jwils

@jwils jwils commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Description

The node-level bitset filter cache introduced in #21179 defers cleanup of entries from closed readers to a periodic sweep over Cache#keys(). That sweep is unreliable: the live keys() iterator walks the LRU linked list without holding the LRU lock, and iteration under concurrent mutation is undefined. Every cache hit relinks the accessed entry to the head of the LRU list, so an entry promoted mid-sweep can end up behind the cursor and never be visited. Because purgeStaleEntries() unconditionally drains staleCacheKeys after each pass and a reader closes only once, a skipped entry is never revisited: it stays in the cache until size-based eviction reaches it — potentially forever — pinning its bitset, its query, and the Value.listener → IndexService graph on heap while being invisible to shard bitset stats.

This applies the same fix as the fielddata cache in #22491: invalidate the exact keys synchronously when the reader closes, instead of marking the reader stale and relying on a sweep.

  • getAndLoadIfNotPresent() records every key cached for a reader in a per-reader registry (keysByReader); the closed listener is registered at most once per reader via the registry's atomic computeIfAbsent. Keys are only unregistered on reader close: trimming on eviction would race with a concurrent reload of the same key re-registering it, and a lost registration would leave the entry with no cleanup path. A registered key whose entry was evicted costs two references, is bounded by the reader's lifetime, and invalidating it on close is a no-op.
  • onClose() invalidates exactly the keys registered for the closed reader, synchronously. A reader closes only once and exact-key invalidation is O(#queries cached for the reader), so there is no need to batch it.
  • With no sweep left, the periodic BitsetCacheCleaner, the indices.cache.bitset.cleanup_interval setting, purgeStaleEntries(), and the stale-mark bookkeeping are removed, and the constructor no longer needs a ThreadPool.
  • New regression tests cover exact per-reader invalidation across multiple cached queries and the eviction-then-reload case that motivates never unregistering keys on eviction; existing purge-based tests now assert entries are gone immediately after reader close.

Related Issues

Related to #21179. Companion to #22491, which made the same change for the fielddata cache. No longer depends on #22499 (Cache#keysSnapshot()): with exact-key invalidation there is no sweep left in this cache.

Check List

  • Functionality includes testing.
  • API changes companion pull request created, if applicable.
  • Public documentation issue/PR created, if applicable.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 413decd)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Setting Removal

The INDICES_BITSET_FILTER_CACHE_CLEAN_INTERVAL_SETTING (indices.cache.bitset.cleanup_interval) has been removed. If this setting was registered in ClusterSettings/IndexScopedSettings or referenced elsewhere in the codebase, its removal may cause node startup failures when users have it configured in their opensearch.yml. Verify the setting is unregistered cleanly and consider deprecation handling for existing configurations.

);

public static final Setting<ByteSizeValue> INDICES_BITSET_FILTER_CACHE_SIZE_SETTING = Setting.memorySizeSetting(
    "indices.cache.bitset.size",
    "5%",
    Property.NodeScope
);

private final Cache<BitsetCacheKey, Value> cache;
/**
 * Every key cached for a reader, so {@link #onClose(IndexReader.CacheKey)} can invalidate the
 * exact keys when the reader closes. Keys are only removed on reader close: trimming on
 * eviction would race with a concurrent reload of the same key re-registering it, and a lost
 * registration would leave the entry with no cleanup path. A registered key whose entry was
 * evicted costs two references, is bounded by the reader's lifetime, and invalidating it on
 * close is a no-op.
 */
private final ConcurrentMap<IndexReader.CacheKey, Set<BitsetCacheKey>> keysByReader = ConcurrentCollections.newConcurrentMap();
Possible Race

In getAndLoadIfNotPresent(), the sequence is: register the key in keysByReader, then call cache.computeIfAbsent(). If the reader closes between these two steps (which shouldn't happen since the caller holds the reader open, per the comment), or if onClose fires on another thread after keysByReader.computeIfAbsent returns but before cache.computeIfAbsent populates the entry, the entry loaded into the cache after onClose runs would never be invalidated. The comment asserts the caller holds the reader open — worth confirming this invariant holds for all callers, including preload paths in Loader.

final BitsetCacheKey cacheKey = new BitsetCacheKey(coreCacheReader, query);
// Register the closed listener at most once per reader (the mapping function runs
// atomically) and record the key so onClose can invalidate it exactly. The caller holds
// the reader open, so onClose cannot fire before the key is recorded.
keysByReader.computeIfAbsent(coreCacheReader, k -> {
    cacheHelper.addClosedListener(this);
    return ConcurrentCollections.newConcurrentSet();
}).add(cacheKey);
return cache.computeIfAbsent(cacheKey, key -> {

@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 413decd

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix race between reader close and key registration

There is a race between onClose removing the reader's key set and a concurrent
computeIfAbsent re-adding to it: if onClose runs between another thread's
computeIfAbsent returning the set and its .add(cacheKey), the newly added key will
never be invalidated, and its cache entry will leak. Iterate/invalidate under a
snapshot that also blocks new additions, e.g., by making the per-reader set
operations synchronized with keysByReader.remove on the same monitor, or by using
compute on keysByReader in both the registration and close paths.

server/src/main/java/org/opensearch/indices/IndicesBitsetFilterCache.java [154-165]

 public void onClose(IndexReader.CacheKey ownerCoreCacheKey) {
-    // Invalidate the exact keys synchronously rather than deferring to a periodic cache
-    // sweep: a reader closes only once, and exact-key invalidation is O(#queries cached for
-    // the reader), so there is no need to batch it. A key whose entry was already evicted is
-    // a no-op to invalidate.
     Set<BitsetCacheKey> keys = keysByReader.remove(ownerCoreCacheKey);
     if (keys != null) {
-        for (BitsetCacheKey key : keys) {
-            cache.invalidate(key);
+        synchronized (keys) {
+            for (BitsetCacheKey key : keys) {
+                cache.invalidate(key);
+            }
         }
     }
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion raises a plausible race concern between onClose and computeIfAbsent, but in practice the caller holds the reader open (as noted in the code comment) so onClose cannot fire before add completes. The proposed fix using synchronized(keys) is also insufficient since computeIfAbsent doesn't hold that monitor when adding. Moderate relevance but questionable correctness of the proposed fix.

Low

Previous suggestions

Suggestions up to commit 2e255f5
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix race between key registration and reader close

There is a race: after computeIfAbsent returns the set but before .add(cacheKey)
runs, the reader could close on another thread, causing onClose to remove the set
from keysByReader and invalidate its current contents. The subsequent .add(cacheKey)
would then register into an orphaned set, and the entry loaded by
cache.computeIfAbsent would never be invalidated. Consider re-checking after add
(e.g., if the set was removed from the map, invalidate the key directly), or
otherwise coordinating with onClose to avoid a leaked entry.

server/src/main/java/org/opensearch/indices/IndicesBitsetFilterCache.java [141-145]

-keysByReader.computeIfAbsent(coreCacheReader, k -> {
+Set<BitsetCacheKey> keys = keysByReader.computeIfAbsent(coreCacheReader, k -> {
     cacheHelper.addClosedListener(this);
     return ConcurrentCollections.newConcurrentSet();
-}).add(cacheKey);
+});
+keys.add(cacheKey);
+// If the reader closed concurrently, ensure we do not leak the entry we're about to load.
+if (keysByReader.get(coreCacheReader) != keys) {
+    cache.invalidate(cacheKey);
+}
 return cache.computeIfAbsent(cacheKey, key -> {
Suggestion importance[1-10]: 3

__

Why: The comment in the PR explicitly states "The caller holds the reader open, so onClose cannot fire before the key is recorded", which addresses this concern. The suggestion may not reflect a real bug given the documented invariant, though the defensive re-check is a minor safety improvement.

Low
Suggestions up to commit 9452a6c
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix race between key registration and reader close

There is a race where onClose can run between computeIfAbsent returning the set and
.add(cacheKey) executing: if the reader closes at that instant, keysByReader.remove
sees an empty (or already-removed) set, and the subsequent add re-inserts the key
with no cleanup path. Recheck after adding and invalidate immediately if the
reader-key mapping is gone.

server/src/main/java/org/opensearch/indices/IndicesBitsetFilterCache.java [141-145]

-keysByReader.computeIfAbsent(coreCacheReader, k -> {
+Set<BitsetCacheKey> keys = keysByReader.computeIfAbsent(coreCacheReader, k -> {
     cacheHelper.addClosedListener(this);
     return ConcurrentCollections.newConcurrentSet();
-}).add(cacheKey);
+});
+keys.add(cacheKey);
+if (keysByReader.get(coreCacheReader) != keys) {
+    // Reader closed concurrently; ensure we don't leak this key.
+    cache.invalidate(cacheKey);
+}
 return cache.computeIfAbsent(cacheKey, key -> {
Suggestion importance[1-10]: 6

__

Why: The comment in the PR notes "The caller holds the reader open, so onClose cannot fire before the key is recorded," which suggests the race may not occur in practice. However, if the invariant does not hold universally, the suggestion identifies a legitimate concern about a potential leak. The improved code's recheck logic is a reasonable defensive measure but its necessity depends on the caller contract.

Low
Suggestions up to commit a2036c8
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix race between key registration and reader close

There is a race between recording the key in keysByReader and the reader's onClose
firing: if onClose runs after computeIfAbsent completes but the closed-listener
registration was already pending, keysByReader.remove may return the set before
add(cacheKey) executes, leaving the loaded cache entry orphaned. Register the key
set first, then add the cacheKey, and finally check that the reader hasn't been
closed in the meantime (e.g., re-check keysByReader.get(coreCacheReader) contains
the key after cache.computeIfAbsent, and invalidate if missing).

server/src/main/java/org/opensearch/indices/IndicesBitsetFilterCache.java [141-145]

-keysByReader.computeIfAbsent(coreCacheReader, k -> {
+Set<BitsetCacheKey> keySet = keysByReader.computeIfAbsent(coreCacheReader, k -> {
     cacheHelper.addClosedListener(this);
     return ConcurrentCollections.newConcurrentSet();
-}).add(cacheKey);
-return cache.computeIfAbsent(cacheKey, key -> {
+});
+keySet.add(cacheKey);
+Value v = cache.computeIfAbsent(cacheKey, key -> {
     final BitSet bitSet = bitsetFromQuery(query, context);
     Value value = new Value(bitSet, shardId, listener);
     listener.onCache(shardId, value.bitset);
+    return value;
+});
+// If onClose fired concurrently after we added the key but before caching, ensure cleanup.
+if (keysByReader.get(coreCacheReader) == null) {
+    cache.invalidate(cacheKey);
+}
+return v.bitset;
Suggestion importance[1-10]: 3

__

Why: The claimed race is questionable: the caller holds the reader open (as noted in the PR comment), so onClose cannot fire before the caching completes. The suggestion's reasoning about a race is not clearly correct, and the proposed fix adds complexity for a scenario the PR explicitly addresses.

Low
Suggestions up to commit d3c5ed6
CategorySuggestion                                                                                                                                    Impact
General
Guard initial capacity against overflow

The field count is a long but ArrayList constructor requires an int; if count
exceeds Integer.MAX_VALUE this will fail to compile, and if it is a long that gets
implicitly narrowed elsewhere it could produce a negative capacity. Cast explicitly
and clamp to a non-negative int to avoid IllegalArgumentException on new
ArrayList<>(negative).

server/src/main/java/org/opensearch/common/cache/Cache.java [754]

 public List<K> keysSnapshot() {
-    List<K> keys = new ArrayList<>(count);
+    List<K> keys = new ArrayList<>((int) Math.min(Math.max(count, 0L), Integer.MAX_VALUE));
     for (CacheSegment<K, V> segment : segments) {
         try (ReleasableLock ignored = segment.readLock.acquire()) {
             keys.addAll(segment.map.keySet());
         }
     }
     return keys;
 }
Suggestion importance[1-10]: 2

__

Why: The count field in Cache is likely a long, but the concern about overflow is largely theoretical for an in-memory cache. Existing code elsewhere (e.g., keys() iterator) uses the same pattern without issue, and this is a minor defensive measure at best.

Low
Suggestions up to commit 511426b
CategorySuggestion                                                                                                                                    Impact
General
Avoid leaking empty per-reader key sets

When entries are evicted by weight (not by reader close), the per-reader set can
become empty but is never removed from keysByReader, causing a slow memory leak of
empty sets keyed by still-open readers. Consider removing the set entry when it
becomes empty, using an atomic compute to avoid racing with a concurrent load that
just re-added a key.

server/src/main/java/org/opensearch/indices/IndicesBitsetFilterCache.java [179-188]

 @Override
 public void onRemoval(RemovalNotification<BitsetCacheKey, Value> notification) {
     final BitsetCacheKey key = notification.getKey();
     if (key != null) {
-        // Keep the per-reader key index in sync on evictions and explicit invalidations.
-        // The set itself is removed when the reader closes.
-        final Set<BitsetCacheKey> keys = keysByReader.get(key.readerCacheKey);
-        if (keys != null) {
-            keys.remove(key);
-        }
+        keysByReader.computeIfPresent(key.readerCacheKey, (k, set) -> {
+            set.remove(key);
+            return set.isEmpty() ? null : set;
+        });
     }
Suggestion importance[1-10]: 6

__

Why: Valid concern: when entries are evicted by weight while the reader remains open, empty sets accumulate in keysByReader. The suggested computeIfPresent fix is correct and prevents a slow memory leak, though the impact is minor since sets are small.

Low
Possible issue
Guard against reader-close race during load

There is a race between computeIfAbsent inserting the key into keysByReader and a
concurrent onClose that has already removed the reader's set. If onClose runs after
registeredKeys.add (which returned true earlier for another thread) but before this
loader completes, the newly created set in keysByReader will never be cleaned up,
and the cache entry will not be invalidated. Consider checking that the reader is
still registered (or re-adding to keysByReader after verifying) and/or invalidating
the just-loaded entry if the reader has closed.

server/src/main/java/org/opensearch/indices/IndicesBitsetFilterCache.java [141-149]

 return cache.computeIfAbsent(cacheKey, key -> {
-    // Record the key before loading so onClose can find it. The reader is held open
-    // by the caller for the duration of the load, so it cannot close concurrently.
-    keysByReader.computeIfAbsent(coreCacheReader, k -> ConcurrentCollections.newConcurrentSet()).add(key);
     final BitSet bitSet = bitsetFromQuery(query, context);
     Value value = new Value(bitSet, shardId, listener);
     listener.onCache(shardId, value.bitset);
+    keysByReader.computeIfAbsent(coreCacheReader, k -> ConcurrentCollections.newConcurrentSet()).add(key);
+    if (registeredKeys.contains(coreCacheReader) == false) {
+        // Reader closed concurrently; ensure we don't leak this entry.
+        keysByReader.remove(coreCacheReader);
+        cache.invalidate(key);
+    }
     return value;
 }).bitset;
Suggestion importance[1-10]: 5

__

Why: The PR comment notes the reader is held open by the caller during load, which likely mitigates this race. However, the concern about ordering between onClose and the loader is plausible, and a defensive check could improve robustness. Moderate impact but not clearly a bug.

Low

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for b79ef1e: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@jwils
jwils force-pushed the fix-bitset-stale-retention branch from b79ef1e to 511426b Compare July 18, 2026 15:50
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 511426b

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 511426b: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@jwils jwils closed this Jul 19, 2026
@jwils jwils reopened this Jul 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 511426b: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@jwils

jwils commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@sgup432 please review this as well.

@kkhatua
kkhatua requested a review from sgup432 July 22, 2026 19:29
@jwils
jwils force-pushed the fix-bitset-stale-retention branch from 511426b to d3c5ed6 Compare July 23, 2026 00:34
@jwils jwils changed the title Fix stale bitset filter cache retention by invalidating exact keys on reader close Use point-in-time key snapshot for bitset filter cache stale entry cleanup Jul 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d3c5ed6

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for d3c5ed6: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

The node-level bitset filter cache deferred cleanup of entries from
closed readers to a periodic sweep over Cache.keys(). That iterator
walks the LRU linked list without holding the LRU lock, so a concurrent
cache hit relinking an entry to the head of the LRU list can cause the
sweep to skip it; because the sweep drains the stale marks after each
pass and a reader closes only once, a skipped entry is retained until
size-based eviction reaches it - potentially forever.

Instead of sweeping, track every key cached for a reader and invalidate
exactly those keys, synchronously, when the reader closes - matching
the fielddata cache fix in opensearch-project#22491. This removes the periodic
BitsetCacheCleaner, its indices.cache.bitset.cleanup_interval setting,
and the stale-mark bookkeeping entirely.

Signed-off-by: Josh Wilson <joshuaw@squareup.com>
@jwils
jwils force-pushed the fix-bitset-stale-retention branch from d3c5ed6 to a2036c8 Compare July 23, 2026 20:52
@jwils jwils changed the title Use point-in-time key snapshot for bitset filter cache stale entry cleanup Invalidate exact bitset filter cache keys on reader close Jul 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit a2036c8

@jwils

jwils commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@andrross I believe this is another concerning case that can cause permeant misses (until the cache is full) if hit. Since these also link to unaccounted for objects like Query, ValueWrapper, etc I think it may also be able to grow larger than the max cache size.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for a2036c8: SUCCESS

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 71.47%. Comparing base (0240b35) to head (413decd).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...g/opensearch/indices/IndicesBitsetFilterCache.java 91.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main   #22498      +/-   ##
============================================
- Coverage     71.48%   71.47%   -0.01%     
+ Complexity    77022    77010      -12     
============================================
  Files          6156     6156              
  Lines        358482   358454      -28     
  Branches      52246    52244       -2     
============================================
- Hits         256255   256213      -42     
- Misses        81810    81877      +67     
+ Partials      20417    20364      -53     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@kkhatua

kkhatua commented Jul 25, 2026

Copy link
Copy Markdown
Member

Thanks for the PR, @jwils

We recently did some minor improvements ( #21179 ) to contain this cache size limit, but a rewrite of the cache (like field data cache) is probably what is needed but that's not going to be trivial.

We'll need some time to review this PR as there are competing priorities at the moment.

@jwils

jwils commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

@kkhatua our clusters are not on 3.7 yet so I can't say for sure of how large the impact is, but #21179 introduced a bug that this attempts to fix. I am concerned the cache size is no longer bounded without either a fix like this or reverting #21179

@jwils

jwils commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Similar to the other pr. I believe removing the async BitsetCacheCleaner flow is safe, but I think the fix is important either way, so more than happy to refactor this to still retain the BitsetCacheCleaner if that makes it easier to review.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9452a6c

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 9452a6c: TIMEOUT

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

Comment on lines +160 to +164
if (keys != null) {
for (BitsetCacheKey key : keys) {
cache.invalidate(key);
}
}

@sgup432 sgup432 Aug 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we instead do this in a different thread rather than doing in a sync manner? As I worry that this method might get triggered on a cluster applier thread while trying to close a shard(and eventually its readers), and might unnecessarily block it if it takes a lot of time (like we have seen in the past) and causes node drops as a result.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

My preference would be to do it on one thread. I think it simplifies the logic where it doesn't need to be async. I don't think this can block for very long as these invalidate operations should be O(1). I think the max number of keys here is bounded by the number of nested fields so I can't imagine this list to invalidate being large enough to cause any issues.

I don't know the history of unnecessary blocks causing cluster blocks, so let me know if you still prefer this async and I'll make the change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the max number of keys here is bounded by the number of nested fields so I can't imagine this list to invalidate being large enough to cause any issues.

Oh yeah! I missed the fact that for this cache, we won't have much queries cached per segment reader as it is bounded by the setting index.mapping.nested_fields.limit which is 50. Someone can set higher ofcourse to a very large value as there is no max defined it seems, but that's not a good practice and will break something else. So I agree that we can keep this as a sync operation.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 2e255f5

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 2e255f5: SUCCESS

@sgup432

sgup432 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

@andrross @jainankitk Can you folks also review/approve and help in merging it in?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 413decd

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 413decd: SUCCESS

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.

3 participants