@soulcraft/cor 3.0.0-rc.1
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/LICENSE +16 -0
- package/README.md +328 -0
- package/dist/aggregation/NativeAggregationEngine.d.ts +118 -0
- package/dist/aggregation/NativeAggregationEngine.js +186 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +236 -0
- package/dist/graph/GraphAccelerationAdapter.d.ts +153 -0
- package/dist/graph/GraphAccelerationAdapter.js +326 -0
- package/dist/graph/NativeGraphAdjacencyIndex.d.ts +286 -0
- package/dist/graph/NativeGraphAdjacencyIndex.js +944 -0
- package/dist/hnsw/AdaptiveDiskAnnModeSelector.d.ts +187 -0
- package/dist/hnsw/AdaptiveDiskAnnModeSelector.js +313 -0
- package/dist/hnsw/NativeDiskAnnWrapper.d.ts +489 -0
- package/dist/hnsw/NativeDiskAnnWrapper.js +1456 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +29 -0
- package/dist/legacyLayoutGuard.d.ts +93 -0
- package/dist/legacyLayoutGuard.js +237 -0
- package/dist/license/constants.d.ts +30 -0
- package/dist/license/constants.js +30 -0
- package/dist/license/onlineValidator.d.ts +43 -0
- package/dist/license/onlineValidator.js +225 -0
- package/dist/license/types.d.ts +39 -0
- package/dist/license/types.js +5 -0
- package/dist/license.d.ts +64 -0
- package/dist/license.js +165 -0
- package/dist/migration/MigrationCoordinator.d.ts +171 -0
- package/dist/migration/MigrationCoordinator.js +249 -0
- package/dist/migration/indexEpochGuard.d.ts +163 -0
- package/dist/migration/indexEpochGuard.js +202 -0
- package/dist/native/NativeEmbeddingEngine.d.ts +79 -0
- package/dist/native/NativeEmbeddingEngine.js +318 -0
- package/dist/native/NativeRoaringBitmap32.d.ts +114 -0
- package/dist/native/NativeRoaringBitmap32.js +221 -0
- package/dist/native/ffi.d.ts +20 -0
- package/dist/native/ffi.js +48 -0
- package/dist/native/idMapperTestSupport.d.ts +67 -0
- package/dist/native/idMapperTestSupport.js +112 -0
- package/dist/native/index.d.ts +49 -0
- package/dist/native/index.js +87 -0
- package/dist/native/napi.d.ts +21 -0
- package/dist/native/napi.js +88 -0
- package/dist/native/types.d.ts +1298 -0
- package/dist/native/types.js +16 -0
- package/dist/plugin.d.ts +50 -0
- package/dist/plugin.js +388 -0
- package/dist/providerContracts.d.ts +277 -0
- package/dist/providerContracts.js +38 -0
- package/dist/resource/OsMemoryProbe.d.ts +175 -0
- package/dist/resource/OsMemoryProbe.js +206 -0
- package/dist/resource/ResourceManager.d.ts +491 -0
- package/dist/resource/ResourceManager.js +960 -0
- package/dist/utils/ColumnManifest.d.ts +97 -0
- package/dist/utils/ColumnManifest.js +129 -0
- package/dist/utils/NativeColumnStore.d.ts +284 -0
- package/dist/utils/NativeColumnStore.js +685 -0
- package/dist/utils/NativeMetadataIndex.d.ts +882 -0
- package/dist/utils/NativeMetadataIndex.js +2631 -0
- package/dist/utils/NativeUnifiedCache.d.ts +87 -0
- package/dist/utils/NativeUnifiedCache.js +273 -0
- package/dist/utils/binaryIdMapperFactory.d.ts +59 -0
- package/dist/utils/binaryIdMapperFactory.js +94 -0
- package/dist/utils/collation.d.ts +30 -0
- package/dist/utils/collation.js +40 -0
- package/dist/utils/columnStoreTypes.d.ts +161 -0
- package/dist/utils/columnStoreTypes.js +29 -0
- package/dist/utils/nativeBinaryEntityIdMapper.d.ts +211 -0
- package/dist/utils/nativeBinaryEntityIdMapper.js +381 -0
- package/dist/utils/nativeEntityIdMapper.d.ts +111 -0
- package/dist/utils/nativeEntityIdMapper.js +170 -0
- package/docs/ADR-002-diskann-100-percent-rust.md +294 -0
- package/docs/ADR-003-semantic-time-travel.md +100 -0
- package/docs/aggregation.md +251 -0
- package/docs/comparison.md +162 -0
- package/docs/deployment-limits.md +87 -0
- package/docs/diskann.md +184 -0
- package/docs/migration-3.0.md +396 -0
- package/docs/performance-budget.md +274 -0
- package/docs/performance.md +117 -0
- package/docs/scaling.md +439 -0
- package/docs/snapshot-safety.md +246 -0
- package/docs/u64-id-space.md +214 -0
- package/docs/verification-report.md +670 -0
- package/native/brainy-native.node +0 -0
- package/native/index.d.ts +3916 -0
- package/package.json +92 -0
- package/scripts/migrate-cortex-2x-to-3x.mjs +970 -0
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module resource/ResourceManager
|
|
3
|
+
* @description Adaptive resource manager for multi-instance Brainy deployments.
|
|
4
|
+
*
|
|
5
|
+
* Observes system resources (RAM, CPU, cgroups limits, active instance count)
|
|
6
|
+
* and dynamically adjusts cache sizing and memory budgets. All products
|
|
7
|
+
* (Workshop, Venue, Academy) benefit automatically — zero configuration needed.
|
|
8
|
+
*
|
|
9
|
+
* Singleton — one ResourceManager per Node.js process, shared across all
|
|
10
|
+
* Brainy instances. Registered as a Cor provider so SDK and brainy can
|
|
11
|
+
* query resource profiles without importing cor directly.
|
|
12
|
+
*
|
|
13
|
+
* ## Per-category memory budget (Piece 8 extension, cor 3.0)
|
|
14
|
+
*
|
|
15
|
+
* The cache budget is split across four named categories — `vectors`,
|
|
16
|
+
* `metadata`, `embedding`, `other` — each with a hardcoded base share of
|
|
17
|
+
* the total cache pool (50% / 25% / 15% / 10% respectively). Subsystems
|
|
18
|
+
* (cor's native LSM metadata index, DiskANN graph, embedding cache, etc.)
|
|
19
|
+
* register as `MemoryBudgetSubscriber`s with their category and a callback
|
|
20
|
+
* that fires whenever their effective allocation changes.
|
|
21
|
+
*
|
|
22
|
+
* Per the zero-config principle: the base shares are the **starting point**,
|
|
23
|
+
* not a hard cap. The ResourceManager observes actual utilization
|
|
24
|
+
* (`reportNativeMemory(name, bytes)` calls) on a 5-second pressure-tick
|
|
25
|
+
* cadence and **dynamically rebalances** allocations at runtime —
|
|
26
|
+
* under-utilized categories donate capacity to over-utilized ones,
|
|
27
|
+
* bounded so no category can shrink below 25% of its base share or grow
|
|
28
|
+
* above 200% of its base share. Operators never tune the split; the
|
|
29
|
+
* system adapts to its workload.
|
|
30
|
+
*
|
|
31
|
+
* The pre-existing `setCacheResizeCallback` API is preserved as a
|
|
32
|
+
* back-compat shim that auto-subscribes to the `other` category with a
|
|
33
|
+
* zero-bytes report — no behavior change for existing wiring.
|
|
34
|
+
*
|
|
35
|
+
* ## OS-memory observation (Piece J, cor 3.0)
|
|
36
|
+
*
|
|
37
|
+
* A built-in {@link OsMemoryProbe} reads `/proc/meminfo` on Linux
|
|
38
|
+
* (with an `os.totalmem`/`os.freemem` fallback elsewhere) and
|
|
39
|
+
* exposes the result via {@link getResourceProfile}'s `osMemory`
|
|
40
|
+
* field and the convenience accessor
|
|
41
|
+
* {@link ResourceManager.getOsMemorySnapshot}. The **Adaptive
|
|
42
|
+
* DiskANN** mode selector (Piece I) reads this snapshot at
|
|
43
|
+
* brain-open time to pick between in-memory / hybrid / on-disk
|
|
44
|
+
* operating modes — the key signal is `availableBytes`
|
|
45
|
+
* (`MemAvailable` on Linux), which counts reclaimable page cache
|
|
46
|
+
* as "available for new allocations." Naïve `os.freemem()`
|
|
47
|
+
* undercounts in multi-tenant scenarios where many brainy+cor
|
|
48
|
+
* instances share a box.
|
|
49
|
+
*/
|
|
50
|
+
import os from 'node:os';
|
|
51
|
+
import v8 from 'node:v8';
|
|
52
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
53
|
+
import { OsMemoryProbe, } from './OsMemoryProbe.js';
|
|
54
|
+
import { prodLog } from '@soulcraft/brainy/internals';
|
|
55
|
+
import { getOpenFileLimitInfo } from '../native/index.js';
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Constants
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
/** Fixed process overhead: embedding model (88MB) + Node.js baseline (~150MB) */
|
|
60
|
+
const FIXED_OVERHEAD_BYTES = 240 * 1024 * 1024;
|
|
61
|
+
/**
|
|
62
|
+
* **Piece 8 commit 2 — fallback per-entity RAM estimate.** Used by
|
|
63
|
+
* `trackInstance` + `canAccommodateInstance` ONLY when no subscriber
|
|
64
|
+
* has reported native memory yet (cold-boot, no instances active).
|
|
65
|
+
* Once any subscriber reports nativeMemoryBytes, the observed
|
|
66
|
+
* `totalReportedBytes / totalEntities` ratio takes precedence — see
|
|
67
|
+
* {@link ResourceManager.observedBytesPerEntity}.
|
|
68
|
+
*
|
|
69
|
+
* The pre-Piece-8 constant was `1024` bytes/entity, which over-
|
|
70
|
+
* estimated the RAM working set by ~300× at the cor 3.0 10B
|
|
71
|
+
* design target (32 GB / 10B entities = ~3.2 bytes/entity for
|
|
72
|
+
* everything that's actually resident). The new fallback of 256
|
|
73
|
+
* bytes is still conservative — accurate for sub-million-entity
|
|
74
|
+
* brains where most state is RAM-resident, intentionally over-
|
|
75
|
+
* estimating for billion-scale so the admission gate errs toward
|
|
76
|
+
* "don't admit a new instance" until real reports correct the
|
|
77
|
+
* estimate.
|
|
78
|
+
*/
|
|
79
|
+
const FALLBACK_BYTES_PER_ENTITY = 256;
|
|
80
|
+
/**
|
|
81
|
+
* Floor for the observed-bytes-per-entity calculation. Prevents an
|
|
82
|
+
* absurdly-low estimate (e.g., a subscriber temporarily reporting
|
|
83
|
+
* zero bytes while a flush is in progress) from making
|
|
84
|
+
* `canAccommodateInstance` say "yes" to instances that can't
|
|
85
|
+
* possibly fit. 4 bytes is the per-entity u32 ID alone; any real
|
|
86
|
+
* deployment carries more state than that.
|
|
87
|
+
*/
|
|
88
|
+
const MIN_BYTES_PER_ENTITY = 4;
|
|
89
|
+
/** Per-instance baseline overhead before entities */
|
|
90
|
+
const INSTANCE_BASELINE_BYTES = 4 * 1024 * 1024;
|
|
91
|
+
/** Minimum cache size — never shrink below this */
|
|
92
|
+
const MIN_CACHE_BYTES = 128 * 1024 * 1024;
|
|
93
|
+
/** Maximum fraction of available RAM for cache when instances are present */
|
|
94
|
+
const MAX_CACHE_RATIO = 0.35;
|
|
95
|
+
/** Memory pressure thresholds (fraction of effective limit) */
|
|
96
|
+
const PRESSURE_ELEVATED = 0.75;
|
|
97
|
+
const PRESSURE_CRITICAL = 0.90;
|
|
98
|
+
/**
|
|
99
|
+
* V8 heap pressure thresholds (fraction of `heap_size_limit`).
|
|
100
|
+
* Matched to the system-RSS thresholds for consistency; the V8 GC
|
|
101
|
+
* starts thrashing around 0.90 ratio so we want to fire `critical`
|
|
102
|
+
* before subsystems run out of allocation headroom.
|
|
103
|
+
*/
|
|
104
|
+
const V8_HEAP_PRESSURE_ELEVATED = 0.75;
|
|
105
|
+
const V8_HEAP_PRESSURE_CRITICAL = 0.90;
|
|
106
|
+
/** Rebalance interval in milliseconds */
|
|
107
|
+
const REBALANCE_INTERVAL_MS = 30_000;
|
|
108
|
+
/**
|
|
109
|
+
* Hardcoded base shares of the cache budget per category. These are the
|
|
110
|
+
* starting point — the ResourceManager dynamically rebalances at runtime
|
|
111
|
+
* based on observed pressure. NEVER expose these as a tuning knob: the
|
|
112
|
+
* [[zero-config-dynamic-adaptation]] principle says configuration is the
|
|
113
|
+
* last escape hatch, not the first. If a workload genuinely needs a
|
|
114
|
+
* different split, the adaptation loop figures it out from the reported
|
|
115
|
+
* bytes.
|
|
116
|
+
*/
|
|
117
|
+
const BASE_CATEGORY_SHARES = {
|
|
118
|
+
vectors: 0.5,
|
|
119
|
+
metadata: 0.25,
|
|
120
|
+
embedding: 0.15,
|
|
121
|
+
other: 0.1,
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Pressure-tick cadence — 5 seconds. The pressure-tick only fires when
|
|
125
|
+
* SOME category is at >90% of its current allocation; it stops on its
|
|
126
|
+
* own when every category drops back under 70%. The 30s rebalance
|
|
127
|
+
* remains the always-on cadence for whole-pool sizing changes (instance
|
|
128
|
+
* count, system memory shifts).
|
|
129
|
+
*/
|
|
130
|
+
const PRESSURE_TICK_INTERVAL_MS = 5_000;
|
|
131
|
+
/**
|
|
132
|
+
* Per-category pressure thresholds used by the pressure-tick.
|
|
133
|
+
*/
|
|
134
|
+
const CATEGORY_PRESSURE_HIGH = 0.9;
|
|
135
|
+
const CATEGORY_PRESSURE_LOW = 0.7;
|
|
136
|
+
/**
|
|
137
|
+
* Per-category bounds on dynamic rebalancing. A category can shrink to
|
|
138
|
+
* 25% of its base allocation (giving the rest to others) or grow to
|
|
139
|
+
* 200% of its base allocation (consuming surplus from underutilized
|
|
140
|
+
* categories). Outside these bounds the system stops adapting in that
|
|
141
|
+
* direction — protecting against pathological workloads that would
|
|
142
|
+
* starve a small category entirely.
|
|
143
|
+
*/
|
|
144
|
+
const CATEGORY_MIN_SHARE = 0.25;
|
|
145
|
+
const CATEGORY_MAX_SHARE = 2.0;
|
|
146
|
+
/**
|
|
147
|
+
* Minimum change in allowed bytes before notifying a subscriber. Avoids
|
|
148
|
+
* spurious callbacks when the rebalance only nudges allocations by a
|
|
149
|
+
* few KB. 5% relative + 1 MB absolute.
|
|
150
|
+
*/
|
|
151
|
+
const NOTIFY_DELTA_RATIO = 0.05;
|
|
152
|
+
const NOTIFY_DELTA_BYTES = 1024 * 1024;
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Cgroup detection (reuses brainy's approach)
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
/**
|
|
157
|
+
* Detect container memory limit from cgroups v1 or v2.
|
|
158
|
+
*
|
|
159
|
+
* @returns Memory limit in bytes, or null if not in a container / unlimited
|
|
160
|
+
*/
|
|
161
|
+
function detectCgroupLimit() {
|
|
162
|
+
try {
|
|
163
|
+
// cgroups v2
|
|
164
|
+
const v2Path = '/sys/fs/cgroup/memory.max';
|
|
165
|
+
if (existsSync(v2Path)) {
|
|
166
|
+
const content = readFileSync(v2Path, 'utf8').trim();
|
|
167
|
+
if (content !== 'max') {
|
|
168
|
+
const bytes = parseInt(content, 10);
|
|
169
|
+
if (bytes > 64 * 1024 * 1024 && bytes < 1024 * 1024 * 1024 * 1024) {
|
|
170
|
+
return bytes;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// cgroups v1
|
|
175
|
+
const v1Path = '/sys/fs/cgroup/memory/memory.limit_in_bytes';
|
|
176
|
+
if (existsSync(v1Path)) {
|
|
177
|
+
const content = readFileSync(v1Path, 'utf8').trim();
|
|
178
|
+
const bytes = parseInt(content, 10);
|
|
179
|
+
if (bytes > 64 * 1024 * 1024 && bytes < os.totalmem() * 2) {
|
|
180
|
+
return bytes;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Not in a cgroup environment
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// ResourceManager
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
/**
|
|
193
|
+
* Adaptive resource manager for multi-instance Brainy deployments.
|
|
194
|
+
*
|
|
195
|
+
* Tracks active brainy instances, observes system resources, and computes
|
|
196
|
+
* dynamic memory budgets for cache sizing and pool eviction.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```typescript
|
|
200
|
+
* const rm = ResourceManager.getInstance()
|
|
201
|
+
* rm.trackInstance('tenant-wicks', 450)
|
|
202
|
+
*
|
|
203
|
+
* const profile = rm.getResourceProfile()
|
|
204
|
+
* console.log(`Cache budget: ${profile.recommendedCacheSize / 1024 / 1024}MB`)
|
|
205
|
+
* console.log(`Headroom for new instances: ${profile.headroom / 1024 / 1024}MB`)
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
export class ResourceManager {
|
|
209
|
+
static instance = null;
|
|
210
|
+
instances = new Map();
|
|
211
|
+
effectiveLimit;
|
|
212
|
+
isContainer;
|
|
213
|
+
rebalanceTimer = null;
|
|
214
|
+
onCacheResize = null;
|
|
215
|
+
/**
|
|
216
|
+
* **Piece J — OS-memory observation.** `/proc/meminfo`-backed
|
|
217
|
+
* snapshot source. Held as an instance field so tests can swap
|
|
218
|
+
* the probe via {@link _testInjectOsMemoryProbe}.
|
|
219
|
+
*/
|
|
220
|
+
osMemoryProbe = new OsMemoryProbe();
|
|
221
|
+
/**
|
|
222
|
+
* Active per-category memory subscribers, keyed by name. Each
|
|
223
|
+
* subscriber receives notifications when its effective allocation
|
|
224
|
+
* changes (whether from a whole-pool rebalance or a category-level
|
|
225
|
+
* dynamic adaptation).
|
|
226
|
+
*/
|
|
227
|
+
subscribers = new Map();
|
|
228
|
+
/**
|
|
229
|
+
* Current dynamic share for each category. Starts at `BASE_CATEGORY_SHARES`
|
|
230
|
+
* and adapts at runtime to reported pressure. Sum across categories is
|
|
231
|
+
* preserved at exactly 1.0 by the rebalance algorithm.
|
|
232
|
+
*/
|
|
233
|
+
categoryShares = { ...BASE_CATEGORY_SHARES };
|
|
234
|
+
/**
|
|
235
|
+
* 5-second pressure-tick timer. Only running while at least one
|
|
236
|
+
* category is at or above the high-water threshold; auto-stops once
|
|
237
|
+
* every category falls back below the low-water threshold.
|
|
238
|
+
*/
|
|
239
|
+
pressureTickTimer = null;
|
|
240
|
+
/**
|
|
241
|
+
* Tracks how many consecutive ticks have shown no pressure — used to
|
|
242
|
+
* decide when to stop the pressure-tick.
|
|
243
|
+
*/
|
|
244
|
+
quietTicks = 0;
|
|
245
|
+
/** One-shot guard for the multi-tenant OS-limits deployment warning. */
|
|
246
|
+
osLimitsWarned = false;
|
|
247
|
+
constructor() {
|
|
248
|
+
const cgroupLimit = detectCgroupLimit();
|
|
249
|
+
const systemTotal = os.totalmem();
|
|
250
|
+
this.isContainer = cgroupLimit !== null;
|
|
251
|
+
this.effectiveLimit = cgroupLimit !== null
|
|
252
|
+
? Math.min(cgroupLimit, systemTotal)
|
|
253
|
+
: systemTotal;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Get the singleton ResourceManager instance.
|
|
257
|
+
*/
|
|
258
|
+
static getInstance() {
|
|
259
|
+
if (!ResourceManager.instance) {
|
|
260
|
+
ResourceManager.instance = new ResourceManager();
|
|
261
|
+
}
|
|
262
|
+
return ResourceManager.instance;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* **Back-compat callback** for the cortex 2.x cache resize wiring.
|
|
266
|
+
* The cor plugin (`src/plugin.ts`) registers brainy's
|
|
267
|
+
* `UnifiedCache.setMaxSize` via this method; the callback fires on
|
|
268
|
+
* every whole-pool rebalance with the TOTAL cache budget (not a
|
|
269
|
+
* per-category slice).
|
|
270
|
+
*
|
|
271
|
+
* Behavior is unchanged from cortex 2.x: the callback receives the
|
|
272
|
+
* full `recommendedCacheSize` from `getResourceProfile()` whenever
|
|
273
|
+
* the pool resizes. The new per-category subscriber API
|
|
274
|
+
* ([`registerMemoryBudgetSubscriber`](#registerMemoryBudgetSubscriber))
|
|
275
|
+
* runs in PARALLEL — they don't interfere. Legacy callers can keep
|
|
276
|
+
* their existing wiring; new code that wants a category-specific
|
|
277
|
+
* allocation registers as a subscriber.
|
|
278
|
+
*
|
|
279
|
+
* @param callback - Called with the new recommended cache size in bytes
|
|
280
|
+
*/
|
|
281
|
+
setCacheResizeCallback(callback) {
|
|
282
|
+
this.onCacheResize = callback;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Register a subscriber for per-category memory budget notifications.
|
|
286
|
+
*
|
|
287
|
+
* Subscribers receive an `allowedBytes` value whenever their effective
|
|
288
|
+
* allocation changes — either because the overall pool resized (system
|
|
289
|
+
* memory shift, instance count change) or because the dynamic
|
|
290
|
+
* rebalance loop shifted capacity between categories under observed
|
|
291
|
+
* pressure.
|
|
292
|
+
*
|
|
293
|
+
* The returned function deregisters the subscriber. Each `name` must
|
|
294
|
+
* be unique; registering twice with the same name replaces the prior
|
|
295
|
+
* registration.
|
|
296
|
+
*
|
|
297
|
+
* @param name - Unique subscriber name (used with `reportNativeMemory`)
|
|
298
|
+
* @param category - The category this subscriber draws from
|
|
299
|
+
* @param callback - Invoked with the current `allowedBytes` ceiling
|
|
300
|
+
* @returns A deregistration function (call to detach the subscriber)
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* ```typescript
|
|
304
|
+
* const rm = ResourceManager.getInstance()
|
|
305
|
+
* const unsubscribe = rm.registerMemoryBudgetSubscriber(
|
|
306
|
+
* 'metadata-lsm',
|
|
307
|
+
* 'metadata',
|
|
308
|
+
* (allowed) => { lsm.setMaxBytes(allowed) }
|
|
309
|
+
* )
|
|
310
|
+
* // Later, when the LSM measures its footprint:
|
|
311
|
+
* rm.reportNativeMemory('metadata-lsm', lsm.currentBytes())
|
|
312
|
+
* // To detach:
|
|
313
|
+
* unsubscribe()
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
registerMemoryBudgetSubscriber(name, category, callback) {
|
|
317
|
+
const subscriber = {
|
|
318
|
+
name,
|
|
319
|
+
category,
|
|
320
|
+
callback,
|
|
321
|
+
reportedBytes: 0,
|
|
322
|
+
lastAllowed: 0,
|
|
323
|
+
};
|
|
324
|
+
this.subscribers.set(name, subscriber);
|
|
325
|
+
// Fire the initial allowed-bytes value so the subscriber knows its
|
|
326
|
+
// starting ceiling without having to call getResourceProfile() first.
|
|
327
|
+
const initial = this.computeAllowedBytes(category);
|
|
328
|
+
subscriber.lastAllowed = initial;
|
|
329
|
+
try {
|
|
330
|
+
callback(initial);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// Subscriber callback threw; swallow — we don't trust foreign
|
|
334
|
+
// callbacks to bring down the rebalance loop.
|
|
335
|
+
}
|
|
336
|
+
return () => {
|
|
337
|
+
const current = this.subscribers.get(name);
|
|
338
|
+
if (current === subscriber) {
|
|
339
|
+
this.subscribers.delete(name);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Report the current resident-memory footprint of a registered
|
|
345
|
+
* subscriber, in bytes. The rebalance loop uses these values to
|
|
346
|
+
* detect category-level pressure and shift capacity dynamically.
|
|
347
|
+
*
|
|
348
|
+
* Call this whenever the subscriber's footprint changes meaningfully
|
|
349
|
+
* (after a flush, after a major allocation, etc). The pressure-tick
|
|
350
|
+
* fires within 5 seconds of any single category exceeding 90% of its
|
|
351
|
+
* current allocation; calling more often is harmless.
|
|
352
|
+
*
|
|
353
|
+
* Unknown names are silently dropped — a subscriber that has
|
|
354
|
+
* deregistered may still have pending reports in flight.
|
|
355
|
+
*/
|
|
356
|
+
reportNativeMemory(name, bytes) {
|
|
357
|
+
const subscriber = this.subscribers.get(name);
|
|
358
|
+
if (!subscriber)
|
|
359
|
+
return;
|
|
360
|
+
subscriber.reportedBytes = Math.max(0, bytes);
|
|
361
|
+
// Cheap check: if THIS subscriber's category is now over the
|
|
362
|
+
// high-water mark, start the pressure-tick. The full rebalance
|
|
363
|
+
// runs inside the tick to keep this call O(1).
|
|
364
|
+
const allocation = this.computeAllowedBytes(subscriber.category);
|
|
365
|
+
if (allocation > 0 && subscriber.reportedBytes >= allocation * CATEGORY_PRESSURE_HIGH) {
|
|
366
|
+
this.ensurePressureTickRunning();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Report the current dynamic share for each category. Mostly used by
|
|
371
|
+
* tests + the diagnostics surface; production callers should subscribe
|
|
372
|
+
* via [`registerMemoryBudgetSubscriber`] instead of polling.
|
|
373
|
+
*
|
|
374
|
+
* The sum across categories is always exactly 1.0 (modulo float
|
|
375
|
+
* rounding); allocations sum to the cache budget.
|
|
376
|
+
*/
|
|
377
|
+
getCategoryShares() {
|
|
378
|
+
return { ...this.categoryShares };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The current per-category allowed-bytes ceiling. Same as what an
|
|
382
|
+
* active subscriber received in its last callback. Provided for
|
|
383
|
+
* diagnostics; production callers subscribe instead of polling.
|
|
384
|
+
*/
|
|
385
|
+
getCategoryAllowedBytes(category) {
|
|
386
|
+
return this.computeAllowedBytes(category);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Start periodic rebalancing (every 30s). Aligns with brainy's existing
|
|
390
|
+
* memory pressure monitoring interval.
|
|
391
|
+
*/
|
|
392
|
+
startRebalancing() {
|
|
393
|
+
if (this.rebalanceTimer)
|
|
394
|
+
return;
|
|
395
|
+
this.rebalanceTimer = setInterval(() => {
|
|
396
|
+
this.rebalance();
|
|
397
|
+
}, REBALANCE_INTERVAL_MS);
|
|
398
|
+
// Don't keep the process alive just for rebalancing
|
|
399
|
+
if (this.rebalanceTimer.unref) {
|
|
400
|
+
this.rebalanceTimer.unref();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Stop periodic rebalancing.
|
|
405
|
+
*/
|
|
406
|
+
stopRebalancing() {
|
|
407
|
+
if (this.rebalanceTimer) {
|
|
408
|
+
clearInterval(this.rebalanceTimer);
|
|
409
|
+
this.rebalanceTimer = null;
|
|
410
|
+
}
|
|
411
|
+
this.stopPressureTick();
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Start the 5-second pressure-tick if it isn't already running.
|
|
415
|
+
* Idempotent. Called from `reportNativeMemory` when a category crosses
|
|
416
|
+
* the high-water threshold; stops on its own when every category
|
|
417
|
+
* falls back below the low-water threshold for 3 consecutive ticks.
|
|
418
|
+
*/
|
|
419
|
+
ensurePressureTickRunning() {
|
|
420
|
+
if (this.pressureTickTimer) {
|
|
421
|
+
this.quietTicks = 0;
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
this.quietTicks = 0;
|
|
425
|
+
this.pressureTickTimer = setInterval(() => {
|
|
426
|
+
this.runPressureTick();
|
|
427
|
+
}, PRESSURE_TICK_INTERVAL_MS);
|
|
428
|
+
if (this.pressureTickTimer.unref) {
|
|
429
|
+
this.pressureTickTimer.unref();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
stopPressureTick() {
|
|
433
|
+
if (this.pressureTickTimer) {
|
|
434
|
+
clearInterval(this.pressureTickTimer);
|
|
435
|
+
this.pressureTickTimer = null;
|
|
436
|
+
}
|
|
437
|
+
this.quietTicks = 0;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Test-only hook to drive a pressure tick synchronously without
|
|
441
|
+
* waiting for the timer. Not exposed to production callers; tests
|
|
442
|
+
* use this to assert the rebalance behaviour deterministically.
|
|
443
|
+
* @internal
|
|
444
|
+
*/
|
|
445
|
+
_testTickPressure() {
|
|
446
|
+
this.runPressureTick();
|
|
447
|
+
}
|
|
448
|
+
// ── Instance tracking ────────────────────────────────────────────────────
|
|
449
|
+
/**
|
|
450
|
+
* Track a new or updated brainy instance. Call when a brainy instance is
|
|
451
|
+
* created or when its entity count changes significantly.
|
|
452
|
+
*
|
|
453
|
+
* @param key - Unique scope key for this instance (e.g. tenant slug, user:workspace)
|
|
454
|
+
* @param entityCount - Current entity count in this instance
|
|
455
|
+
*/
|
|
456
|
+
trackInstance(key, entityCount) {
|
|
457
|
+
const estimatedMemory = INSTANCE_BASELINE_BYTES + entityCount * this.observedBytesPerEntity();
|
|
458
|
+
this.instances.set(key, {
|
|
459
|
+
key,
|
|
460
|
+
entityCount,
|
|
461
|
+
estimatedMemory,
|
|
462
|
+
lastAccess: Date.now(),
|
|
463
|
+
});
|
|
464
|
+
this.rebalance();
|
|
465
|
+
this.maybeWarnOsLimits();
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* One-shot deployment warning: when enough brains are pooled in ONE
|
|
469
|
+
* process that OS file/map limits could bottleneck before RAM — and the
|
|
470
|
+
* limits are below the projected need — log the exact fix. Cor
|
|
471
|
+
* auto-raises the SOFT `nofile` limit itself; the hard cap +
|
|
472
|
+
* `vm.max_map_count` must be set by the deployment (a process cannot
|
|
473
|
+
* raise those). Context-aware: silent for a single big brain (Venue);
|
|
474
|
+
* fires only at real pool scale. See `docs/ADR-004-multi-tenant-storage.md`.
|
|
475
|
+
*/
|
|
476
|
+
maybeWarnOsLimits() {
|
|
477
|
+
if (this.osLimitsWarned)
|
|
478
|
+
return;
|
|
479
|
+
const n = this.instances.size;
|
|
480
|
+
if (n < 100)
|
|
481
|
+
return; // only matters once a real pool is running
|
|
482
|
+
const info = getOpenFileLimitInfo();
|
|
483
|
+
if (!info)
|
|
484
|
+
return;
|
|
485
|
+
// Post-lazy-shard per-brain estimates, with margin.
|
|
486
|
+
const projFds = n * 16;
|
|
487
|
+
const projMaps = n * 24;
|
|
488
|
+
const fdTight = info.hard > 0 && info.hard < projFds;
|
|
489
|
+
const mapTight = info.vmMaxMapCount > 0 && info.vmMaxMapCount < projMaps;
|
|
490
|
+
if (!fdTight && !mapTight)
|
|
491
|
+
return;
|
|
492
|
+
this.osLimitsWarned = true;
|
|
493
|
+
prodLog.warn(`[cor] ${n} brains pooled in one process — OS limits may bottleneck before RAM. ` +
|
|
494
|
+
`A process can't raise these; set them in the deployment:\n` +
|
|
495
|
+
` docker run --ulimit nofile=65536:65536 --sysctl vm.max_map_count=262144 …\n` +
|
|
496
|
+
` (current hard nofile=${info.hard}, vm.max_map_count=${info.vmMaxMapCount}; ` +
|
|
497
|
+
`cor already raised soft nofile to ${info.softAfter}). See cor ADR-004.`);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Stop tracking a brainy instance. Call when the instance is evicted or closed.
|
|
501
|
+
*
|
|
502
|
+
* @param key - The scope key that was passed to trackInstance()
|
|
503
|
+
*/
|
|
504
|
+
untrackInstance(key) {
|
|
505
|
+
this.instances.delete(key);
|
|
506
|
+
this.rebalance();
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Record an access to an instance (updates lastAccess for weighted eviction).
|
|
510
|
+
*
|
|
511
|
+
* @param key - The scope key
|
|
512
|
+
*/
|
|
513
|
+
touchInstance(key) {
|
|
514
|
+
const info = this.instances.get(key);
|
|
515
|
+
if (info) {
|
|
516
|
+
info.lastAccess = Date.now();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// ── Resource profiling ───────────────────────────────────────────────────
|
|
520
|
+
/**
|
|
521
|
+
* **Piece 8 — V8 heap tracking.** Snapshot the current V8 heap
|
|
522
|
+
* state and classify pressure. Cheap call (a single
|
|
523
|
+
* `v8.getHeapStatistics()` syscall + a few divisions); fine to
|
|
524
|
+
* invoke per-query on the hot path. Subsystems like brainy's filter
|
|
525
|
+
* lowering use this to early-exit before allocating big
|
|
526
|
+
* intermediate bitmaps that would push the heap over the ceiling.
|
|
527
|
+
*
|
|
528
|
+
* The pressure classification uses the same three-level shape as
|
|
529
|
+
* system-RSS pressure (`normal` / `elevated` / `critical`) so
|
|
530
|
+
* subscribers can branch uniformly.
|
|
531
|
+
*/
|
|
532
|
+
getHeapStats() {
|
|
533
|
+
const stats = v8.getHeapStatistics();
|
|
534
|
+
const ratio = stats.heap_size_limit > 0
|
|
535
|
+
? stats.used_heap_size / stats.heap_size_limit
|
|
536
|
+
: 0;
|
|
537
|
+
let pressure = 'normal';
|
|
538
|
+
if (ratio >= V8_HEAP_PRESSURE_CRITICAL)
|
|
539
|
+
pressure = 'critical';
|
|
540
|
+
else if (ratio >= V8_HEAP_PRESSURE_ELEVATED)
|
|
541
|
+
pressure = 'elevated';
|
|
542
|
+
return {
|
|
543
|
+
usedHeapSize: stats.used_heap_size,
|
|
544
|
+
heapSizeLimit: stats.heap_size_limit,
|
|
545
|
+
externalMemory: stats.external_memory,
|
|
546
|
+
ratio,
|
|
547
|
+
pressure,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* **Piece 8.** Convenience accessor returning just the heap
|
|
552
|
+
* pressure classification — cheaper than constructing a full
|
|
553
|
+
* {@link V8HeapSnapshot} when the caller only needs the branch.
|
|
554
|
+
*/
|
|
555
|
+
getHeapPressure() {
|
|
556
|
+
return this.getHeapStats().pressure;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* **Piece J.** Latest OS-memory snapshot from `/proc/meminfo`
|
|
560
|
+
* (Linux) or the os-fallback path. Cached for 250 ms by the
|
|
561
|
+
* underlying {@link OsMemoryProbe}; consecutive calls inside the
|
|
562
|
+
* cache window reuse the value. Cheaper than
|
|
563
|
+
* {@link getResourceProfile} when the caller only needs the
|
|
564
|
+
* memory snapshot (the Adaptive DiskANN selector — Piece I — is
|
|
565
|
+
* the canonical caller).
|
|
566
|
+
*/
|
|
567
|
+
getOsMemorySnapshot() {
|
|
568
|
+
return this.osMemoryProbe.snapshot();
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Test-only hook to swap the OS-memory probe with a deterministic
|
|
572
|
+
* stand-in. Production code must not call this — the singleton's
|
|
573
|
+
* probe is part of the resource-observation contract.
|
|
574
|
+
* @internal
|
|
575
|
+
*/
|
|
576
|
+
_testInjectOsMemoryProbe(probe) {
|
|
577
|
+
this.osMemoryProbe = probe;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Compute a full resource profile snapshot.
|
|
581
|
+
*
|
|
582
|
+
* @returns Current system resources, instance stats, and recommendations
|
|
583
|
+
*/
|
|
584
|
+
getResourceProfile() {
|
|
585
|
+
const mem = process.memoryUsage();
|
|
586
|
+
const totalEntities = this.getTotalEntities();
|
|
587
|
+
const totalInstanceMemory = this.getTotalInstanceMemory();
|
|
588
|
+
const activeInstances = this.instances.size;
|
|
589
|
+
const recommendedCacheSize = this.computeCacheBudget(totalInstanceMemory);
|
|
590
|
+
const pressure = this.computePressure(mem.rss);
|
|
591
|
+
const usedByFixed = FIXED_OVERHEAD_BYTES;
|
|
592
|
+
const usedByInstances = totalInstanceMemory;
|
|
593
|
+
const usedByCache = recommendedCacheSize;
|
|
594
|
+
const headroom = Math.max(0, this.effectiveLimit - usedByFixed - usedByInstances - usedByCache);
|
|
595
|
+
const avgInstanceMemory = activeInstances > 0
|
|
596
|
+
? totalInstanceMemory / activeInstances
|
|
597
|
+
: INSTANCE_BASELINE_BYTES;
|
|
598
|
+
return {
|
|
599
|
+
totalMemory: os.totalmem(),
|
|
600
|
+
freeMemory: os.freemem(),
|
|
601
|
+
effectiveLimit: this.effectiveLimit,
|
|
602
|
+
isContainer: this.isContainer,
|
|
603
|
+
processRss: mem.rss,
|
|
604
|
+
heapUsed: mem.heapUsed,
|
|
605
|
+
cpuCount: os.cpus().length,
|
|
606
|
+
activeInstances,
|
|
607
|
+
totalEntities,
|
|
608
|
+
recommendedCacheSize,
|
|
609
|
+
pressure,
|
|
610
|
+
avgInstanceMemory,
|
|
611
|
+
headroom,
|
|
612
|
+
v8Heap: this.getHeapStats(),
|
|
613
|
+
osMemory: this.getOsMemorySnapshot(),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Check if there's enough memory headroom to create a new instance
|
|
618
|
+
* with the given entity count.
|
|
619
|
+
*
|
|
620
|
+
* @param estimatedEntities - Expected entity count for the new instance
|
|
621
|
+
* @returns true if headroom is sufficient
|
|
622
|
+
*/
|
|
623
|
+
canAccommodateInstance(estimatedEntities = 500) {
|
|
624
|
+
const profile = this.getResourceProfile();
|
|
625
|
+
const needed = INSTANCE_BASELINE_BYTES + estimatedEntities * this.observedBytesPerEntity();
|
|
626
|
+
return profile.headroom >= needed && profile.pressure !== 'critical';
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* **Piece 8 commit 2 — observed per-entity RAM estimate.** When
|
|
630
|
+
* any subscriber has reported native memory via
|
|
631
|
+
* {@link reportNativeMemory}, the ratio of total reported bytes
|
|
632
|
+
* to total tracked entities is the actual observed per-entity
|
|
633
|
+
* footprint — accurate for THIS deployment's workload mix instead
|
|
634
|
+
* of a one-size-fits-all guess. Falls back to
|
|
635
|
+
* {@link FALLBACK_BYTES_PER_ENTITY} when subscribers haven't
|
|
636
|
+
* reported (cold boot, no instances tracked, or workloads where
|
|
637
|
+
* the brain hasn't done any reading yet).
|
|
638
|
+
*
|
|
639
|
+
* Floored at {@link MIN_BYTES_PER_ENTITY} so a subscriber that
|
|
640
|
+
* temporarily under-reports during a flush doesn't make
|
|
641
|
+
* `canAccommodateInstance` say "yes" to instances that can't fit.
|
|
642
|
+
*
|
|
643
|
+
* Cheap call: O(subscribers + instances) — both bounded by a few
|
|
644
|
+
* dozen in production. Fine to invoke per trackInstance /
|
|
645
|
+
* canAccommodateInstance call.
|
|
646
|
+
*/
|
|
647
|
+
observedBytesPerEntity() {
|
|
648
|
+
const totalEntities = this.getTotalEntities();
|
|
649
|
+
if (totalEntities === 0)
|
|
650
|
+
return FALLBACK_BYTES_PER_ENTITY;
|
|
651
|
+
let totalReported = 0;
|
|
652
|
+
for (const sub of this.subscribers.values()) {
|
|
653
|
+
totalReported += sub.reportedBytes;
|
|
654
|
+
}
|
|
655
|
+
if (totalReported === 0)
|
|
656
|
+
return FALLBACK_BYTES_PER_ENTITY;
|
|
657
|
+
const ratio = Math.floor(totalReported / totalEntities);
|
|
658
|
+
return Math.max(MIN_BYTES_PER_ENTITY, ratio);
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Get the scope key of the instance that should be evicted first.
|
|
662
|
+
* Uses weighted scoring: large + idle instances are evicted before
|
|
663
|
+
* small + active ones.
|
|
664
|
+
*
|
|
665
|
+
* @returns The key to evict, or null if no instances are tracked
|
|
666
|
+
*/
|
|
667
|
+
getEvictionCandidate() {
|
|
668
|
+
if (this.instances.size === 0)
|
|
669
|
+
return null;
|
|
670
|
+
const now = Date.now();
|
|
671
|
+
let bestKey = null;
|
|
672
|
+
let bestScore = -Infinity;
|
|
673
|
+
for (const [key, info] of this.instances) {
|
|
674
|
+
// Score: idle time weighted by memory usage
|
|
675
|
+
// Higher score = better eviction candidate
|
|
676
|
+
const idleMs = now - info.lastAccess;
|
|
677
|
+
const memoryWeight = info.estimatedMemory / (1024 * 1024); // MB
|
|
678
|
+
const score = (idleMs / 1000) * memoryWeight; // seconds × MB
|
|
679
|
+
if (score > bestScore) {
|
|
680
|
+
bestScore = score;
|
|
681
|
+
bestKey = key;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return bestKey;
|
|
685
|
+
}
|
|
686
|
+
// ── Internal ─────────────────────────────────────────────────────────────
|
|
687
|
+
/**
|
|
688
|
+
* Rebalance cache sizing based on current instance count and memory state.
|
|
689
|
+
*
|
|
690
|
+
* Three responsibilities:
|
|
691
|
+
* 1. Recompute the total cache budget from instance count + system
|
|
692
|
+
* memory (the pre-Piece-8 behaviour).
|
|
693
|
+
* 2. Fire the back-compat `onCacheResize` if it exists.
|
|
694
|
+
* 3. Notify every per-category subscriber of its current
|
|
695
|
+
* `allowedBytes` ceiling — picks up any whole-pool shift even
|
|
696
|
+
* when the per-category dynamic adaptation isn't pressed.
|
|
697
|
+
*/
|
|
698
|
+
rebalance() {
|
|
699
|
+
const totalInstanceMemory = this.getTotalInstanceMemory();
|
|
700
|
+
const newCacheSize = this.computeCacheBudget(totalInstanceMemory);
|
|
701
|
+
if (this.onCacheResize) {
|
|
702
|
+
try {
|
|
703
|
+
this.onCacheResize(newCacheSize);
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
// back-compat callback threw; protect the loop
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
this.notifySubscribers();
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Drive one tick of the per-category dynamic rebalance. Reads the
|
|
713
|
+
* latest `reportNativeMemory` values, identifies over- and under-
|
|
714
|
+
* utilized categories, shifts the dynamic shares between them
|
|
715
|
+
* (bounded by `CATEGORY_MIN_SHARE` / `CATEGORY_MAX_SHARE`), and
|
|
716
|
+
* notifies any subscriber whose allowance changed meaningfully.
|
|
717
|
+
*
|
|
718
|
+
* Auto-stops the pressure-tick loop after 3 consecutive quiet ticks
|
|
719
|
+
* (no category > low-water).
|
|
720
|
+
*/
|
|
721
|
+
runPressureTick() {
|
|
722
|
+
const categoryBytes = this.aggregateReportedBytesByCategory();
|
|
723
|
+
const cacheBudget = this.computeCacheBudget(this.getTotalInstanceMemory());
|
|
724
|
+
let anyPressure = false;
|
|
725
|
+
// Identify donors (low utilization) and recipients (high utilization).
|
|
726
|
+
const donors = [];
|
|
727
|
+
const recipients = [];
|
|
728
|
+
for (const category of Object.keys(BASE_CATEGORY_SHARES)) {
|
|
729
|
+
const allocation = cacheBudget * this.categoryShares[category];
|
|
730
|
+
if (allocation <= 0)
|
|
731
|
+
continue;
|
|
732
|
+
const reported = categoryBytes[category];
|
|
733
|
+
const utilization = reported / allocation;
|
|
734
|
+
if (utilization >= CATEGORY_PRESSURE_HIGH) {
|
|
735
|
+
anyPressure = true;
|
|
736
|
+
recipients.push(category);
|
|
737
|
+
}
|
|
738
|
+
else if (utilization <= 1 - CATEGORY_PRESSURE_HIGH) {
|
|
739
|
+
// Symmetric low-water: a category using <10% of its allocation
|
|
740
|
+
// is a candidate donor. Lower threshold than the high-water on
|
|
741
|
+
// purpose — we don't want to flap on small fluctuations.
|
|
742
|
+
donors.push(category);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (recipients.length > 0 && donors.length > 0) {
|
|
746
|
+
this.rebalanceShares(donors, recipients, categoryBytes, cacheBudget);
|
|
747
|
+
}
|
|
748
|
+
// Quiet-tick detection: if no category is over the LOW-water mark
|
|
749
|
+
// (i.e., everyone is using < 70% of their allocation), count it as
|
|
750
|
+
// a quiet tick. Three in a row → stop the pressure-tick.
|
|
751
|
+
const anyAboveLow = Object.keys(BASE_CATEGORY_SHARES).some((c) => {
|
|
752
|
+
const allocation = cacheBudget * this.categoryShares[c];
|
|
753
|
+
return allocation > 0 && categoryBytes[c] >= allocation * CATEGORY_PRESSURE_LOW;
|
|
754
|
+
});
|
|
755
|
+
if (anyAboveLow) {
|
|
756
|
+
this.quietTicks = 0;
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
this.quietTicks++;
|
|
760
|
+
if (this.quietTicks >= 3) {
|
|
761
|
+
this.stopPressureTick();
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// Always notify after a tick — subscribers should see the new
|
|
765
|
+
// ceiling even if we didn't shift shares this round (e.g. if a
|
|
766
|
+
// subscriber's reported bytes alone changed the allowed ceiling
|
|
767
|
+
// via the back-compat cache resize callback).
|
|
768
|
+
if (anyPressure || this.quietTicks === 0) {
|
|
769
|
+
this.notifySubscribers();
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Shift dynamic share from donor categories to recipient categories.
|
|
774
|
+
*
|
|
775
|
+
* Both sides are PRE-CAPPED by the per-category min/max share bounds
|
|
776
|
+
* (25% / 200% of base share, respectively) BEFORE the proportional
|
|
777
|
+
* distribution runs. Pre-capping is the only safe pattern: clamping
|
|
778
|
+
* after a proportional split would either (a) lose capacity (donors
|
|
779
|
+
* give shares that recipients can't accept due to their ceiling) or
|
|
780
|
+
* (b) require renormalization, which silently violates the ceiling.
|
|
781
|
+
*
|
|
782
|
+
* `transfer = min(total bounded contribution, total bounded demand)`
|
|
783
|
+
* — by construction the per-donor `give` and per-recipient `take`
|
|
784
|
+
* sums are equal, so the shares always sum to 1.0 without
|
|
785
|
+
* renormalization. The float drift over many cycles is bounded by
|
|
786
|
+
* the floating-point precision of a single division, well under any
|
|
787
|
+
* test's threshold.
|
|
788
|
+
*/
|
|
789
|
+
rebalanceShares(donors, recipients, categoryBytes, cacheBudget) {
|
|
790
|
+
// Pre-cap contributions: each donor's bounded contribution is the
|
|
791
|
+
// smaller of (headroom × 0.5) and (currentShare - minShare).
|
|
792
|
+
let totalContribution = 0;
|
|
793
|
+
const donorContributions = {};
|
|
794
|
+
for (const donor of donors) {
|
|
795
|
+
const allocation = cacheBudget * this.categoryShares[donor];
|
|
796
|
+
const reported = categoryBytes[donor];
|
|
797
|
+
const headroomShare = (allocation - reported) / cacheBudget;
|
|
798
|
+
const unboundedGive = Math.max(0, headroomShare * 0.5);
|
|
799
|
+
const baseShare = BASE_CATEGORY_SHARES[donor];
|
|
800
|
+
const minShare = baseShare * CATEGORY_MIN_SHARE;
|
|
801
|
+
const maxGive = Math.max(0, this.categoryShares[donor] - minShare);
|
|
802
|
+
const bounded = Math.min(unboundedGive, maxGive);
|
|
803
|
+
if (bounded > 0) {
|
|
804
|
+
donorContributions[donor] = bounded;
|
|
805
|
+
totalContribution += bounded;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (totalContribution <= 0)
|
|
809
|
+
return;
|
|
810
|
+
// Pre-cap demands: each recipient's bounded demand is the smaller of
|
|
811
|
+
// (over-utilization + growth wish) and (maxShare - currentShare).
|
|
812
|
+
let totalDemand = 0;
|
|
813
|
+
const recipientDemands = {};
|
|
814
|
+
for (const recipient of recipients) {
|
|
815
|
+
const allocation = cacheBudget * this.categoryShares[recipient];
|
|
816
|
+
const reported = categoryBytes[recipient];
|
|
817
|
+
const overByShare = Math.max(0, (reported - allocation) / cacheBudget);
|
|
818
|
+
const growthWish = this.categoryShares[recipient] * 0.05;
|
|
819
|
+
const unboundedWant = overByShare + growthWish;
|
|
820
|
+
const baseShare = BASE_CATEGORY_SHARES[recipient];
|
|
821
|
+
const maxShare = baseShare * CATEGORY_MAX_SHARE;
|
|
822
|
+
const maxTake = Math.max(0, maxShare - this.categoryShares[recipient]);
|
|
823
|
+
const bounded = Math.min(unboundedWant, maxTake);
|
|
824
|
+
if (bounded > 0) {
|
|
825
|
+
recipientDemands[recipient] = bounded;
|
|
826
|
+
totalDemand += bounded;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (totalDemand <= 0)
|
|
830
|
+
return;
|
|
831
|
+
// Transfer = min of bounded totals. Equally distributed across both
|
|
832
|
+
// sides → sum of give == sum of take → categoryShares sum is
|
|
833
|
+
// preserved without renormalization.
|
|
834
|
+
const transfer = Math.min(totalContribution, totalDemand);
|
|
835
|
+
for (const donor of donors) {
|
|
836
|
+
const contribution = donorContributions[donor];
|
|
837
|
+
if (!contribution)
|
|
838
|
+
continue;
|
|
839
|
+
const give = (contribution / totalContribution) * transfer;
|
|
840
|
+
this.categoryShares[donor] -= give;
|
|
841
|
+
}
|
|
842
|
+
for (const recipient of recipients) {
|
|
843
|
+
const demand = recipientDemands[recipient];
|
|
844
|
+
if (!demand)
|
|
845
|
+
continue;
|
|
846
|
+
const take = (demand / totalDemand) * transfer;
|
|
847
|
+
this.categoryShares[recipient] += take;
|
|
848
|
+
}
|
|
849
|
+
// Defensive renormalize only if float drift accumulated meaningfully
|
|
850
|
+
// (1e-9 threshold). Should be a no-op in steady state because the
|
|
851
|
+
// pre-capped transfer is symmetric.
|
|
852
|
+
this.renormalizeShares();
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Compute the current allowed-bytes ceiling for a category.
|
|
856
|
+
*/
|
|
857
|
+
computeAllowedBytes(category) {
|
|
858
|
+
const cacheBudget = this.computeCacheBudget(this.getTotalInstanceMemory());
|
|
859
|
+
return Math.floor(cacheBudget * this.categoryShares[category]);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Sum the reportedBytes of every subscriber by category.
|
|
863
|
+
*/
|
|
864
|
+
aggregateReportedBytesByCategory() {
|
|
865
|
+
const totals = {
|
|
866
|
+
vectors: 0,
|
|
867
|
+
metadata: 0,
|
|
868
|
+
embedding: 0,
|
|
869
|
+
other: 0,
|
|
870
|
+
};
|
|
871
|
+
for (const sub of this.subscribers.values()) {
|
|
872
|
+
totals[sub.category] += sub.reportedBytes;
|
|
873
|
+
}
|
|
874
|
+
return totals;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Notify every subscriber of its current allowed-bytes ceiling. Fires
|
|
878
|
+
* the callback only when the allowance has shifted by more than the
|
|
879
|
+
* `NOTIFY_DELTA` threshold from the last delivered value — avoids
|
|
880
|
+
* spurious wakeups on small re-rebalances.
|
|
881
|
+
*/
|
|
882
|
+
notifySubscribers() {
|
|
883
|
+
for (const subscriber of this.subscribers.values()) {
|
|
884
|
+
const allowed = this.computeAllowedBytes(subscriber.category);
|
|
885
|
+
const delta = Math.abs(allowed - subscriber.lastAllowed);
|
|
886
|
+
const ratio = subscriber.lastAllowed > 0 ? delta / subscriber.lastAllowed : Infinity;
|
|
887
|
+
if (delta >= NOTIFY_DELTA_BYTES && ratio >= NOTIFY_DELTA_RATIO) {
|
|
888
|
+
subscriber.lastAllowed = allowed;
|
|
889
|
+
try {
|
|
890
|
+
subscriber.callback(allowed);
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
// foreign callback threw; swallow
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Normalize categoryShares so they sum to exactly 1.0. Compensates for
|
|
900
|
+
* float drift over many rebalance cycles. Preserves the relative
|
|
901
|
+
* proportions of the four categories.
|
|
902
|
+
*/
|
|
903
|
+
renormalizeShares() {
|
|
904
|
+
const sum = this.categoryShares.vectors +
|
|
905
|
+
this.categoryShares.metadata +
|
|
906
|
+
this.categoryShares.embedding +
|
|
907
|
+
this.categoryShares.other;
|
|
908
|
+
if (sum <= 0)
|
|
909
|
+
return;
|
|
910
|
+
if (Math.abs(sum - 1) < 1e-9)
|
|
911
|
+
return;
|
|
912
|
+
this.categoryShares.vectors /= sum;
|
|
913
|
+
this.categoryShares.metadata /= sum;
|
|
914
|
+
this.categoryShares.embedding /= sum;
|
|
915
|
+
this.categoryShares.other /= sum;
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Compute the recommended cache budget given current instance memory usage.
|
|
919
|
+
*
|
|
920
|
+
* Formula: min(MAX_CACHE_RATIO × available, available - fixed - instances - headroom)
|
|
921
|
+
* Clamped to MIN_CACHE_BYTES.
|
|
922
|
+
*/
|
|
923
|
+
computeCacheBudget(totalInstanceMemory) {
|
|
924
|
+
const available = this.effectiveLimit;
|
|
925
|
+
const afterFixed = available - FIXED_OVERHEAD_BYTES;
|
|
926
|
+
const afterInstances = afterFixed - totalInstanceMemory;
|
|
927
|
+
// Reserve 20% of remaining for headroom (new instances, spikes)
|
|
928
|
+
const forCache = afterInstances * 0.8;
|
|
929
|
+
// Also cap at MAX_CACHE_RATIO of total available (don't let cache dominate)
|
|
930
|
+
const ratioCap = afterFixed * MAX_CACHE_RATIO;
|
|
931
|
+
const cacheSize = Math.min(forCache, ratioCap);
|
|
932
|
+
return Math.max(MIN_CACHE_BYTES, Math.floor(cacheSize));
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Determine memory pressure level from current RSS.
|
|
936
|
+
*/
|
|
937
|
+
computePressure(rss) {
|
|
938
|
+
const ratio = rss / this.effectiveLimit;
|
|
939
|
+
if (ratio >= PRESSURE_CRITICAL)
|
|
940
|
+
return 'critical';
|
|
941
|
+
if (ratio >= PRESSURE_ELEVATED)
|
|
942
|
+
return 'elevated';
|
|
943
|
+
return 'normal';
|
|
944
|
+
}
|
|
945
|
+
getTotalEntities() {
|
|
946
|
+
let total = 0;
|
|
947
|
+
for (const info of this.instances.values()) {
|
|
948
|
+
total += info.entityCount;
|
|
949
|
+
}
|
|
950
|
+
return total;
|
|
951
|
+
}
|
|
952
|
+
getTotalInstanceMemory() {
|
|
953
|
+
let total = 0;
|
|
954
|
+
for (const info of this.instances.values()) {
|
|
955
|
+
total += info.estimatedMemory;
|
|
956
|
+
}
|
|
957
|
+
return total;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
//# sourceMappingURL=ResourceManager.js.map
|