Lab K5: Fix It — A Real k-NN Issue

Background

The k-NN plugin's native-memory circuit breaker is one of the most-reported, least-understood subsystems in the repo. It guards memory that no JVM tool can see (faiss/nmslib graphs live off-heap — see native integration and memory), it is entangled with the NativeMemoryCacheManager Guava cache and with cluster-settings plumbing, and its "tripped / un-tripped" hysteresis has sharp edges. Two real issues frame the territory:

This Fix-It lab works in the flavor of #585: a settings-plumbing bug where the configured circuit-breaker limit is not honored. You will reproduce the symptom, locate the code with grep in org.opensearch.knn.index.memory.* and KNNSettings, apply a minimal diff, write a unit test that asserts the corrected behavior, build and test, and ship a PR with DCO sign-off and a CHANGELOG.md entry. The point is not just the patch — it is doing it the way k-NN maintainers expect, in a subsystem where a careless test leaks off-heap memory.

Note on terminology: the cluster manager (formerly master) publishes the cluster setting knn.memory.circuit_breaker.limit; each data node applies it locally to its own NativeMemoryCacheManager. The bug in this lab is on the apply side — per-node — which is exactly why it is invisible from the cluster-settings API alone.

Warning: This lab uses a representative reconstruction of a #585-flavored bug so it is reproducible on today's tree (the original was fixed years ago). The class names, settings keys, and APIs are real; grep your checkout to find where the equivalent logic lives now, because this file has been refactored more than once.


Why This Lab Matters for Contributors

  • Settings-plumbing bugs are a classic, well-scoped contributor on-ramp: small surface, high impact, easy to test once you find them. They are also where backward-compat rules bite — see Issue Roadmap Stage 8: Plugin Compatibility.
  • The native-memory breaker is the difference between "node degrades gracefully under vector load" and "the Linux OOM-killer reaps the process." A limit that is silently ignored is a production landmine.
  • This subsystem punishes sloppy tests harder than any other: faiss allocations are off-heap, so a test that loads a graph and forgets to free it leaks native memory across the whole test JVM. You will learn to test it without leaking.
  • "Fix a config bug, add a regression test, un-break the limit" is a concrete, mergeable PR with a clean before/after.

Prerequisites

  • native integration and memory read — you understand NativeMemoryCacheManager, the native breaker, and that the limit is a percentage of node RAM (or an absolute size), not a percentage of -Xmx.
  • You can build and test the plugin (lab-k1): ./gradlew test, ./gradlew run.
  • You can read a cluster setting via the API and set it dynamically.
cd ~/src/oss-repos/k-NN
./gradlew test --tests "*KNNSettings*"   # sanity: the settings tests compile & run

Step 1: Reproduce the symptom

The reported behavior: an operator sets knn.memory.circuit_breaker.limit to a smaller value to bound native memory, but the cache keeps growing past it — the new limit is ignored until restart, or an absolute value like "4kb" is parsed wrong. Reproduce it against a running node.

./gradlew run    # single node with k-NN

# Read the current native breaker settings (this is the cluster-published view):
curl -s 'localhost:9200/_cluster/settings?include_defaults=true&flat_settings=true&pretty' \
  | grep -E 'knn\.memory\.circuit_breaker|knn\.circuit_breaker'

# Set a deliberately tiny absolute limit so any graph trips it:
curl -XPUT 'localhost:9200/_cluster/settings' -H 'Content-Type: application/json' -d '
{ "persistent": { "knn.memory.circuit_breaker.limit": "1kb" } }'

# Index a faiss field, refresh, warm it up so a native graph loads:
curl -XPUT 'localhost:9200/cb' -H 'Content-Type: application/json' -d '
{ "settings": { "index.knn": true },
  "mappings": { "properties": { "e": { "type": "knn_vector", "dimension": 8,
    "method": { "name": "hnsw", "engine": "faiss", "space_type": "l2" } } } } }'
