@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,944 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NativeGraphAdjacencyIndex — Thin TS wrapper around the Rust graph engine
|
|
3
|
+
*
|
|
4
|
+
* Handles async storage I/O while the Rust engine handles all in-memory
|
|
5
|
+
* data operations (4 LSM-trees, verb tracking, relationship counts).
|
|
6
|
+
*
|
|
7
|
+
* Storage pattern: adapter-controlled I/O with 3-tier fallback.
|
|
8
|
+
* - Tier 1 (mmap): saveBinaryBlob + getBinaryBlobPath → zero-copy queries
|
|
9
|
+
* - Tier 2 (binary blob): saveBinaryBlob only → binary format in memory
|
|
10
|
+
* - Tier 3 (legacy): saveMetadata → base64 JSON
|
|
11
|
+
*
|
|
12
|
+
* UnifiedCache integration stays in TS (getVerbCached, getVerbsBatchCached)
|
|
13
|
+
* since it coordinates cross-subsystem caching and requires async storage access.
|
|
14
|
+
*/
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
import { TypeUtils, VerbType } from '@soulcraft/brainy/types/graphTypes';
|
|
17
|
+
import { loadNativeModule } from '../native/index.js';
|
|
18
|
+
import { getGlobalCache, prodLog } from '@soulcraft/brainy/internals';
|
|
19
|
+
/**
|
|
20
|
+
* Fallback verb-type index for unknown / missing verb-type strings.
|
|
21
|
+
* Mirrors cor's `VERB_FALLBACK_INDEX` from `graph_types.rs` and
|
|
22
|
+
* brainy's `TypeUtils.getVerbIndex` runtime fallback to
|
|
23
|
+
* `VerbType.RelatedTo`. Resolved once at module load — the lookup
|
|
24
|
+
* itself is sub-microsecond but the constant is part of the
|
|
25
|
+
* closed-vocabulary contract documented at
|
|
26
|
+
* [[nountype-verbtype-closed-vocabulary]].
|
|
27
|
+
*/
|
|
28
|
+
const VERB_FALLBACK_INDEX = TypeUtils.getVerbIndex(VerbType.RelatedTo) ?? 3;
|
|
29
|
+
/**
|
|
30
|
+
* Blob key probed to derive the durable verb-ID namespace directory when
|
|
31
|
+
* the caller didn't pass an explicit {@link GraphIndexConfig.persistenceDir}.
|
|
32
|
+
*
|
|
33
|
+
* On a filesystem-backed adapter, `getBinaryBlobPath(key)` resolves to a real
|
|
34
|
+
* on-disk path such as `<root>/_blobs/_graph_verb_ns/x.bin`; taking its
|
|
35
|
+
* `dirname` yields a local, per-brain directory (`<root>/_blobs/_graph_verb_ns`)
|
|
36
|
+
* that sits under the storage root ALONGSIDE the graph LSM SSTables
|
|
37
|
+
* (`<root>/_blobs/graph-lsm/…`), so brainy's hard-link snapshot captures the
|
|
38
|
+
* verb namespace + endpoints files together with the SSTables coherently.
|
|
39
|
+
*
|
|
40
|
+
* The trailing `/x` segment exists only so `dirname` peels back to the
|
|
41
|
+
* dedicated directory; no blob is ever written at this exact key — the native
|
|
42
|
+
* `VerbIdNamespace` owns the files inside the directory
|
|
43
|
+
* (`verb-uuid-to-int.mkv`, `verb-int-to-uuid.bin`, `verb-endpoints/`).
|
|
44
|
+
*/
|
|
45
|
+
const GRAPH_VERB_NS_PROBE_KEY = '_graph_verb_ns/x';
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a verb-type string to its canonical `VerbTypeEnum` index,
|
|
48
|
+
* falling back to {@link VERB_FALLBACK_INDEX} (`VerbType.RelatedTo`)
|
|
49
|
+
* for unknown / missing input. Avoids two FFI hops by doing the
|
|
50
|
+
* lookup in TS via brainy's `TypeUtils`, then handing the index
|
|
51
|
+
* directly to the native `addVerbByIndex` / `trackVerbIdByIndex`
|
|
52
|
+
* surface.
|
|
53
|
+
*/
|
|
54
|
+
function resolveVerbTypeIndex(verbType) {
|
|
55
|
+
if (!verbType)
|
|
56
|
+
return VERB_FALLBACK_INDEX;
|
|
57
|
+
const idx = TypeUtils.getVerbIndex(verbType);
|
|
58
|
+
return idx ?? VERB_FALLBACK_INDEX;
|
|
59
|
+
}
|
|
60
|
+
// Storage prefixes for each tree
|
|
61
|
+
const TREE_PREFIXES = {
|
|
62
|
+
'source': 'graph-lsm-source',
|
|
63
|
+
'target': 'graph-lsm-target',
|
|
64
|
+
'verbs-source': 'graph-lsm-verbs-source',
|
|
65
|
+
'verbs-target': 'graph-lsm-verbs-target',
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Native 4-LSM-tree graph adjacency index. Implements {@link GraphIndexProvider}
|
|
69
|
+
* so a compile error fires the moment this wrapper drops or drifts a member
|
|
70
|
+
* brainy calls via `this.graphIndex.*` (e.g. the `isInitialized` getter).
|
|
71
|
+
*/
|
|
72
|
+
export class GraphAdjacencyIndex {
|
|
73
|
+
migrationCoordinatorGetter;
|
|
74
|
+
storage;
|
|
75
|
+
native;
|
|
76
|
+
unifiedCache;
|
|
77
|
+
config;
|
|
78
|
+
initialized = false;
|
|
79
|
+
isRebuilding = false;
|
|
80
|
+
flushTimer;
|
|
81
|
+
rebuildStartTime = 0;
|
|
82
|
+
totalRelationshipsIndexed = 0;
|
|
83
|
+
isCompacting = {};
|
|
84
|
+
// Adapter capability detection (set in constructor)
|
|
85
|
+
hasBinaryBlobs;
|
|
86
|
+
hasMmapPaths;
|
|
87
|
+
/**
|
|
88
|
+
* Lazy resolver to the shared entity-id mapper (the metadata index's mapper —
|
|
89
|
+
* the one brainy resolves verb endpoints through at write time). Used ONLY by
|
|
90
|
+
* `rebuild()` to turn persisted `verb.sourceId`/`targetId` UUIDs back into the
|
|
91
|
+
* entity ints the 4 LSM adjacency trees are keyed on. Lazy because the
|
|
92
|
+
* metadata-index instance may be constructed after this one; it is resolved at
|
|
93
|
+
* rebuild time (post-init), by when both providers exist. `undefined` when cor
|
|
94
|
+
* is used without a metadata index — `rebuild()` then degrades to the
|
|
95
|
+
* membership-only pass and says so. (#68)
|
|
96
|
+
*/
|
|
97
|
+
mapperResolver;
|
|
98
|
+
get isInitialized() {
|
|
99
|
+
return this.initialized;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The underlying native graph engine handle. Exposed so the
|
|
103
|
+
* `graphAcceleration` provider adapter ({@link GraphAccelerationAdapter})
|
|
104
|
+
* can drive `traverse` / the streaming cursor against the SAME native
|
|
105
|
+
* instance this provider already holds — never a second index (which
|
|
106
|
+
* would split state + double RAM). Read-only by contract: the adapter
|
|
107
|
+
* only calls read/cursor methods on it.
|
|
108
|
+
*/
|
|
109
|
+
get nativeInstance() {
|
|
110
|
+
return this.native;
|
|
111
|
+
}
|
|
112
|
+
constructor(storage, config = {}, mapperResolver,
|
|
113
|
+
/**
|
|
114
|
+
* Lazy resolver to the shared {@link MigrationCoordinator} — the same getter
|
|
115
|
+
* the metadata + vector providers receive, so Brainy's feature-detected lock
|
|
116
|
+
* methods return a consistent answer across all three. Undefined when no
|
|
117
|
+
* migration path is wired.
|
|
118
|
+
*/
|
|
119
|
+
migrationCoordinatorGetter) {
|
|
120
|
+
this.migrationCoordinatorGetter = migrationCoordinatorGetter;
|
|
121
|
+
this.storage = storage;
|
|
122
|
+
this.mapperResolver = mapperResolver;
|
|
123
|
+
// **related()-correctness + billion-scale RAM (#23).** When the caller
|
|
124
|
+
// did not pass an explicit `persistenceDir` but the storage adapter is
|
|
125
|
+
// filesystem-backed, derive one from a dedicated blob key so the native
|
|
126
|
+
// engine selects the DURABLE mmap `VerbIdNamespace` (+ mmap
|
|
127
|
+
// `VerbEndpointsStore`) instead of the in-memory fallback. This matters
|
|
128
|
+
// for correctness, not just speed:
|
|
129
|
+
// • The in-memory namespace's reverse map (verbInt → verb-UUID) is a
|
|
130
|
+
// no-op — `get_uuid` returns `None` — so `verbIntsToIds()` yields
|
|
131
|
+
// `[null]`, brainy filters those out, and `getVerbsBySource()` /
|
|
132
|
+
// `related()` come back empty even though the edge exists.
|
|
133
|
+
// • The in-memory forward map is O(edges)-resident (~75–85 GB at 1B
|
|
134
|
+
// edges) — a live RAM-invariant violation; the mmap namespace is
|
|
135
|
+
// demand-paged (O(working-set)).
|
|
136
|
+
// • The in-memory map re-interns from zero on reopen, so the fresh ints
|
|
137
|
+
// never match the durable LSM's baked ints; only the persisted mmap
|
|
138
|
+
// namespace is reopen-coherent.
|
|
139
|
+
//
|
|
140
|
+
// **Tier guard — gate on the RETURN VALUE, not method presence.**
|
|
141
|
+
// `getBinaryBlobPath` is an ABSTRACT member every adapter implements; the
|
|
142
|
+
// cloud/memory adapters implement it and return `null`. A `typeof … ===
|
|
143
|
+
// 'function'` guard would mis-route cloud into the filesystem path and
|
|
144
|
+
// hand native a bogus directory. So we invoke it and branch strictly on a
|
|
145
|
+
// non-null path. Cloud/memory return `null` → `persistenceDir` stays
|
|
146
|
+
// undefined → the in-memory namespace, unchanged (durable cloud
|
|
147
|
+
// materialization is task #26). Never throws for cloud.
|
|
148
|
+
let persistenceDir = config.persistenceDir;
|
|
149
|
+
if (persistenceDir === undefined) {
|
|
150
|
+
const probePath = storage.getBinaryBlobPath?.(GRAPH_VERB_NS_PROBE_KEY);
|
|
151
|
+
if (probePath) {
|
|
152
|
+
persistenceDir = dirname(probePath);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
this.config = {
|
|
156
|
+
maxIndexSize: config.maxIndexSize ?? 100000,
|
|
157
|
+
rebuildThreshold: config.rebuildThreshold ?? 0.1,
|
|
158
|
+
autoOptimize: config.autoOptimize ?? true,
|
|
159
|
+
flushInterval: config.flushInterval ?? 30000,
|
|
160
|
+
memTableThreshold: config.memTableThreshold ?? 100000,
|
|
161
|
+
maxSSTablesPerLevel: config.maxSSTablesPerLevel ?? 10,
|
|
162
|
+
persistenceDir,
|
|
163
|
+
};
|
|
164
|
+
const bindings = loadNativeModule();
|
|
165
|
+
// Cor 3.0 zero-config: graph wrapper passes verbIdSpace='U64'
|
|
166
|
+
// so the verb-ID interning namespace + live-verb / per-(type,
|
|
167
|
+
// subtype) membership bitmaps support `u64::MAX - 1` verb IDs
|
|
168
|
+
// out of the box. The raw napi GraphAdjacencyConfig still
|
|
169
|
+
// defaults to 'U32' for direct-napi back-compat — the production
|
|
170
|
+
// path goes through this wrapper.
|
|
171
|
+
this.native = new bindings.NativeGraphAdjacencyIndex({
|
|
172
|
+
memTableThreshold: this.config.memTableThreshold,
|
|
173
|
+
maxSstablesPerLevel: this.config.maxSSTablesPerLevel,
|
|
174
|
+
persistenceDir: this.config.persistenceDir,
|
|
175
|
+
verbIdSpace: 'U64',
|
|
176
|
+
});
|
|
177
|
+
this.unifiedCache = getGlobalCache();
|
|
178
|
+
// Detect adapter capabilities via optional method presence
|
|
179
|
+
this.hasBinaryBlobs = typeof storage.saveBinaryBlob === 'function';
|
|
180
|
+
this.hasMmapPaths = typeof storage.getBinaryBlobPath === 'function';
|
|
181
|
+
if (this.hasMmapPaths) {
|
|
182
|
+
prodLog.info('GraphAdjacencyIndex initialized with native Rust engine (4 embedded LSM-trees, mmap SSTable I/O)');
|
|
183
|
+
}
|
|
184
|
+
else if (this.hasBinaryBlobs) {
|
|
185
|
+
prodLog.info('GraphAdjacencyIndex initialized with native Rust engine (4 embedded LSM-trees, binary blob I/O)');
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
prodLog.info('GraphAdjacencyIndex initialized with native Rust engine (4 embedded LSM-trees)');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Initialization
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
/**
|
|
195
|
+
* **Eager cold-load entry — brainy 8.0 rebuild gate (#36).** Brainy
|
|
196
|
+
* awaits this right after `metadataIndex.init()` and BEFORE it consults
|
|
197
|
+
* {@link isReady}, so the durable graph is restored (the four u64 LSM
|
|
198
|
+
* trees cold-loaded from their `.sst` blobs) before the gate reads
|
|
199
|
+
* readiness — otherwise `isReady()` would be `false` until the first
|
|
200
|
+
* lazy op and brainy would needlessly rebuild a graph that is already on
|
|
201
|
+
* disk. Idempotent and a no-op for an in-memory index; mirrors
|
|
202
|
+
* {@link MetadataIndexProvider.init}. Delegates to the same
|
|
203
|
+
* {@link ensureInitialized} guard the lazy read/mutation paths use, so
|
|
204
|
+
* an index that is never `init()`-ed still cold-loads on first touch.
|
|
205
|
+
*/
|
|
206
|
+
async init() {
|
|
207
|
+
await this.ensureInitialized();
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Idempotent lazy-init guard. Runs the cold-load
|
|
211
|
+
* ({@link openGraphLsmEngine}) exactly once, then arms auto-flush. Both
|
|
212
|
+
* the eager {@link init} and every read/mutation path funnel through
|
|
213
|
+
* here, so a caller that skips `init()` still gets a fully cold-loaded
|
|
214
|
+
* graph on first touch.
|
|
215
|
+
*/
|
|
216
|
+
async ensureInitialized() {
|
|
217
|
+
if (this.initialized) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await this.openGraphLsmEngine();
|
|
221
|
+
this.startAutoFlush();
|
|
222
|
+
this.initialized = true;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Cold-load the four u64 LSM trees from persistence: load each tree's
|
|
226
|
+
* manifest and register every persisted SSTable (mmap tier, then binary
|
|
227
|
+
* blob tier) so the adjacency is queryable with NO rebuild. Each SSTable
|
|
228
|
+
* load latches the native `is_ready()` flag, so {@link isReady} reports
|
|
229
|
+
* `true` the instant durable edges are mapped in.
|
|
230
|
+
*
|
|
231
|
+
* The trailing membership-consistency walk is the **deferred O(N)
|
|
232
|
+
* fast-follow** (#19): it repopulates the verb-id membership bitmap when
|
|
233
|
+
* the trees hold edges but membership came back empty. It is explicitly
|
|
234
|
+
* NOT part of readiness — `isReady()` is already `true` from the SSTable
|
|
235
|
+
* loads above, so a slow membership walk can never make a graph with
|
|
236
|
+
* queryable edges report "not ready".
|
|
237
|
+
*/
|
|
238
|
+
async openGraphLsmEngine() {
|
|
239
|
+
// Load manifests and SSTables for all 4 trees
|
|
240
|
+
const treeNames = this.native.treeNames();
|
|
241
|
+
for (const treeName of treeNames) {
|
|
242
|
+
await this.loadTreeFromStorage(treeName);
|
|
243
|
+
}
|
|
244
|
+
// Defensive check: if LSM-trees have data but verbIdSet is empty, populate.
|
|
245
|
+
// This is the deferred membership fast-follow — it does NOT gate isReady().
|
|
246
|
+
const sourceSize = this.native.size();
|
|
247
|
+
if (sourceSize > 0 && this.native.verbIdCount() === 0) {
|
|
248
|
+
prodLog.warn(`GraphAdjacencyIndex: LSM-trees have ${sourceSize} relationships but verbIdSet is empty. ` +
|
|
249
|
+
`Triggering auto-rebuild to restore consistency.`);
|
|
250
|
+
await this.populateVerbIdSetFromStorage();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async populateVerbIdSetFromStorage() {
|
|
254
|
+
prodLog.info('GraphAdjacencyIndex: Populating verbIdSet from storage...');
|
|
255
|
+
const startTime = Date.now();
|
|
256
|
+
let hasMore = true;
|
|
257
|
+
let cursor = undefined;
|
|
258
|
+
let count = 0;
|
|
259
|
+
while (hasMore) {
|
|
260
|
+
const result = await this.storage.getVerbs({
|
|
261
|
+
pagination: { limit: 10000, cursor }
|
|
262
|
+
});
|
|
263
|
+
for (const verb of result.items) {
|
|
264
|
+
// Cor 3.0 Piece D: trackVerbIdByIndex was deleted along with
|
|
265
|
+
// the other strict-index variants. trackVerbId takes the verb-
|
|
266
|
+
// type as a string; unknown strings fall back to RelatedTo —
|
|
267
|
+
// acceptable on the rebuild slow path because the membership
|
|
268
|
+
// bitmap converges to the same set either way.
|
|
269
|
+
this.native.trackVerbId(verb.id, verb.verb);
|
|
270
|
+
count++;
|
|
271
|
+
}
|
|
272
|
+
hasMore = result.hasMore;
|
|
273
|
+
cursor = result.nextCursor;
|
|
274
|
+
}
|
|
275
|
+
const elapsed = Date.now() - startTime;
|
|
276
|
+
prodLog.info(`GraphAdjacencyIndex: Populated verbIdSet with ${count} verb IDs in ${elapsed}ms`);
|
|
277
|
+
}
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Core graph operations
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
async getNeighbors(id, optionsOrDirection) {
|
|
282
|
+
await this.ensureInitialized();
|
|
283
|
+
const options = typeof optionsOrDirection === 'string'
|
|
284
|
+
? { direction: optionsOrDirection }
|
|
285
|
+
: (optionsOrDirection || {});
|
|
286
|
+
const startTime = performance.now();
|
|
287
|
+
const direction = options.direction || 'both';
|
|
288
|
+
const limit = options.limit ?? null;
|
|
289
|
+
const offset = options.offset ?? null;
|
|
290
|
+
const result = this.native.getNeighbors(id, direction, limit, offset);
|
|
291
|
+
const elapsed = performance.now() - startTime;
|
|
292
|
+
if (elapsed > 5.0) {
|
|
293
|
+
prodLog.warn(`GraphAdjacencyIndex: Slow neighbor lookup for ${id}: ${elapsed.toFixed(2)}ms`);
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
async getVerbIdsBySource(sourceInt, options) {
|
|
298
|
+
await this.ensureInitialized();
|
|
299
|
+
const startTime = performance.now();
|
|
300
|
+
const limit = options?.limit ?? null;
|
|
301
|
+
const offset = options?.offset ?? null;
|
|
302
|
+
const result = this.native.getVerbIdsBySource(sourceInt, limit, offset);
|
|
303
|
+
const elapsed = performance.now() - startTime;
|
|
304
|
+
if (elapsed > 5.0) {
|
|
305
|
+
prodLog.warn(`GraphAdjacencyIndex: Slow getVerbIdsBySource for ${sourceInt}: ${elapsed.toFixed(2)}ms`);
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
async getVerbIdsByTarget(targetInt, options) {
|
|
310
|
+
await this.ensureInitialized();
|
|
311
|
+
const startTime = performance.now();
|
|
312
|
+
const limit = options?.limit ?? null;
|
|
313
|
+
const offset = options?.offset ?? null;
|
|
314
|
+
const result = this.native.getVerbIdsByTarget(targetInt, limit, offset);
|
|
315
|
+
const elapsed = performance.now() - startTime;
|
|
316
|
+
if (elapsed > 5.0) {
|
|
317
|
+
prodLog.warn(`GraphAdjacencyIndex: Slow getVerbIdsByTarget for ${targetInt}: ${elapsed.toFixed(2)}ms`);
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
async getVerbCached(verbId) {
|
|
322
|
+
const cacheKey = `graph:verb:${verbId}`;
|
|
323
|
+
const verb = await this.unifiedCache.get(cacheKey, async () => {
|
|
324
|
+
const loadedVerb = await this.storage.getVerb(verbId);
|
|
325
|
+
if (loadedVerb) {
|
|
326
|
+
this.unifiedCache.set(cacheKey, loadedVerb, 'other', 128, 50);
|
|
327
|
+
}
|
|
328
|
+
return loadedVerb;
|
|
329
|
+
});
|
|
330
|
+
return verb;
|
|
331
|
+
}
|
|
332
|
+
async getVerbsBatchCached(verbIds) {
|
|
333
|
+
const results = new Map();
|
|
334
|
+
const uncached = [];
|
|
335
|
+
for (const verbId of verbIds) {
|
|
336
|
+
const cacheKey = `graph:verb:${verbId}`;
|
|
337
|
+
const cached = this.unifiedCache.getSync(cacheKey);
|
|
338
|
+
if (cached) {
|
|
339
|
+
results.set(verbId, cached);
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
uncached.push(verbId);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (uncached.length > 0 && this.storage.getVerbsBatch) {
|
|
346
|
+
const loadedVerbs = await this.storage.getVerbsBatch(uncached);
|
|
347
|
+
for (const [verbId, verb] of loadedVerbs.entries()) {
|
|
348
|
+
const cacheKey = `graph:verb:${verbId}`;
|
|
349
|
+
this.unifiedCache.set(cacheKey, verb, 'other', 128, 50);
|
|
350
|
+
results.set(verbId, verb);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return results;
|
|
354
|
+
}
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Mutation operations
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
/**
|
|
359
|
+
* Add a verb to the graph. Cor 3.0 Piece D requires the brain's
|
|
360
|
+
* canonical `(sourceInt, targetInt)` entity-int pair as explicit
|
|
361
|
+
* parameters — brainy 8.0 resolves these via its `BinaryIdMapper`
|
|
362
|
+
* before calling. The `verb` object provides the verb-ID, verb-type,
|
|
363
|
+
* and optional subtype; entity UUIDs on the verb (`verb.sourceId`,
|
|
364
|
+
* `verb.targetId`) are no longer used by cor.
|
|
365
|
+
*/
|
|
366
|
+
/**
|
|
367
|
+
* Index a new verb (relationship). Returns the verb's interned
|
|
368
|
+
* `u64` integer ID so brainy 8.0's `verb_int ↔ verb_id_string`
|
|
369
|
+
* sidecar map populates without a follow-up
|
|
370
|
+
* `getVerbIntForString` FFI roundtrip — one boundary crossing
|
|
371
|
+
* saved per add at billion-scale ingest (PLATFORM-HANDOFF
|
|
372
|
+
* BRAINY-8.0-RENAME-COORDINATION A.3).
|
|
373
|
+
*
|
|
374
|
+
* The wrapper's previous brainy 7.x signature was
|
|
375
|
+
* `Promise<void>`; brainy 7.x callers that ignore the new
|
|
376
|
+
* return value continue to work unchanged.
|
|
377
|
+
*/
|
|
378
|
+
async addVerb(verb, sourceInt, targetInt, generation) {
|
|
379
|
+
await this.ensureInitialized();
|
|
380
|
+
const startTime = performance.now();
|
|
381
|
+
const verbTypeIndex = resolveVerbTypeIndex(verb.verb);
|
|
382
|
+
// Native subtype param is `String` with '' = "no subtype" (the empty-string
|
|
383
|
+
// escape; graph_adjacency.rs guards on `!subtype.is_empty()`). Passing JS
|
|
384
|
+
// null throws "Failed to convert Null into String" at the napi boundary, so
|
|
385
|
+
// coalesce absent subtype to '' here.
|
|
386
|
+
const subtype = verb.subtype ?? '';
|
|
387
|
+
const result = this.native.addVerbWithEndpointsByIndex(verb.id, sourceInt, targetInt, verbTypeIndex, subtype, generation);
|
|
388
|
+
// Flush any trees that hit their threshold
|
|
389
|
+
const flushPromises = [];
|
|
390
|
+
if (result.needsFlushSource) {
|
|
391
|
+
flushPromises.push(this.flushTree('source'));
|
|
392
|
+
}
|
|
393
|
+
if (result.needsFlushTarget) {
|
|
394
|
+
flushPromises.push(this.flushTree('target'));
|
|
395
|
+
}
|
|
396
|
+
if (result.needsFlushVerbsSource) {
|
|
397
|
+
flushPromises.push(this.flushTree('verbs-source'));
|
|
398
|
+
}
|
|
399
|
+
if (result.needsFlushVerbsTarget) {
|
|
400
|
+
flushPromises.push(this.flushTree('verbs-target'));
|
|
401
|
+
}
|
|
402
|
+
if (flushPromises.length > 0) {
|
|
403
|
+
await Promise.all(flushPromises);
|
|
404
|
+
}
|
|
405
|
+
const elapsed = performance.now() - startTime;
|
|
406
|
+
this.totalRelationshipsIndexed++;
|
|
407
|
+
if (elapsed > 10.0) {
|
|
408
|
+
prodLog.warn(`GraphAdjacencyIndex: Slow addVerb for ${verb.id}: ${elapsed.toFixed(2)}ms`);
|
|
409
|
+
}
|
|
410
|
+
return result.verbInt;
|
|
411
|
+
}
|
|
412
|
+
async removeVerb(verbId, generation) {
|
|
413
|
+
await this.ensureInitialized();
|
|
414
|
+
// Load verb to get type + subtype info
|
|
415
|
+
const verb = await this.getVerbCached(verbId);
|
|
416
|
+
if (!verb)
|
|
417
|
+
return;
|
|
418
|
+
const startTime = performance.now();
|
|
419
|
+
const verbTypeIndex = resolveVerbTypeIndex(verb.verb);
|
|
420
|
+
// Native subtype param is `String` with '' = "no subtype" (the empty-string
|
|
421
|
+
// escape; graph_adjacency.rs guards on `!subtype.is_empty()`). Passing JS
|
|
422
|
+
// null throws "Failed to convert Null into String" at the napi boundary, so
|
|
423
|
+
// coalesce absent subtype to '' here.
|
|
424
|
+
const subtype = verb.subtype ?? '';
|
|
425
|
+
this.native.removeVerb(verbId, verbTypeIndex, subtype, generation);
|
|
426
|
+
const elapsed = performance.now() - startTime;
|
|
427
|
+
if (elapsed > 5.0) {
|
|
428
|
+
prodLog.warn(`GraphAdjacencyIndex: Slow removeVerb for ${verbId}: ${elapsed.toFixed(2)}ms`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// Size, counts, stats
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
size() {
|
|
435
|
+
return this.native.size();
|
|
436
|
+
}
|
|
437
|
+
getRelationshipCountByType(type) {
|
|
438
|
+
return this.native.getRelationshipCountByType(type);
|
|
439
|
+
}
|
|
440
|
+
getTotalRelationshipCount() {
|
|
441
|
+
return this.native.verbIdCount();
|
|
442
|
+
}
|
|
443
|
+
getAllRelationshipCounts() {
|
|
444
|
+
const json = this.native.getAllRelationshipCountsJson();
|
|
445
|
+
const obj = JSON.parse(json);
|
|
446
|
+
return new Map(Object.entries(obj));
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* **Batch verb-int reverse resolver** — brainy 8.0
|
|
450
|
+
* `GraphIndexProvider.verbIntsToIds` contract (handoff Section L.7).
|
|
451
|
+
*
|
|
452
|
+
* Given a batch of verb-int BigInts, return the canonical UUID
|
|
453
|
+
* string for each — `null` for any int that has no entry in
|
|
454
|
+
* cor's persistent reverse map.
|
|
455
|
+
*
|
|
456
|
+
* Cor's namespace is the single source of truth for the
|
|
457
|
+
* `verb-int ↔ verb-id` mapping (the persisted `verb-int-to-uuid.bin`
|
|
458
|
+
* is direct-indexed for O(1) random reads). Brainy keeps only a
|
|
459
|
+
* bounded in-memory LRU warm cache fed by `addVerb` returns and
|
|
460
|
+
* resolver hits — no full reverse map on the JS side.
|
|
461
|
+
*
|
|
462
|
+
* The identity-fingerprint design (handoff L.7) makes this
|
|
463
|
+
* lossless: brainy's `relate()` validates verb-ids are canonical
|
|
464
|
+
* UUIDs, cor stores the raw 16 UUID bytes, and `verbIntsToIds`
|
|
465
|
+
* formats them back to the canonical lowercase 8-4-4-4-12 form.
|
|
466
|
+
*/
|
|
467
|
+
async verbIntsToIds(verbInts) {
|
|
468
|
+
if (verbInts.length === 0)
|
|
469
|
+
return [];
|
|
470
|
+
const raw = this.native.verbIntsToUuids(verbInts);
|
|
471
|
+
// napi-rs renders `Option<String>` as `string | undefined | null`
|
|
472
|
+
// on the .d.ts surface; normalise to `string | null` for the
|
|
473
|
+
// GraphIndexProvider contract (brainy 8.0 declares the
|
|
474
|
+
// null-or-string return shape strictly).
|
|
475
|
+
return raw.map((v) => v ?? null);
|
|
476
|
+
}
|
|
477
|
+
getRelationshipStats() {
|
|
478
|
+
const json = this.native.getRelationshipStatsJson();
|
|
479
|
+
return JSON.parse(json);
|
|
480
|
+
}
|
|
481
|
+
getStats() {
|
|
482
|
+
const stats = this.native.getStats();
|
|
483
|
+
return {
|
|
484
|
+
totalRelationships: stats.totalRelationships,
|
|
485
|
+
sourceNodes: stats.sourceSstableCount,
|
|
486
|
+
targetNodes: stats.targetSstableCount,
|
|
487
|
+
memoryUsage: stats.sourceMemTableMemory + stats.targetMemTableMemory + (stats.verbIdCount * 8),
|
|
488
|
+
lastRebuild: this.rebuildStartTime,
|
|
489
|
+
rebuildTime: this.isRebuilding ? Date.now() - this.rebuildStartTime : 0,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
isHealthy() {
|
|
493
|
+
if (!this.initialized) {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
return !this.isRebuilding && this.native.isHealthy();
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Whether the graph's edges are loaded and queryable WITHOUT a rebuild —
|
|
500
|
+
* brainy 8.0's rebuild gate (#36). Delegates straight to the native
|
|
501
|
+
* edges-ready latch (set on SSTable cold-load, a non-empty manifest, or a
|
|
502
|
+
* memtable flush) ORed with a live non-empty memtable. Synchronous (no
|
|
503
|
+
* await) and **never gated on the verb-id membership walk**, so the
|
|
504
|
+
* deferred O(N) membership fast-follow can never depress readiness for a
|
|
505
|
+
* graph whose edges are already mapped in.
|
|
506
|
+
*/
|
|
507
|
+
isReady() {
|
|
508
|
+
return this.native.isReady();
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* @description **#18 migration lock — feature-detected by Brainy.** `true` while
|
|
512
|
+
* the brain is undergoing (or has failed) the coordinated 7.x → 8.0 rebuild, so
|
|
513
|
+
* Brainy holds reads+writes behind the lock (and, in rc.9, `verifyGraphAdjacencyLive`
|
|
514
|
+
* short-circuits to `'live'` rather than firing its own rebuild-on-read).
|
|
515
|
+
* Delegates to the shared {@link MigrationCoordinator}; `false` when none is wired.
|
|
516
|
+
* @returns `true` when the brain must not be read from or written to.
|
|
517
|
+
*/
|
|
518
|
+
isMigrating() {
|
|
519
|
+
return this.migrationCoordinatorGetter?.()?.isMigrating() ?? false;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* @description **#18 migration lock — structured progress** Brainy relays into
|
|
523
|
+
* `getIndexStatus().migration`. `null` unless a migration is in flight.
|
|
524
|
+
* @returns The current {@link MigrationStatus}, or `null`.
|
|
525
|
+
*/
|
|
526
|
+
migrationStatus() {
|
|
527
|
+
return this.migrationCoordinatorGetter?.()?.migrationStatus() ?? null;
|
|
528
|
+
}
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// Rebuild
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
/**
|
|
533
|
+
* Rebuild the graph adjacency from brainy's persisted verbs — used after a
|
|
534
|
+
* restore/migration that wiped the native subdirs (#68).
|
|
535
|
+
*
|
|
536
|
+
* **Full adjacency reconstruction** when a shared entity-id mapper resolver is
|
|
537
|
+
* wired (the metadata index's mapper — the SAME one brainy resolves verb
|
|
538
|
+
* endpoints through at write time, `metadataIndex.getIdMapper().getOrAssign`).
|
|
539
|
+
* For each persisted verb this resolves `sourceId`/`targetId` → entity ints via
|
|
540
|
+
* that mapper and replays `addVerbWithEndpointsByIndex`, rebuilding all four
|
|
541
|
+
* u64-keyed LSM trees exactly as the live write path does (which also
|
|
542
|
+
* repopulates the membership bitmap + per-type counts — so `trackVerbId` is NOT
|
|
543
|
+
* also called for a reconstructed edge, or membership/counts would double). Brainy
|
|
544
|
+
* 8.0 `restore()` awaits `metadataIndex.getIdMapper().rebuild()` BEFORE the
|
|
545
|
+
* `Promise.all` that runs this, so the mapper is fully populated with the
|
|
546
|
+
* snapshot's assignments before endpoints are resolved here.
|
|
547
|
+
*
|
|
548
|
+
* Cursor-paginated (#74) — O(batch) RAM at any edge count; memtable flush
|
|
549
|
+
* thresholds are respected mid-walk so RAM stays bounded at billion-edge scale.
|
|
550
|
+
* Endpoints whose UUID does not resolve in the mapper (an entity that is a graph
|
|
551
|
+
* node but was not walked as a noun) are tracked for membership, counted, and
|
|
552
|
+
* warned — never silently dropped. Restored edges reconstruct at the floor
|
|
553
|
+
* generation (0): present-state is exact; historical as-of-g for pre-restore
|
|
554
|
+
* edges is not reconstructable from canonical records (the same limit the
|
|
555
|
+
* metadata + vector rebuilds carry).
|
|
556
|
+
*
|
|
557
|
+
* Without a mapper resolver (cor used without a metadata index), it degrades to
|
|
558
|
+
* the membership-only pass (bitmap + per-type counts via `trackVerbId`) and logs
|
|
559
|
+
* that adjacency was not reconstructed — the on-disk `.sst` trees loaded in
|
|
560
|
+
* {@link ensureInitialized} remain the adjacency source in that case.
|
|
561
|
+
*/
|
|
562
|
+
async rebuild() {
|
|
563
|
+
await this.ensureInitialized();
|
|
564
|
+
if (this.isRebuilding) {
|
|
565
|
+
prodLog.warn('GraphAdjacencyIndex: Rebuild already in progress');
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
this.isRebuilding = true;
|
|
569
|
+
this.rebuildStartTime = Date.now();
|
|
570
|
+
try {
|
|
571
|
+
const mapper = this.mapperResolver?.();
|
|
572
|
+
const reconstructAdjacency = mapper !== undefined;
|
|
573
|
+
prodLog.info(reconstructAdjacency
|
|
574
|
+
? 'GraphAdjacencyIndex: Starting rebuild (full adjacency — resolving endpoints via shared mapper)...'
|
|
575
|
+
: 'GraphAdjacencyIndex: Starting rebuild (membership-only — no shared mapper wired)...');
|
|
576
|
+
this.native.clearVerbTracking();
|
|
577
|
+
// Restored edges reconstruct present-state at the floor generation; the
|
|
578
|
+
// canonical verb records carry no per-edge history, so as-of-g for
|
|
579
|
+
// pre-restore edges is not reconstructable (same limit as metadata/vector
|
|
580
|
+
// rebuilds). Current (non-as-of) reads see them — a single floor-gen entry
|
|
581
|
+
// is the latest in each verb's version chain.
|
|
582
|
+
const REBUILD_GENERATION = 0n;
|
|
583
|
+
// Cursor-paginate for EVERY storage type — O(batch) RAM, complete at any
|
|
584
|
+
// scale; never load-all (the OOM / silent-10M-truncation trap). (#74)
|
|
585
|
+
let totalVerbs = 0;
|
|
586
|
+
let edgesRebuilt = 0;
|
|
587
|
+
let unresolvedEndpoints = 0;
|
|
588
|
+
let hasMore = true;
|
|
589
|
+
let cursor = undefined;
|
|
590
|
+
const batchSize = 10000;
|
|
591
|
+
while (hasMore) {
|
|
592
|
+
const result = await this.storage.getVerbs({ pagination: { limit: batchSize, cursor } });
|
|
593
|
+
for (const verb of result.items) {
|
|
594
|
+
totalVerbs++;
|
|
595
|
+
const verbType = verb.verb ?? 'relatedTo';
|
|
596
|
+
if (!reconstructAdjacency) {
|
|
597
|
+
this.native.trackVerbId(verb.id, verbType);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const sourceId = verb.sourceId;
|
|
601
|
+
const targetId = verb.targetId;
|
|
602
|
+
const sourceInt = sourceId !== undefined ? mapper.getInt(sourceId) : undefined;
|
|
603
|
+
const targetInt = targetId !== undefined ? mapper.getInt(targetId) : undefined;
|
|
604
|
+
if (sourceInt === undefined || targetInt === undefined) {
|
|
605
|
+
// Endpoint not in the mapper — still record membership so the verb
|
|
606
|
+
// isn't lost; its adjacency can't be rebuilt without the int.
|
|
607
|
+
this.native.trackVerbId(verb.id, verbType);
|
|
608
|
+
unresolvedEndpoints++;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const verbTypeIndex = resolveVerbTypeIndex(verbType);
|
|
612
|
+
// Native subtype param is `String` with '' = "no subtype" (the
|
|
613
|
+
// empty-string escape; graph_adjacency.rs guards on
|
|
614
|
+
// `!subtype.is_empty()`). JS null throws "Null into String" at the
|
|
615
|
+
// napi boundary, so coalesce absent subtype to ''.
|
|
616
|
+
const subtype = verb.subtype ?? '';
|
|
617
|
+
const addResult = this.native.addVerbWithEndpointsByIndex(verb.id, BigInt(sourceInt), BigInt(targetInt), verbTypeIndex, subtype, REBUILD_GENERATION);
|
|
618
|
+
edgesRebuilt++;
|
|
619
|
+
// Respect memtable flush thresholds mid-walk so RAM stays bounded.
|
|
620
|
+
const flushPromises = [];
|
|
621
|
+
if (addResult.needsFlushSource)
|
|
622
|
+
flushPromises.push(this.flushTree('source'));
|
|
623
|
+
if (addResult.needsFlushTarget)
|
|
624
|
+
flushPromises.push(this.flushTree('target'));
|
|
625
|
+
if (addResult.needsFlushVerbsSource)
|
|
626
|
+
flushPromises.push(this.flushTree('verbs-source'));
|
|
627
|
+
if (addResult.needsFlushVerbsTarget)
|
|
628
|
+
flushPromises.push(this.flushTree('verbs-target'));
|
|
629
|
+
if (flushPromises.length > 0)
|
|
630
|
+
await Promise.all(flushPromises);
|
|
631
|
+
}
|
|
632
|
+
hasMore = result.hasMore;
|
|
633
|
+
cursor = result.nextCursor;
|
|
634
|
+
}
|
|
635
|
+
this.totalRelationshipsIndexed = totalVerbs;
|
|
636
|
+
if (reconstructAdjacency) {
|
|
637
|
+
// Persist partially-filled memtables from the final batch.
|
|
638
|
+
await this.flush();
|
|
639
|
+
if (unresolvedEndpoints > 0) {
|
|
640
|
+
prodLog.warn(`GraphAdjacencyIndex: ${unresolvedEndpoints.toLocaleString()} verb endpoint(s) did not ` +
|
|
641
|
+
`resolve in the shared mapper and kept membership-only (entity is a graph node but was ` +
|
|
642
|
+
`not walked as a noun). ${edgesRebuilt.toLocaleString()} edges fully rebuilt.`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const rebuildTime = Date.now() - this.rebuildStartTime;
|
|
646
|
+
const memoryUsage = this.calculateMemoryUsage();
|
|
647
|
+
prodLog.info(`GraphAdjacencyIndex: Rebuild complete in ${rebuildTime}ms`);
|
|
648
|
+
prodLog.info(` - Total verbs tracked: ${totalVerbs.toLocaleString()}`);
|
|
649
|
+
if (reconstructAdjacency) {
|
|
650
|
+
prodLog.info(` - Adjacency edges rebuilt: ${edgesRebuilt.toLocaleString()} (unresolved endpoints: ${unresolvedEndpoints.toLocaleString()})`);
|
|
651
|
+
}
|
|
652
|
+
prodLog.info(` - Memory usage: ${(memoryUsage / 1024 / 1024).toFixed(1)}MB`);
|
|
653
|
+
prodLog.info(` - Stats:`, this.native.getStats());
|
|
654
|
+
}
|
|
655
|
+
finally {
|
|
656
|
+
this.isRebuilding = false;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// Flush and close
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
async flush() {
|
|
663
|
+
if (!this.initialized) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const startTime = Date.now();
|
|
667
|
+
const treeNames = this.native.treeNames();
|
|
668
|
+
const flushPromises = treeNames
|
|
669
|
+
.filter(name => !this.native.memTableIsEmpty(name))
|
|
670
|
+
.map(name => this.flushTree(name));
|
|
671
|
+
if (flushPromises.length > 0) {
|
|
672
|
+
await Promise.all(flushPromises);
|
|
673
|
+
}
|
|
674
|
+
const elapsed = Date.now() - startTime;
|
|
675
|
+
prodLog.debug(`GraphAdjacencyIndex: Flush completed in ${elapsed}ms`);
|
|
676
|
+
}
|
|
677
|
+
async close() {
|
|
678
|
+
if (this.flushTimer) {
|
|
679
|
+
clearInterval(this.flushTimer);
|
|
680
|
+
this.flushTimer = undefined;
|
|
681
|
+
}
|
|
682
|
+
if (this.initialized) {
|
|
683
|
+
// Flush all trees that have pending data
|
|
684
|
+
await this.flush();
|
|
685
|
+
}
|
|
686
|
+
prodLog.info('GraphAdjacencyIndex: Shutdown complete');
|
|
687
|
+
}
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
// Private: Per-tree storage I/O (adapter-controlled)
|
|
690
|
+
// ---------------------------------------------------------------------------
|
|
691
|
+
/** Blob key for an SSTable: "graph-lsm/{treeName}/{sstableId}" */
|
|
692
|
+
blobKey(treeName, sstableId) {
|
|
693
|
+
return `graph-lsm/${treeName}/${sstableId}`;
|
|
694
|
+
}
|
|
695
|
+
async loadTreeFromStorage(treeName) {
|
|
696
|
+
const prefix = TREE_PREFIXES[treeName];
|
|
697
|
+
if (!prefix)
|
|
698
|
+
return;
|
|
699
|
+
try {
|
|
700
|
+
const metadata = await this.storage.getMetadata(`${prefix}-manifest`);
|
|
701
|
+
if (metadata && metadata.data) {
|
|
702
|
+
const data = metadata.data;
|
|
703
|
+
// Pass the FULL persisted manifest to the native loader, NOT a hand-picked
|
|
704
|
+
// subset. `saveTreeManifest` persists `JSON.parse(getManifestJson(tree))`
|
|
705
|
+
// verbatim, so `data` already IS the native manifest shape. The previous
|
|
706
|
+
// rebuild kept only {sstables, lastCompaction, totalRelationships} and
|
|
707
|
+
// DROPPED the other fields `getManifestSstableIds` reads — so on a cold
|
|
708
|
+
// reopen the native loader saw ZERO sstable ids, loaded nothing, and the
|
|
709
|
+
// adjacency came back empty (manifest count present, getNeighbors → []).
|
|
710
|
+
// This is the 2.7.6 Venue-P1 cold-load bug; 3.0 had the identical defect.
|
|
711
|
+
const manifestJson = JSON.stringify({
|
|
712
|
+
...data,
|
|
713
|
+
sstables: data.sstables || {},
|
|
714
|
+
lastCompaction: data.lastCompaction || Date.now(),
|
|
715
|
+
totalRelationships: data.totalRelationships || 0,
|
|
716
|
+
});
|
|
717
|
+
this.native.loadManifestJson(treeName, manifestJson);
|
|
718
|
+
await this.loadTreeSSTables(treeName, prefix);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
catch (loadErr) {
|
|
722
|
+
// Previously a silent `catch {}` ("no manifest, starting fresh") that ALSO
|
|
723
|
+
// swallowed a real cold-load failure, leaving the count loaded and the
|
|
724
|
+
// adjacency empty. Surface it so a genuine failure is never silent.
|
|
725
|
+
prodLog.warn(`GraphAdjacencyIndex: cold-load failed for ${treeName} — adjacency may be incomplete`, loadErr);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
async loadTreeSSTables(treeName, prefix) {
|
|
729
|
+
const ids = this.native.getManifestSstableIds(treeName);
|
|
730
|
+
let mmapLoaded = 0;
|
|
731
|
+
let binaryLoaded = 0;
|
|
732
|
+
const loadPromises = ids.map(async (sstableId) => {
|
|
733
|
+
try {
|
|
734
|
+
const level = this.native.getManifestSstableLevel(treeName, sstableId);
|
|
735
|
+
if (level === null || level === undefined) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const key = this.blobKey(treeName, sstableId);
|
|
739
|
+
// Tier 1: Try mmap path (getBinaryBlobPath → loadSstableFromFile)
|
|
740
|
+
if (this.hasMmapPaths) {
|
|
741
|
+
const filePath = this.storage.getBinaryBlobPath(key);
|
|
742
|
+
if (filePath) {
|
|
743
|
+
try {
|
|
744
|
+
this.native.loadSstableFromFile(treeName, sstableId, level, filePath);
|
|
745
|
+
mmapLoaded++;
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
catch (mmapErr) {
|
|
749
|
+
// On a cold reopen the file exists, so a throw here is a real
|
|
750
|
+
// failure (not "first run") — surface it, then fall through to the
|
|
751
|
+
// binary tier which can still serve it. Never silent (2.7.6 parity).
|
|
752
|
+
prodLog.warn(`GraphAdjacencyIndex: mmap SSTable load failed for ${treeName}/${sstableId} @ ${filePath} — trying binary tier`, mmapErr);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// Tier 2: Try binary blob (loadBinaryBlob → loadSstableFromBinary).
|
|
757
|
+
// Cor 3.0 Piece D dropped the legacy base64-in-JSON tier
|
|
758
|
+
// because the napi-side `loadSstable` (msgpack) is gone — the
|
|
759
|
+
// u64-keyed LSM core only ships the binary `.sst` format. Any
|
|
760
|
+
// brain persisted under the old shape needs the offline
|
|
761
|
+
// migrator (Piece E) before it can be opened.
|
|
762
|
+
if (this.hasBinaryBlobs) {
|
|
763
|
+
const blobData = await this.storage.loadBinaryBlob(key);
|
|
764
|
+
if (blobData) {
|
|
765
|
+
this.native.loadSstableFromBinary(treeName, sstableId, level, blobData);
|
|
766
|
+
binaryLoaded++;
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// No tier matched — log and skip.
|
|
771
|
+
prodLog.warn(`GraphAdjacencyIndex: SSTable ${sstableId} for ${treeName} found in manifest but no readable backing exists (skipped)`);
|
|
772
|
+
}
|
|
773
|
+
catch (error) {
|
|
774
|
+
prodLog.warn(`GraphAdjacencyIndex: Failed to load SSTable ${sstableId} for ${treeName}`, error);
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
await Promise.all(loadPromises);
|
|
778
|
+
const parts = [];
|
|
779
|
+
if (mmapLoaded > 0)
|
|
780
|
+
parts.push(`${mmapLoaded} mmap`);
|
|
781
|
+
if (binaryLoaded > 0)
|
|
782
|
+
parts.push(`${binaryLoaded} binary`);
|
|
783
|
+
const detail = parts.length > 0 ? ` (${parts.join(', ')})` : '';
|
|
784
|
+
prodLog.info(`GraphAdjacencyIndex: Loaded ${ids.length} SSTables for ${treeName}${detail}`);
|
|
785
|
+
}
|
|
786
|
+
async flushTree(treeName) {
|
|
787
|
+
if (this.native.memTableIsEmpty(treeName)) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const prefix = TREE_PREFIXES[treeName];
|
|
791
|
+
if (!prefix)
|
|
792
|
+
return;
|
|
793
|
+
const startTime = Date.now();
|
|
794
|
+
try {
|
|
795
|
+
// Binary blob flush (adapter-controlled): Rust serializes → adapter writes
|
|
796
|
+
if (this.hasBinaryBlobs) {
|
|
797
|
+
const result = this.native.flushTreeToBinary(treeName);
|
|
798
|
+
if (!result)
|
|
799
|
+
return;
|
|
800
|
+
const key = this.blobKey(treeName, result.sstableId);
|
|
801
|
+
await this.storage.saveBinaryBlob(key, Buffer.from(result.data));
|
|
802
|
+
// If adapter supports mmap, upgrade InMemory → Mapped
|
|
803
|
+
if (this.hasMmapPaths) {
|
|
804
|
+
const filePath = this.storage.getBinaryBlobPath(key);
|
|
805
|
+
if (filePath) {
|
|
806
|
+
try {
|
|
807
|
+
this.native.registerMmapSstable(treeName, result.sstableId, result.level, filePath);
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
// Non-fatal: InMemory variant still works
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
await this.saveTreeManifest(treeName, prefix);
|
|
815
|
+
const elapsed = Date.now() - startTime;
|
|
816
|
+
prodLog.info(`GraphAdjacencyIndex: Flushed ${treeName} in ${elapsed}ms (${result.entryCount} entries, ${result.valueCount} values, binary blob)`);
|
|
817
|
+
if (this.native.needsCompaction(treeName, 0)) {
|
|
818
|
+
setImmediate(() => this.compactTree(treeName, 0));
|
|
819
|
+
}
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
// Cor 3.0 Piece D dropped the legacy base64-in-JSON flush
|
|
823
|
+
// path — the napi-side `flushTree` (msgpack) is gone. The only
|
|
824
|
+
// remaining flush path is the binary blob branch above which
|
|
825
|
+
// requires an adapter exposing `saveBinaryBlob`.
|
|
826
|
+
prodLog.warn(`GraphAdjacencyIndex: Skipping flush for ${treeName}: storage adapter does not expose saveBinaryBlob (Piece D requires binary blob I/O)`);
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
prodLog.error(`GraphAdjacencyIndex: Failed to flush ${treeName}`, error);
|
|
830
|
+
throw error;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
async compactTree(treeName, level) {
|
|
834
|
+
if (this.isCompacting[treeName]) {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
this.isCompacting[treeName] = true;
|
|
838
|
+
const prefix = TREE_PREFIXES[treeName];
|
|
839
|
+
if (!prefix)
|
|
840
|
+
return;
|
|
841
|
+
const startTime = Date.now();
|
|
842
|
+
try {
|
|
843
|
+
// Binary blob compaction (adapter-controlled).
|
|
844
|
+
//
|
|
845
|
+
// **Piece 6 manifest ordering invariant**: durability requires
|
|
846
|
+
// 1. write new blob (fully persisted)
|
|
847
|
+
// 2. update manifest (the durable source-of-truth that
|
|
848
|
+
// references the new blob and no longer references the
|
|
849
|
+
// old blobs)
|
|
850
|
+
// 3. THEN delete old blobs (orphan cleanup, only safe to do
|
|
851
|
+
// AFTER the manifest no longer references them)
|
|
852
|
+
//
|
|
853
|
+
// The earlier shape deleted old blobs BEFORE updating the
|
|
854
|
+
// manifest. A crash between those two steps left the
|
|
855
|
+
// manifest pointing at blobs that had been physically removed,
|
|
856
|
+
// so reopening the brain failed with "missing SSTable file".
|
|
857
|
+
// Putting the manifest update before the delete makes the
|
|
858
|
+
// crash-between-steps state recoverable: the manifest still
|
|
859
|
+
// references the just-deleted (or pending-deletion) blobs,
|
|
860
|
+
// both of which exist on disk during that window.
|
|
861
|
+
if (this.hasBinaryBlobs) {
|
|
862
|
+
const result = this.native.compactTreeToBinary(treeName, level);
|
|
863
|
+
if (!result)
|
|
864
|
+
return;
|
|
865
|
+
prodLog.info(`GraphAdjacencyIndex: Compacting ${treeName} L${level} → L${result.newLevel} (binary blob)`);
|
|
866
|
+
// 1. Save new merged SSTable via adapter — must complete
|
|
867
|
+
// BEFORE any reference (manifest) is updated.
|
|
868
|
+
const newKey = this.blobKey(treeName, result.newSstableId);
|
|
869
|
+
await this.storage.saveBinaryBlob(newKey, Buffer.from(result.data));
|
|
870
|
+
// If adapter supports mmap, upgrade InMemory → Mapped.
|
|
871
|
+
// Best-effort; the InMemory variant still works.
|
|
872
|
+
if (this.hasMmapPaths) {
|
|
873
|
+
const filePath = this.storage.getBinaryBlobPath(newKey);
|
|
874
|
+
if (filePath) {
|
|
875
|
+
try {
|
|
876
|
+
this.native.registerMmapSstable(treeName, result.newSstableId, result.newLevel, filePath);
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
// Non-fatal: InMemory variant still works
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
// 2. Update the manifest. After this point the durable
|
|
884
|
+
// source-of-truth references the new blob and no longer
|
|
885
|
+
// references the old blobs — so a crash before step 3
|
|
886
|
+
// just leaves orphans, never a missing-blob error.
|
|
887
|
+
await this.saveTreeManifest(treeName, prefix);
|
|
888
|
+
// 3. Delete old blobs (best-effort orphan cleanup AFTER the
|
|
889
|
+
// manifest no longer references them). Failures here are
|
|
890
|
+
// harmless — the worst outcome is leaked disk space until
|
|
891
|
+
// a future operator-level GC sweep.
|
|
892
|
+
for (const oldId of result.oldIds) {
|
|
893
|
+
const oldBlobKey = this.blobKey(treeName, oldId);
|
|
894
|
+
this.storage.deleteBinaryBlob(oldBlobKey).catch(() => { });
|
|
895
|
+
}
|
|
896
|
+
const elapsed = Date.now() - startTime;
|
|
897
|
+
prodLog.info(`GraphAdjacencyIndex: Compaction ${treeName} L${level} → L${result.newLevel} complete in ${elapsed}ms`);
|
|
898
|
+
if (result.newLevel < 6 && this.native.needsCompaction(treeName, result.newLevel)) {
|
|
899
|
+
setImmediate(() => this.compactTree(treeName, result.newLevel));
|
|
900
|
+
}
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
// Cor 3.0 Piece D dropped the legacy base64-in-JSON compaction
|
|
904
|
+
// path — the napi-side `compactTree` (msgpack) is gone. The only
|
|
905
|
+
// remaining path is the binary blob branch above which requires
|
|
906
|
+
// an adapter exposing `saveBinaryBlob`.
|
|
907
|
+
prodLog.warn(`GraphAdjacencyIndex: Skipping compaction for ${treeName} L${level}: storage adapter does not expose saveBinaryBlob (Piece D requires binary blob I/O)`);
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
prodLog.error(`GraphAdjacencyIndex: Compaction failed for ${treeName} L${level}`, error);
|
|
911
|
+
}
|
|
912
|
+
finally {
|
|
913
|
+
this.isCompacting[treeName] = false;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
async saveTreeManifest(treeName, prefix) {
|
|
917
|
+
try {
|
|
918
|
+
const manifestJson = this.native.getManifestJson(treeName);
|
|
919
|
+
const manifestData = JSON.parse(manifestJson);
|
|
920
|
+
await this.storage.saveMetadata(`${prefix}-manifest`, {
|
|
921
|
+
noun: 'thing',
|
|
922
|
+
data: manifestData,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
prodLog.error(`GraphAdjacencyIndex: Failed to save manifest for ${treeName}`, error);
|
|
927
|
+
throw error;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
startAutoFlush() {
|
|
931
|
+
this.flushTimer = setInterval(async () => {
|
|
932
|
+
await this.flush();
|
|
933
|
+
}, this.config.flushInterval);
|
|
934
|
+
}
|
|
935
|
+
calculateMemoryUsage() {
|
|
936
|
+
const stats = this.native.getStats();
|
|
937
|
+
let bytes = 0;
|
|
938
|
+
bytes += stats.sourceMemTableMemory;
|
|
939
|
+
bytes += stats.targetMemTableMemory;
|
|
940
|
+
bytes += stats.verbIdCount * 8;
|
|
941
|
+
return bytes;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
//# sourceMappingURL=NativeGraphAdjacencyIndex.js.map
|