sweet-search 2.6.8 → 2.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/core/cli.js +7 -0
- package/core/graph/graph-extractor.js +24 -14
- package/core/graph/hcgs-generator.js +4 -3
- package/core/incremental-indexing/application/production-li-delta.mjs +28 -3
- package/core/incremental-indexing/application/production-reconciler.mjs +40 -11
- package/core/incremental-indexing/application/reconciler.mjs +1 -1
- package/core/incremental-indexing/domain/encoder-input.mjs +9 -4
- package/core/indexing/artifact-builder.js +5 -3
- package/core/indexing/ast-chunker.js +65 -62
- package/core/indexing/index-maintainer.mjs +49 -6
- package/core/indexing/indexer-build.js +18 -4
- package/core/indexing/indexer-phases.js +45 -24
- package/core/indexing/indexer-utils.js +96 -6
- package/core/indexing/indexing-file-policy.js +30 -7
- package/core/indexing/model-pool.js +26 -0
- package/core/indexing/rss-budget.mjs +65 -0
- package/core/infrastructure/code-graph-repository.js +18 -18
- package/core/infrastructure/db-utils.js +29 -0
- package/core/infrastructure/simd-distance.js +35 -21
- package/core/infrastructure/tombstone-bitmap-reader.js +3 -1
- package/core/ranking/file-kind-ranking.js +14 -2
- package/core/ranking/late-interaction-index.js +86 -15
- package/core/ranking/late-interaction-model.js +25 -4
- package/core/ranking/mmr.js +15 -10
- package/core/search/search-anchor.js +21 -3
- package/core/search/search-boost.js +25 -13
- package/core/search/search-fusion.js +13 -5
- package/core/search/search-read-semantic.js +13 -12
- package/core/search/search-server.js +2 -1
- package/core/vector-store/binary-hnsw-index.js +338 -33
- package/core/vector-store/float-vector-store.js +9 -5
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +11 -0
- package/package.json +8 -8
|
@@ -9,7 +9,7 @@ import { existsSync } from 'fs';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
|
|
11
11
|
import { DB_PATHS, EMBEDDING_CONFIG, PROJECT_ROOT } from '../infrastructure/config/index.js';
|
|
12
|
-
import { GraphExtractor, createGraphSchema, insertGraph } from '../graph/graph-extractor.js';
|
|
12
|
+
import { GraphExtractor, createGraphSchema, insertGraph, rebuildGraphFts } from '../graph/graph-extractor.js';
|
|
13
13
|
import { resolveRelationshipTargets } from '../graph/relationship-resolver.js';
|
|
14
14
|
import { populatePageRankColumn } from '../graph/structural-pagerank.js';
|
|
15
15
|
import { getEmbeddings, getModelInfo } from '../embedding/embedding-service.js';
|
|
@@ -153,6 +153,7 @@ export async function buildCodeGraph(files, dryRun = false) {
|
|
|
153
153
|
let errors = 0;
|
|
154
154
|
let totalEntities = 0;
|
|
155
155
|
let totalRelationships = 0;
|
|
156
|
+
let graphFlushed = false;
|
|
156
157
|
|
|
157
158
|
for (let i = 0; i < files.length; i++) {
|
|
158
159
|
try {
|
|
@@ -170,10 +171,14 @@ export async function buildCodeGraph(files, dryRun = false) {
|
|
|
170
171
|
errors++;
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
// Flush batch every GRAPH_BATCH_SIZE files or at the end
|
|
174
|
+
// Flush batch every GRAPH_BATCH_SIZE files or at the end. FTS5 sync is
|
|
175
|
+
// deferred to ONE rebuild after the loop — 'rebuild' reconstructs the
|
|
176
|
+
// whole index from the entities table, so per-batch rebuilds were
|
|
177
|
+
// O(entities × batches) work discarded by the next batch.
|
|
174
178
|
if ((i + 1) % GRAPH_BATCH_SIZE === 0 || i === files.length - 1) {
|
|
175
179
|
if (entityBatch.length > 0 || relBatch.length > 0) {
|
|
176
|
-
insertGraph(db, entityBatch, relBatch, hasFts5);
|
|
180
|
+
insertGraph(db, entityBatch, relBatch, hasFts5, { syncFts: false });
|
|
181
|
+
graphFlushed = true;
|
|
177
182
|
totalEntities += entityBatch.length;
|
|
178
183
|
totalRelationships += relBatch.length;
|
|
179
184
|
entityBatch = [];
|
|
@@ -186,6 +191,13 @@ export async function buildCodeGraph(files, dryRun = false) {
|
|
|
186
191
|
}
|
|
187
192
|
}
|
|
188
193
|
|
|
194
|
+
// Single FTS5 rebuild at the exact point the last per-batch rebuild used to
|
|
195
|
+
// run (before relationship resolution, whose entity updates were never
|
|
196
|
+
// reflected in FTS).
|
|
197
|
+
if (graphFlushed && hasFts5) {
|
|
198
|
+
rebuildGraphFts(db);
|
|
199
|
+
}
|
|
200
|
+
|
|
189
201
|
log(`\n✓ Extracted ${totalEntities} entities, ${totalRelationships} relationships`, 'green');
|
|
190
202
|
if (errors > 0) {
|
|
191
203
|
log(`⚠ ${errors} files had errors`, 'yellow');
|
|
@@ -360,7 +372,9 @@ function annotateChunksForVectorInsert(chunks) {
|
|
|
360
372
|
const idx = indices[i];
|
|
361
373
|
annotations[idx] = {
|
|
362
374
|
...ids[i],
|
|
363
|
-
|
|
375
|
+
// Full indexing never persists dedup_fingerprint (only the reconcile
|
|
376
|
+
// delta writer does) — skip that hash.
|
|
377
|
+
hashes: chunkInputHashes(chunks[idx], { includeDedup: false }),
|
|
364
378
|
};
|
|
365
379
|
}
|
|
366
380
|
}
|
|
@@ -10,7 +10,7 @@ import path from 'path';
|
|
|
10
10
|
import { DB_PATHS, PROJECT_ROOT, EMBEDDING_CONFIG, HCGS_CONFIG } from '../infrastructure/config/index.js';
|
|
11
11
|
import { getChangedFiles, updateState, getStats as getIncrementalStats, updatePhaseProgress, markPhaseComplete, clearPhaseProgress } from './incremental-tracker.js';
|
|
12
12
|
import { backupSummaries, restoreSummaries, markForRegeneration } from '../graph/summary-manager.js';
|
|
13
|
-
import { colors, log, logProgress, logError, discoverFiles, readFilesFromStdin, atomicSwapDatabase } from './indexer-utils.js';
|
|
13
|
+
import { colors, log, logProgress, logError, discoverFiles, readFilesFromStdin, atomicSwapDatabase, shouldStreamVectors } from './indexer-utils.js';
|
|
14
14
|
import { buildCodeGraph, buildVectorIndex, chunkFiles } from './indexer-build.js';
|
|
15
15
|
import { runDedupPhase, formatDedupSummary } from './dedup/dedup-phase.js';
|
|
16
16
|
import { DEDUP_CONFIG } from '../infrastructure/config/index.js';
|
|
@@ -420,15 +420,24 @@ export async function buildVectorsAndArtifactsPhase(options = {}) {
|
|
|
420
420
|
// model. For large full rebuilds we instead spill chunks to disk and embed/LI
|
|
421
421
|
// in bounded windows (see streaming-vectors.js) so peak heap is O(window).
|
|
422
422
|
//
|
|
423
|
-
// Gated by file count
|
|
424
|
-
// in-
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
423
|
+
// Gated by file count OR total admitted source bytes (see shouldStreamVectors
|
|
424
|
+
// in indexer-utils.js) so small repos + incremental runs keep the original
|
|
425
|
+
// in-memory path byte-for-byte (benchmark indexes unaffected). The byte
|
|
426
|
+
// trigger catches few-files-huge-bytes repos (amalgamations, vendored blobs)
|
|
427
|
+
// that OOM the in-memory path while staying under the file gate.
|
|
428
|
+
// Auto-selected, no opt-in flag; SWEET_SEARCH_STREAM_VECTORS=0 forces the
|
|
429
|
+
// legacy path, SWEET_SEARCH_STREAM_MIN_FILES / SWEET_SEARCH_STREAM_MIN_BYTES
|
|
430
|
+
// tune the thresholds.
|
|
431
|
+
const streamingDecision = await shouldStreamVectors({ filesToIndex, dryRun, fullReindex });
|
|
432
|
+
const useStreaming = streamingDecision.useStreaming;
|
|
433
|
+
if (streamingDecision.reason === 'bytes') {
|
|
434
|
+
log(
|
|
435
|
+
` Streaming vectors: ${filesToIndex.length} files total ` +
|
|
436
|
+
`${(streamingDecision.totalBytes / 1048576).toFixed(0)}+ MB >= ` +
|
|
437
|
+
`${(streamingDecision.thresholdBytes / 1048576).toFixed(0)} MB (bounded memory)`,
|
|
438
|
+
'dim'
|
|
439
|
+
);
|
|
440
|
+
}
|
|
432
441
|
|
|
433
442
|
// The in-memory path pre-chunks up front so both vector + LI encoders share
|
|
434
443
|
// one chunk list. The streaming path does its own windowed chunking + dedup,
|
|
@@ -814,20 +823,32 @@ export async function updateIncrementalStatePhase(options = {}) {
|
|
|
814
823
|
log('\nIncremental state updated', 'green');
|
|
815
824
|
} else if (fullReindex) {
|
|
816
825
|
const hashes = {};
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
826
|
+
// Batched read+stat (order-preserving assignment — key insertion order,
|
|
827
|
+
// and therefore the serialized state file, matches the sequential loop).
|
|
828
|
+
// Mirrors the H6 batching in incremental-tracker.getChangedFiles.
|
|
829
|
+
const HASH_BATCH = 100;
|
|
830
|
+
for (let i = 0; i < allFiles.length; i += HASH_BATCH) {
|
|
831
|
+
const batch = allFiles.slice(i, i + HASH_BATCH);
|
|
832
|
+
const results = await Promise.all(batch.map(async (file) => {
|
|
833
|
+
try {
|
|
834
|
+
const fullPath = path.join(PROJECT_ROOT, file);
|
|
835
|
+
const [content, stat] = await Promise.all([
|
|
836
|
+
fs.readFile(fullPath),
|
|
837
|
+
fs.stat(fullPath, { bigint: true }).catch(() => null),
|
|
838
|
+
]);
|
|
839
|
+
return {
|
|
840
|
+
hash: contentHashSync(content),
|
|
841
|
+
size: stat ? stat.size.toString() : null,
|
|
842
|
+
mtime_ns: stat ? stat.mtimeNs.toString() : null,
|
|
843
|
+
inode: stat ? stat.ino.toString() : null,
|
|
844
|
+
};
|
|
845
|
+
} catch (e) {
|
|
846
|
+
return null; // skip
|
|
847
|
+
}
|
|
848
|
+
}));
|
|
849
|
+
for (let j = 0; j < batch.length; j++) {
|
|
850
|
+
if (results[j]) hashes[batch[j]] = results[j];
|
|
851
|
+
}
|
|
831
852
|
}
|
|
832
853
|
await updateState(hashes, {
|
|
833
854
|
totalChunks: vectorStats.chunks,
|
|
@@ -395,16 +395,22 @@ export async function discoverFiles(options = {}) {
|
|
|
395
395
|
|
|
396
396
|
const files = [];
|
|
397
397
|
let oversized = 0;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
398
|
+
// Stat in batches (order-preserving) instead of one serialized await per
|
|
399
|
+
// file; results are consumed in the original allFiles order.
|
|
400
|
+
const STAT_BATCH = 200;
|
|
401
|
+
for (let i = 0; i < allFiles.length; i += STAT_BATCH) {
|
|
402
|
+
const batch = allFiles.slice(i, i + STAT_BATCH);
|
|
403
|
+
const stats = await Promise.all(
|
|
404
|
+
batch.map((file) => fs.stat(path.join(projectRoot, file)).catch(() => null)),
|
|
405
|
+
);
|
|
406
|
+
for (let j = 0; j < batch.length; j++) {
|
|
407
|
+
const stat = stats[j];
|
|
408
|
+
if (!stat) continue; // File disappeared between glob and stat
|
|
401
409
|
if (stat.size > maxFileSize) {
|
|
402
410
|
oversized++;
|
|
403
411
|
} else {
|
|
404
|
-
files.push(
|
|
412
|
+
files.push(batch[j]);
|
|
405
413
|
}
|
|
406
|
-
} catch {
|
|
407
|
-
// File disappeared between glob and stat
|
|
408
414
|
}
|
|
409
415
|
}
|
|
410
416
|
|
|
@@ -426,3 +432,87 @@ export async function discoverFiles(options = {}) {
|
|
|
426
432
|
|
|
427
433
|
return files;
|
|
428
434
|
}
|
|
435
|
+
|
|
436
|
+
// =============================================================================
|
|
437
|
+
// STREAMING-VECTORS GATE
|
|
438
|
+
// =============================================================================
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Sum on-disk sizes of `files` (relative to `projectRoot`), stopping as soon
|
|
442
|
+
* as the running total crosses `stopAt`. The streaming gate only needs to
|
|
443
|
+
* know whether the total crosses the threshold, not the exact figure, so on
|
|
444
|
+
* large-byte repos this exits after a fraction of the stats.
|
|
445
|
+
*/
|
|
446
|
+
export async function sumFileSizesUpTo(files, stopAt, projectRoot = PROJECT_ROOT) {
|
|
447
|
+
let total = 0;
|
|
448
|
+
for (const file of files) {
|
|
449
|
+
try {
|
|
450
|
+
const stat = await fs.stat(path.isAbsolute(file) ? file : path.join(projectRoot, file));
|
|
451
|
+
total += stat.size;
|
|
452
|
+
if (total >= stopAt) return total;
|
|
453
|
+
} catch {
|
|
454
|
+
// File disappeared between discovery and this gate — skip it.
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return total;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Decide whether a full rebuild should take the bounded-memory streaming
|
|
462
|
+
* vectors path (see streaming-vectors.js) instead of the in-memory path.
|
|
463
|
+
*
|
|
464
|
+
* Two independent triggers, either is sufficient:
|
|
465
|
+
* - file count ≥ SWEET_SEARCH_STREAM_MIN_FILES (default 5000)
|
|
466
|
+
* - total admitted source bytes ≥ SWEET_SEARCH_STREAM_MIN_BYTES
|
|
467
|
+
* (default 512 MB; set to 0 to disable the byte trigger)
|
|
468
|
+
*
|
|
469
|
+
* The byte trigger exists because peak heap on the in-memory path scales
|
|
470
|
+
* with the chunk corpus (≈ source bytes), not file count: a repo with
|
|
471
|
+
* few-but-huge files (amalgamations, vendored/generated blobs, extreme
|
|
472
|
+
* duplication) can OOM the default ~4 GB heap while staying far under the
|
|
473
|
+
* file gate.
|
|
474
|
+
*
|
|
475
|
+
* The 512 MB default is deliberately conservative so the trigger only fires
|
|
476
|
+
* where the in-memory path would fail outright, never where it would merely
|
|
477
|
+
* be tight. The one MEASURED failure at this scale is libsql (596 MB
|
|
478
|
+
* admitted source; the in-memory path needed a 9.6+ GB heap); the wider
|
|
479
|
+
* crash zone is ESTIMATED — not measured — to start around ~200 MB of
|
|
480
|
+
* admitted source on a default ~4 GB heap. A repo the byte trigger newly
|
|
481
|
+
* moves to streaming was therefore not getting a usable in-memory index at
|
|
482
|
+
* all, so streaming strictly improves on a crash; every repo below the
|
|
483
|
+
* threshold keeps the byte-for-byte-identical in-memory path and identical
|
|
484
|
+
* retrieval behaviour.
|
|
485
|
+
*
|
|
486
|
+
* Sizes are only stat'd when the (free) count trigger hasn't already fired,
|
|
487
|
+
* so the byte check costs at most one stat per file on sub-threshold repos.
|
|
488
|
+
* The re-stat duplicates work discoverFiles already did for its size cap —
|
|
489
|
+
* accepted deliberately: threading sizes through would change discoverFiles'
|
|
490
|
+
* public return shape, and ≤5000 extra stats on a full rebuild is noise next
|
|
491
|
+
* to chunking + embedding.
|
|
492
|
+
*
|
|
493
|
+
* @returns {Promise<{useStreaming: boolean, reason?: 'files'|'bytes', totalBytes?: number, thresholdBytes?: number}>}
|
|
494
|
+
*/
|
|
495
|
+
export async function shouldStreamVectors({ filesToIndex, dryRun, fullReindex, projectRoot = PROJECT_ROOT, env = process.env }) {
|
|
496
|
+
if (dryRun || !fullReindex || env.SWEET_SEARCH_STREAM_VECTORS === '0') {
|
|
497
|
+
return { useStreaming: false };
|
|
498
|
+
}
|
|
499
|
+
const streamMinFiles = Number(env.SWEET_SEARCH_STREAM_MIN_FILES) || 5000;
|
|
500
|
+
if (filesToIndex.length >= streamMinFiles) {
|
|
501
|
+
return { useStreaming: true, reason: 'files' };
|
|
502
|
+
}
|
|
503
|
+
// Unset/empty/invalid → 512 MB default; an explicit 0 (or negative)
|
|
504
|
+
// disables the byte trigger alone, leaving the count trigger active.
|
|
505
|
+
const rawMinBytes = env.SWEET_SEARCH_STREAM_MIN_BYTES;
|
|
506
|
+
const parsedMinBytes = (rawMinBytes === undefined || rawMinBytes === '') ? NaN : Number(rawMinBytes);
|
|
507
|
+
const streamMinBytes = Number.isFinite(parsedMinBytes)
|
|
508
|
+
? (parsedMinBytes > 0 ? parsedMinBytes : Infinity)
|
|
509
|
+
: 512 * 1024 * 1024;
|
|
510
|
+
if (!Number.isFinite(streamMinBytes)) {
|
|
511
|
+
return { useStreaming: false };
|
|
512
|
+
}
|
|
513
|
+
const totalBytes = await sumFileSizesUpTo(filesToIndex, streamMinBytes, projectRoot);
|
|
514
|
+
if (totalBytes >= streamMinBytes) {
|
|
515
|
+
return { useStreaming: true, reason: 'bytes', totalBytes, thresholdBytes: streamMinBytes };
|
|
516
|
+
}
|
|
517
|
+
return { useStreaming: false };
|
|
518
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { existsSync, readFileSync } from 'fs';
|
|
11
|
-
import {
|
|
11
|
+
import { Minimatch } from 'minimatch';
|
|
12
12
|
import { loadProjectConfig } from '../infrastructure/config/index.js';
|
|
13
13
|
|
|
14
14
|
const MM_OPTS = { dot: true, nocase: false };
|
|
@@ -54,6 +54,31 @@ function getExcludes(projectRoot) {
|
|
|
54
54
|
function resetCache() {
|
|
55
55
|
_excludesByRoot.clear();
|
|
56
56
|
_cachedExtraPatterns = null;
|
|
57
|
+
_excludeMatchersByRoot.clear();
|
|
58
|
+
_cachedExtraMatchers = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Precompiled Minimatch instances — `minimatch(p, g, opts)` recompiles the
|
|
62
|
+
// glob to a regex on every call; `new Minimatch(g, opts).match(p)` is the
|
|
63
|
+
// documented equivalent with the compile amortized.
|
|
64
|
+
const _excludeMatchersByRoot = new Map();
|
|
65
|
+
|
|
66
|
+
function getExcludeMatchers(projectRoot) {
|
|
67
|
+
const key = projectRoot || '__cwd__';
|
|
68
|
+
let cached = _excludeMatchersByRoot.get(key);
|
|
69
|
+
if (cached) return cached;
|
|
70
|
+
cached = getExcludes(projectRoot)
|
|
71
|
+
.filter((g) => typeof g === 'string')
|
|
72
|
+
.map((g) => new Minimatch(g, MM_OPTS));
|
|
73
|
+
_excludeMatchersByRoot.set(key, cached);
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let _cachedExtraMatchers = null;
|
|
78
|
+
function getExtraMatchers() {
|
|
79
|
+
if (_cachedExtraMatchers !== null) return _cachedExtraMatchers;
|
|
80
|
+
_cachedExtraMatchers = loadExtraPatternsFromFile().map((g) => new Minimatch(g, MM_OPTS));
|
|
81
|
+
return _cachedExtraMatchers;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
const GENERATED_MARKERS = [
|
|
@@ -87,13 +112,11 @@ function loadExtraPatternsFromFile() {
|
|
|
87
112
|
export function isExcludedByConfig(filePath, projectRoot) {
|
|
88
113
|
if (!filePath) return false;
|
|
89
114
|
const p = normalizePath(filePath);
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
if (typeof g === 'string' && minimatch(p, g, MM_OPTS)) return true;
|
|
115
|
+
for (const m of getExcludeMatchers(projectRoot)) {
|
|
116
|
+
if (m.match(p)) return true;
|
|
93
117
|
}
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
if (minimatch(p, g, MM_OPTS)) return true;
|
|
118
|
+
for (const m of getExtraMatchers()) {
|
|
119
|
+
if (m.match(p)) return true;
|
|
97
120
|
}
|
|
98
121
|
return false;
|
|
99
122
|
}
|
|
@@ -29,12 +29,15 @@ import {
|
|
|
29
29
|
getLocalPipeline,
|
|
30
30
|
callLocalModelCpu,
|
|
31
31
|
unloadLocalModel,
|
|
32
|
+
configureLocalModelRuntime,
|
|
32
33
|
} from '../embedding/embedding-local-model.js';
|
|
33
34
|
import {
|
|
34
35
|
getLateInteractionPipeline,
|
|
35
36
|
encodeDocumentsCpu,
|
|
36
37
|
unloadLateInteractionModel,
|
|
38
|
+
configureLateInteractionRuntime,
|
|
37
39
|
} from '../ranking/late-interaction-model.js';
|
|
40
|
+
import { backgroundIntraOpThreads } from '../infrastructure/onnx-session-utils.js';
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
43
|
* Small-changeset threshold. Incremental runs with fewer files than this use
|
|
@@ -69,6 +72,29 @@ export function selectAcceleratorDeviceKind(pref) {
|
|
|
69
72
|
return null;
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
/**
|
|
76
|
+
* G3: arm the BACKGROUND/maintainer ORT profile for BOTH resident CPU
|
|
77
|
+
* sessions — dense (embedding-local-model) and late-interaction. The profile
|
|
78
|
+
* (force_spinning_stop + arena-off + 2–4 intra-op threads) prevents the two
|
|
79
|
+
* resident-daemon pathologies: idle spinning workers (~a core of idle CPU)
|
|
80
|
+
* and monotonic RSS from ORT arena extension (#25325; measured 354 × 128MB
|
|
81
|
+
* ≈ 34GB after one edit-heavy day when the LI session was left on the
|
|
82
|
+
* foreground profile). MUST run before the first encode — both session
|
|
83
|
+
* singletons are built once; configuring after is a silent no-op.
|
|
84
|
+
*
|
|
85
|
+
* Lives here (model-pool owns indexing-side model lifecycle) so daemon
|
|
86
|
+
* entrypoints don't need their own cross-context imports of the two model
|
|
87
|
+
* modules.
|
|
88
|
+
*
|
|
89
|
+
* @returns {{armed: boolean}}
|
|
90
|
+
*/
|
|
91
|
+
export function armBackgroundOrtProfiles() {
|
|
92
|
+
const intraOpThreads = backgroundIntraOpThreads();
|
|
93
|
+
configureLocalModelRuntime({ intraOpThreads, background: true });
|
|
94
|
+
configureLateInteractionRuntime({ intraOpThreads, background: true });
|
|
95
|
+
return { armed: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
72
98
|
/**
|
|
73
99
|
* Whether this process can use an inference accelerator (Metal / CoreML
|
|
74
100
|
* cascade / CUDA) for indexing. Requires both accelerator-capable hardware
|
|
@@ -112,6 +112,71 @@ export function isOverBudget(totalRssBytes, budgetBytesValue) {
|
|
|
112
112
|
);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// D.5: per-process maintainer RSS recycle ceiling
|
|
117
|
+
// =============================================================================
|
|
118
|
+
//
|
|
119
|
+
// The fleet budget above evicts the longest-IDLE daemon, so a single fat
|
|
120
|
+
// ACTIVE maintainer can legitimately ride up to the whole fleet budget
|
|
121
|
+
// (~76 GB on a 128 GB host) — measured in the wild when a session-config
|
|
122
|
+
// regression left the LI ORT session on the foreground arena profile
|
|
123
|
+
// (354 × 128MB arena extensions ≈ 34 GB RSS). This ceiling is the
|
|
124
|
+
// per-process backstop: when the maintainer's OWN rss crosses the line at a
|
|
125
|
+
// tick boundary, it requests the same graceful shutdown the idle-TTL uses
|
|
126
|
+
// (finish tick, publish, release lock, exit). The O_EXCL launch trigger
|
|
127
|
+
// respawns a fresh few-hundred-MB process on the next dirty event, so the
|
|
128
|
+
// index is unchanged — exit-to-reclaim is already the sanctioned way to
|
|
129
|
+
// return ORT memory (#25325).
|
|
130
|
+
//
|
|
131
|
+
// Guards against restart-thrash:
|
|
132
|
+
// - at least one COMPLETED tick (never abort startup work),
|
|
133
|
+
// - minimum uptime (default 10 min ⇒ worst case 6 recycles/hour even when
|
|
134
|
+
// the ceiling is misconfigured below the resident floor — visible via
|
|
135
|
+
// the per-recycle WARN, which names the env knob to raise).
|
|
136
|
+
//
|
|
137
|
+
// Measured anchors (2026-07-03, arena-off profile): steady floor ~2.7-2.9 GB
|
|
138
|
+
// on a mid-size repo, encode-active peaks ~4.7 GB — hence the 4 GiB default
|
|
139
|
+
// floor and 8 GiB cap.
|
|
140
|
+
|
|
141
|
+
const MAINTAINER_RSS_CEILING_FRACTION = 0.25;
|
|
142
|
+
const MAINTAINER_RSS_CEILING_MIN_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB
|
|
143
|
+
const MAINTAINER_RSS_CEILING_MAX_BYTES = 8 * 1024 * 1024 * 1024; // 8 GiB
|
|
144
|
+
const MAINTAINER_RSS_MIN_UPTIME_MS_DEFAULT = 10 * 60 * 1000; // 10 min
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the per-process maintainer RSS ceiling in bytes.
|
|
148
|
+
* `SWEET_SEARCH_MAINTAINER_RSS_MAX_MB` set: explicit MB value; 0/garbage
|
|
149
|
+
* disables (returns 0). Unset: clamp(25% of RAM, 4 GiB, 8 GiB).
|
|
150
|
+
*/
|
|
151
|
+
export function maintainerRssCeilingBytes(env = process.env, totalMem = os.totalmem()) {
|
|
152
|
+
const raw = env.SWEET_SEARCH_MAINTAINER_RSS_MAX_MB;
|
|
153
|
+
if (raw != null && raw !== '') {
|
|
154
|
+
const mb = Number(raw);
|
|
155
|
+
if (!Number.isFinite(mb) || mb <= 0) return 0;
|
|
156
|
+
return Math.floor(mb * 1024 * 1024);
|
|
157
|
+
}
|
|
158
|
+
const scaled = Math.floor(totalMem * MAINTAINER_RSS_CEILING_FRACTION);
|
|
159
|
+
return Math.min(MAINTAINER_RSS_CEILING_MAX_BYTES, Math.max(MAINTAINER_RSS_CEILING_MIN_BYTES, scaled));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Minimum uptime before an RSS recycle may fire. */
|
|
163
|
+
export function maintainerRssMinUptimeMs(env = process.env) {
|
|
164
|
+
const raw = Number(env.SWEET_SEARCH_MAINTAINER_RSS_MIN_UPTIME_MS);
|
|
165
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : MAINTAINER_RSS_MIN_UPTIME_MS_DEFAULT;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Pure recycle decision — evaluated at tick boundaries only.
|
|
170
|
+
* @returns {{recycle: boolean, reason: string}}
|
|
171
|
+
*/
|
|
172
|
+
export function shouldRecycleForRss({ rssBytes, ceilingBytes, uptimeMs, minUptimeMs, ticksCompleted }) {
|
|
173
|
+
if (!Number.isFinite(ceilingBytes) || ceilingBytes <= 0) return { recycle: false, reason: 'disabled' };
|
|
174
|
+
if (!Number.isFinite(rssBytes) || rssBytes <= ceilingBytes) return { recycle: false, reason: 'under-ceiling' };
|
|
175
|
+
if (!(ticksCompleted >= 1)) return { recycle: false, reason: 'no-completed-tick' };
|
|
176
|
+
if (!(uptimeMs >= minUptimeMs)) return { recycle: false, reason: 'min-uptime' };
|
|
177
|
+
return { recycle: true, reason: 'over-ceiling' };
|
|
178
|
+
}
|
|
179
|
+
|
|
115
180
|
/**
|
|
116
181
|
* Read the resident-set size (bytes) of an arbitrary pid, best-effort and
|
|
117
182
|
* cross-platform. Returns a non-negative integer, or 0 when unknown (dead pid,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import Database from 'better-sqlite3';
|
|
12
12
|
import { existsSync, statSync } from 'fs';
|
|
13
|
-
import { applyReadPragmas } from './db-utils.js';
|
|
13
|
+
import { applyReadPragmas, prepareCached } from './db-utils.js';
|
|
14
14
|
import { readAdjacentManifest, resolveManifestCodeGraphPath, sqlAliasPrefix } from './code-graph-visibility.js';
|
|
15
15
|
|
|
16
16
|
export class CodeGraphRepository {
|
|
@@ -72,7 +72,7 @@ export class CodeGraphRepository {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
_hasColumns(db, table, columns) {
|
|
75
|
-
const rows = db
|
|
75
|
+
const rows = prepareCached(db, `PRAGMA table_info(${table})`).all();
|
|
76
76
|
const names = new Set(rows.map((row) => row.name));
|
|
77
77
|
return columns.every((column) => names.has(column));
|
|
78
78
|
}
|
|
@@ -138,7 +138,7 @@ export class CodeGraphRepository {
|
|
|
138
138
|
const db = this._open();
|
|
139
139
|
if (!db) return null;
|
|
140
140
|
try {
|
|
141
|
-
const row = db
|
|
141
|
+
const row = prepareCached(db, `
|
|
142
142
|
SELECT id, name, type, start_line, end_line, parent_class
|
|
143
143
|
FROM entities
|
|
144
144
|
WHERE file_path = ?
|
|
@@ -207,7 +207,7 @@ export class CodeGraphRepository {
|
|
|
207
207
|
// in the chunk range (catches entities like a multi-chunk struct whose
|
|
208
208
|
// declaration starts in this chunk but body extends beyond — e.g. gin
|
|
209
209
|
// Engine struct at gin.go:92-189 split across chunks 92-133 and 134-189).
|
|
210
|
-
const containedRows = db
|
|
210
|
+
const containedRows = prepareCached(db, `
|
|
211
211
|
SELECT name, type, start_line, end_line FROM entities
|
|
212
212
|
WHERE file_path = ? AND start_line >= ? AND end_line <= ? AND ${this._entityVisibilitySql(db)}
|
|
213
213
|
ORDER BY (end_line - start_line) DESC
|
|
@@ -219,7 +219,7 @@ export class CodeGraphRepository {
|
|
|
219
219
|
return { name: row.name, type: row.type, startLine: row.start_line, endLine: row.end_line };
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
-
const startsInRows = db
|
|
222
|
+
const startsInRows = prepareCached(db, `
|
|
223
223
|
SELECT name, type, start_line, end_line FROM entities
|
|
224
224
|
WHERE file_path = ? AND start_line >= ? AND start_line <= ? AND ${this._entityVisibilitySql(db)}
|
|
225
225
|
ORDER BY (end_line - start_line) DESC
|
|
@@ -245,7 +245,7 @@ export class CodeGraphRepository {
|
|
|
245
245
|
const db = this._open();
|
|
246
246
|
if (!db) return null;
|
|
247
247
|
try {
|
|
248
|
-
const row = db
|
|
248
|
+
const row = prepareCached(db, `
|
|
249
249
|
SELECT id, name, type, start_line, end_line, parent_class
|
|
250
250
|
FROM entities
|
|
251
251
|
WHERE file_path = ?
|
|
@@ -289,7 +289,7 @@ export class CodeGraphRepository {
|
|
|
289
289
|
const db = this._open();
|
|
290
290
|
if (!db) return [];
|
|
291
291
|
try {
|
|
292
|
-
const rows = db
|
|
292
|
+
const rows = prepareCached(db, `
|
|
293
293
|
SELECT id, name, type, start_line, end_line, parent_class
|
|
294
294
|
FROM entities
|
|
295
295
|
WHERE file_path = ?
|
|
@@ -322,7 +322,7 @@ export class CodeGraphRepository {
|
|
|
322
322
|
const db = this._open();
|
|
323
323
|
if (!db) return null;
|
|
324
324
|
try {
|
|
325
|
-
const row = db
|
|
325
|
+
const row = prepareCached(db, `
|
|
326
326
|
SELECT id, name, type, file_path, start_line, end_line, parent_class
|
|
327
327
|
FROM entities
|
|
328
328
|
WHERE id = ? AND ${this._entityVisibilitySql(db)}
|
|
@@ -379,7 +379,7 @@ export class CodeGraphRepository {
|
|
|
379
379
|
const args = types
|
|
380
380
|
? [...this._entityVisibilityParams(db), sourceId, ...this._relationshipVisibilityParams(db), ...types, limit]
|
|
381
381
|
: [...this._entityVisibilityParams(db), sourceId, ...this._relationshipVisibilityParams(db), limit];
|
|
382
|
-
const rows = db
|
|
382
|
+
const rows = prepareCached(db, baseSql).all(...args);
|
|
383
383
|
return rows.map(r => ({
|
|
384
384
|
type: r.rel_type,
|
|
385
385
|
targetName: r.target_name,
|
|
@@ -445,7 +445,7 @@ export class CodeGraphRepository {
|
|
|
445
445
|
const args = excludeFile
|
|
446
446
|
? [...uniq, ...types, ...this._entityVisibilityParams(db), excludeFile, limit]
|
|
447
447
|
: [...uniq, ...types, ...this._entityVisibilityParams(db), limit];
|
|
448
|
-
const rows = db
|
|
448
|
+
const rows = prepareCached(db, sql).all(...args);
|
|
449
449
|
// De-dup by name+type, keeping the first (smallest body).
|
|
450
450
|
const seen = new Set();
|
|
451
451
|
const out = [];
|
|
@@ -493,7 +493,7 @@ export class CodeGraphRepository {
|
|
|
493
493
|
ORDER BY (end_line - start_line) ASC
|
|
494
494
|
LIMIT ?
|
|
495
495
|
`;
|
|
496
|
-
const rows = db
|
|
496
|
+
const rows = prepareCached(db, sql).all(...uniq, ...types, ...this._entityVisibilityParams(db), limit);
|
|
497
497
|
return rows.map(row => ({
|
|
498
498
|
id: row.id,
|
|
499
499
|
name: row.name,
|
|
@@ -549,7 +549,7 @@ export class CodeGraphRepository {
|
|
|
549
549
|
ORDER BY (end_line - start_line) ASC
|
|
550
550
|
LIMIT ?
|
|
551
551
|
`;
|
|
552
|
-
const rows = db
|
|
552
|
+
const rows = prepareCached(db, sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db), limit);
|
|
553
553
|
return rows.map(row => ({
|
|
554
554
|
id: row.id,
|
|
555
555
|
name: row.name,
|
|
@@ -598,7 +598,7 @@ export class CodeGraphRepository {
|
|
|
598
598
|
AND ${this._entityVisibilitySql(db)}
|
|
599
599
|
GROUP BY lname
|
|
600
600
|
`;
|
|
601
|
-
const rows = db
|
|
601
|
+
const rows = prepareCached(db, sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db));
|
|
602
602
|
const map = new Map();
|
|
603
603
|
for (const r of rows) map.set(r.lname, r.count);
|
|
604
604
|
// Names with no row are absent — caller treats absent as 0.
|
|
@@ -640,7 +640,7 @@ export class CodeGraphRepository {
|
|
|
640
640
|
const args = types
|
|
641
641
|
? [...this._entityVisibilityParams(db), targetId, ...this._relationshipVisibilityParams(db), ...types, limit]
|
|
642
642
|
: [...this._entityVisibilityParams(db), targetId, ...this._relationshipVisibilityParams(db), limit];
|
|
643
|
-
const rows = db
|
|
643
|
+
const rows = prepareCached(db, baseSql).all(...args);
|
|
644
644
|
return rows.map(r => ({
|
|
645
645
|
type: r.rel_type,
|
|
646
646
|
contextLine: r.context_line || null,
|
|
@@ -689,7 +689,7 @@ export class CodeGraphRepository {
|
|
|
689
689
|
return Number.isFinite(n) && n > 0 ? n : 12;
|
|
690
690
|
})();
|
|
691
691
|
try {
|
|
692
|
-
const rows = db
|
|
692
|
+
const rows = prepareCached(db, `
|
|
693
693
|
SELECT r.target_name, COUNT(*) as cnt
|
|
694
694
|
FROM relationships r
|
|
695
695
|
LEFT JOIN entities e ON e.id = r.target_id AND ${this._entityVisibilitySql(db, 'e')}
|
|
@@ -798,7 +798,7 @@ export class CodeGraphRepository {
|
|
|
798
798
|
if (!db) return null;
|
|
799
799
|
try {
|
|
800
800
|
if (this._hasColumns(db, 'entities', ['epoch_written', 'epoch_retired'])) {
|
|
801
|
-
const visible = db
|
|
801
|
+
const visible = prepareCached(db, `
|
|
802
802
|
SELECT 1 FROM entities
|
|
803
803
|
WHERE file_path = ? AND ${this._entityVisibilitySql(db)}
|
|
804
804
|
LIMIT 1
|
|
@@ -827,10 +827,10 @@ export class CodeGraphRepository {
|
|
|
827
827
|
const staleArgs = this._manifestEpoch !== null
|
|
828
828
|
? [filePath, this._manifestEpoch, this._manifestEpoch]
|
|
829
829
|
: [filePath];
|
|
830
|
-
const staleRow = db
|
|
830
|
+
const staleRow = prepareCached(db, staleSql).get(...staleArgs);
|
|
831
831
|
return staleRow ? { staleSince: staleRow.stale_since || null } : null;
|
|
832
832
|
}
|
|
833
|
-
const row = db
|
|
833
|
+
const row = prepareCached(db, `
|
|
834
834
|
SELECT stale_since, MIN(ROWID) as first_row
|
|
835
835
|
FROM entities
|
|
836
836
|
WHERE file_path = ?
|
|
@@ -17,6 +17,35 @@ import Database from 'better-sqlite3';
|
|
|
17
17
|
*/
|
|
18
18
|
export const SAFE_IN_CLAUSE_BATCH = 999;
|
|
19
19
|
|
|
20
|
+
// Per-connection prepared-statement cache. Statements belong to their
|
|
21
|
+
// Database object, so the WeakMap self-invalidates when a connection object
|
|
22
|
+
// is dropped (closed connections are never passed back in by callers).
|
|
23
|
+
// Only use for .get/.all/.run statements — never .iterate (a cached statement
|
|
24
|
+
// mid-iteration cannot be re-entered).
|
|
25
|
+
const _stmtCaches = new WeakMap();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* db.prepare with a per-connection cache — compiles each distinct SQL string
|
|
29
|
+
* once per Database object instead of once per call.
|
|
30
|
+
*
|
|
31
|
+
* @param {import('better-sqlite3').Database} db
|
|
32
|
+
* @param {string} sql
|
|
33
|
+
* @returns {import('better-sqlite3').Statement}
|
|
34
|
+
*/
|
|
35
|
+
export function prepareCached(db, sql) {
|
|
36
|
+
let cache = _stmtCaches.get(db);
|
|
37
|
+
if (!cache) {
|
|
38
|
+
cache = new Map();
|
|
39
|
+
_stmtCaches.set(db, cache);
|
|
40
|
+
}
|
|
41
|
+
let stmt = cache.get(sql);
|
|
42
|
+
if (!stmt) {
|
|
43
|
+
stmt = db.prepare(sql);
|
|
44
|
+
cache.set(sql, stmt);
|
|
45
|
+
}
|
|
46
|
+
return stmt;
|
|
47
|
+
}
|
|
48
|
+
|
|
20
49
|
/**
|
|
21
50
|
* Apply read-path PRAGMA optimizations to a read-only database connection.
|
|
22
51
|
*
|