mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
* perf(query): batch per-symbol process/cohesion/content lookups (N+1 -> 2-3) Port of the local-backend query-batching from gitnexus-enterprise PR #222 into the OSS local MCP backend. The query tool traced each matched symbol to its processes + cohesion (+ content) with up to 3N sequential pool round-trips; batch them into 2-3 'WHERE n.id IN $nodeIds' queries keyed back to each symbol by a prepended 'n.id AS nodeId' column. Output is identical: the aggregation loop is unchanged, iterates merged in the same order, and reads pre-fetched maps instead of issuing a query per symbol. Adaptations over a blind cherry-pick (would otherwise change output): - per-nodeId first-row community pick replaces the per-symbol LIMIT 1, so each symbol keeps its own community (not one for the whole batch); - batched rows regrouped to the originating merged item by nodeId so the JS-side RRF item.score still drives process ranking; - positional fallbacks shift +1 (process row[1..6], cohesion [1]/[2], content [1]); CodeRelation{type:...} relation form kept; IN-list chunked at 100 like the impact path. Adds a regression test asserting per-node community/content association (func:login keeps comm:auth; func:validate inherits no community). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(docker): bake LadybugDB FTS extension into the CLI/serve image The container runs `serve` under the default `load-only` extension policy (the read pool pins {policy:'load-only'}), so a runtime LOAD EXTENSION fts never INSTALLs. Dockerfile.cli copied the extension installer but never ran it, so the runtime user's HOME had no FTS extension: keyword search silently degraded (no FTS indexes written, ranking falls back to vector-only with only a warning field). Same class of footgun fixed for the Hub image in gitnexus-enterprise PR #222. Run install-duckdb-extension.mjs as the `node` user with the runtime HOME so INSTALL fts materializes the extension under $HOME/.lbdb/extension where the runtime LOAD resolves it offline. Pin ENV HOME=/home/node because Docker does not derive HOME from USER — without it the build-install and runtime-load would resolve different paths. Verified locally: INSTALL lands in $HOME/.lbdb/extension/0.17.0 and a fresh offline load-only `LOAD EXTENSION fts` resolves it. Dockerfile.web is unaffected (static frontend, no @ladybugdb backend). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(lbug): FTS evict->reload RSS repro + inert pool RSS tracing Settles the gitnexus-enterprise PR #222 root-cause hypothesis for OSS: does re-running LOAD EXTENSION fts on every pool evict->reload strand the native FTS arena (unbounded RSS growth in long-lived MCP serve), or does db.close() reclaim it (bounded by MAX_POOL_SIZE)? Static read could not decide — the native lbugjs.node binary documents no close->extension-unload contract. Adds gitnexus/scripts/bench/fts-evict-reload-rss.mjs: a NATIVE mode that reproduces the exact native sequence doInitLbug()+closeOne() perform (open Database -> Connection -> LOAD EXTENSION fts -> QUERY_FTS_INDEX -> close) across K self-built FTS fixtures, and a --via-pool mode that drives the real compiled pool (initLbug/executeParameterized/closeLbug) against an existing analyzed repo. Plus a behavior-neutral GITNEXUS_POOL_RSS_TRACE=1 stderr trace on pool init/close (stdout reserved for MCP JSON-RPC; single env read when disabled). RESULT (native, 24 and 40 cycles x 6 fixtures, --expose-gc): PLATEAU. RSS warms up to ~400 MB then flattens (40-cycle: +36 MB over cycles 1-10, +3 MB over 30-40; decelerating), not the linear climb a per-reload arena leak would produce (240 reloads x stranded arena = multi-GB). db.close() reclaims the FTS arena. The unbounded-leak hypothesis is NOT reproduced for the OSS path: the pool's LRU eviction + close-on-evict BOUNDS the footprint, which is exactly the protection the enterprise Hub supervisor lacked (it opened bridge DBs in-process without eviction -> 15 GB). => plan U4 (worker/process isolation) is NOT justified by this evidence; U1 + U2 are the only OSS-shared changes. Caveat: small fixtures + awaited close; a --via-pool run against a large analyzed repo over a long session is the production-faithful follow-up (instrumentation is in place for it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): apply ce-code-review autofix feedback (#222 migration) Adversarial review found the U3 bench PLATEAU->no-leak conclusion was over-claimed from a 600-row fixture: a size-proportional FTS-arena leak would be sub-threshold at that scale. Strengthen the bench and make its verdict honest: - scale the fixture (--rows, UNWIND batch insert), probe ALL 5 FTS indexes in --via-pool (not 2 of 5), add a --no-await-close variant (the pool fire-and-forget close shape), and replace the absolute-delta gate with a SLOPE-DECELERATION 3-way verdict (PLATEAU / CLIMB / INCONCLUSIVE) plus step-discontinuity detection. At production-representative scale the synthetic runs are noisy/INCONCLUSIVE (deceleration argues against an UNBOUNDED leak but does not prove bounded), so plan U4 stays GATED on a --via-pool run against a real large analyzed repo -- not closed. - Dockerfile.cli: source the scratch-DB size from ENV GITNEXUS_LBUG_MAX_DB_SIZE (single source of truth) and add a build-time verify-only LOAD gate that fails the build on a HOME/extension-dir mismatch instead of silently degrading runtime keyword search. - install-duckdb-extension.mjs: additive verify-only mode (LOAD-only in a fresh process) + robust size parse; back-compatible with the runtime positional-size caller (validated). - tests: wire func:validate into a second process (proc:beta-flow) so the batched STEP_IN_PROCESS row[1..6] positional shift is exercised by a genuine multi-process symbol, and assert process ranking. No blast radius (75 seed-consuming tests pass). - pool-adapter.ts: trim the traceRss narrated-code comment (DoD 2.3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bench): classify a sustained sub-floor RSS slope as INCONCLUSIVE, not PLATEAU Tri-review P2: the FTS evict->reload verdict short-circuited to PLATEAU whenever secondHalfSlope < SUSTAIN_FLOOR, BEFORE the deceleration check — so a sustained (non-decelerating) linear leak below 0.5 MB/cycle was labeled PLATEAU ("no leak"), the label that would wrongly close plan U4. Extract median/slopeMbPerCycle/classifyVerdict into a pure, side-effect-free fts-rss-verdict.mjs (zero imports) so it is unit-testable without loading the native addon or running the bench, and fix the classifier: - epsilon-first gate: a truly flat tail (< 0.1 MB/cycle) is PLATEAU regardless of decelRatio (guards against over-correcting a real negative into INCONCLUSIVE); - a sustained sub-floor positive slope (>= epsilon, < floor, decelRatio >= 0.6) is INCONCLUSIVE — a slow creep RSS cannot distinguish from noise at this scale, so the honest label is "not resolved", never a clean PLATEAU; - the noise floor now scales with the WORKING-SET growth (peak-baseline), not the pre-DB baseline RSS (which is interpreter/addon overhead, larger in --via-pool mode, and would inflate the floor and HIDE leaks). Reconcile the stale "per-row-relative delta floor" docstring; add floor + decelRatio to the MACHINE line. New fts-rss-verdict.test.ts pins all label boundaries (flat->PLATEAU, sustained-sub-floor->INCONCLUSIVE, decelerated->PLATEAU, sustained-linear->CLIMB, step->INCONCLUSIVE, working-set floor, no import side effects). U1 does NOT add detection power for sub-floor leaks (RSS cannot attribute that magnitude) — it stops the false PLATEAU and routes that regime to the --via-pool run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(query): signal partial/warning on a real enrichment failure (not benign missing-table) Tri-review P2: when a batched enrichment query (process/cohesion/content) threw, it was caught + logged and the chunk's symbols silently fell back to `definitions` with no signal — the caller could not tell "genuinely standalone" from "enrichment failed". Track an `enrichmentDegraded` flag in the three enrichment catch blocks and, at response build, compose a single `warning` (FTS-missing and/or the enrichment message, so neither overwrites the other) plus `partial: true`. Both fields are omitted on the clean path, so the success-path response shape is byte-identical. Crucially, the flag fires ONLY for a REAL failure (timeout / lock / native fault), NOT the benign "no Process/Community table" prepare error — a repo analyzed without processes/communities is a normal config, and firing `partial` on every such query would desensitize callers (isBenignMissingTableError gates it). New unit test test/unit/query-degraded-signal.test.ts (vi.mock pool-adapter, override hybrid search to feed one matched symbol, route STEP_IN_PROCESS -> throw): real failure -> warning+partial+symbol still returned; benign missing-table -> no signal; FTS-missing + enrichment failure -> both messages in one warning. Plus a success-path no-warning/no-partial assertion in the calltool integration test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
374 lines
18 KiB
JavaScript
374 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
// FTS evict→reload RSS repro (gitnexus-enterprise PR #222 / local U3).
|
||
//
|
||
// Settles ONE empirical question that no static read can answer: when a
|
||
// LadybugDB database that has `LOAD EXTENSION fts` applied is closed and a
|
||
// fresh one is opened + re-LOADed (the pool's evict→reload cycle), does the
|
||
// native FTS arena get reclaimed by `db.close()` — or is it stranded, so RSS
|
||
// climbs without bound over a long-lived MCP `serve` session?
|
||
//
|
||
// • PLATEAU across cycles → db.close() reclaims the FTS arena; the OSS pool's
|
||
// footprint is bounded by MAX_POOL_SIZE (~5 live arenas). No unbounded leak;
|
||
// the #222 worker-isolation rewrite (plan U4) is NOT justified for OSS.
|
||
// • MONOTONIC CLIMB → the FTS arena is stranded per reopen; the user's
|
||
// hypothesis holds and U4 (route FTS reads through a reclaimable worker) is
|
||
// justified.
|
||
//
|
||
// SCOPE OF THE VERDICT (read before citing it). A per-reload FTS-arena leak
|
||
// would be PROPORTIONAL to the index size. A small fixture therefore produces a
|
||
// small per-cycle increment that an absolute threshold can read as PLATEAU even
|
||
// when a production-scale graph would leak visibly. So:
|
||
// - `--rows` controls fixture size; run it LARGE (tens of thousands) before
|
||
// concluding "no leak". The default is deliberately not tiny.
|
||
// - The verdict (in fts-rss-verdict.mjs) keys on slope DECELERATION, not total
|
||
// delta, with a noise floor that scales with the working-set growth
|
||
// (peak−baseline) so sensitivity tracks fixture/arena size — NOT the pre-DB
|
||
// baseline RSS. A sustained sub-floor positive slope is INCONCLUSIVE (a slow
|
||
// creep RSS can't distinguish from noise), never a clean PLATEAU.
|
||
// - The PLATEAU verdict is only valid for the corpus size it was run at; the
|
||
// output states that size. The production-faithful confirmation is a
|
||
// `--via-pool` run against a real large analyzed repo over a long session.
|
||
//
|
||
// Two modes:
|
||
// (default) NATIVE — reproduces the native sequence doInitLbug()+closeOne()
|
||
// perform (open Database → new Connection → LOAD EXTENSION fts →
|
||
// QUERY_FTS_INDEX → close), against K self-built FTS fixtures, with no
|
||
// gitnexus build required. `--no-await-close` mirrors the pool's
|
||
// fire-and-forget close instead of awaiting (the production close shape).
|
||
// --via-pool <lbugPath> — drives the REAL gitnexus pool from compiled dist
|
||
// (initLbug → executeParameterized → closeLbug) against an existing analyzed
|
||
// repo, exercising the production path + the GITNEXUS_POOL_RSS_TRACE
|
||
// instrumentation. Probes ALL FTS indexes the repo has. Forces an explicit
|
||
// close+reinit each cycle. Run `node scripts/build.js` first so the dist
|
||
// reflects the current pool-adapter (incl. the RSS trace).
|
||
//
|
||
// Run with --expose-gc so RSS excludes V8-heap noise:
|
||
// node --expose-gc gitnexus/scripts/bench/fts-evict-reload-rss.mjs
|
||
// node --expose-gc gitnexus/scripts/bench/fts-evict-reload-rss.mjs --rows 40000 --cycles 30
|
||
// GITNEXUS_POOL_RSS_TRACE=1 node --expose-gc \
|
||
// gitnexus/scripts/bench/fts-evict-reload-rss.mjs --via-pool /path/to/repo/.gitnexus/lbug
|
||
//
|
||
// Flags by mode: --rows/--repos/--read-write/--no-await-close apply to NATIVE
|
||
// only; --cycles applies to both. VIA-POOL warns when a NATIVE-only flag is set.
|
||
//
|
||
// Memory benches are noisy. Default is 24 cycles; trust the TREND (slope /
|
||
// first-third vs last-third), never a single delta. A flat trend at a LARGE
|
||
// fixture is a real NEGATIVE result (no unbounded leak), not a failed run.
|
||
|
||
import { createRequire } from 'node:module';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import fs from 'node:fs';
|
||
// Pure verdict classifier (median, slopeMbPerCycle, classifyVerdict) lives in a
|
||
// side-effect-free sibling module so it is unit-testable without loading the
|
||
// native addon or running this bench. See fts-rss-verdict.mjs.
|
||
import { classifyVerdict, median, slopeMbPerCycle } from './fts-rss-verdict.mjs';
|
||
|
||
const require = createRequire(import.meta.url);
|
||
const lbugModule = require('@ladybugdb/core');
|
||
const lbug = lbugModule.default ?? lbugModule;
|
||
|
||
const LBUG_MAX_DB_SIZE = 16 * 1024 * 1024 * 1024;
|
||
|
||
// ── args ──────────────────────────────────────────────────────────────────
|
||
function argVal(flag, dflt) {
|
||
const i = process.argv.indexOf(flag);
|
||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
|
||
}
|
||
const CYCLES = Math.max(6, parseInt(argVal('--cycles', '24'), 10) || 24);
|
||
const REPOS = Math.max(1, parseInt(argVal('--repos', '6'), 10) || 6); // >5 mirrors LRU thrash
|
||
// Fixture size. Default is large enough that a size-proportional leak would be
|
||
// visible across cycles; raise it further before trusting a PLATEAU verdict.
|
||
const ROWS = Math.max(100, parseInt(argVal('--rows', '8000'), 10) || 8000);
|
||
const VIA_POOL = argVal('--via-pool', null);
|
||
const READONLY = !process.argv.includes('--read-write');
|
||
const AWAIT_CLOSE = !process.argv.includes('--no-await-close');
|
||
|
||
if (VIA_POOL) {
|
||
// These flags are consumed only by NATIVE mode; warn rather than ignore
|
||
// silently so a VIA-POOL run is not misread as honoring them.
|
||
const ignored = ['--rows', '--repos', '--read-write', '--no-await-close'].filter((f) =>
|
||
process.argv.includes(f),
|
||
);
|
||
if (ignored.length) {
|
||
console.error(
|
||
`[fts-rss] NOTE: ${ignored.join(', ')} apply to NATIVE mode only; ignored in --via-pool.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (typeof global.gc !== 'function') {
|
||
console.error(
|
||
'[fts-rss] WARNING: run with --expose-gc for clean RSS samples ' +
|
||
'(`node --expose-gc <thisfile>`). Continuing without forced GC — results are noisier.',
|
||
);
|
||
}
|
||
|
||
const gc = () => {
|
||
if (typeof global.gc === 'function') {
|
||
global.gc();
|
||
global.gc();
|
||
}
|
||
};
|
||
const rssMb = () => Math.round(process.memoryUsage().rss / (1024 * 1024));
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
// ── fixture: a minimal FTS-bearing .lbug ────────────────────────────────────
|
||
const WORDS = [
|
||
'login auth session token user password validate verify credential',
|
||
'parse tree syntax node grammar lexer token ast traversal visitor',
|
||
'graph query cypher match relation node edge pattern aggregate index',
|
||
'memory pool buffer arena allocate reclaim evict cache resident heap',
|
||
'search rank score bm25 fts index stem porter keyword document corpus',
|
||
'worker fork process spawn kill reclaim isolate native binding addon',
|
||
];
|
||
|
||
function buildFixture(dir) {
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const dbPath = path.join(dir, 'fixture.lbug');
|
||
const db = new lbug.Database(dbPath, 0, false, false, LBUG_MAX_DB_SIZE);
|
||
const conn = new lbug.Connection(db);
|
||
return (async () => {
|
||
await conn.query('LOAD EXTENSION fts');
|
||
await conn.query(
|
||
'CREATE NODE TABLE Doc(id STRING, name STRING, content STRING, PRIMARY KEY(id))',
|
||
);
|
||
// Batch-insert via UNWIND so large fixtures (`--rows`) build in seconds
|
||
// instead of one round-trip per row. The fixture size drives the per-arena
|
||
// FTS allocation, which is what makes a size-proportional leak observable.
|
||
const rows = [];
|
||
for (let i = 0; i < ROWS; i++) {
|
||
const w = WORDS[i % WORDS.length];
|
||
const name = `sym_${i}`;
|
||
const content = `${w} ${name} block number ${i} ${WORDS[(i + 3) % WORDS.length]}`;
|
||
rows.push({ id: `doc:${i}`, name, content });
|
||
}
|
||
const INSERT_CHUNK = 2000;
|
||
for (let i = 0; i < rows.length; i += INSERT_CHUNK) {
|
||
const chunk = rows.slice(i, i + INSERT_CHUNK);
|
||
const stmt = await conn.prepare(
|
||
'UNWIND $rows AS r CREATE (:Doc {id: r.id, name: r.name, content: r.content})',
|
||
);
|
||
await conn.execute(stmt, { rows: chunk });
|
||
}
|
||
await conn.query(
|
||
"CALL CREATE_FTS_INDEX('Doc', 'doc_fts', ['name', 'content'], stemmer := 'porter')",
|
||
);
|
||
await conn.close();
|
||
await db.close();
|
||
return dbPath;
|
||
})();
|
||
}
|
||
|
||
const QUERIES = ['login token', 'parse node', 'memory arena', 'search index', 'worker reclaim'];
|
||
|
||
// ── NATIVE mode ─────────────────────────────────────────────────────────────
|
||
async function runNative() {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fts-rss-'));
|
||
console.error(
|
||
`[fts-rss] NATIVE: ${REPOS} fixtures × ${ROWS} rows × ${CYCLES} cycles ` +
|
||
`(readOnly=${READONLY}, awaitClose=${AWAIT_CLOSE})`,
|
||
);
|
||
console.error(`[fts-rss] building ${REPOS} FTS fixture(s) under ${root} …`);
|
||
|
||
const srcDb = await buildFixture(path.join(root, 'src'));
|
||
const repoPaths = [];
|
||
for (let k = 0; k < REPOS; k++) {
|
||
const dst = path.join(root, `repo-${k}`);
|
||
fs.cpSync(path.dirname(srcDb), dst, { recursive: true });
|
||
repoPaths.push(path.join(dst, 'fixture.lbug'));
|
||
}
|
||
|
||
// Mirror the pool's evict→reload: each visit opens a FRESH Database, makes a
|
||
// Connection, LOADs fts, runs an FTS query, then closes — no caching, so every
|
||
// visit is a reload. K>5 amplifies the LRU-thrash signal the pool would see.
|
||
const series = [];
|
||
gc();
|
||
await sleep(50);
|
||
const baseline = rssMb();
|
||
console.error(`[fts-rss] baseline RSS=${baseline}MB`);
|
||
|
||
for (let cycle = 0; cycle < CYCLES; cycle++) {
|
||
for (let k = 0; k < REPOS; k++) {
|
||
const db = new lbug.Database(repoPaths[k], 0, false, READONLY, LBUG_MAX_DB_SIZE);
|
||
const conn = new lbug.Connection(db);
|
||
try {
|
||
await conn.query('LOAD EXTENSION fts'); // the per-reload re-LOAD under test
|
||
const q = QUERIES[(cycle + k) % QUERIES.length];
|
||
const res = await conn.query(
|
||
`CALL QUERY_FTS_INDEX('Doc', 'doc_fts', '${q}') RETURN node.id AS id, score ORDER BY score DESC LIMIT 20`,
|
||
);
|
||
// Drain so the query actually materializes results.
|
||
if (res && typeof res.getAll === 'function') await res.getAll();
|
||
} catch (e) {
|
||
console.error(`[fts-rss] query error (cycle ${cycle}, repo ${k}): ${e?.message || e}`);
|
||
} finally {
|
||
// AWAIT_CLOSE (default) is the best case for reclamation. --no-await-close
|
||
// mirrors the pool's fire-and-forget close (closeOne: db.close().catch())
|
||
// so a leak that only manifests without awaiting is not hidden.
|
||
if (AWAIT_CLOSE) {
|
||
try {
|
||
await conn.close();
|
||
await db.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
} else {
|
||
conn.close().catch(() => {});
|
||
db.close().catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
gc();
|
||
// Longer settle when not awaiting close, so fire-and-forget native teardown
|
||
// has a chance to complete before the RSS sample (avoids a false PLATEAU).
|
||
await sleep(AWAIT_CLOSE ? 20 : 200);
|
||
const rss = rssMb();
|
||
series.push(rss);
|
||
console.error(`[fts-rss] cycle ${String(cycle + 1).padStart(3)}/${CYCLES} rssMB=${rss}`);
|
||
}
|
||
|
||
fs.rmSync(root, { recursive: true, force: true });
|
||
return { baseline, series, corpus: `${REPOS}×${ROWS} rows, native, awaitClose=${AWAIT_CLOSE}` };
|
||
}
|
||
|
||
// ── VIA-POOL mode (real gitnexus pool from compiled dist) ───────────────────
|
||
async function runViaPool(lbugPath) {
|
||
if (!fs.existsSync(lbugPath)) {
|
||
console.error(`[fts-rss] --via-pool path not found: ${lbugPath}`);
|
||
process.exit(2);
|
||
}
|
||
// Compiled dist is required (the pool pulls the native addon + many modules).
|
||
const distUrl = new URL('../../dist/core/lbug/pool-adapter.js', import.meta.url);
|
||
let pool;
|
||
try {
|
||
pool = await import(distUrl.href);
|
||
} catch (e) {
|
||
console.error(
|
||
`[fts-rss] could not import compiled pool-adapter (${e?.message}). ` +
|
||
`Run \`node scripts/build.js\` first, or use NATIVE mode.`,
|
||
);
|
||
process.exit(2);
|
||
}
|
||
const { initLbug, executeParameterized, closeLbug } = pool;
|
||
console.error(
|
||
`[fts-rss] VIA-POOL on ${lbugPath} × ${CYCLES} cycles ` +
|
||
`(explicit closeLbug+initLbug per cycle = forced evict→reload)`,
|
||
);
|
||
|
||
// Probe ALL FTS indexes the analyzed graph carries (mirrors fts-schema.ts
|
||
// FTS_INDEXES) so the per-cycle FTS arena load matches production, not a
|
||
// 2-of-5 subset that would understate it.
|
||
const FTS_INDEXES = [
|
||
{ table: 'File', indexName: 'file_fts' },
|
||
{ table: 'Function', indexName: 'function_fts' },
|
||
{ table: 'Class', indexName: 'class_fts' },
|
||
{ table: 'Method', indexName: 'method_fts' },
|
||
{ table: 'Interface', indexName: 'interface_fts' },
|
||
];
|
||
|
||
const series = [];
|
||
gc();
|
||
const baseline = rssMb();
|
||
console.error(`[fts-rss] baseline RSS=${baseline}MB`);
|
||
|
||
for (let cycle = 0; cycle < CYCLES; cycle++) {
|
||
try {
|
||
await initLbug(lbugPath, lbugPath);
|
||
const q = QUERIES[cycle % QUERIES.length];
|
||
for (const { table, indexName } of FTS_INDEXES) {
|
||
await executeParameterized(
|
||
lbugPath,
|
||
`CALL QUERY_FTS_INDEX('${table}', '${indexName}', $q) RETURN node.id AS id, score ORDER BY score DESC LIMIT 20`,
|
||
{ q },
|
||
).catch(() => []); // index may not exist for this graph — that's fine
|
||
}
|
||
await closeLbug(lbugPath); // force eviction → next cycle reopens + re-LOADs fts
|
||
} catch (e) {
|
||
console.error(`[fts-rss] pool cycle ${cycle} error: ${e?.message || e}`);
|
||
}
|
||
gc();
|
||
// closeLbug fires a fire-and-forget native close (pool closeOne:
|
||
// db.close().catch()), so settle longer than NATIVE's awaited close to let
|
||
// native teardown finish before sampling — else a real leak reads PLATEAU.
|
||
await sleep(200);
|
||
const rss = rssMb();
|
||
series.push(rss);
|
||
console.error(`[fts-rss] cycle ${String(cycle + 1).padStart(3)}/${CYCLES} rssMB=${rss}`);
|
||
}
|
||
await closeLbug().catch(() => {});
|
||
return { baseline, series, corpus: `via-pool ${path.basename(path.dirname(lbugPath))}` };
|
||
}
|
||
|
||
// ── verdict ─────────────────────────────────────────────────────────────────
|
||
function verdict({ baseline, series, corpus }) {
|
||
const third = Math.max(1, Math.floor(series.length / 3));
|
||
const firstMed = median(series.slice(0, third));
|
||
const lastMed = median(series.slice(-third));
|
||
const delta = lastMed - firstMed;
|
||
const slope = slopeMbPerCycle(series);
|
||
|
||
// All label logic lives in the pure, unit-tested classifier (fts-rss-verdict.mjs):
|
||
// epsilon-first flat→PLATEAU, decelerated→PLATEAU, sustained-sub-floor→INCONCLUSIVE,
|
||
// ≥floor sustained→CLIMB, step→INCONCLUSIVE; floor scales with the working-set
|
||
// growth (peak−baseline), not the pre-DB baseline RSS.
|
||
const {
|
||
verdict: label,
|
||
firstHalfSlope,
|
||
secondHalfSlope,
|
||
decelRatio,
|
||
floor,
|
||
stepDiscontinuity,
|
||
maxJump,
|
||
peak,
|
||
} = classifyVerdict(series, baseline);
|
||
|
||
console.log('\n==================== FTS evict→reload RSS verdict ====================');
|
||
console.log(`corpus: ${corpus}`);
|
||
console.log(`samples (MB): ${series.join(' ')}`);
|
||
console.log(
|
||
`baseline=${baseline} firstThirdMed=${firstMed} lastThirdMed=${lastMed} delta=${delta}MB ` +
|
||
`peak=${peak} overallSlope=${slope.toFixed(2)} firstHalfSlope=${firstHalfSlope.toFixed(2)} ` +
|
||
`secondHalfSlope=${secondHalfSlope.toFixed(2)}MB/cycle floor=${floor.toFixed(2)} decelRatio=${decelRatio.toFixed(2)} ` +
|
||
`maxJump=${maxJump}MB step=${stepDiscontinuity} cycles=${series.length}`,
|
||
);
|
||
if (label === 'CLIMB') {
|
||
console.log(
|
||
'VERDICT: CLIMB — the per-cycle increment is SUSTAINED (second-half slope ≈ first-half),\n' +
|
||
' i.e. RSS rises ~linearly with no decay. The native FTS arena is NOT reclaimed\n' +
|
||
' by db.close(); the leak is real over a long-lived session.\n' +
|
||
' → plan U4 (worker/process isolation of the FTS read path) is JUSTIFIED.',
|
||
);
|
||
} else if (label === 'PLATEAU') {
|
||
console.log(
|
||
`VERDICT: PLATEAU at this corpus (${corpus}) — the per-cycle increment DECAYS to flat\n` +
|
||
' (second-half slope below the noise floor). db.close() reclaims the FTS arena;\n' +
|
||
' footprint is bounded (and the pool further caps it at MAX_POOL_SIZE). No\n' +
|
||
' unbounded leak. Caveat: synthetic fixture — confirm with a --via-pool run\n' +
|
||
' against a real large analyzed repo before fully closing plan U4.',
|
||
);
|
||
} else {
|
||
console.log(
|
||
`VERDICT: INCONCLUSIVE at this corpus (${corpus}) — the run is noisy (step discontinuity)\n` +
|
||
' or still decelerating without reaching flat, so neither a clean PLATEAU nor a\n' +
|
||
' sustained linear CLIMB can be asserted. NATIVE synthetic runs do not resolve\n' +
|
||
' this reliably at scale. The definitive test is a --via-pool run against a real\n' +
|
||
' large analyzed repo over many cycles (with GITNEXUS_POOL_RSS_TRACE=1). Plan U4\n' +
|
||
' stays GATED — neither closed nor built on this evidence.',
|
||
);
|
||
}
|
||
console.log(
|
||
`MACHINE: ${JSON.stringify({ mode: VIA_POOL ? 'via-pool' : 'native', corpus, baseline, firstMed, lastMed, delta, overallSlope: Number(slope.toFixed(3)), firstHalfSlope: Number(firstHalfSlope.toFixed(3)), secondHalfSlope: Number(secondHalfSlope.toFixed(3)), floor: Number(floor.toFixed(3)), decelRatio: Number(decelRatio.toFixed(3)), maxJump, stepDiscontinuity, peak, cycles: series.length, verdict: label })}`,
|
||
);
|
||
console.log('=====================================================================\n');
|
||
}
|
||
|
||
// ── main ────────────────────────────────────────────────────────────────────
|
||
(async () => {
|
||
const result = VIA_POOL ? await runViaPool(VIA_POOL) : await runNative();
|
||
verdict(result);
|
||
process.exit(0);
|
||
})().catch((e) => {
|
||
console.error('[fts-rss] fatal:', e?.stack || e);
|
||
process.exit(1);
|
||
});
|