for i in $(seq 1 200); do
  curl -s -XPOST 'localhost:9200/cb/_doc' -H 'Content-Type: application/json' \
    -d "{\"e\":[$i,1,2,3,4,5,6,7]}" >/dev/null
done
curl -s -XPOST 'localhost:9200/cb/_refresh' >/dev/null
curl -s -XPOST 'localhost:9200/_plugins/_knn/warmup/cb?pretty' >/dev/null

# Now check whether the breaker tripped. With a 1kb limit and a real graph, it MUST.
curl -s 'localhost:9200/_plugins/_knn/stats?pretty' \
  | grep -E 'graph_memory|circuit_breaker|cache_capacity|eviction'

The bug's signature: graph_memory_usage exceeds the configured limit but knn.circuit_breaker.triggered reads false, or the dynamically-updated limit is not reflected in the cache's maximumWeight. The configured limit was parsed and stored but never propagated to the live cache. That disconnect is the bug class #585 is about.

Warning: Do not conclude "the breaker is broken" from RSS alone. glibc's allocator keeps freed arenas, so RSS lags eviction (see native integration § eviction). Judge by the cache accounting in _plugins/_knn/stats, not by ps.


Step 2: Locate the code

Two suspects: where the setting is defined and parsed (KNNSettings), and where the limit is applied to the cache (org.opensearch.knn.index.memory.*). Grep both.

# The setting definition, parsing, and the byte/percentage resolution:
grep -rn "circuit_breaker.limit\|CIRCUIT_BREAKER_LIMIT\|getCircuitBreakerLimit\|ByteSizeValue\|parseknnMemoryCircuitBreakerValue\|KNN_MEMORY_CIRCUIT_BREAKER_LIMIT" \
  src/main/java/org/opensearch/knn/index/KNNSettings.java

# Where the cache reads the limit to size its maximumWeight, and the settings-update hook:
grep -rn "maximumWeight\|setMaximumWeight\|circuitBreakerLimit\|rebuildCache\|addSettingsUpdateConsumer\|onCacheConfigChanged" \
  src/main/java/org/opensearch/knn/index/memory/NativeMemoryCacheManager.java \
  src/main/java/org/opensearch/knn/index/KNNSettings.java

You are looking for the seam where a dynamic settings update should call back into the cache and rebuild it with the new weight bound — and isn't, or where the parse of an absolute size ("4kb", "60%") returns the wrong number of bytes.

# Trace the update consumer registration — the dynamic-settings callback:
grep -rn "clusterSettings.addSettingsUpdateConsumer\|settingsUpdateConsumer\|consumer" \
  src/main/java/org/opensearch/knn/index/KNNSettings.java

The two #585-flavored failure modes you might find:

Failure modeWhereWhy it manifests
Stale limitthe dynamic-settings update consumer never rebuilds the cache's maximumWeightnew limit stored in KNNSettings but the live Guava cache keeps the old bound until restart
Mis-parseparseknnMemoryCircuitBreakerValue resolves a percentage against the wrong base, or an absolute ByteSizeValue is read in the wrong unit"60%" or "4kb" becomes a wildly wrong byte count, so the limit is effectively ignored

This lab fixes the stale-limit mode: the update consumer doesn't propagate to the cache.


Step 3: The diff

The fix wires the dynamic settings update into a cache rebuild, so changing knn.memory.circuit_breaker.limit at runtime actually re-bounds the live cache. The exact file/lines drift — this is the shape; adapt the surrounding lines to your tree.

--- a/src/main/java/org/opensearch/knn/index/KNNSettings.java
+++ b/src/main/java/org/opensearch/knn/index/KNNSettings.java
@@ -1,5 +1,7 @@
 package org.opensearch.knn.index;

+import org.opensearch.knn.index.memory.NativeMemoryCacheManager;
+
 import org.opensearch.common.settings.ClusterSettings;
 import org.opensearch.common.settings.Setting;
 import org.opensearch.common.unit.ByteSizeValue;
@@ -120,9 +122,18 @@ public class KNNSettings {
     private void setClusterSettings(final ClusterSettings clusterSettings) {
         this.clusterSettings = clusterSettings;
-        // BUG: the limit setting is registered as dynamic, but nothing propagates a
-        //      changed value into the live NativeMemoryCacheManager weight bound, so a
-        //      runtime update is silently ignored until the next node restart.
-        clusterSettings.addSettingsUpdateConsumer(KNN_MEMORY_CIRCUIT_BREAKER_LIMIT, newLimit -> {
-            // value stored in the settings map, but the cache is never rebuilt
-        });
+        // FIX: on a dynamic update of the circuit-breaker limit, rebuild the cache so its
+        //      maximumWeight reflects the new bound immediately (no restart required).
+        clusterSettings.addSettingsUpdateConsumer(KNN_MEMORY_CIRCUIT_BREAKER_LIMIT, newLimit -> {
+            NativeMemoryCacheManager.getInstance().rebuildCache();
+        });
     }
--- a/src/main/java/org/opensearch/knn/index/memory/NativeMemoryCacheManager.java
+++ b/src/main/java/org/opensearch/knn/index/memory/NativeMemoryCacheManager.java
@@ -60,12 +60,27 @@ public class NativeMemoryCacheManager implements Closeable {

-    // BUG: maximumWeight is read once at construction from the limit; a later settings
-    //      update has no path to change the live cache's bound.
-    private void initCache() {
-        long maxWeightKb = KNNSettings.getCircuitBreakerLimit().getKb();
-        this.cache = CacheBuilder.newBuilder()
-            .maximumWeight(maxWeightKb)
-            .weigher((k, v) -> v.getSizeInKb())
-            .removalListener(this::onRemoval)
-            .build();
-    }
+    private void initCache() {
+        this.cache = buildCache(KNNSettings.getCircuitBreakerLimit().getKb());
+    }
+
+    private Cache<String, NativeMemoryAllocation> buildCache(long maxWeightKb) {
+        return CacheBuilder.newBuilder()
+            .maximumWeight(maxWeightKb)
+            .weigher((String k, NativeMemoryAllocation v) -> v.getSizeInKb())
+            .removalListener(this::onRemoval)
+            .build();
+    }
+
+    /**
+     * Rebuild the cache against the CURRENT circuit-breaker limit. Invalidating the old
+     * cache fires the RemovalListener for every entry, which frees the native memory via
+     * JNI — so a shrunk limit immediately evicts down to the new bound, and nothing leaks.
+     */
+    public synchronized void rebuildCache() {
+        long newMaxWeightKb = KNNSettings.getCircuitBreakerLimit().getKb();
+        Cache<String, NativeMemoryAllocation> old = this.cache;
+        this.cache = buildCache(newMaxWeightKb);
+        if (old != null) {
+            old.invalidateAll();   // RemovalListener -> JNI free() for every entry
+        }
+    }

The crucial detail is the last comment: invalidateAll() fires the RemovalListener, which calls the engine's free across JNI. Rebuilding the cache without invalidating the old one would leak every loaded faiss graph — a non-obvious off-heap leak. The fix re-bounds the cache and releases native memory in one atomic, synchronized step.

Note: A real PR would likely also debounce or guard the rebuild (it briefly drops all cached graphs, so the next queries pay reload cost), and reconcile with the rearchitecture direction in k-NN #1582. Note that in your PR description — maintainers will ask how your fix relates to the planned rework.


Step 4: A unit test that asserts the corrected behavior — without leaking

The test must prove the limit propagates, without loading a real faiss graph (that allocates off-heap; a unit test that loads and forgets to free leaks native memory for the rest of the JVM run). Assert on the cache's configured bound, not on a live graph.

/*
 * SPDX-License-Identifier: Apache-2.0
 *
 * The OpenSearch Contributors require contributions made to
 * this file be licensed under the Apache-2.0 license or a
 * compatible open source license.
 */
package org.opensearch.knn.index.memory;

import org.opensearch.common.settings.ClusterSettings;
import org.opensearch.common.settings.Settings;
import org.opensearch.knn.KNNTestCase;
import org.opensearch.knn.index.KNNSettings;

import static org.opensearch.knn.index.KNNSettings.KNN_MEMORY_CIRCUIT_BREAKER_LIMIT;

public class NativeMemoryCircuitBreakerLimitTests extends KNNTestCase {

    /**
     * Updating knn.memory.circuit_breaker.limit at runtime must re-bound the live cache.
     * Before the fix, the cache kept its construction-time maximumWeight and the update
     * was silently ignored. We assert on the cache's CONFIGURED weight, never load a
     * native graph, and free nothing — so this test allocates no off-heap memory.
     */
    public void testDynamicLimitUpdateRebuildsCacheBound() {
        // Register settings with a starting limit.
        Settings initial = Settings.builder()
            .put(KNN_MEMORY_CIRCUIT_BREAKER_LIMIT.getKey(), "10kb")
            .build();
        ClusterSettings clusterSettings = new ClusterSettings(
            initial, KNNSettings.state().getSettings());     // grep for the real registration helper
        KNNSettings.state().setClusterSettings(clusterSettings);

        NativeMemoryCacheManager cm = NativeMemoryCacheManager.getInstance();
        cm.rebuildCache();
        long before = cm.getMaxCacheSizeInKilobytes();
        assertEquals("initial limit should be honored", 10L, before);

        // Apply a dynamic update to a smaller limit.
        clusterSettings.applySettings(Settings.builder()
            .put(KNN_MEMORY_CIRCUIT_BREAKER_LIMIT.getKey(), "4kb")
            .build());

        long after = cm.getMaxCacheSizeInKilobytes();
        assertEquals("dynamic update must re-bound the live cache", 4L, after);
        assertTrue("limit must actually shrink, not stay stale", after < before);
    }

    @Override
    public void tearDown() throws Exception {
        // Hygiene: drop any cache state so this test never leaks native allocations into
        // the shared test JVM. invalidateAll() fires the RemovalListener (JNI free()).
        NativeMemoryCacheManager.getInstance().invalidateAll();
        super.tearDown();
    }
}

Warning — native memory hygiene in tests: Every k-NN test that touches the cache must leave it empty. Off-heap allocations are not GC'd; a leaked faiss pointer survives the test method and inflates the test JVM's RSS, which can OOM-kill the entire test run on CI. Always invalidateAll() (or the equivalent) in tearDown, and prefer asserting on cache configuration over loading real graphs. This is the single rule that separates a green k-NN test PR from a flaky, memory-leaking one.

./gradlew test --tests "org.opensearch.knn.index.memory.NativeMemoryCircuitBreakerLimitTests"

Step 5: Build, test, and verify end to end

# 1) Unit test (fast inner loop).
./gradlew test --tests "*NativeMemoryCircuitBreakerLimit*"

# 2) Full module test + precommit (formatting, license headers, forbidden APIs).
./gradlew spotlessApply
./gradlew precommit

# 3) Manual proof on a running node: the Step-1 reproduction now behaves.
./gradlew run
# ...repeat the Step 1 curls: set limit to "1kb", warmup the faiss field...
# Now the breaker trips / the cache evicts down to the new bound, visible in stats:
curl -s 'localhost:9200/_plugins/_knn/stats?pretty' \
  | grep -E 'graph_memory|circuit_breaker|eviction'
# Lower the limit at runtime and confirm cache_capacity reflects it WITHOUT a restart:
curl -XPUT 'localhost:9200/_cluster/settings' -H 'Content-Type: application/json' \
  -d '{ "persistent": { "knn.memory.circuit_breaker.limit": "2kb" } }'
curl -s 'localhost:9200/_plugins/_knn/stats?pretty' | grep -E 'cache_capacity|eviction'

A real fix changes the manual reproduction from "limit ignored until restart" to "limit honored immediately," with the eviction count climbing as the shrunk cache sheds entries.


Step 6: Ship the PR (DCO, CHANGELOG, compat)

git checkout -b fix/native-cb-limit-dynamic-update

# CHANGELOG entry — k-NN keeps a Keep-a-Changelog style CHANGELOG.md:
#   ### Fixed
#   - Honor dynamic updates to knn.memory.circuit_breaker.limit by rebuilding the
#     native-memory cache bound at runtime ([#NNNN](https://github.com/opensearch-project/k-NN/pull/NNNN))

git add src/main/java/org/opensearch/knn/index/KNNSettings.java \
        src/main/java/org/opensearch/knn/index/memory/NativeMemoryCacheManager.java \
        src/test/java/org/opensearch/knn/index/memory/NativeMemoryCircuitBreakerLimitTests.java \
        CHANGELOG.md

# DCO sign-off is mandatory in opensearch-project repos (-s adds Signed-off-by):
git commit -s -m "Honor dynamic native-memory circuit-breaker limit updates

The dynamic settings update for knn.memory.circuit_breaker.limit was
registered but never propagated to the live NativeMemoryCacheManager, so
a runtime limit change was ignored until restart. Rebuild the cache on
update (invalidateAll -> JNI free, no native leak) so the new bound takes
effect immediately. Adds a regression test asserting the cache re-bounds
without loading a native graph.

Relates to #585 and the rearchitecture discussion in #1582."

Note on compatibility (Stage 8): changing settings behavior is a compatibility surface. The fix honors the documented contract (the limit is dynamic), so it is a bug fix, not a breaking change — but in the PR, state explicitly that the setting key, type, and default are unchanged. See Issue Roadmap Stage 8: Plugin Compatibility for what reviewers check: setting renames, default changes, and BWC of serialized state are the things that get a PR blocked.


Pitfalls

TrapWhy it's wrongDo instead
Rebuild the cache without invalidateAll()leaks every loaded faiss graph (off-heap, never GC'd)invalidate the old cache so the RemovalListener JNI-frees each entry
Test by loading a real faiss graphallocates native memory; if not freed, leaks for the whole test JVM → CI OOMassert on the cache's configured bound; invalidateAll() in tearDown
Judge the fix by RSS droppingglibc keeps freed arenas; RSS lags evictionjudge by cache accounting in _plugins/_knn/stats, not ps
Thread.sleep to "let the update apply"settings application is synchronous via the consumer; a sleep hides the real seamcall the update path directly and assert the new bound
Skip the CHANGELOG / DCOk-NN PRs fail CI without DCO and a maintainer will bounce a missing CHANGELOGgit commit -s; add a ### Fixed entry
Parse-base assumptiona percentage limit resolves against node RAM, not -Xmxconfirm against native integration before touching the parse path

Expected Output

Before the fix, after setting the limit to "1kb" and warming a graph:

"graph_memory_usage" : 37,            # KB, well over the 1kb limit
"circuit_breaker_triggered" : false,  # WRONG — limit silently ignored
"cache_capacity_reached" : false

After the fix:

"graph_memory_usage" : 0,             # cache evicted down to the new bound
"circuit_breaker_triggered" : true,
"cache_capacity_reached" : true,
"eviction_count" : 1                  # the shrink fired the RemovalListener (JNI free)

And the regression test:

> Task :test
org.opensearch.knn.index.memory.NativeMemoryCircuitBreakerLimitTests >
  testDynamicLimitUpdateRebuildsCacheBound PASSED

BUILD SUCCESSFUL

Stretch Goals

  1. Fix the other failure mode. Reconstruct the mis-parse variant: make parseknnMemoryCircuitBreakerValue resolve a percentage against the wrong base, write a test that pins the byte count for both "60%" and "4gb", and fix it. Compare to the stale-limit fix — which is easier to catch in review, and why?
  2. Read the real #585. Open k-NN #585, find the actual merged fix PR, and diff your approach against the maintainers'. What did they do that you didn't (debounce? a dedicated config object? a test you missed)?
  3. Engage #1582. Read k-NN #1582 and write two paragraphs on how a clean rearchitecture would make your fix unnecessary — separating the breaker policy from the cache mechanism so a limit change does not require a cache rebuild at all.
  4. Find a live one. Search https://github.com/opensearch-project/k-NN/issues?q=is%3Aissue+is%3Aopen+label%3Abug+circuit+breaker and ... memory cache eviction for a current, real defect in this subsystem and take it.

Coding Exercises

The lab fixed one circuit-breaker bug. These exercises drill the fix-it muscle harder: regression tests that pin behavior, the other failure mode, a leak detector, and finally a second representative bug reproduced and fixed from scratch. The iron rule of this subsystem applies to every exercise: assert on cache accounting, never load a real graph you forget to free — invalidateAll() in tearDown. Locate code with rg; this file is refactored often.

  1. (warm-up) A regression test that pins the parse contract. Independent of the stale-limit fix, write a KNNSettings-level unit test that pins parseknnMemoryCircuitBreakerValue (find it with rg -n "parseknnMemoryCircuitBreakerValue\|CIRCUIT_BREAKER_LIMIT" src/main/java/org/opensearch/knn/index/KNNSettings.java) for a table of inputs: "4kb", "60%", "1gb". Assert the resolved byte count for each, and document what base a percentage resolves against (node RAM, not -Xmx — confirm against native integration). This is the test that would have caught the mis-parse failure mode the lab's table describes.

  2. (core) Test the dynamic-update propagation directly. Harden the lab's testDynamicLimitUpdateRebuildsCacheBound: add cases that update the limit up (4kb → 10kb) and assert the bound grows, and a no-op update (same value) and assert the cache isn't needlessly rebuilt (expose or count rebuilds). Assert only on getMaxCacheSizeInKilobytes() — never load a graph. This pins the exact seam the fix repairs and guards against a future regression re-breaking propagation.

  3. (core) Fix the mis-parse failure mode (the other bug). Implement Stretch Goal 1 as a real fix-it: reconstruct the variant where a percentage resolves against the wrong base (or an absolute ByteSizeValue is read in the wrong unit), write a test that pins the byte count for "60%" and "4gb" before fixing, watch it fail, then fix the parse and watch it pass. Compare in writing: which failure mode is easier to catch in review, and why? (Hint: a mis-parse is a pure function — trivially unit-testable; a stale limit needs the settings plumbing.)

  4. (core) A native-leak detector test helper. Write a small test utility (a JUnit rule or a @Before/@After helper, or a method other tests call) that snapshots NativeMemoryCacheManager entry count / configured weight before a test body and asserts it returns to baseline after — failing loudly if a test leaked a cached allocation. Add it to one existing memory test to prove it works (induce a leak by skipping invalidateAll(), watch the helper catch it, then restore). This operationalizes the lab's single most important rule: k-NN tests must leave the cache empty.

  5. (core) Add a circuit-breaker config assertion to the live path. Beyond the unit test, write an integration test (*IT) that boots a node, sets a tiny limit, indexes + warms a faiss field so a graph loads, lowers the limit at runtime, and asserts via the stats API that eviction_count climbs and graph_memory_usage drops to the new bound — without restart. This is the end-to-end proof that the per-node apply side honors the cluster-published setting, the exact gap that made the original bug invisible from GET /_cluster/settings.

  6. (advanced challenge) Reproduce-and-fix a second representative bug, soup to nuts. Pick a different defect class in this subsystem — e.g. the eviction-vs-rejection hysteresis muddiness called out in #1582, or a stat that double-counts after a rebuild. Reproduce it (reconstruct the symptom on today's tree if no live bug fits), locate the seam with rg in org.opensearch.knn.index.memory.*, write a failing regression test first (TDD-style, asserting on cache accounting), apply a minimal diff, and ship a PR-shaped change: branch, fix, regression test, CHANGELOG.md ### Fixed entry, DCO sign-off, and a PR description that states how your point fix relates to the #1582 rearchitecture and confirms the setting key/type/default are unchanged (the Stage 8 compat surface). The whole deliverable is the discipline: failing test → fix → green test → mergeable PR, in a subsystem where a careless test leaks off-heap memory. Prove the fix doesn't regress performance in lab-k6.


Issues to Practice On

The native-memory circuit breaker and cache are among the most-reported, well-scoped areas in k-NN — settings-plumbing and memory-accounting bugs are classic contributor on-ramps. The repo is opensearch-project/k-NN.

GoalCommand
Beginner-friendly bugsgh issue list --repo opensearch-project/k-NN --label "good first issue" --state open
Circuit-breaker / memory / cache bugsgh issue list --repo opensearch-project/k-NN --label "bug" --search "circuit breaker OR memory OR cache OR eviction"
Settings / config bugsgh issue list --repo opensearch-project/k-NN --label "bug" --search "setting OR limit OR config"
Breaker rearchitecture / roadmapgh issue list --repo opensearch-project/k-NN --label "Roadmap" --search "memory OR circuit"

Labels drift — list and pick (gh label list --repo opensearch-project/k-NN). The two anchor issues are #585 (the config bug this lab is flavored on) and #1582 (the rearchitecture).

Representative issue patterns. (1) "Setting X is ignored / not honored at runtime" — the stale-propagation class this lab fixes. Approach: reproduce by setting it dynamically and checking the applied side (stats), rg for the addSettingsUpdateConsumer seam, wire the callback, add a non-leaking regression test, PR with CHANGELOG + DCO + a compat note. (2) "Native memory grows past the limit / OOM under vector load" — judge by cache accounting, not RSS (glibc arena caveat); the bug is usually a weight/eviction accounting gap. Both demand the "assert on configuration, free everything" test discipline.

Planted-bug exercise. In NativeMemoryCacheManager.rebuildCache() (or your fix), delete the old.invalidateAll() line so the cache rebuilds without freeing the old entries. The unit test asserting on the bound still passes — but your Exercise 4 leak detector (or an integ test watching native memory) catches the off-heap leak the lab warns is "non-obvious." Restore the line. The lesson, stated outright in the lab: rebuilding without invalidating leaks every loaded faiss graph, and only a test that watches native accounting — not the bound — will catch it.

Etiquette. Claim the issue before working it, reproduce first (judge by cache accounting, not ps), and every PR needs a non-leaking regression test, a CHANGELOG.md ### Fixed entry, a compat note, and a DCO sign-off (git commit -s). See community interaction and Stage 8: Plugin Compatibility.


Validation / Self-check

  1. What is the difference between the setting being stored and the setting being applied? Which side was broken in this lab, and why is that invisible from GET /_cluster/settings?
  2. Why must rebuildCache() call invalidateAll() on the old cache? What leaks if it doesn't, and why won't the GC catch that leak?
  3. Why does the unit test assert on the cache's configured bound instead of loading a real faiss graph and watching it get evicted?
  4. Why is RSS a misleading signal for whether the breaker worked? What is the reliable signal instead?
  5. Is the native-memory circuit-breaker limit a percentage of -Xmx or of node RAM? How does that change the failure analysis for an OOM-killed node?
  6. What two CI gates will reject your PR if you forget them, and what does each enforce?
  7. How does your point fix relate to the rearchitecture in #1582? When you propose a point fix in a subsystem with an open redesign, what should the PR description say?

When you can reproduce the symptom, locate the seam in KNNSettings/NativeMemoryCacheManager, fix it without leaking native memory, prove it with a non-leaking regression test, and ship it with DCO + CHANGELOG, you have done real k-NN maintenance work. Revisit native integration and memory for the full breaker model, and Stage 8: Plugin Compatibility for the compat lens every settings change is read through. Then prove your fix doesn't regress performance in lab-k6: Benchmark Recall and Latency.