fix(8.0): drive query-cap off MemAvailable + floor auto-detected caps
The auto-detected query-limit cap was computed from os.freemem() (MemFree), which excludes reclaimable page cache. On hosts that memory-map large index files the cache holding those pages dominates RAM, so MemFree collapses to a sliver and the cap cratered to its floor on perfectly healthy machines, rejecting legitimate find() calls. - getAvailableMemory() now prefers /proc/meminfo MemAvailable (counts reclaimable cache), falling back to os.freemem() off-Linux, then a 2 GB constant where no OS module is available. - Auto-detected caps (container + free-memory tiers) are floored at MIN_AUTO_QUERY_LIMIT (10_000); a transient low reading can never throttle queries to a near-useless ceiling. Consumer-supplied maxQueryLimit / reservedQueryMemory bypass the floor — an explicit caller knows their box. Also scrubs two stale comments referencing the removed cloud storage adapters and the retired mmap-vector backend: 8.0's native vector provider persists its own .dkann file via getBinaryBlobPath, so the old rootDirectory hook does not apply on this line. Tests: memoryLimits 26/26 (4 new floor regressions), unit 1398/1398, find-limits + db-mvcc + api-parameter-validation 37/37.
This commit is contained in:
parent
c605b34f98
commit
b26d3d42b3
3 changed files with 117 additions and 17 deletions
|
|
@ -770,10 +770,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
|
|
||||||
// Wire the connections codec (2.4.0 #3). When the graph:compression
|
// Wire the connections codec (2.4.0 #3). When the graph:compression
|
||||||
// provider is registered AND the metadata index exposes a stable
|
// provider is registered AND the metadata index exposes a stable
|
||||||
// idMapper, inject a codec that encodes HNSW connections as
|
// idMapper, inject a codec that encodes JS HNSW connections as
|
||||||
// delta-varint blobs at save time and decodes on load. The blob
|
// delta-varint blobs at save time and decodes on load. It rides the
|
||||||
// primitive itself works on every brainy 7.25.0 adapter, so unlike the
|
// storage adapter's generic binary-blob primitive (both the filesystem
|
||||||
// mmap-vector backend this layer engages even on cloud adapters.
|
// and memory adapters implement it), so it is independent of any native
|
||||||
|
// vector provider's single-file on-disk format.
|
||||||
this.wireConnectionsCodec()
|
this.wireConnectionsCodec()
|
||||||
|
|
||||||
// 8.0 generational MVCC: if crash recovery rolled back an uncommitted
|
// 8.0 generational MVCC: if crash recovery rolled back an uncommitted
|
||||||
|
|
@ -10533,9 +10534,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
* metadata index exposes a stable idMapper. Failures are non-fatal: HNSW
|
* metadata index exposes a stable idMapper. Failures are non-fatal: HNSW
|
||||||
* keeps working via the legacy JSON-array path.
|
* keeps working via the legacy JSON-array path.
|
||||||
*
|
*
|
||||||
* Unlike the mmap-vector backend, this layer does NOT require a real local
|
* This layer does NOT require a real local path — the binary-blob primitive
|
||||||
* path — the binary-blob primitive saves the compressed bytes through the
|
* saves the compressed bytes through the storage adapter directly, so the
|
||||||
* storage adapter directly, so the codec is engaged on cloud adapters too.
|
* codec works on the memory adapter as well as filesystem. (A native vector
|
||||||
|
* provider, by contrast, persists its own single `.dkann` file and has no
|
||||||
|
* per-node connection lists, so it is skipped — see the feature-detect below.)
|
||||||
* Format convergence is lazy: pre-2.4.0 nodes load via the legacy path,
|
* Format convergence is lazy: pre-2.4.0 nodes load via the legacy path,
|
||||||
* then the next dirty save writes the compressed form (and the legacy
|
* then the next dirty save writes the compressed form (and the legacy
|
||||||
* field of saveVectorIndexData becomes empty), so all reads converge over time
|
* field of saveVectorIndexData becomes empty), so all reads converge over time
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,37 @@ const getSystemMemory = (): number => {
|
||||||
return 4 * 1024 * 1024 * 1024
|
return 4 * 1024 * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort "memory we can actually use right now", in bytes.
|
||||||
|
*
|
||||||
|
* Prefers `/proc/meminfo` **MemAvailable**, which the kernel computes as the
|
||||||
|
* memory obtainable for a new workload *including reclaimable page cache*. This
|
||||||
|
* matters on hosts that memory-map large index files: the cache holding those
|
||||||
|
* mmap'd pages is instantly reclaimable, yet `os.freemem()` reports only
|
||||||
|
* **MemFree** (the unused sliver), which collapses to near-zero on a warm box.
|
||||||
|
* Driving the auto query-cap off MemFree made the cap crater to its floor on
|
||||||
|
* perfectly healthy machines (BRAINY-QUERYCAP-MISREAD); MemAvailable fixes that.
|
||||||
|
*
|
||||||
|
* Falls back to `os.freemem()` where `/proc/meminfo` is absent (non-Linux), then
|
||||||
|
* to a conservative 2 GB when no OS module is available at all (browser/edge).
|
||||||
|
*
|
||||||
|
* @returns Usable memory in bytes.
|
||||||
|
*/
|
||||||
const getAvailableMemory = (): number => {
|
const getAvailableMemory = (): number => {
|
||||||
|
if (fs) {
|
||||||
|
try {
|
||||||
|
const meminfo = fs.readFileSync('/proc/meminfo', 'utf8') as string
|
||||||
|
const match = meminfo.match(/^MemAvailable:\s+(\d+)\s*kB/m)
|
||||||
|
if (match) {
|
||||||
|
const bytes = parseInt(match[1], 10) * 1024
|
||||||
|
if (bytes > 0) {
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// /proc/meminfo unreadable (non-Linux, sandboxed) — fall through.
|
||||||
|
}
|
||||||
|
}
|
||||||
if (os) {
|
if (os) {
|
||||||
return os.freemem()
|
return os.freemem()
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +164,18 @@ const getContainerMemoryLimit = (): number | null => {
|
||||||
*/
|
*/
|
||||||
const MAX_LIMIT_KB_PER_RESULT = 25
|
const MAX_LIMIT_KB_PER_RESULT = 25
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor for *auto-detected* query caps (the container- and free-memory tiers).
|
||||||
|
*
|
||||||
|
* Memory probing is a heuristic; a transiently low reading must never silently
|
||||||
|
* throttle legitimate queries to a near-useless ceiling. 10 000 is a generous
|
||||||
|
* working limit that still sits far below any OOM danger (10 000 × 25 KB ≈
|
||||||
|
* 250 MB worst case). It is a floor, not an override: consumer-supplied
|
||||||
|
* `maxQueryLimit` / `reservedQueryMemory` bypass it entirely (Priorities 1–2),
|
||||||
|
* because an explicit caller knows their box better than the probe does.
|
||||||
|
*/
|
||||||
|
const MIN_AUTO_QUERY_LIMIT = 10000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time-per-call-site warning dedup. Keyed on the caller location returned
|
* One-time-per-call-site warning dedup. Keyed on the caller location returned
|
||||||
* by `findCallerLocation()` plus the exceeding limit value so the warning fires
|
* by `findCallerLocation()` plus the exceeding limit value so the warning fires
|
||||||
|
|
@ -226,9 +268,12 @@ export class ValidationConfig {
|
||||||
// Reserve 25% for query operations
|
// Reserve 25% for query operations
|
||||||
const queryMemory = this.detectedContainerLimit * 0.25
|
const queryMemory = this.detectedContainerLimit * 0.25
|
||||||
|
|
||||||
this.maxLimit = Math.min(
|
this.maxLimit = Math.max(
|
||||||
100000,
|
MIN_AUTO_QUERY_LIMIT,
|
||||||
Math.floor(queryMemory / (1024 * 1024 * MAX_LIMIT_KB_PER_RESULT)) * 1000
|
Math.min(
|
||||||
|
100000,
|
||||||
|
Math.floor(queryMemory / (1024 * 1024 * MAX_LIMIT_KB_PER_RESULT)) * 1000
|
||||||
|
)
|
||||||
)
|
)
|
||||||
this.limitBasis = 'containerMemory'
|
this.limitBasis = 'containerMemory'
|
||||||
|
|
||||||
|
|
@ -242,9 +287,12 @@ export class ValidationConfig {
|
||||||
// Priority 4: Free memory (fallback, current behavior)
|
// Priority 4: Free memory (fallback, current behavior)
|
||||||
const availableMemory = getAvailableMemory()
|
const availableMemory = getAvailableMemory()
|
||||||
|
|
||||||
this.maxLimit = Math.min(
|
this.maxLimit = Math.max(
|
||||||
100000,
|
MIN_AUTO_QUERY_LIMIT,
|
||||||
Math.floor(availableMemory / (1024 * 1024 * MAX_LIMIT_KB_PER_RESULT)) * 1000
|
Math.min(
|
||||||
|
100000,
|
||||||
|
Math.floor(availableMemory / (1024 * 1024 * MAX_LIMIT_KB_PER_RESULT)) * 1000
|
||||||
|
)
|
||||||
)
|
)
|
||||||
this.limitBasis = 'freeMemory'
|
this.limitBasis = 'freeMemory'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,17 +115,66 @@ describe('Memory Limits - Container Detection & Smart Calculation', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle small containers gracefully', () => {
|
it('should handle small containers gracefully', () => {
|
||||||
process.env.CLOUD_RUN_MEMORY = '512Mi' // Use 512MB instead of 128MB for realistic test
|
process.env.CLOUD_RUN_MEMORY = '512Mi'
|
||||||
|
|
||||||
const config = ValidationConfig.getInstance()
|
const config = ValidationConfig.getInstance()
|
||||||
|
|
||||||
// 512MB * 0.25 = 128MB for queries
|
// 512 MB × 0.25 = 128 MB for queries; at 25 KB / result that raw figure
|
||||||
// 128MB / 100MB = 1.28 floor to 1 * 1000 = 1000 limit
|
// is ~5_242 → floor to 5 × 1000 = 5_000, which the MIN_AUTO_QUERY_LIMIT
|
||||||
expect(config.maxLimit).toBeGreaterThan(0)
|
// floor lifts to 10_000. A small container must never throttle queries to
|
||||||
|
// a near-useless ceiling (BRAINY-QUERYCAP-MISREAD).
|
||||||
|
expect(config.maxLimit).toBe(10000)
|
||||||
expect(config.limitBasis).toBe('containerMemory')
|
expect(config.limitBasis).toBe('containerMemory')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Auto-cap floor (BRAINY-QUERYCAP-MISREAD regression)', () => {
|
||||||
|
// A transiently low memory reading once collapsed the auto-detected query
|
||||||
|
// cap to a near-useless ceiling on healthy hosts (the cap was driven off
|
||||||
|
// os.freemem()/MemFree, which excludes reclaimable page cache). The fix:
|
||||||
|
// drive detection off /proc/meminfo MemAvailable AND floor every
|
||||||
|
// *auto-detected* tier at MIN_AUTO_QUERY_LIMIT (10_000). Consumer-supplied
|
||||||
|
// limits bypass the floor — an explicit caller knows their box best.
|
||||||
|
|
||||||
|
it('floors a tiny container to 10_000, never below', () => {
|
||||||
|
// 50 MB × 0.25 = 12.5 MB for queries → raw cap rounds to 0; the floor
|
||||||
|
// must lift it to 10_000 rather than let it throttle to nothing.
|
||||||
|
process.env.MEMORY_LIMIT = String(50 * 1024 * 1024)
|
||||||
|
|
||||||
|
const config = ValidationConfig.getInstance()
|
||||||
|
|
||||||
|
expect(config.limitBasis).toBe('containerMemory')
|
||||||
|
expect(config.maxLimit).toBe(10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('floors the free-memory tier at 10_000', () => {
|
||||||
|
// No container env → free-memory tier (now MemAvailable-based). Whatever
|
||||||
|
// the host reports, the auto cap is guaranteed never to fall below 10_000.
|
||||||
|
const config = ValidationConfig.getInstance()
|
||||||
|
|
||||||
|
expect(config.limitBasis).toBe('freeMemory')
|
||||||
|
expect(config.maxLimit).toBeGreaterThanOrEqual(10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT floor an explicit maxQueryLimit below 10_000', () => {
|
||||||
|
// A consumer asking for 500 means 500 — the floor is for auto-detection.
|
||||||
|
const config = ValidationConfig.getInstance({ maxQueryLimit: 500 })
|
||||||
|
|
||||||
|
expect(config.limitBasis).toBe('override')
|
||||||
|
expect(config.maxLimit).toBe(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT floor an explicit reservedQueryMemory below 10_000', () => {
|
||||||
|
// 100 MB / 25 KB = ~4_000 → kept as-is (no floor on the explicit tier).
|
||||||
|
const config = ValidationConfig.getInstance({
|
||||||
|
reservedQueryMemory: 100 * 1024 * 1024
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(config.limitBasis).toBe('reservedMemory')
|
||||||
|
expect(config.maxLimit).toBe(4000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Configuration Overrides', () => {
|
describe('Configuration Overrides', () => {
|
||||||
it('should respect maxQueryLimit override', () => {
|
it('should respect maxQueryLimit override', () => {
|
||||||
const config = ValidationConfig.getInstance({ maxQueryLimit: 50000 })
|
const config = ValidationConfig.getInstance({ maxQueryLimit: 50000 })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue