feat: OS-limit detection for pool-scale deployments

- New src/utils/osLimits.ts: reads RLIMIT_NOFILE (soft/hard, from
  /proc/self/limits) and vm.max_map_count at open — once per process,
  Linux-only, measurement-only — and warns loudly when either sits below the
  pool-scale floors (soft NOFILE < 65536, max_map_count < 262144), with the
  exact raise commands. On stock defaults the failure otherwise arrives as
  EMFILE or a failed mmap deep inside an index open, long after the cause
  stopped being visible. An unreadable limit produces NO warning — no
  measurement, no claim — so non-Linux platforms stay silent.
- Exported for ops doors: checkOsLimits() returns the full OsLimitsReport;
  floors exported as constants. Wired fire-and-forget in performInit after
  storage init; the check can never affect open.
- Unit tests pin the parser (incl. 'unlimited'), the floor thresholds, the
  null-never-warns rule, and the off-Linux silent path.
This commit is contained in:
David Snelling 2026-07-17 17:51:54 -07:00
parent dcd5036fe9
commit 16a73b8475
5 changed files with 246 additions and 0 deletions

View file

@ -10,6 +10,21 @@ Full auto-generated changelog: `CHANGELOG.md` · Releases: https://github.com/so
--- ---
## v8.8.0 — 2026-07-17 (OS-limit detection for pool-scale deployments)
Small minor: brains now detect the two OS limits that bite at pool scale and warn **before**
the incident instead of during it.
- At open (once per process, Linux-only, measurement-only), Brainy reads `RLIMIT_NOFILE`
(soft/hard, from `/proc/self/limits`) and `vm.max_map_count`, and warns loudly when either
sits below the pool-scale floors (soft NOFILE < 65 536; max_map_count < 262 144) with the
exact raise commands (`ulimit -n` / `LimitNOFILE=` / `sysctl vm.max_map_count`). On stock
defaults the failure otherwise arrives as `EMFILE` or a failed mmap deep inside an index
open, long after the real cause stopped being visible. An unreadable limit produces **no**
warning — no measurement, no claim (non-Linux platforms stay silent).
- Exported for ops doors: `checkOsLimits()` returns the full `OsLimitsReport`
(values + warnings) programmatically, with the floors exported as constants.
## v8.7.1 — 2026-07-17 (writer-lock acquisition is race-proof + machine-readable through init) ## v8.7.1 — 2026-07-17 (writer-lock acquisition is race-proof + machine-readable through init)
Two hardenings of the multi-process writer lock (the `locks/_writer.lock` lease that makes a Two hardenings of the multi-process writer lock (the `locks/_writer.lock` lease that makes a

View file

@ -49,6 +49,7 @@ import {
import { runGraphAudit, type GraphAuditReport } from './graph/graphAudit.js' import { runGraphAudit, type GraphAuditReport } from './graph/graphAudit.js'
import { createPipeline } from './streaming/pipeline.js' import { createPipeline } from './streaming/pipeline.js'
import { configureLogger, LogLevel, prodLog } from './utils/logger.js' import { configureLogger, LogLevel, prodLog } from './utils/logger.js'
import { warnOnLowOsLimits } from './utils/osLimits.js'
import { setGlobalCache } from './utils/unifiedCache.js' import { setGlobalCache } from './utils/unifiedCache.js'
import type { UnifiedCache } from './utils/unifiedCache.js' import type { UnifiedCache } from './utils/unifiedCache.js'
import { rankIndicesByScore, reorderByIndices } from './utils/resultRanking.js' import { rankIndicesByScore, reorderByIndices } from './utils/resultRanking.js'
@ -935,6 +936,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
this.storage = await this.setupStorage() this.storage = await this.setupStorage()
await this.storage.init() await this.storage.init()
// OS-limit detection (once per process, Linux-only, measurement-only):
// warn NOW about RLIMIT_NOFILE / vm.max_map_count values that will bite
// at pool scale, instead of letting the operator meet them as EMFILE or
// a failed mmap deep inside an index open. Fire-and-forget — the check
// never affects open.
void warnOnLowOsLimits()
// Acquire the writer lock for filesystem (and other locking-capable) backends. // Acquire the writer lock for filesystem (and other locking-capable) backends.
// Skipped in reader mode and on backends that don't support multi-process locking. // Skipped in reader mode and on backends that don't support multi-process locking.
// Throws if another live writer holds the directory (unless force: true). // Throws if another live writer holds the directory (unless force: true).

View file

@ -32,6 +32,12 @@ export type {
GraphAuditReport, GraphAuditReport,
GraphAuditDiscrepancy GraphAuditDiscrepancy
} from './graph/graphAudit.js' } from './graph/graphAudit.js'
export {
checkOsLimits,
NOFILE_POOL_FLOOR,
MAX_MAP_COUNT_POOL_FLOOR
} from './utils/osLimits.js'
export type { OsLimitsReport } from './utils/osLimits.js'
// Export Brainy configuration and types // Export Brainy configuration and types
export type { export type {

141
src/utils/osLimits.ts Normal file
View file

@ -0,0 +1,141 @@
/**
* @module utils/osLimits
* @description Detect-and-warn for OS resource limits that bite at POOL scale.
*
* A single brain rarely notices them, but a pool of brains especially with a
* native accelerator memory-mapping many index files per brain consumes file
* descriptors and memory mappings multiplicatively. On stock Linux defaults
* (RLIMIT_NOFILE soft 1024, vm.max_map_count 65530) the failure arrives as
* EMFILE or a failed mmap deep inside an index open, long after the real cause
* (the limit) stopped being visible. This module reads the limits at open and
* WARNS ONCE per process with the exact raise commands, so the operator learns
* the fix before the incident instead of from it.
*
* Read-only and Linux-only by construction: both sources are `/proc` files.
* On platforms where they are absent the check reports nulls and stays silent
* no limit read means no claim made, never a guessed warning.
*/
import * as fs from 'node:fs'
import { prodLog } from './logger.js'
/** Soft-NOFILE floor below which pool-scale use is at EMFILE risk. */
export const NOFILE_POOL_FLOOR = 65536
/** vm.max_map_count floor below which mmap-heavy native indexes are at risk. */
export const MAX_MAP_COUNT_POOL_FLOOR = 262144
export interface OsLimitsReport {
/** RLIMIT_NOFILE soft limit (null when unreadable; Infinity for 'unlimited'). */
nofileSoft: number | null
/** RLIMIT_NOFILE hard limit (null when unreadable; Infinity for 'unlimited'). */
nofileHard: number | null
/** vm.max_map_count (null when unreadable). */
maxMapCount: number | null
/** Human-actionable warnings for limits below the pool floors. Empty = fine. */
warnings: string[]
}
/**
* Parse the `Max open files` row of a `/proc/<pid>/limits` document into
* soft/hard values. Returns nulls when the row is absent or malformed.
*/
export function parseProcLimits(content: string): { soft: number | null; hard: number | null } {
const line = content.split('\n').find((l) => l.startsWith('Max open files'))
if (!line) return { soft: null, hard: null }
const m = line.match(/^Max open files\s+(\S+)\s+(\S+)/)
if (!m) return { soft: null, hard: null }
const parse = (v: string): number | null => {
if (v === 'unlimited') return Infinity
const n = Number.parseInt(v, 10)
return Number.isNaN(n) ? null : n
}
return { soft: parse(m[1]), hard: parse(m[2]) }
}
/**
* Assess readable limits against the pool floors. Pure feed it any values.
* A null (unreadable) limit produces NO warning: no measurement, no claim.
*/
export function assessOsLimits(limits: {
nofileSoft: number | null
nofileHard: number | null
maxMapCount: number | null
}): string[] {
const warnings: string[] = []
if (limits.nofileSoft !== null && limits.nofileSoft < NOFILE_POOL_FLOOR) {
const hardNote =
limits.nofileHard !== null && limits.nofileHard >= NOFILE_POOL_FLOOR
? ` (the hard limit ${limits.nofileHard === Infinity ? 'unlimited' : limits.nofileHard} already allows it — raise the soft limit only)`
: ''
warnings.push(
`RLIMIT_NOFILE soft limit is ${limits.nofileSoft} — below the ${NOFILE_POOL_FLOOR} recommended ` +
`for pool-scale use (a pool of brains with a native accelerator opens many index files per brain; ` +
`the failure mode is EMFILE deep inside an index open). Raise with \`ulimit -n ${NOFILE_POOL_FLOOR}\` ` +
`or LimitNOFILE=${NOFILE_POOL_FLOOR} in the service unit${hardNote}.`
)
}
if (limits.maxMapCount !== null && limits.maxMapCount < MAX_MAP_COUNT_POOL_FLOOR) {
warnings.push(
`vm.max_map_count is ${limits.maxMapCount} — below the ${MAX_MAP_COUNT_POOL_FLOOR} recommended ` +
`for mmap-heavy native indexes at pool scale (each mapped index segment consumes map entries; ` +
`the failure mode is a failed mmap mid-heal). Raise with ` +
`\`sysctl -w vm.max_map_count=${MAX_MAP_COUNT_POOL_FLOOR}\` (persist in /etc/sysctl.d/).`
)
}
return warnings
}
/**
* Read the limits from /proc and assess them. `readFile` is injectable for
* tests; absent/unreadable sources yield nulls (and therefore no warnings).
*/
export async function checkOsLimits(
readFile: (path: string) => Promise<string> = async (p) => fs.promises.readFile(p, 'utf-8')
): Promise<OsLimitsReport> {
let nofileSoft: number | null = null
let nofileHard: number | null = null
let maxMapCount: number | null = null
try {
const parsed = parseProcLimits(await readFile('/proc/self/limits'))
nofileSoft = parsed.soft
nofileHard = parsed.hard
} catch {
// Not Linux (or /proc unavailable) — no measurement, no claim.
}
try {
const raw = (await readFile('/proc/sys/vm/max_map_count')).trim()
const n = Number.parseInt(raw, 10)
maxMapCount = Number.isNaN(n) ? null : n
} catch {
// Not Linux — same rule.
}
const warnings = assessOsLimits({ nofileSoft, nofileHard, maxMapCount })
return { nofileSoft, nofileHard, maxMapCount, warnings }
}
/** Once-per-process latch so a brain pool warns once, not once per brain. */
let osLimitsWarned = false
/**
* Run the check and warn (once per process) about limits below the pool
* floors. Called from brain open; safe everywhere (silent off-Linux).
*/
export async function warnOnLowOsLimits(): Promise<void> {
if (osLimitsWarned) return
osLimitsWarned = true
try {
const report = await checkOsLimits()
for (const warning of report.warnings) {
prodLog.warn(`[Brainy] OS limit check: ${warning}`)
}
} catch {
// The check must never affect open — measurement-only.
}
}

View file

@ -0,0 +1,76 @@
/**
* @module tests/unit/utils/osLimits
* @description OS-limit detection for pool-scale use. Laws:
* (1) the /proc/self/limits parser reads soft/hard NOFILE exactly, including
* 'unlimited'; (2) assessment warns ONLY below the pool floors and NEVER
* on an unreadable (null) limit no measurement, no claim; (3) the full
* check composes both sources and survives unreadable /proc silently.
*/
import { describe, it, expect } from 'vitest'
import {
parseProcLimits,
assessOsLimits,
checkOsLimits,
NOFILE_POOL_FLOOR,
MAX_MAP_COUNT_POOL_FLOOR
} from '../../../src/utils/osLimits.js'
const SAMPLE_LIMITS = [
'Limit Soft Limit Hard Limit Units',
'Max cpu time unlimited unlimited seconds',
'Max open files 1024 1048576 files',
'Max locked memory 8388608 8388608 bytes'
].join('\n')
describe('osLimits — detect + warn at pool scale', () => {
it('parses soft/hard NOFILE from /proc/self/limits, including unlimited', () => {
expect(parseProcLimits(SAMPLE_LIMITS)).toEqual({ soft: 1024, hard: 1048576 })
expect(
parseProcLimits('Max open files unlimited unlimited files')
).toEqual({ soft: Infinity, hard: Infinity })
expect(parseProcLimits('no such row here')).toEqual({ soft: null, hard: null })
})
it('warns below the floors, stays quiet at or above them', () => {
const low = assessOsLimits({ nofileSoft: 1024, nofileHard: 1048576, maxMapCount: 65530 })
expect(low).toHaveLength(2)
expect(low[0]).toContain('RLIMIT_NOFILE soft limit is 1024')
expect(low[0]).toContain(`ulimit -n ${NOFILE_POOL_FLOOR}`)
expect(low[0]).toContain('raise the soft limit only') // hard already allows it
expect(low[1]).toContain('vm.max_map_count is 65530')
expect(low[1]).toContain(`vm.max_map_count=${MAX_MAP_COUNT_POOL_FLOOR}`)
expect(
assessOsLimits({
nofileSoft: NOFILE_POOL_FLOOR,
nofileHard: Infinity,
maxMapCount: MAX_MAP_COUNT_POOL_FLOOR
})
).toEqual([])
})
it('an unreadable limit makes NO claim — nulls never warn', () => {
expect(assessOsLimits({ nofileSoft: null, nofileHard: null, maxMapCount: null })).toEqual([])
})
it('checkOsLimits composes both sources and survives unreadable /proc silently', async () => {
const report = await checkOsLimits(async (p) => {
if (p === '/proc/self/limits') return SAMPLE_LIMITS
if (p === '/proc/sys/vm/max_map_count') return '65530\n'
throw new Error('unexpected path')
})
expect(report.nofileSoft).toBe(1024)
expect(report.maxMapCount).toBe(65530)
expect(report.warnings).toHaveLength(2)
const offLinux = await checkOsLimits(async () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
expect(offLinux).toEqual({
nofileSoft: null,
nofileHard: null,
maxMapCount: null,
warnings: []
})
})
})