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:
- k-NN #1582 — Investigate rearchitecture of the native memory circuit breaker — the breaker is too entangled with the cache and the settings layer; the eviction-vs-rejection relationship is muddy.
- k-NN #585 — circuit-breaker limit config bug
— a concrete defect in how the
knn.memory.circuit_breaker.limitsetting was handled.
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 ownNativeMemoryCacheManager. 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 byps.
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 mode | Where | Why it manifests |
|---|---|---|
| Stale limit | the dynamic-settings update consumer never rebuilds the cache's maximumWeight | new limit stored in KNNSettings but the live Guava cache keeps the old bound until restart |
| Mis-parse | parseknnMemoryCircuitBreakerValue 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) intearDown, 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
| Trap | Why it's wrong | Do 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 graph | allocates native memory; if not freed, leaks for the whole test JVM → CI OOM | assert on the cache's configured bound; invalidateAll() in tearDown |
| Judge the fix by RSS dropping | glibc keeps freed arenas; RSS lags eviction | judge 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 seam | call the update path directly and assert the new bound |
| Skip the CHANGELOG / DCO | k-NN PRs fail CI without DCO and a maintainer will bounce a missing CHANGELOG | git commit -s; add a ### Fixed entry |
| Parse-base assumption | a percentage limit resolves against node RAM, not -Xmx | confirm 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
- Fix the other failure mode. Reconstruct the mis-parse variant: make
parseknnMemoryCircuitBreakerValueresolve 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? - 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)?
- 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.
- Find a live one. Search
https://github.com/opensearch-project/k-NN/issues?q=is%3Aissue+is%3Aopen+label%3Abug+circuit+breakerand... memory cache evictionfor 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.
-
(warm-up) A regression test that pins the parse contract. Independent of the stale-limit fix, write a
KNNSettings-level unit test that pinsparseknnMemoryCircuitBreakerValue(find it withrg -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. -
(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 ongetMaxCacheSizeInKilobytes()— never load a graph. This pins the exact seam the fix repairs and guards against a future regression re-breaking propagation. -
(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
ByteSizeValueis 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.) -
(core) A native-leak detector test helper. Write a small test utility (a JUnit rule or a
@Before/@Afterhelper, or a method other tests call) that snapshotsNativeMemoryCacheManagerentry 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 skippinginvalidateAll(), watch the helper catch it, then restore). This operationalizes the lab's single most important rule: k-NN tests must leave the cache empty. -
(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 thateviction_countclimbs andgraph_memory_usagedrops 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 fromGET /_cluster/settings. -
(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
rginorg.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### Fixedentry, 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.
| Goal | Command |
|---|---|
| Beginner-friendly bugs | gh issue list --repo opensearch-project/k-NN --label "good first issue" --state open |
| Circuit-breaker / memory / cache bugs | gh issue list --repo opensearch-project/k-NN --label "bug" --search "circuit breaker OR memory OR cache OR eviction" |
| Settings / config bugs | gh issue list --repo opensearch-project/k-NN --label "bug" --search "setting OR limit OR config" |
| Breaker rearchitecture / roadmap | gh 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
- 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? - Why must
rebuildCache()callinvalidateAll()on the old cache? What leaks if it doesn't, and why won't the GC catch that leak? - Why does the unit test assert on the cache's configured bound instead of loading a real faiss graph and watching it get evicted?
- Why is RSS a misleading signal for whether the breaker worked? What is the reliable signal instead?
- Is the native-memory circuit-breaker limit a percentage of
-Xmxor of node RAM? How does that change the failure analysis for an OOM-killed node? - What two CI gates will reject your PR if you forget them, and what does each enforce?
- 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.