From 16a73b8475c359c47fbb498f77ca832ac17de612 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Fri, 17 Jul 2026 17:51:54 -0700 Subject: [PATCH] feat: OS-limit detection for pool-scale deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- RELEASES.md | 15 ++++ src/brainy.ts | 8 ++ src/index.ts | 6 ++ src/utils/osLimits.ts | 141 ++++++++++++++++++++++++++++++ tests/unit/utils/osLimits.test.ts | 76 ++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 src/utils/osLimits.ts create mode 100644 tests/unit/utils/osLimits.test.ts diff --git a/RELEASES.md b/RELEASES.md index a07d9326..7baa89ec 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -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) Two hardenings of the multi-process writer lock (the `locks/_writer.lock` lease that makes a diff --git a/src/brainy.ts b/src/brainy.ts index 7aa7e6ca..89e1267a 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -49,6 +49,7 @@ import { import { runGraphAudit, type GraphAuditReport } from './graph/graphAudit.js' import { createPipeline } from './streaming/pipeline.js' import { configureLogger, LogLevel, prodLog } from './utils/logger.js' +import { warnOnLowOsLimits } from './utils/osLimits.js' import { setGlobalCache } from './utils/unifiedCache.js' import type { UnifiedCache } from './utils/unifiedCache.js' import { rankIndicesByScore, reorderByIndices } from './utils/resultRanking.js' @@ -935,6 +936,13 @@ export class Brainy implements BrainyInterface { this.storage = await this.setupStorage() 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. // 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). diff --git a/src/index.ts b/src/index.ts index d1eab40a..5c57fe29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,12 @@ export type { GraphAuditReport, GraphAuditDiscrepancy } 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 type { diff --git a/src/utils/osLimits.ts b/src/utils/osLimits.ts new file mode 100644 index 00000000..57e1ccf1 --- /dev/null +++ b/src/utils/osLimits.ts @@ -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//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 = async (p) => fs.promises.readFile(p, 'utf-8') +): Promise { + 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 { + 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. + } +} diff --git a/tests/unit/utils/osLimits.test.ts b/tests/unit/utils/osLimits.test.ts new file mode 100644 index 00000000..a56d593e --- /dev/null +++ b/tests/unit/utils/osLimits.test.ts @@ -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: [] + }) + }) +})