@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,2631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NativeMetadataIndex — TypeScript wrapper around the Rust NativeMetadataIndex.
|
|
3
|
+
*
|
|
4
|
+
* Implements the same public API as the old MetadataIndexManager (3,721 lines),
|
|
5
|
+
* delegating core operations (query, mutation, normalization) to Rust.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - Rust owns: posting-list algebra, filter evaluation, value normalization,
|
|
9
|
+
* field extraction, text search, entity ID mapping, and the LSM engine
|
|
10
|
+
* (the sole metadata storage engine — SSTables + manifest)
|
|
11
|
+
* - TS owns: async storage I/O, LSM SSTable/manifest persistence, lazy
|
|
12
|
+
* value-enumeration field-index loading, rebuild orchestration, flush,
|
|
13
|
+
* getSortedIdsForFilter (loads sort values from storage)
|
|
14
|
+
*
|
|
15
|
+
* Buffer exchange pattern: TS loads data from storage, passes JSON to Rust.
|
|
16
|
+
* Rust operates in-memory, returns serialized state for TS to persist.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { prodLog, getGlobalCache, FieldTypeInference, resolveEntityField } from '@soulcraft/brainy/internals';
|
|
21
|
+
import { compareCodePoints } from './collation.js';
|
|
22
|
+
/**
|
|
23
|
+
* Fields whose values are stored in the sparse index as BUCKETED values
|
|
24
|
+
* (rounded to a coarser granularity to keep the index compact). Sorting
|
|
25
|
+
* on these fields must bypass the index and read the actual value from
|
|
26
|
+
* entity storage to avoid precision loss.
|
|
27
|
+
*
|
|
28
|
+
* Mirrors BUCKETED_INDEX_FIELDS in brainy's src/utils/metadataIndex.ts —
|
|
29
|
+
* keep in lockstep when Brainy adds bucketed fields.
|
|
30
|
+
*/
|
|
31
|
+
const BUCKETED_INDEX_FIELDS = new Set([
|
|
32
|
+
'createdAt',
|
|
33
|
+
'updatedAt'
|
|
34
|
+
]);
|
|
35
|
+
import { TypeUtils, NOUN_TYPE_COUNT, VERB_TYPE_COUNT } from '@soulcraft/brainy/types/graphTypes';
|
|
36
|
+
import { loadNativeModule } from '../native/index.js';
|
|
37
|
+
import { openOrCreateBinaryIdMapper } from './binaryIdMapperFactory.js';
|
|
38
|
+
import { NativeColumnStore } from './NativeColumnStore.js';
|
|
39
|
+
import { ResourceManager } from '../resource/ResourceManager.js';
|
|
40
|
+
import { COR_EXPECTED_INDEX_EPOCH, IndexMigrationRequiredError, detectBrainSize, isEpochStale, readOnDiskBrainFormat, resolveAutoMigrate, resolveLargeBrainThreshold, } from '../migration/indexEpochGuard.js';
|
|
41
|
+
/**
|
|
42
|
+
* Process-wide counter for unique `MetadataIndexManager` subscriber
|
|
43
|
+
* names. Each LSM-enabled instance reserves a fresh id so the
|
|
44
|
+
* `ResourceManager.registerMemoryBudgetSubscriber` slot doesn't collide
|
|
45
|
+
* across instances in the same process (e.g., multi-tenant deployments
|
|
46
|
+
* with one brain per workspace).
|
|
47
|
+
*/
|
|
48
|
+
let lsmSubscriberIdCounter = 0;
|
|
49
|
+
function nextLsmSubscriberId() {
|
|
50
|
+
lsmSubscriberIdCounter += 1;
|
|
51
|
+
return `cor:metadata-lsm:${lsmSubscriberIdCounter}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Manifest format version this build writes and accepts. Mirrors the Rust
|
|
55
|
+
* `LSM_MANIFEST_VERSION` constant (native/src/metadata_index/lsm/format.rs).
|
|
56
|
+
* The wrapper stamps every manifest it writes with this value and refuses
|
|
57
|
+
* to cold-load a manifest carrying a different one — a forward-incompatible
|
|
58
|
+
* manifest must fail loud, never be half-read into a partial SSTable set.
|
|
59
|
+
*/
|
|
60
|
+
const LSM_MANIFEST_VERSION = 2;
|
|
61
|
+
/**
|
|
62
|
+
* Storage key (flat `__metadata_*` system key, same family as
|
|
63
|
+
* `__metadata_field_registry__`) under which the LSM manifest JSON is
|
|
64
|
+
* persisted via `storage.saveMetadata` / `storage.getMetadata`.
|
|
65
|
+
*
|
|
66
|
+
* @remarks The original durability plan named this `_metadata/manifest.json`,
|
|
67
|
+
* but a slashed, non-UUID key routes through brainy's storage adapter as an
|
|
68
|
+
* "Unknown key format" system resource (noisy + fragile). The established
|
|
69
|
+
* clean pattern for index-level metadata is the flat `__metadata_*` key, so
|
|
70
|
+
* the manifest rides the exact same atomic `writeCanonicalObject` path the
|
|
71
|
+
* field registry already depends on.
|
|
72
|
+
*/
|
|
73
|
+
const LSM_MANIFEST_KEY = '__metadata_lsm_manifest__';
|
|
74
|
+
/**
|
|
75
|
+
* **LSM durability (step 5).** Lower clamp (32 MiB) for the bounded-memtable
|
|
76
|
+
* size-flush trigger. Even when the dynamic 'metadata' budget is tiny, the
|
|
77
|
+
* trigger never fires below this — sub-32 MiB SSTables would amplify
|
|
78
|
+
* read-merge fan-out (every query unions across more on-disk files) without a
|
|
79
|
+
* matching memory win.
|
|
80
|
+
*/
|
|
81
|
+
const LSM_FLUSH_THRESHOLD_MIN_BYTES = 32 * 1024 * 1024;
|
|
82
|
+
/**
|
|
83
|
+
* **LSM durability (step 5).** Upper clamp (256 MiB) for the bounded-memtable
|
|
84
|
+
* size-flush trigger. Caps a single flush's resident peak (and the resulting
|
|
85
|
+
* SSTable's build cost) even when the dynamic 'metadata' budget is many GiB,
|
|
86
|
+
* keeping flush latency bounded at billion-entity scale.
|
|
87
|
+
*/
|
|
88
|
+
const LSM_FLUSH_THRESHOLD_MAX_BYTES = 256 * 1024 * 1024;
|
|
89
|
+
/**
|
|
90
|
+
* **LSM durability (step 5).** Resolve the memtable size-flush threshold from
|
|
91
|
+
* the engine's current admission ceiling (`allowedBytes`): ~50% of the
|
|
92
|
+
* ceiling, clamped to `[32 MiB, 256 MiB]`. Draining at half the ceiling
|
|
93
|
+
* leaves the upper half as headroom so a burst of writes between the trigger
|
|
94
|
+
* firing and the flush completing cannot reach the admission gate and wedge
|
|
95
|
+
* the writer — the gate stays a backstop, never the steady-state limiter.
|
|
96
|
+
*
|
|
97
|
+
* @param allowedBytes - The engine's current admission ceiling in bytes.
|
|
98
|
+
* @returns The clamped memtable-drain threshold in bytes.
|
|
99
|
+
*/
|
|
100
|
+
function computeLsmFlushThreshold(allowedBytes) {
|
|
101
|
+
const half = Math.floor(Math.max(0, allowedBytes) / 2);
|
|
102
|
+
return Math.min(LSM_FLUSH_THRESHOLD_MAX_BYTES, Math.max(LSM_FLUSH_THRESHOLD_MIN_BYTES, half));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Build a fresh, empty manifest ready to receive its first SSTable.
|
|
106
|
+
*
|
|
107
|
+
* @returns A version-stamped manifest with no SSTables and a zero durable epoch.
|
|
108
|
+
*/
|
|
109
|
+
function newEmptyLsmManifest() {
|
|
110
|
+
return { version: LSM_MANIFEST_VERSION, sstables: [], last_durable_epoch: 0 };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Allocate the next SSTable id: one past the highest id currently in the
|
|
114
|
+
* manifest. SSTables are append-only in this durability slice (compaction —
|
|
115
|
+
* which removes entries — is a later slice), so `max + 1` is strictly
|
|
116
|
+
* monotonic and can never collide with a live entry. It is also crash-safe:
|
|
117
|
+
* if a flush writes the `.sst` but crashes before persisting the manifest,
|
|
118
|
+
* the orphaned id is simply re-allocated on the next flush (the records
|
|
119
|
+
* replay from the still-uncommitted memtable log), overwriting a file no
|
|
120
|
+
* manifest ever referenced.
|
|
121
|
+
*
|
|
122
|
+
* @param manifest - The in-memory manifest whose ids bound the allocation.
|
|
123
|
+
* @returns The next monotonic SSTable id (≥ 1).
|
|
124
|
+
*/
|
|
125
|
+
function nextSstableId(manifest) {
|
|
126
|
+
let max = 0;
|
|
127
|
+
for (const e of manifest.sstables) {
|
|
128
|
+
if (e.id > max)
|
|
129
|
+
max = e.id;
|
|
130
|
+
}
|
|
131
|
+
return max + 1;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Build the binary-blob storage key for an SSTable. Both the write
|
|
135
|
+
* (`saveBinaryBlob`) and the mmap-open (`getBinaryBlobPath` →
|
|
136
|
+
* `registerSstablePath`) go through this single helper, so the logical key is
|
|
137
|
+
* internally consistent regardless of any extension the adapter appends.
|
|
138
|
+
*
|
|
139
|
+
* @param id - The SSTable's monotonic on-disk id.
|
|
140
|
+
* @returns Key like `_metadata/sstables/1.sst`.
|
|
141
|
+
*/
|
|
142
|
+
function lsmSstableKey(id) {
|
|
143
|
+
return `_metadata/sstables/${id}.sst`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* MetadataIndexManager — native Rust implementation with TS storage bridge.
|
|
147
|
+
*
|
|
148
|
+
* Drop-in replacement for the old pure-TS MetadataIndexManager.
|
|
149
|
+
* All bitmap AND/OR/NOT operations execute in Rust without crossing FFI.
|
|
150
|
+
*
|
|
151
|
+
* Implements {@link MetadataIndexProvider} so a compile error fires the moment
|
|
152
|
+
* this wrapper drops or drifts a member brainy calls via `this.metadataIndex.*`.
|
|
153
|
+
*/
|
|
154
|
+
/**
|
|
155
|
+
* **Piece 11 closeout — init-time gate.** Thrown by
|
|
156
|
+
* {@link MetadataIndexManager.init} when the brain carries cortex 2.x legacy
|
|
157
|
+
* JSON metadata state on disk. cor 3.0 uses the LSM engine exclusively (the
|
|
158
|
+
* legacy JSON chunk engine has been removed), so an un-migrated brain must be
|
|
159
|
+
* migrated rather than silently switched. Catchable for graceful fallback
|
|
160
|
+
* paths in callers that prefer a one-shot warning over a hard refusal; the
|
|
161
|
+
* default behaviour (uncaught) is the right one for production deployments —
|
|
162
|
+
* silent degradation to an empty LSM engine would mask the on-disk JSON state
|
|
163
|
+
* and surface as a mysteriously-empty brain at first query.
|
|
164
|
+
*/
|
|
165
|
+
export class MetadataIndexLegacyJsonStateDetected extends Error {
|
|
166
|
+
name = 'MetadataIndexLegacyJsonStateDetected';
|
|
167
|
+
}
|
|
168
|
+
export class MetadataIndexManager {
|
|
169
|
+
migrationCoordinatorGetter;
|
|
170
|
+
storage;
|
|
171
|
+
config;
|
|
172
|
+
native;
|
|
173
|
+
isRebuilding = false;
|
|
174
|
+
/**
|
|
175
|
+
* **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
|
|
176
|
+
* auto-migrate enabled) and read once at the END of {@link init}, which kicks the
|
|
177
|
+
* coordinated migration only AFTER the index is fully constructed. Flagging here
|
|
178
|
+
* (not kicking inline) avoids racing the migration's destructive rebuild
|
|
179
|
+
* (`reopenLsmEngineFresh` + `columnStore.reset`) against the remainder of the same
|
|
180
|
+
* init() (`columnStore.init` / `warmCache` / `lazyLoadCounts`) on shared state.
|
|
181
|
+
*/
|
|
182
|
+
epochMigrationPending = false;
|
|
183
|
+
lastFlushTime = Date.now();
|
|
184
|
+
autoFlushThreshold = 10;
|
|
185
|
+
dirtyFields = new Set();
|
|
186
|
+
// Lazy field loading: tracks which fields have been loaded into Rust
|
|
187
|
+
loadedFields = new Set();
|
|
188
|
+
// Tracks which field indexes are known (from field registry)
|
|
189
|
+
knownFields = new Set();
|
|
190
|
+
/**
|
|
191
|
+
* **Piece 6 generation counter — reader-side cache.** Records the
|
|
192
|
+
* generation observed at the most recent `invalidateIfStale` call.
|
|
193
|
+
* Mutations on the native side bump `core.generation` via
|
|
194
|
+
* `bump_generation`; a subsequent reader's `invalidateIfStale` call
|
|
195
|
+
* sees a delta and clears `loadedFields`, forcing the next query to
|
|
196
|
+
* re-load from the current state instead of trusting the lazy-load
|
|
197
|
+
* snapshot.
|
|
198
|
+
*
|
|
199
|
+
* Default `-1n` means "never observed"; the first call will see any
|
|
200
|
+
* non-zero current generation and trigger an initial invalidate.
|
|
201
|
+
* (We don't invalidate on the very first call regardless because
|
|
202
|
+
* `loadedFields` is empty anyway.)
|
|
203
|
+
*/
|
|
204
|
+
lastSeenGeneration = -1n;
|
|
205
|
+
/**
|
|
206
|
+
* **Piece 15 — per-field generation cache.** Keyed by field name,
|
|
207
|
+
* value is the per-field generation last observed via
|
|
208
|
+
* `native.fieldGeneration(field)`. Updated on every
|
|
209
|
+
* {@link invalidateFieldIfStale} call.
|
|
210
|
+
*
|
|
211
|
+
* Selective invalidation contract: when a probe of field X's
|
|
212
|
+
* counter advances, ONLY X is removed from `loadedFields` — every
|
|
213
|
+
* other loaded field stays valid. This is the load-bearing
|
|
214
|
+
* difference vs the index-wide {@link lastSeenGeneration} +
|
|
215
|
+
* {@link invalidateIfStale} path, which clears the entire
|
|
216
|
+
* `loadedFields` Set on any mutation.
|
|
217
|
+
*
|
|
218
|
+
* Cold-boot: an empty Map. The first probe of any field records
|
|
219
|
+
* its current native counter without invalidating (matches the
|
|
220
|
+
* `lastSeenGeneration: -1n` semantic — never-observed implies
|
|
221
|
+
* loadedFields is empty anyway).
|
|
222
|
+
*/
|
|
223
|
+
lastSeenFieldGenerations = new Map();
|
|
224
|
+
// TS-only analytics (not in Rust)
|
|
225
|
+
fieldStats = new Map();
|
|
226
|
+
typeFieldAffinity = new Map();
|
|
227
|
+
totalEntitiesByType = new Map();
|
|
228
|
+
entityCountsByTypeFixed = new Uint32Array(NOUN_TYPE_COUNT);
|
|
229
|
+
verbCountsByTypeFixed = new Uint32Array(VERB_TYPE_COUNT);
|
|
230
|
+
// Unified cache for coordinated memory management
|
|
231
|
+
unifiedCache;
|
|
232
|
+
// Field Type Inference
|
|
233
|
+
fieldTypeInference;
|
|
234
|
+
/**
|
|
235
|
+
* **The native LSM engine (cor 3.0 — the sole metadata engine).** Opened
|
|
236
|
+
* in {@link init} (persistent + mmap-backed on filesystem storage, or a
|
|
237
|
+
* non-persistent in-RAM engine on non-filesystem adapters) and attached to
|
|
238
|
+
* `this.native` via `setLsmEngine` so the metadata-index dispatch routes
|
|
239
|
+
* through the engine's memtable + admission control. `null` only between
|
|
240
|
+
* construction and {@link openLsmEngine} during `init()`.
|
|
241
|
+
*/
|
|
242
|
+
lsmEngine = null;
|
|
243
|
+
/**
|
|
244
|
+
* **LSM durability (steps 3-4).** In-memory mirror of the on-disk LSM
|
|
245
|
+
* manifest. `null` in JSON mode and on the non-persistent fallback engine
|
|
246
|
+
* (where there is no SSTable surface). Set by {@link openLsmEngine} at cold
|
|
247
|
+
* open (parsed from {@link LSM_MANIFEST_KEY}, or a fresh empty manifest) and
|
|
248
|
+
* grown by {@link flushLsm} as each memtable drain produces an SSTable.
|
|
249
|
+
*/
|
|
250
|
+
lsmManifest = null;
|
|
251
|
+
/**
|
|
252
|
+
* **LSM durability (steps 3-4).** The resolved on-disk metadata directory
|
|
253
|
+
* (`getBinaryBlobPath('_metadata')`) backing the persistent engine's
|
|
254
|
+
* memtable logs. `null` in JSON mode and on the non-persistent fallback.
|
|
255
|
+
* Its presence is the load-bearing signal that {@link flushLsm} should drain
|
|
256
|
+
* the memtable to a durable SSTable.
|
|
257
|
+
*/
|
|
258
|
+
lsmMetaDir = null;
|
|
259
|
+
/**
|
|
260
|
+
* **LSM durability (step 5) — bounded-memtable flush trigger.** The memtable
|
|
261
|
+
* resident-byte ceiling at which {@link addToIndex} proactively drains the
|
|
262
|
+
* memtable to a durable SSTable via {@link flushLsm}, so the memtable never
|
|
263
|
+
* climbs to the admission ceiling in steady state (which would wedge every
|
|
264
|
+
* writer — the failure mode the persistent-open switch would otherwise
|
|
265
|
+
* introduce). Computed by {@link computeLsmFlushThreshold} as ~50% of the
|
|
266
|
+
* engine's dynamic `allowedBytes`, clamped to `[32 MiB, 256 MiB]`, and
|
|
267
|
+
* RECOMPUTED inside the ResourceManager subscriber callback
|
|
268
|
+
* ({@link attachLsmBudgetSubscriber}) so it tracks the live per-category
|
|
269
|
+
* budget. Seeded to the floor until the first budget callback fires.
|
|
270
|
+
*/
|
|
271
|
+
lsmFlushThresholdBytes = LSM_FLUSH_THRESHOLD_MIN_BYTES;
|
|
272
|
+
/**
|
|
273
|
+
* Deregistration hook returned by
|
|
274
|
+
* {@link ResourceManager.registerMemoryBudgetSubscriber}. Called from
|
|
275
|
+
* {@link close} so the rebalance loop stops firing the engine's
|
|
276
|
+
* `setAllowedBytes` after this index has shut down. `null` in JSON
|
|
277
|
+
* mode (no subscriber registered).
|
|
278
|
+
*/
|
|
279
|
+
unsubscribeFromBudget = null;
|
|
280
|
+
/**
|
|
281
|
+
* Unique subscriber name used for both the
|
|
282
|
+
* `ResourceManager.registerMemoryBudgetSubscriber` call and the
|
|
283
|
+
* corresponding `reportNativeMemory` callback that drives the
|
|
284
|
+
* dynamic per-category rebalance. Each MetadataIndexManager instance
|
|
285
|
+
* gets a fresh name from {@link nextLsmSubscriberId} so multiple
|
|
286
|
+
* brains in one process don't collide on the same subscriber slot.
|
|
287
|
+
*/
|
|
288
|
+
lsmSubscriberName = null;
|
|
289
|
+
/**
|
|
290
|
+
* Native column store — per-field sorted columns backing brainy's unfiltered
|
|
291
|
+
* `find({ orderBy })` path. Brainy calls `metadataIndex.columnStore.sortTopK(...)`
|
|
292
|
+
* directly, so this MUST be present (and is why it is public). Cor's metadata
|
|
293
|
+
* index does its own native filtering; the column store's jobs here are populate
|
|
294
|
+
* (on `addToIndex`), persist (on `flush`), and sort (`sortTopK`).
|
|
295
|
+
*/
|
|
296
|
+
columnStore;
|
|
297
|
+
/**
|
|
298
|
+
* Lazily built id-mapper façade over the native UUID ↔ int methods, returned by
|
|
299
|
+
* {@link getIdMapper}. Cached so brainy's orderBy path reuses one object.
|
|
300
|
+
*/
|
|
301
|
+
idMapperFacade = null;
|
|
302
|
+
/**
|
|
303
|
+
* **#72 Phase C.** The native mmap `BinaryIdMapper` injected into the
|
|
304
|
+
* Rust metadata core as its canonical UUID ↔ entity-int allocator,
|
|
305
|
+
* replacing the resident JSON id-maps (~56 GB @1B + the ~13M-UUID JSON
|
|
306
|
+
* cliff). Built in {@link init} from the brain's `_id_mapper/*` paths and
|
|
307
|
+
* handed to the core via `native.setIdMapper`. Kept here so {@link flush}
|
|
308
|
+
* can msync it and a full {@link rebuild} can truncate + re-inject it.
|
|
309
|
+
* `null` until init wires it (or on non-filesystem storage, where the
|
|
310
|
+
* mapper cannot engage — the metadata index then has no allocator and
|
|
311
|
+
* the brain must run unaccelerated).
|
|
312
|
+
*/
|
|
313
|
+
idMapperNative = null;
|
|
314
|
+
/**
|
|
315
|
+
* Set by `getIdMapper().rebuild()` so the immediately-following
|
|
316
|
+
* `rebuild()` (brainy `restore()` runs metadataIndex.rebuild() in a
|
|
317
|
+
* `Promise.all` right after the mapper hook) is a no-op: re-clearing the
|
|
318
|
+
* entity-id mapper there would race the concurrent `graphIndex.rebuild()`
|
|
319
|
+
* that resolves verb endpoints through this same mapper. The hook already
|
|
320
|
+
* did the full, identical rebuild, so the second one is skipped and the
|
|
321
|
+
* mapper stays stable for the graph pass. One-shot: consumed by the next
|
|
322
|
+
* `rebuild()`. (#68)
|
|
323
|
+
*/
|
|
324
|
+
skipRebuildOnce = false;
|
|
325
|
+
constructor(storage, config = {},
|
|
326
|
+
/**
|
|
327
|
+
* Lazy resolver to the shared {@link MigrationCoordinator} (the plugin creates
|
|
328
|
+
* one per brain and hands the same getter to all three derived-index providers).
|
|
329
|
+
* Undefined for a raw-napi construction (tests) or a tier that never migrates —
|
|
330
|
+
* then {@link isMigrating} is always `false` and the epoch guard's large-brain
|
|
331
|
+
* outcome falls through to Brainy's own path.
|
|
332
|
+
*/
|
|
333
|
+
migrationCoordinatorGetter) {
|
|
334
|
+
this.migrationCoordinatorGetter = migrationCoordinatorGetter;
|
|
335
|
+
this.storage = storage;
|
|
336
|
+
this.config = {
|
|
337
|
+
maxIndexSize: config.maxIndexSize ?? 10000,
|
|
338
|
+
rebuildThreshold: config.rebuildThreshold ?? 0.1,
|
|
339
|
+
autoOptimize: config.autoOptimize ?? true,
|
|
340
|
+
indexedFields: config.indexedFields ?? [],
|
|
341
|
+
excludeFields: config.excludeFields ?? [
|
|
342
|
+
'embedding', 'vector', 'embeddings', 'vectors',
|
|
343
|
+
'content', 'data', 'originalData', '_data',
|
|
344
|
+
'id'
|
|
345
|
+
],
|
|
346
|
+
};
|
|
347
|
+
// Build Rust config JSON
|
|
348
|
+
const nativeConfig = {};
|
|
349
|
+
if (this.config.excludeFields.length > 0) {
|
|
350
|
+
nativeConfig.excludeFields = this.config.excludeFields;
|
|
351
|
+
}
|
|
352
|
+
if (this.config.indexedFields.length > 0) {
|
|
353
|
+
nativeConfig.indexedFields = this.config.indexedFields;
|
|
354
|
+
}
|
|
355
|
+
const bindings = loadNativeModule();
|
|
356
|
+
// Cor 3.0 zero-config: pass 'u64' explicitly so the
|
|
357
|
+
// wrapper's LSM engine (also constructed with idSpace='u64'
|
|
358
|
+
// below) and the metadata index core agree. The raw napi
|
|
359
|
+
// constructor still defaults to u32 for back-compat with
|
|
360
|
+
// direct napi callers + internal tests.
|
|
361
|
+
this.native = new bindings.NativeMetadataIndex(Object.keys(nativeConfig).length > 0 ? JSON.stringify(nativeConfig) : null, 'u64');
|
|
362
|
+
this.unifiedCache = getGlobalCache();
|
|
363
|
+
this.fieldTypeInference = new FieldTypeInference(storage);
|
|
364
|
+
this.columnStore = new NativeColumnStore();
|
|
365
|
+
// LSM durability (steps 3-4): engine construction MOVED to init().
|
|
366
|
+
// The persistent engine is opened via `NativeLsmEngine.openWithDir`,
|
|
367
|
+
// which must be seeded from the on-disk manifest (an async storage
|
|
368
|
+
// read) so its epoch counter outranks every already-flushed SSTable.
|
|
369
|
+
// The constructor therefore does no LSM work — `init()` opens the
|
|
370
|
+
// engine (cold-loading any persisted SSTables) and registers the
|
|
371
|
+
// ResourceManager subscriber once the engine exists.
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* **LSM durability (steps 3-4) — the async open.** Construct the native LSM
|
|
375
|
+
* engine, attach it to the metadata index, register the ResourceManager
|
|
376
|
+
* budget subscriber, and cold-load every persisted SSTable into the
|
|
377
|
+
* engine's live read set. Called once from {@link init} (after
|
|
378
|
+
* {@link wireIdMapper}) — the LSM engine is the sole metadata engine.
|
|
379
|
+
*
|
|
380
|
+
* ## Persistent vs fallback
|
|
381
|
+
*
|
|
382
|
+
* When the storage is filesystem-backed (exposes `getBinaryBlobPath`), the
|
|
383
|
+
* engine is opened via `NativeLsmEngine.openWithDir(metaDir, manifestJson,
|
|
384
|
+
* …)`: its memtable is backed by mmap'd per-shard append logs and its epoch
|
|
385
|
+
* counter is seeded from the persisted manifest so new writes outrank every
|
|
386
|
+
* already-flushed SSTable. Non-filesystem storage falls back to a
|
|
387
|
+
* non-persistent in-RAM engine (`new NativeLsmEngine`) — reads/writes work
|
|
388
|
+
* for the session but there is no SSTable durability (and {@link flushLsm}
|
|
389
|
+
* becomes a no-op).
|
|
390
|
+
*
|
|
391
|
+
* ## Cold-load = no rebuild
|
|
392
|
+
*
|
|
393
|
+
* Cold start reads the manifest, seeds the epoch via `openWithDir`, then
|
|
394
|
+
* calls `registerSstablePath` once per SSTable — the entire restore. The
|
|
395
|
+
* O(N) {@link rebuild} path is NOT taken: the SSTables ARE the durable
|
|
396
|
+
* posting lists, served from mmap.
|
|
397
|
+
*
|
|
398
|
+
* @throws If a persisted SSTable's path cannot be resolved.
|
|
399
|
+
*/
|
|
400
|
+
async openLsmEngine() {
|
|
401
|
+
if (this.lsmEngine !== null)
|
|
402
|
+
return; // already attached
|
|
403
|
+
const bindings = loadNativeModule();
|
|
404
|
+
const blobStorage = this.storage;
|
|
405
|
+
// Resolve the metadata directory for the persistent engine, mirroring
|
|
406
|
+
// wireIdMapper's filesystem guard. `getBinaryBlobPath` returns null on
|
|
407
|
+
// non-local adapters → fall back to the in-RAM engine.
|
|
408
|
+
const metaDir = typeof blobStorage.getBinaryBlobPath === 'function'
|
|
409
|
+
? blobStorage.getBinaryBlobPath('_metadata')
|
|
410
|
+
: null;
|
|
411
|
+
if (metaDir === null) {
|
|
412
|
+
// Fallback: non-persistent in-RAM engine (the legacy `new` path).
|
|
413
|
+
// U64 IdSpace matches the cor 3.0 production default + the metadata
|
|
414
|
+
// index core constructed above.
|
|
415
|
+
this.lsmEngine = new bindings.NativeLsmEngine({ idSpace: 'u64' });
|
|
416
|
+
this.lsmManifest = null;
|
|
417
|
+
this.lsmMetaDir = null;
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
// Persistent cold-load: parse the manifest (null on a fresh brain),
|
|
421
|
+
// seed the engine's epoch from it via openWithDir, then register every
|
|
422
|
+
// persisted SSTable below. NO rebuild.
|
|
423
|
+
const persisted = await this.loadLsmManifest();
|
|
424
|
+
const manifestJson = persisted !== null ? JSON.stringify(persisted) : null;
|
|
425
|
+
this.lsmEngine = bindings.NativeLsmEngine.openWithDir(metaDir, manifestJson, {
|
|
426
|
+
idSpace: 'u64',
|
|
427
|
+
});
|
|
428
|
+
this.lsmManifest = persisted ?? newEmptyLsmManifest();
|
|
429
|
+
this.lsmMetaDir = metaDir;
|
|
430
|
+
}
|
|
431
|
+
this.native.setLsmEngine(this.lsmEngine);
|
|
432
|
+
// Register the budget subscriber NOW that the engine exists, so its
|
|
433
|
+
// synchronous initial callback gives the engine a budgeted allowedBytes
|
|
434
|
+
// before the first write (closing the u64::MAX unwired-admission window).
|
|
435
|
+
this.attachLsmBudgetSubscriber();
|
|
436
|
+
// Cold-load SSTable registration (persistent engine only). The ordering
|
|
437
|
+
// invariant in flushLsm guarantees every entry the manifest references
|
|
438
|
+
// was made durable BEFORE the manifest — so each path exists here. Call
|
|
439
|
+
// getBinaryBlobPath as a METHOD on the storage object (not via an extracted
|
|
440
|
+
// reference) so its `this` binding survives — the adapter resolves the path
|
|
441
|
+
// through `this.blobPath(...)`.
|
|
442
|
+
if (this.lsmManifest !== null && metaDir !== null) {
|
|
443
|
+
const fsStorage = this.storage;
|
|
444
|
+
const engine = this.lsmEngine;
|
|
445
|
+
for (const entry of this.lsmManifest.sstables) {
|
|
446
|
+
const key = lsmSstableKey(entry.id);
|
|
447
|
+
const path = fsStorage.getBinaryBlobPath(key);
|
|
448
|
+
if (path === null) {
|
|
449
|
+
throw new Error(`openLsmEngine: getBinaryBlobPath returned null for persisted SSTable '${key}'`);
|
|
450
|
+
}
|
|
451
|
+
engine.registerSstablePath(path);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* **LSM durability (steps 3-4) — the sync part.** Register the
|
|
457
|
+
* ResourceManager budget subscriber that keeps the engine's admission
|
|
458
|
+
* ceiling in sync with the dynamic 'metadata' budget. Split out of the
|
|
459
|
+
* async {@link openLsmEngine} so the registration (and its synchronous
|
|
460
|
+
* initial callback) is an isolated, side-effect-free-on-failure step that
|
|
461
|
+
* runs immediately after the engine is attached.
|
|
462
|
+
*
|
|
463
|
+
* Idempotent: a second call (e.g. a re-init) is a no-op while a subscriber
|
|
464
|
+
* is live. The unique subscriber name keeps multiple brains in one process
|
|
465
|
+
* from colliding on the same ResourceManager slot.
|
|
466
|
+
*
|
|
467
|
+
* The callback path is load-bearing for the dynamic rebalance: every
|
|
468
|
+
* `notifySubscribers` cycle (rebalance + pressure tick) hits
|
|
469
|
+
* `this.lsmEngine.setAllowedBytes(BigInt(allowed))`, applied by the native
|
|
470
|
+
* engine on its very next admission check.
|
|
471
|
+
*/
|
|
472
|
+
attachLsmBudgetSubscriber() {
|
|
473
|
+
if (this.unsubscribeFromBudget !== null)
|
|
474
|
+
return; // already subscribed
|
|
475
|
+
const rm = ResourceManager.getInstance();
|
|
476
|
+
this.lsmSubscriberName = nextLsmSubscriberId();
|
|
477
|
+
this.unsubscribeFromBudget = rm.registerMemoryBudgetSubscriber(this.lsmSubscriberName, 'metadata', (allowedBytes) => {
|
|
478
|
+
// Native side wants u64 BigInt. Negative or NaN values from a
|
|
479
|
+
// pathological budget calculation would round to 0n via the
|
|
480
|
+
// Math.max guard — admission would block every write, which
|
|
481
|
+
// is the SAFE failure (no over-budget writes) rather than the
|
|
482
|
+
// unsafe alternative (unwired u64::MAX → no admission gate).
|
|
483
|
+
const clamped = Math.max(0, Math.floor(allowedBytes));
|
|
484
|
+
this.lsmEngine?.setAllowedBytes(BigInt(clamped));
|
|
485
|
+
// **Step 5.** Track the dynamic budget: the memtable size-flush
|
|
486
|
+
// trigger ({@link addToIndex}) drains at ~50% of this admission
|
|
487
|
+
// ceiling (clamped [32 MiB, 256 MiB]), so the memtable empties
|
|
488
|
+
// well before admission would block a writer. Recomputed in the
|
|
489
|
+
// SAME callback the engine's ceiling is updated from so the two
|
|
490
|
+
// never drift apart across a rebalance.
|
|
491
|
+
this.lsmFlushThresholdBytes = computeLsmFlushThreshold(clamped);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* **LSM durability (steps 3-4) — cold-load read.** Read and validate the
|
|
496
|
+
* persisted LSM manifest from storage. Returns `null` when no manifest
|
|
497
|
+
* exists yet (a fresh brain). Validates the format version + shape and
|
|
498
|
+
* throws on a mismatch rather than registering a partial SSTable set (which
|
|
499
|
+
* would silently drop rows) — a forward-incompatible or torn manifest must
|
|
500
|
+
* fail loud.
|
|
501
|
+
*
|
|
502
|
+
* @returns The parsed manifest, or `null` if absent.
|
|
503
|
+
* @throws If the manifest is present but malformed or carries an
|
|
504
|
+
* unsupported {@link LsmManifest.version}.
|
|
505
|
+
*/
|
|
506
|
+
async loadLsmManifest() {
|
|
507
|
+
let raw;
|
|
508
|
+
try {
|
|
509
|
+
raw = await this.storage.getMetadata(LSM_MANIFEST_KEY);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
// Storage read error during the probe is treated as "no manifest" — a
|
|
513
|
+
// transient hiccup should not block engine open; a genuinely-present
|
|
514
|
+
// manifest is re-read on the next open.
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
if (raw === null || raw === undefined)
|
|
518
|
+
return null;
|
|
519
|
+
const m = raw;
|
|
520
|
+
if (typeof m.version !== 'number' || !Array.isArray(m.sstables)) {
|
|
521
|
+
throw new Error(`loadLsmManifest: malformed manifest at '${LSM_MANIFEST_KEY}' ` +
|
|
522
|
+
`(expected { version, sstables[], last_durable_epoch })`);
|
|
523
|
+
}
|
|
524
|
+
if (m.version !== LSM_MANIFEST_VERSION) {
|
|
525
|
+
throw new Error(`loadLsmManifest: manifest version ${m.version}, this build supports ` +
|
|
526
|
+
`${LSM_MANIFEST_VERSION}`);
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
version: m.version,
|
|
530
|
+
sstables: m.sstables,
|
|
531
|
+
last_durable_epoch: typeof m.last_durable_epoch === 'number' ? m.last_durable_epoch : 0,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* **LSM durability (step 6).** Whether the LSM engine recovered durable
|
|
536
|
+
* on-disk state at cold open — used by {@link init} to demote the
|
|
537
|
+
* field-registry-missing rebuild to disaster-recovery-only. Durable state
|
|
538
|
+
* is either ≥1 SSTable in the cold-loaded manifest OR a non-empty memtable
|
|
539
|
+
* replayed from the per-shard logs (records inserted but not yet flushed
|
|
540
|
+
* before the last shutdown/crash). When present, the posting lists are
|
|
541
|
+
* served from the engine independent of the JSON field registry, so a
|
|
542
|
+
* rebuild would be a redundant O(N) re-scan.
|
|
543
|
+
*
|
|
544
|
+
* @returns `true` when the engine holds durable posting-list state.
|
|
545
|
+
*/
|
|
546
|
+
lsmHasDurableState() {
|
|
547
|
+
const engine = this.lsmEngine;
|
|
548
|
+
if (engine === null)
|
|
549
|
+
return false;
|
|
550
|
+
if (this.lsmManifest !== null && this.lsmManifest.sstables.length >= 1) {
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
if (Number(engine.memtableBytes()) > 0)
|
|
554
|
+
return true;
|
|
555
|
+
if (engine.memtableEntryCount() > 0n)
|
|
556
|
+
return true;
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* **LSM durability (step 6) — restore/rebuild re-open.** Re-open the LSM
|
|
561
|
+
* engine fresh against the (wiped + recreated) storage tree and re-attach
|
|
562
|
+
* it to the native core. Called from {@link rebuild} in LSM mode.
|
|
563
|
+
*
|
|
564
|
+
* ## Why a full re-open, not just a `mkdir`
|
|
565
|
+
*
|
|
566
|
+
* A rebuild commonly follows brainy's `restore()`, which WIPES and replaces
|
|
567
|
+
* the storage tree under the still-live engine. But `native.clear()` (the
|
|
568
|
+
* in-memory reset rebuild runs) deliberately does NOT touch the attached
|
|
569
|
+
* engine — the engine owns its own memtable + copy-on-write live-SSTable
|
|
570
|
+
* set, neither of which `clear()` can see. So WITHOUT this re-open the
|
|
571
|
+
* engine would keep:
|
|
572
|
+
* - **stale live-SSTable readers** — mmap handles on the pre-restore
|
|
573
|
+
* `.sst` files. On Linux an mmap survives the file's `unlink`, so those
|
|
574
|
+
* readers keep serving PRE-restore rows; a query after rebuild would
|
|
575
|
+
* merge the restored data WITH ghosts of the wiped entities.
|
|
576
|
+
* - **a stale in-memory manifest** ({@link lsmManifest}) still listing the
|
|
577
|
+
* deleted SSTables — the next cold open would try to `registerSstablePath`
|
|
578
|
+
* files that no longer exist and fail loud.
|
|
579
|
+
* - **a stale memtable** holding pre-restore writes not yet flushed.
|
|
580
|
+
*
|
|
581
|
+
* This method makes the engine adopt the new tree: it deletes the stale
|
|
582
|
+
* on-disk SSTables + memtable logs, drops the stale manifest, constructs a
|
|
583
|
+
* FRESH `openWithDir` engine (empty live set, empty memtable, epoch reset
|
|
584
|
+
* from the clean logs), and re-attaches it via `setLsmEngine` (which
|
|
585
|
+
* overwrites the core's engine Arc — verified in the Rust binding). The
|
|
586
|
+
* ResourceManager subscriber callback dereferences `this.lsmEngine`
|
|
587
|
+
* dynamically, so the fresh engine inherits budget tracking on the next
|
|
588
|
+
* tick; meanwhile the re-applied admission ceiling below keeps it bounded,
|
|
589
|
+
* and the step-5 size-flush trigger drains it during the rebuild's
|
|
590
|
+
* re-index walk.
|
|
591
|
+
*
|
|
592
|
+
* A no-op on the non-persistent fallback engine (no `lsmMetaDir`), where
|
|
593
|
+
* there is no SSTable surface to go stale.
|
|
594
|
+
*/
|
|
595
|
+
async reopenLsmEngineFresh() {
|
|
596
|
+
if (this.lsmMetaDir === null)
|
|
597
|
+
return;
|
|
598
|
+
const metaDir = this.lsmMetaDir;
|
|
599
|
+
const bindings = loadNativeModule();
|
|
600
|
+
const blobStorage = this.storage;
|
|
601
|
+
// (1) Delete the stale on-disk SSTables of the pre-restore tree. The live
|
|
602
|
+
// engine's readers mmap the `.sst` files (surviving unlink), so they must
|
|
603
|
+
// be dropped together with the engine below; deleting the files also stops
|
|
604
|
+
// the next cold open from registering a ghost SSTable. The SSTables live
|
|
605
|
+
// under the blob root (resolved via the SAME `getBinaryBlobPath` key
|
|
606
|
+
// mapping flushLsm uses) — which is a SIBLING of `metaDir`, NOT a child of
|
|
607
|
+
// it (e.g. FileSystemStorage maps `_metadata` → `…/_metadata.bin` for the
|
|
608
|
+
// memtable logs but `_metadata/sstables/<id>.sst` → `…/_metadata/sstables/
|
|
609
|
+
// <id>.sst.bin`). Wiping the whole sstables directory reaps the
|
|
610
|
+
// manifest-listed SSTables AND any orphan from an interrupted flush.
|
|
611
|
+
if (typeof blobStorage.getBinaryBlobPath === 'function') {
|
|
612
|
+
const probe = blobStorage.getBinaryBlobPath(lsmSstableKey(0));
|
|
613
|
+
if (probe !== null) {
|
|
614
|
+
rmSync(dirname(probe), { recursive: true, force: true });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
rmSync(join(metaDir, 'memtable'), { recursive: true, force: true });
|
|
618
|
+
// (2) Drop the stale manifest (it still references the deleted SSTables).
|
|
619
|
+
try {
|
|
620
|
+
await this.storage.saveMetadata(LSM_MANIFEST_KEY, null);
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
// Best-effort: a fresh-restore tree may already lack the manifest.
|
|
624
|
+
}
|
|
625
|
+
// (3) Recreate the memtable directory the fresh engine's lazy per-shard
|
|
626
|
+
// logs append into; the native append path fails LOUD on a missing dir.
|
|
627
|
+
mkdirSync(join(metaDir, 'memtable'), { recursive: true });
|
|
628
|
+
// (4) Construct a FRESH persistent engine (null manifest → epoch seeded
|
|
629
|
+
// from the now-clean logs) and re-attach. This drops the old engine's
|
|
630
|
+
// stale live-SSTable readers + memtable; `setLsmEngine` replaces the
|
|
631
|
+
// core's engine reference.
|
|
632
|
+
const engine = bindings.NativeLsmEngine.openWithDir(metaDir, null, {
|
|
633
|
+
idSpace: 'u64',
|
|
634
|
+
});
|
|
635
|
+
this.lsmEngine = engine;
|
|
636
|
+
this.lsmManifest = newEmptyLsmManifest();
|
|
637
|
+
this.native.setLsmEngine(engine);
|
|
638
|
+
// Re-apply the current admission ceiling immediately (the subscriber
|
|
639
|
+
// callback only refreshes it on the next rebalance/pressure tick). The
|
|
640
|
+
// fresh engine opens with `allowedBytes = u64::MAX`; seeding it now keeps
|
|
641
|
+
// the rebuild's writes inside the same budget the old engine had.
|
|
642
|
+
if (this.lsmSubscriberName !== null) {
|
|
643
|
+
const allowed = ResourceManager.getInstance().getCategoryAllowedBytes('metadata');
|
|
644
|
+
const clamped = Math.max(0, Math.floor(allowed));
|
|
645
|
+
engine.setAllowedBytes(BigInt(clamped));
|
|
646
|
+
this.lsmFlushThresholdBytes = computeLsmFlushThreshold(clamped);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* **LSM durability (steps 3-4) — the flush/close drain.** Drain the LSM
|
|
651
|
+
* engine's memtable to a durable SSTable and splice it into the manifest.
|
|
652
|
+
* Called from {@link flush} (and therefore {@link close}, which flushes).
|
|
653
|
+
* No-op when there is no memtable content to flush, and on the
|
|
654
|
+
* non-persistent fallback engine (no manifest / no metaDir).
|
|
655
|
+
*
|
|
656
|
+
* ## Ordering invariant (load-bearing — a reviewer MUST preserve it)
|
|
657
|
+
*
|
|
658
|
+
* The four steps run in this exact order, and `commitFlushThrough` is NEVER
|
|
659
|
+
* called before the `.sst` bytes AND the manifest referencing them are
|
|
660
|
+
* durable:
|
|
661
|
+
*
|
|
662
|
+
* 1. write the `.sst` bytes (`saveBinaryBlob`, atomic tmp+rename)
|
|
663
|
+
* 2. write the manifest that references the new SSTable (`saveMetadata`)
|
|
664
|
+
* 3. register the SSTable into the engine's live read set
|
|
665
|
+
* (`registerSstablePath` — mmap + SHA-256 verify)
|
|
666
|
+
* 4. ONLY NOW retire the memtable log below the flushed epoch
|
|
667
|
+
* (`commitFlushThrough`)
|
|
668
|
+
*
|
|
669
|
+
* A crash between any two steps is safe: the just-flushed records still live
|
|
670
|
+
* in the uncommitted memtable log, so the next `openWithDir` replays them
|
|
671
|
+
* and merge-by-epoch idempotently collapses the transient double presence. A
|
|
672
|
+
* crash before step 2 leaves an orphaned `.sst` no manifest references — its
|
|
673
|
+
* id is harmlessly re-used on the next flush. Calling `commitFlushThrough`
|
|
674
|
+
* early would drop the log copy while the only on-disk copy might not yet be
|
|
675
|
+
* durable — unrecoverable data loss. Hence step 4 is strictly last.
|
|
676
|
+
*/
|
|
677
|
+
async flushLsm() {
|
|
678
|
+
const engine = this.lsmEngine;
|
|
679
|
+
const manifest = this.lsmManifest;
|
|
680
|
+
// Only the persistent (filesystem-backed) engine has an SSTable surface.
|
|
681
|
+
if (engine === null || manifest === null || this.lsmMetaDir === null)
|
|
682
|
+
return;
|
|
683
|
+
// Defensive: skip the drain if the persistent memtable directory has been
|
|
684
|
+
// removed out from under us. The realistic trigger is teardown, not
|
|
685
|
+
// production: brainy registers a global process-exit `flushOnShutdown`
|
|
686
|
+
// hook that calls `metadataIndex.flush()` on EVERY still-registered brain,
|
|
687
|
+
// and a test commonly deletes its temp storage before that hook fires. The
|
|
688
|
+
// native memtable-log rotation in `commitFlushThrough` fails LOUD on a
|
|
689
|
+
// missing directory ("persistence guarantee broken — terminate"), aborting
|
|
690
|
+
// the process. There is nothing to persist when the directory is gone (the
|
|
691
|
+
// memtable logs lived there too), so a no-op is the correct, safe outcome.
|
|
692
|
+
// In production the directory never vanishes, so this guard always passes.
|
|
693
|
+
if (!existsSync(join(this.lsmMetaDir, 'memtable')))
|
|
694
|
+
return;
|
|
695
|
+
const result = engine.flush();
|
|
696
|
+
if (result == null)
|
|
697
|
+
return; // memtable empty — nothing to persist
|
|
698
|
+
const blobStorage = this.storage;
|
|
699
|
+
// Monotonic, manifest-persisted id (see nextSstableId for the crash-safety
|
|
700
|
+
// argument).
|
|
701
|
+
const id = nextSstableId(manifest);
|
|
702
|
+
const key = lsmSstableKey(id);
|
|
703
|
+
// --- Ordering invariant (see JSDoc) ---
|
|
704
|
+
// (1) .sst bytes durable.
|
|
705
|
+
await blobStorage.saveBinaryBlob(key, result.sstable);
|
|
706
|
+
// (2) manifest (references the .sst) durable. Stamp the native flush's
|
|
707
|
+
// placeholder-id entry with the allocated id, keep the (level, id) sort
|
|
708
|
+
// invariant, and advance the durable-epoch high-water mark.
|
|
709
|
+
const entry = JSON.parse(result.manifestEntryJson);
|
|
710
|
+
entry.id = id;
|
|
711
|
+
manifest.sstables.push(entry);
|
|
712
|
+
manifest.sstables.sort((a, b) => a.level - b.level || a.id - b.id);
|
|
713
|
+
manifest.last_durable_epoch = Number(result.lastDurableEpoch);
|
|
714
|
+
await this.storage.saveMetadata(LSM_MANIFEST_KEY, manifest);
|
|
715
|
+
// (3) register into the engine's live read set (mmap + verify).
|
|
716
|
+
const path = blobStorage.getBinaryBlobPath(key);
|
|
717
|
+
if (path === null) {
|
|
718
|
+
throw new Error(`flushLsm: getBinaryBlobPath returned null for '${key}'`);
|
|
719
|
+
}
|
|
720
|
+
engine.registerSstablePath(path);
|
|
721
|
+
// (4) Retire the memtable log below the flushed epoch. The `.sst` bytes
|
|
722
|
+
// AND the manifest referencing them are already durable here, so a failure
|
|
723
|
+
// at THIS step is non-fatal per the ordering invariant above: the records
|
|
724
|
+
// simply stay in the memtable log and replay on the next `openWithDir`,
|
|
725
|
+
// where merge-by-epoch idempotently collapses them against the SSTable
|
|
726
|
+
// copy (no double counting, no loss — the data lives safely in two places).
|
|
727
|
+
// The one realistic trigger is a `rebuild()` following brainy's `restore()`,
|
|
728
|
+
// which wipes + recreates the storage tree under the still-live engine; the
|
|
729
|
+
// native shard-log rotation can then fail to rename a log even though the
|
|
730
|
+
// flushed data is safely in the new SSTable. Warn + continue rather than
|
|
731
|
+
// abort the rebuild — the next clean flush retires the log.
|
|
732
|
+
try {
|
|
733
|
+
engine.commitFlushThrough(result.lastDurableEpoch);
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
prodLog.warn(`flushLsm: commitFlushThrough deferred for SSTable ${id} (it + the ` +
|
|
737
|
+
`manifest are already durable; the memtable log replays + collapses ` +
|
|
738
|
+
`by epoch on the next open):`, error);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* **Piece 11 closeout.** Detect cortex 2.x legacy JSON metadata
|
|
743
|
+
* state on storage. The canonical marker is the
|
|
744
|
+
* `__metadata_field_registry__` key the JSON path writes on
|
|
745
|
+
* every flush. If present, the brain was built before the LSM
|
|
746
|
+
* engine became the default and refuses to silently switch.
|
|
747
|
+
*
|
|
748
|
+
* On detection: throw an init-time error directing the operator to run
|
|
749
|
+
* the migration script (the cor 3.0 LSM engine is the only engine — the
|
|
750
|
+
* legacy JSON chunk path has been removed). The error message is the
|
|
751
|
+
* migration runbook.
|
|
752
|
+
*
|
|
753
|
+
* No legacy state present → the brain is either fresh (no prior
|
|
754
|
+
* cor install) or already on the LSM path → proceed normally
|
|
755
|
+
* with the LSM engine.
|
|
756
|
+
*
|
|
757
|
+
* @throws {@link MetadataIndexLegacyJsonStateDetected} when cortex 2.x
|
|
758
|
+
* JSON metadata state is detected on disk.
|
|
759
|
+
*/
|
|
760
|
+
async detectLegacyJsonStateAndGate() {
|
|
761
|
+
let registry;
|
|
762
|
+
try {
|
|
763
|
+
registry = await this.storage.getMetadata('__metadata_field_registry__');
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
// Storage errors during the probe are treated as "no legacy
|
|
767
|
+
// state" — we can't read the file, so we can't be sure it's
|
|
768
|
+
// there, and the default behaviour (use LSM) is safer than
|
|
769
|
+
// erroring out on every transient storage hiccup.
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (registry === null || registry === undefined) {
|
|
773
|
+
// Fresh brain — no field registry — proceed with the LSM engine.
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
// A reopened cor 3.0 LSM brain ALSO carries `__metadata_field_registry__`
|
|
777
|
+
// (the value-enumeration registry is shared — it is NOT a cortex 2.x
|
|
778
|
+
// marker by itself). The 2.x-specific marker is a per-field sparse index
|
|
779
|
+
// (`__sparse_index__<field>`), which the LSM path never writes — the
|
|
780
|
+
// metadata-storage analog of {@link assertNoLegacyLayout}'s chunk-file
|
|
781
|
+
// check. Probe the registered fields for one; only genuine 2.x chunk
|
|
782
|
+
// state trips the gate, so a legitimate LSM reopen passes through.
|
|
783
|
+
const fields = Array.isArray(registry.fields)
|
|
784
|
+
? registry.fields.filter((f) => typeof f === 'string')
|
|
785
|
+
: [];
|
|
786
|
+
let hasLegacySparseIndex = false;
|
|
787
|
+
for (const field of fields) {
|
|
788
|
+
let sparse;
|
|
789
|
+
try {
|
|
790
|
+
sparse = await this.storage.getMetadata(`__sparse_index__${field}`);
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (sparse !== null && sparse !== undefined) {
|
|
796
|
+
hasLegacySparseIndex = true;
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (!hasLegacySparseIndex) {
|
|
801
|
+
// Registry present but no 2.x sparse indices → an LSM-native brain.
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
// Genuine cortex 2.x JSON chunk state. Refuse to proceed silently: the
|
|
805
|
+
// cor 3.0 LSM engine would shadow this on-disk state and surface as a
|
|
806
|
+
// mysteriously-empty brain on first query. The legacy JSON chunk engine
|
|
807
|
+
// has been removed, so the only path forward is migration.
|
|
808
|
+
throw new MetadataIndexLegacyJsonStateDetected(`[NativeMetadataIndex] cortex 2.x JSON metadata state detected ` +
|
|
809
|
+
`(__metadata_field_registry__ + __sparse_index__ chunk markers). ` +
|
|
810
|
+
`cor 3.0 uses the LSM metadata engine exclusively (the legacy JSON ` +
|
|
811
|
+
`chunk engine has been removed), which would shadow this on-disk ` +
|
|
812
|
+
`state and surface as a mysteriously-empty brain on first query.\n\n` +
|
|
813
|
+
`Migrate the brain to the LSM engine before reopening it:\n` +
|
|
814
|
+
` - Run the migration script: ` +
|
|
815
|
+
`scripts/migrate-cortex-2x-to-3x.mjs <brain-path>\n` +
|
|
816
|
+
` (it snapshots the brain via Brainy 7.x, wipes the legacy JSON ` +
|
|
817
|
+
`state, and restores via Brainy 8.0 + cor 3.0, producing the ` +
|
|
818
|
+
`LSM-native on-disk layout).`);
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* **#18 read-side epoch guard.** Detect 7.x → 8.0 derived-index epoch drift
|
|
822
|
+
* on open and route it to the right outcome. Called from {@link init} AFTER
|
|
823
|
+
* {@link detectLegacyJsonStateAndGate} (the cortex-2.x JSON gate) and
|
|
824
|
+
* {@link openLsmEngine} (the LSM cold-open), and BEFORE the column-store init
|
|
825
|
+
* and any rebuild branch — i.e. while reads are still un-served.
|
|
826
|
+
*
|
|
827
|
+
* The on-disk version-handshake marker (`_system/brain-format.json`) carries a
|
|
828
|
+
* shared, lockstep-bumped `indexEpoch`. cor reads it directly off storage (it
|
|
829
|
+
* cannot reach `brain.formatInfo()`, and Brainy's marker module is not
|
|
830
|
+
* exported) and compares against {@link COR_EXPECTED_INDEX_EPOCH}.
|
|
831
|
+
*
|
|
832
|
+
* Four fully-wired outcomes — no dangling flag:
|
|
833
|
+
*
|
|
834
|
+
* 1. **Epoch CURRENT (the common case)** — a single tiny marker read + an
|
|
835
|
+
* integer compare, then return. NO size probe, NO further work: the
|
|
836
|
+
* warm-restart path stays a cheap no-op.
|
|
837
|
+
* 2. **Stale + SMALL** (`< {@link resolveLargeBrainThreshold}`) — NO-OP. Brainy's
|
|
838
|
+
* `rebuildIndexesIfNeeded` already drives the rebuild from the canonical
|
|
839
|
+
* records for sub-threshold brains (its `AUTO_REBUILD_THRESHOLD` matches
|
|
840
|
+
* ours) and re-stamps the marker after the rebuild verifies. cor must NOT
|
|
841
|
+
* throw here; it simply lets Brainy's path run.
|
|
842
|
+
* 3. **Stale + LARGE + auto-migrate disabled** (`COR_AUTO_MIGRATE=false`) —
|
|
843
|
+
* THROW {@link IndexMigrationRequiredError}, loud and pre-serve. A service
|
|
844
|
+
* that refuses to start is recoverable; serving on indexes that predate
|
|
845
|
+
* this build would surface stale/empty results with no signal.
|
|
846
|
+
* 4. **Stale + LARGE + auto-migrate enabled (default)** — NO-OP / fall through.
|
|
847
|
+
* Brainy's existing large-brain path defers to a lazy first-query rebuild
|
|
848
|
+
* (force=true, BLOCKING inline). That blocking rebuild is correct but not
|
|
849
|
+
* yet "optimized": the non-blocking background build-new → verify → swap
|
|
850
|
+
* that removes the first-query freeze is the **rc.8 increment** (it needs
|
|
851
|
+
* Brainy's deference hook + a cor-invokable stamp method, neither shipped on
|
|
852
|
+
* rc.7). We deliberately do NOT set an `epochMigrationPending` flag here:
|
|
853
|
+
* nothing on rc.7 reads it (the MigrationCoordinator is the rc.8 unit), and
|
|
854
|
+
* an unread flag would be unwired dead state.
|
|
855
|
+
*
|
|
856
|
+
* @throws {@link IndexMigrationRequiredError} on outcome 3 only.
|
|
857
|
+
*/
|
|
858
|
+
async guardIndexEpoch() {
|
|
859
|
+
const onDisk = await readOnDiskBrainFormat(this.storage);
|
|
860
|
+
if (!isEpochStale(onDisk)) {
|
|
861
|
+
// Outcome 1: epoch current — zero-overhead no-op (no size probe).
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
// Epoch is stale (drifted or absent/corrupt marker). Size the brain to
|
|
865
|
+
// decide who drives the rebuild.
|
|
866
|
+
const size = await detectBrainSize(this.storage);
|
|
867
|
+
const isLargeBrain = size >= resolveLargeBrainThreshold();
|
|
868
|
+
if (!isLargeBrain) {
|
|
869
|
+
// Outcome 2: small brain — Brainy's rebuildIndexesIfNeeded drives the
|
|
870
|
+
// rebuild from canonical and re-stamps. Nothing for cor to do.
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (!resolveAutoMigrate()) {
|
|
874
|
+
// Outcome 3: large brain, auto-migrate disabled — refuse to serve.
|
|
875
|
+
throw new IndexMigrationRequiredError(`[NativeMetadataIndex] derived-index epoch drift detected on a large ` +
|
|
876
|
+
`brain (${size.toLocaleString()} entities ≥ migration threshold) and ` +
|
|
877
|
+
`COR_AUTO_MIGRATE is disabled. The on-disk derived indexes (DiskANN ` +
|
|
878
|
+
`vectors, LSM metadata postings, graph adjacency) predate this build ` +
|
|
879
|
+
`(on-disk indexEpoch ${onDisk?.indexEpoch ?? 'absent'} ≠ expected ` +
|
|
880
|
+
`${COR_EXPECTED_INDEX_EPOCH}) and would serve stale/empty results.\n\n` +
|
|
881
|
+
`Re-enable automatic migration (rebuild from the canonical records on ` +
|
|
882
|
+
`open) by unsetting COR_AUTO_MIGRATE, or set COR_AUTO_MIGRATE=true.`);
|
|
883
|
+
}
|
|
884
|
+
// Outcome 4: large brain, auto-migrate enabled — the #18 coordinated blocking
|
|
885
|
+
// migration will run, but we do NOT kick it here. guardIndexEpoch() runs in the
|
|
886
|
+
// MIDDLE of init() (before columnStore.init / warmCache / lazyLoadCounts), and
|
|
887
|
+
// the migration's first step (metadata.rebuild → reopenLsmEngineFresh +
|
|
888
|
+
// columnStore.reset, both destructive) would run CONCURRENTLY with — and race,
|
|
889
|
+
// unsynchronized — the remainder of THIS init(). Flag it instead; init() kicks
|
|
890
|
+
// the coordinator at its very END, so the rebuild starts against a fully
|
|
891
|
+
// settled index. The flag is read (init()'s tail) — not dead state.
|
|
892
|
+
this.epochMigrationPending = true;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* @description **#18 migration lock — feature-detected by Brainy.** `true` while
|
|
896
|
+
* this brain is undergoing (or has failed) the coordinated 7.x → 8.0 rebuild, so
|
|
897
|
+
* Brainy holds every public read and write behind the lock. Delegates to the
|
|
898
|
+
* shared {@link MigrationCoordinator}; `false` when no coordinator is wired.
|
|
899
|
+
* @returns `true` when the brain must not be read from or written to.
|
|
900
|
+
*/
|
|
901
|
+
isMigrating() {
|
|
902
|
+
return this.migrationCoordinatorGetter?.()?.isMigrating() ?? false;
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* @description **#18 migration lock — structured progress** Brainy relays into
|
|
906
|
+
* `getIndexStatus().migration`. `null` unless a migration is in flight.
|
|
907
|
+
* @returns The current {@link MigrationStatus}, or `null`.
|
|
908
|
+
*/
|
|
909
|
+
migrationStatus() {
|
|
910
|
+
return this.migrationCoordinatorGetter?.()?.migrationStatus() ?? null;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* **#18 in-place migration runner (stub).** Reads the un-migrated metadata
|
|
914
|
+
* state from storage and replays it through the LSM-attached mutation path,
|
|
915
|
+
* leaving the brain on the cor 3.0 LSM-native on-disk layout.
|
|
916
|
+
*
|
|
917
|
+
* **NOT YET IMPLEMENTED — GA path is the rc.8 increment.** The read-side
|
|
918
|
+
* epoch guard (#18, {@link guardIndexEpoch}) ships now and makes the SMALL
|
|
919
|
+
* 7.x → 8.0 brain GA on rc.7 (Brainy drives the rebuild from canonical). The
|
|
920
|
+
* LARGE-brain non-blocking background build-new → verify → swap that this
|
|
921
|
+
* runner will drive needs Brainy's deference hook + a cor-invokable stamp
|
|
922
|
+
* method, which land together in the rc.8 increment; until then a large stale
|
|
923
|
+
* brain falls through to Brainy's blocking inline rebuild on first query.
|
|
924
|
+
*
|
|
925
|
+
* The wrapper for this method exists so callers have a stable reference point
|
|
926
|
+
* in the API surface; today it throws with a pointer to the supported
|
|
927
|
+
* back-compat path. Until the in-place runner lands, migrate via the
|
|
928
|
+
* standalone script `scripts/migrate-cortex-2x-to-3x.mjs`, which snapshots the
|
|
929
|
+
* brain via Brainy 7.x, wipes the legacy state, and restores via Brainy 8.0 +
|
|
930
|
+
* cor 3.0 (producing the LSM-native on-disk layout).
|
|
931
|
+
*
|
|
932
|
+
* @throws Always — the in-place runner is the rc.8 increment.
|
|
933
|
+
*/
|
|
934
|
+
async migrateToLsm() {
|
|
935
|
+
throw new Error(`[NativeMetadataIndex.migrateToLsm] not yet implemented — the in-place ` +
|
|
936
|
+
`runner is the #18 rc.8 increment (needs Brainy's deference hook + ` +
|
|
937
|
+
`stamp method). Until then, migrate via the standalone script: ` +
|
|
938
|
+
`scripts/migrate-cortex-2x-to-3x.mjs <brain-path>.`);
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Get the shared entity id-mapper façade. Brainy's unfiltered `orderBy` path
|
|
942
|
+
* calls `metadataIndex.getIdMapper().getUuid(intId)` to turn the column store's
|
|
943
|
+
* sorted int ids back into UUIDs for batchGet. The façade delegates to the
|
|
944
|
+
* native index's O(1) UUID ↔ int methods — the canonical mapping that
|
|
945
|
+
* `addToIndex` assigns and the column store is populated against.
|
|
946
|
+
*
|
|
947
|
+
* @returns An object exposing the brainy `EntityIdMapper` methods callers use.
|
|
948
|
+
*/
|
|
949
|
+
getIdMapper() {
|
|
950
|
+
if (this.idMapperFacade)
|
|
951
|
+
return this.idMapperFacade;
|
|
952
|
+
const native = this.native;
|
|
953
|
+
this.idMapperFacade = {
|
|
954
|
+
getUuid: (intId) => native.intToUuid(intId) ?? undefined,
|
|
955
|
+
getInt: (uuid) => native.uuidToInt(uuid) ?? undefined,
|
|
956
|
+
getOrAssign: (uuid) => {
|
|
957
|
+
// The native index assigns ids inside addToIndex. If a caller resolves an
|
|
958
|
+
// id before the entity is indexed, surface the existing mapping; an
|
|
959
|
+
// unmapped UUID has no int id yet and is reported as such rather than
|
|
960
|
+
// fabricating one (which would desync the canonical native mapper).
|
|
961
|
+
const existing = native.uuidToInt(uuid);
|
|
962
|
+
if (existing !== null)
|
|
963
|
+
return existing;
|
|
964
|
+
throw new Error(`EntityIdMapper.getOrAssign: '${uuid}' has no native id yet — ids are ` +
|
|
965
|
+
`assigned by addToIndex. Index the entity before resolving its int id.`);
|
|
966
|
+
},
|
|
967
|
+
intsIterableToUuids: (ints) => native.intsToUuids([...ints]),
|
|
968
|
+
// Brainy 8.0 `restore()` calls this BEFORE the metadata/vector/graph
|
|
969
|
+
// `Promise.all` so the entity-id mapper reflects the restored snapshot's
|
|
970
|
+
// assignments before `graphIndex.rebuild()` resolves verb endpoints through
|
|
971
|
+
// it. The metadata rebuild IS cor's canonical UUID→int assignment pass, so
|
|
972
|
+
// we run the full rebuild here, then mark the immediately-following
|
|
973
|
+
// `rebuild()` (the one in brainy's Promise.all) to skip — otherwise it would
|
|
974
|
+
// re-clear the mapper and race the concurrent graph rebuild (#68).
|
|
975
|
+
rebuild: async () => {
|
|
976
|
+
this.skipRebuildOnce = false; // ensure the call below actually rebuilds
|
|
977
|
+
await this.rebuild();
|
|
978
|
+
this.skipRebuildOnce = true; // brainy's following Promise.all rebuild() is a no-op
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
return this.idMapperFacade;
|
|
982
|
+
}
|
|
983
|
+
// ==========================================================================
|
|
984
|
+
// Initialization
|
|
985
|
+
// ==========================================================================
|
|
986
|
+
async init() {
|
|
987
|
+
// **Piece 11 closeout.** Detect cortex 2.x legacy JSON metadata state on
|
|
988
|
+
// disk. The cor 3.0 LSM engine is the only metadata engine, so an
|
|
989
|
+
// un-migrated 2.x brain would have its on-disk JSON state shadowed and
|
|
990
|
+
// surface as a mysteriously-empty brain on first query. Throw a clear
|
|
991
|
+
// migration error instead so the operator runs the migration script.
|
|
992
|
+
await this.detectLegacyJsonStateAndGate();
|
|
993
|
+
// Load field registry to discover persisted indices
|
|
994
|
+
await this.loadFieldRegistry();
|
|
995
|
+
// #72 Phase C: build the shared mmap BinaryIdMapper from the brain's
|
|
996
|
+
// `_id_mapper/*` paths and inject it as the core's canonical
|
|
997
|
+
// UUID ↔ entity-int allocator. Must run before columnStore.init (which
|
|
998
|
+
// resolves ids through getIdMapper()) and before any mutation.
|
|
999
|
+
this.wireIdMapper();
|
|
1000
|
+
// LSM durability (steps 3-4): open the native LSM engine here (not in the
|
|
1001
|
+
// constructor) so it can be seeded from the on-disk manifest and cold-load
|
|
1002
|
+
// every persisted SSTable into its live read set — the entire restore,
|
|
1003
|
+
// with NO O(N) rebuild. Runs after wireIdMapper (the engine's posting
|
|
1004
|
+
// lists reference the mapper's entity ints) and before any read/mutation.
|
|
1005
|
+
await this.openLsmEngine();
|
|
1006
|
+
// **#18 read-side epoch guard.** AFTER the cortex-2.x legacy gate and the
|
|
1007
|
+
// LSM cold-open, classify the brain's derived-index epoch against this
|
|
1008
|
+
// build's expectation. The common case (current epoch) is a single tiny
|
|
1009
|
+
// marker read + an integer compare, then return — no size probe, no
|
|
1010
|
+
// overhead on the warm-restart path. Only an epoch drift (a 7.x→8.0 upgrade,
|
|
1011
|
+
// or an absent/corrupt marker) does any further work. See
|
|
1012
|
+
// {@link guardIndexEpoch}.
|
|
1013
|
+
await this.guardIndexEpoch();
|
|
1014
|
+
// Initialize the column store with the shared storage + id mapper façade.
|
|
1015
|
+
// Discovers any persisted `_column_index/` segments and re-applies tombstones.
|
|
1016
|
+
// Done before the rebuild branch below so that a rebuild repopulates a
|
|
1017
|
+
// freshly-initialized column store via addToIndex.
|
|
1018
|
+
await this.columnStore.init(this.storage, this.getIdMapper());
|
|
1019
|
+
const hasFields = this.knownFields.size > 0;
|
|
1020
|
+
if (!hasFields) {
|
|
1021
|
+
// Don't trust "empty" — field registry may be missing due to interrupted flush.
|
|
1022
|
+
// Probe storage for actual entities before concluding the workspace is empty.
|
|
1023
|
+
try {
|
|
1024
|
+
const probe = await this.storage.getNouns({ pagination: { limit: 1, offset: 0 } });
|
|
1025
|
+
const hasEntities = (probe.totalCount ?? 0) > 0 || probe.items.length > 0;
|
|
1026
|
+
if (hasEntities) {
|
|
1027
|
+
// **LSM durability (step 6) — rebuild demoted to disaster-recovery.**
|
|
1028
|
+
// A missing field registry no longer implies the index is lost: in
|
|
1029
|
+
// LSM mode the posting lists live in the registered SSTables + the
|
|
1030
|
+
// replayed memtable log, wholly independent of the JSON field
|
|
1031
|
+
// registry. If `openLsmEngine` recovered durable state (≥1 manifest
|
|
1032
|
+
// SSTable OR a non-empty replayed memtable), reads serve from there
|
|
1033
|
+
// and an O(N) rebuild would be a redundant re-scan of the entire
|
|
1034
|
+
// corpus. Skip it. `rebuild()` stays reachable only for GENUINE
|
|
1035
|
+
// corruption — no durable LSM state AND entities on storage — which
|
|
1036
|
+
// falls through to the rebuild below.
|
|
1037
|
+
if (this.lsmHasDurableState()) {
|
|
1038
|
+
prodLog.info('[NativeMetadataIndex] Field registry missing but the LSM engine ' +
|
|
1039
|
+
'recovered durable state (SSTables/memtable) — serving from it, ' +
|
|
1040
|
+
'NO rebuild.');
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
console.warn(`[NativeMetadataIndex] Field registry missing but ${probe.totalCount ?? 'unknown'} entities exist — rebuilding index`);
|
|
1044
|
+
await this.rebuild();
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
// Storage probe failed — genuinely empty
|
|
1050
|
+
}
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
await this.warmCache();
|
|
1054
|
+
await this.lazyLoadCounts();
|
|
1055
|
+
this.syncTypeCountsToFixed();
|
|
1056
|
+
// **#18 migration kick — LAST, after the index is fully constructed.** If the
|
|
1057
|
+
// epoch guard flagged a large stale brain (outcome 4), start the coordinated
|
|
1058
|
+
// blocking migration NOW: `start()` flips the lock (`isMigrating()` → true)
|
|
1059
|
+
// SYNCHRONOUSLY before this init() returns (so Brainy sees it the instant init
|
|
1060
|
+
// completes and holds reads+writes behind it), then runs the in-place
|
|
1061
|
+
// rebuild-from-canonical of all three derived indexes asynchronously — the LOCK,
|
|
1062
|
+
// not this call, serializes access, so init() is not blocked. Kicking HERE (not
|
|
1063
|
+
// from guardIndexEpoch mid-init) is what keeps the migration's destructive
|
|
1064
|
+
// rebuild from racing the rest of init(). Fire-and-forget: brainy polls
|
|
1065
|
+
// isMigrating() at 250 ms (rc.9 dropped the whenMigrationComplete hook), so the
|
|
1066
|
+
// `.catch` only prevents an unhandled rejection on the start() promise when a
|
|
1067
|
+
// failed migration surfaces (it is logged loudly and keeps the brain locked). No coordinator
|
|
1068
|
+
// wired (raw-napi test / non-migrating tier) → no-op.
|
|
1069
|
+
if (this.epochMigrationPending) {
|
|
1070
|
+
this.epochMigrationPending = false;
|
|
1071
|
+
this.migrationCoordinatorGetter?.()?.start().catch(() => { });
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
// ==========================================================================
|
|
1075
|
+
// Storage I/O helpers
|
|
1076
|
+
// ==========================================================================
|
|
1077
|
+
/**
|
|
1078
|
+
* **#72 Phase C.** Build the shared mmap `BinaryIdMapper` from the
|
|
1079
|
+
* brain's `_id_mapper/*` storage paths and inject it into the Rust
|
|
1080
|
+
* core as the canonical UUID ↔ entity-int allocator. This replaces the
|
|
1081
|
+
* legacy `loadEntityIdMapper`/`saveEntityIdMapper` JSON path, whose two
|
|
1082
|
+
* resident HashMaps cost ~56 GB at 1 B entities and whose serialize
|
|
1083
|
+
* threw at the V8 ~512 MB / ~13 M-UUID cliff.
|
|
1084
|
+
*
|
|
1085
|
+
* @throws If storage is not filesystem-backed (no `getBinaryBlobPath`)
|
|
1086
|
+
* — cor's metadata index requires local paths; brainy runs
|
|
1087
|
+
* unaccelerated on cloud adapters.
|
|
1088
|
+
*/
|
|
1089
|
+
wireIdMapper() {
|
|
1090
|
+
// The native core is always constructed in u64 mode (the cor 3.0
|
|
1091
|
+
// billion-scale default); setIdMapper rejects a width mismatch.
|
|
1092
|
+
const handle = openOrCreateBinaryIdMapper(this.storage, {
|
|
1093
|
+
idSpace: 'u64',
|
|
1094
|
+
});
|
|
1095
|
+
this.idMapperNative = handle.mapper;
|
|
1096
|
+
this.native.setIdMapper(handle.mapper);
|
|
1097
|
+
}
|
|
1098
|
+
async loadFieldRegistry() {
|
|
1099
|
+
try {
|
|
1100
|
+
const registry = await this.storage.getMetadata('__metadata_field_registry__');
|
|
1101
|
+
if (!registry?.fields || !Array.isArray(registry.fields)) {
|
|
1102
|
+
prodLog.debug('No field registry found - will build on first flush');
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
// Load into Rust
|
|
1106
|
+
this.native.loadFieldRegistry(JSON.stringify(registry));
|
|
1107
|
+
// Track known fields
|
|
1108
|
+
for (const field of registry.fields) {
|
|
1109
|
+
if (typeof field === 'string' && field.length > 0) {
|
|
1110
|
+
this.knownFields.add(field);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
prodLog.info(`Loaded field registry: ${registry.fields.length} persisted fields discovered\n` +
|
|
1114
|
+
` Fields: ${registry.fields.slice(0, 5).join(', ')}${registry.fields.length > 5 ? '...' : ''}`);
|
|
1115
|
+
}
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
prodLog.debug('Could not load field registry:', error);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
async saveFieldRegistry() {
|
|
1121
|
+
if (this.knownFields.size === 0)
|
|
1122
|
+
return;
|
|
1123
|
+
try {
|
|
1124
|
+
const json = this.native.saveFieldRegistry();
|
|
1125
|
+
const data = JSON.parse(json);
|
|
1126
|
+
await this.storage.saveMetadata('__metadata_field_registry__', data);
|
|
1127
|
+
}
|
|
1128
|
+
catch (error) {
|
|
1129
|
+
prodLog.warn('Failed to save field registry:', error);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* **Piece 6 generation counter — invalidate stale lazy-load state.**
|
|
1134
|
+
* Checks the native generation counter against our last-seen value.
|
|
1135
|
+
* If a mutation has bumped the counter since we last looked,
|
|
1136
|
+
* `loadedFields` is cleared so every subsequent `ensureFieldLoaded`
|
|
1137
|
+
* re-pulls the current value-enumeration field index from storage.
|
|
1138
|
+
*
|
|
1139
|
+
* Called at the start of every read entry point. The check itself
|
|
1140
|
+
* is one atomic load + one BigInt compare — sub-microsecond.
|
|
1141
|
+
*
|
|
1142
|
+
* Without this hook, after a mutation on the native side the
|
|
1143
|
+
* cached `loadedFields` set would prevent re-reading the updated
|
|
1144
|
+
* value enumeration, surfacing as stale-result bugs of the same shape
|
|
1145
|
+
* as R5 (but for the lazy-load path rather than the filter path).
|
|
1146
|
+
*/
|
|
1147
|
+
invalidateIfStale() {
|
|
1148
|
+
// **Piece 15 nuclear path.** Pre-Piece-15 this method gated the
|
|
1149
|
+
// clear on `currentGeneration()` having changed since the last
|
|
1150
|
+
// observation; that gate was load-bearing when EVERY read entry
|
|
1151
|
+
// point called this method (the counter delta was the canary
|
|
1152
|
+
// for "some mutation happened"). Post-Piece-15, the read entry
|
|
1153
|
+
// points call {@link invalidateFieldIfStale} instead — which
|
|
1154
|
+
// never updates `lastSeenGeneration`, so the old delta check
|
|
1155
|
+
// would never trigger from the on-path callers. The method
|
|
1156
|
+
// survives as an unconditional reset for cross-cutting state
|
|
1157
|
+
// transitions (rebuilds, debug callers, future code paths
|
|
1158
|
+
// that need a clean slate) — the gate is gone because the
|
|
1159
|
+
// method's purpose is now "wipe everything", full stop.
|
|
1160
|
+
this.loadedFields.clear();
|
|
1161
|
+
this.lastSeenFieldGenerations.clear();
|
|
1162
|
+
this.lastSeenGeneration = this.native.currentGeneration();
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* **Piece 15 — per-field selective invalidation.** Reads the
|
|
1166
|
+
* field's per-field generation from the native side and compares
|
|
1167
|
+
* against {@link lastSeenFieldGenerations}. If the field's counter
|
|
1168
|
+
* advanced since we last observed it, removes ONLY that field
|
|
1169
|
+
* from {@link loadedFields} — every other loaded field stays
|
|
1170
|
+
* valid. Then records the current value so the next call sees a
|
|
1171
|
+
* no-op until another mutation touches this field.
|
|
1172
|
+
*
|
|
1173
|
+
* Called at the start of every read entry point that knows which
|
|
1174
|
+
* fields it needs. The check is one FFI hop (native atomic load)
|
|
1175
|
+
* + one Map get/set — sub-microsecond per field. For multi-field
|
|
1176
|
+
* filters (`ensureFieldsLoaded`) the wrapper iterates this method
|
|
1177
|
+
* once per field instead of doing the old index-wide check.
|
|
1178
|
+
*
|
|
1179
|
+
* Cold-boot semantic: first probe records the current counter
|
|
1180
|
+
* without invalidating (matches the old `lastSeenGeneration ===
|
|
1181
|
+
* -1n` initial-call behavior). A counter advance from 0 → 1 on
|
|
1182
|
+
* the FIRST observation is treated as "first sighting", NOT
|
|
1183
|
+
* "stale" — `loadedFields` doesn't have this field yet anyway, so
|
|
1184
|
+
* there's nothing to invalidate.
|
|
1185
|
+
*/
|
|
1186
|
+
invalidateFieldIfStale(field) {
|
|
1187
|
+
const current = this.native.fieldGeneration(field);
|
|
1188
|
+
const lastSeen = this.lastSeenFieldGenerations.get(field);
|
|
1189
|
+
if (lastSeen !== undefined && current !== lastSeen) {
|
|
1190
|
+
this.loadedFields.delete(field);
|
|
1191
|
+
}
|
|
1192
|
+
this.lastSeenFieldGenerations.set(field, current);
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Ensure a field's persisted value-enumeration index is loaded into the
|
|
1196
|
+
* native core (the LSM read paths enumerate candidate values from it).
|
|
1197
|
+
* Lazy + idempotent — guarded by {@link loadedFields}.
|
|
1198
|
+
*/
|
|
1199
|
+
async ensureFieldLoaded(field) {
|
|
1200
|
+
// Piece 15: selective per-field invalidation. A mutation on
|
|
1201
|
+
// ANOTHER field no longer drops this field's cached load —
|
|
1202
|
+
// brainy's lazy-load survives the cross-field churn.
|
|
1203
|
+
this.invalidateFieldIfStale(field);
|
|
1204
|
+
if (this.loadedFields.has(field))
|
|
1205
|
+
return;
|
|
1206
|
+
await this.loadPersistedFieldIndex(field);
|
|
1207
|
+
this.loadedFields.add(field);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* **LSM durability (steps 3-4) — cold-load value enumeration.** Restore a
|
|
1211
|
+
* field's persisted value-enumeration index
|
|
1212
|
+
* (`__metadata_field_index__field_<field>`) into the native core. In LSM
|
|
1213
|
+
* mode the read paths for field-wide `exists` / `missing` / `ne` and range
|
|
1214
|
+
* (`between` / `gt` / `lt`) queries enumerate a field's distinct values from
|
|
1215
|
+
* `core.field_indexes`. That map is built incrementally by `addToIndex`
|
|
1216
|
+
* within a session but is EMPTY after a cold reopen — the registered
|
|
1217
|
+
* SSTables hold the posting lists, but without the value enumeration the
|
|
1218
|
+
* read paths have no value keys to look those lists up by. Loading this JSON
|
|
1219
|
+
* (persisted by `persistMutationResult` / `flush`) closes that gap so the
|
|
1220
|
+
* cold-loaded SSTables serve every filter shape with no rebuild.
|
|
1221
|
+
*
|
|
1222
|
+
* No-op for a field with no persisted index (a brand-new field this
|
|
1223
|
+
* session) — `addToIndex` then populates `core.field_indexes` incrementally.
|
|
1224
|
+
* Only the first access loads it (the caller guards via `loadedFields`), so
|
|
1225
|
+
* an in-session field index built by `addToIndex` is never overwritten.
|
|
1226
|
+
*
|
|
1227
|
+
* @param field - The field whose value-enumeration index to restore.
|
|
1228
|
+
*/
|
|
1229
|
+
async loadPersistedFieldIndex(field) {
|
|
1230
|
+
try {
|
|
1231
|
+
const data = await this.storage.getMetadata(`__metadata_field_index__field_${field}`);
|
|
1232
|
+
if (data) {
|
|
1233
|
+
this.native.loadFieldIndex(field, JSON.stringify(data));
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
catch (error) {
|
|
1237
|
+
prodLog.debug(`Failed to load field index '${field}':`, error);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Ensure multiple fields are loaded (parallel).
|
|
1242
|
+
*
|
|
1243
|
+
* **Piece 15.** Per-field stale checks instead of one
|
|
1244
|
+
* index-wide check. The wrapper now iterates {@link invalidateFieldIfStale}
|
|
1245
|
+
* once per requested field — selectively dropping ONLY the
|
|
1246
|
+
* fields whose counters advanced since we last looked. Cross-
|
|
1247
|
+
* field churn (a mutation on `noun` while we're reading `status`)
|
|
1248
|
+
* no longer invalidates `status`'s lazy-load. Cost: N FFI hops
|
|
1249
|
+
* (each a single atomic Acquire load) instead of 1 — typically
|
|
1250
|
+
* 3-5 hops on a brainy filter query, negligible vs the storage
|
|
1251
|
+
* I/O the wrapper does next.
|
|
1252
|
+
*/
|
|
1253
|
+
async ensureFieldsLoaded(fields) {
|
|
1254
|
+
for (const f of fields) {
|
|
1255
|
+
this.invalidateFieldIfStale(f);
|
|
1256
|
+
}
|
|
1257
|
+
const unloaded = fields.filter(f => !this.loadedFields.has(f));
|
|
1258
|
+
if (unloaded.length === 0)
|
|
1259
|
+
return;
|
|
1260
|
+
// Pass a flag to skip the redundant per-field check.
|
|
1261
|
+
await Promise.all(unloaded.map(f => this.ensureFieldLoadedNoCheck(f)));
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Internal variant of `ensureFieldLoaded` that skips the generation
|
|
1265
|
+
* check (used by `ensureFieldsLoaded` which already did one batched
|
|
1266
|
+
* check at the top).
|
|
1267
|
+
*/
|
|
1268
|
+
async ensureFieldLoadedNoCheck(field) {
|
|
1269
|
+
if (this.loadedFields.has(field))
|
|
1270
|
+
return;
|
|
1271
|
+
await this.loadPersistedFieldIndex(field);
|
|
1272
|
+
this.loadedFields.add(field);
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Extract filter field names for lazy loading.
|
|
1276
|
+
*/
|
|
1277
|
+
extractFilterFields(filter) {
|
|
1278
|
+
if (!filter || typeof filter !== 'object')
|
|
1279
|
+
return [];
|
|
1280
|
+
const fields = [];
|
|
1281
|
+
if (filter.allOf && Array.isArray(filter.allOf)) {
|
|
1282
|
+
for (const sub of filter.allOf) {
|
|
1283
|
+
fields.push(...this.extractFilterFields(sub));
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
if (filter.anyOf && Array.isArray(filter.anyOf)) {
|
|
1287
|
+
for (const sub of filter.anyOf) {
|
|
1288
|
+
fields.push(...this.extractFilterFields(sub));
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
for (const key of Object.keys(filter)) {
|
|
1292
|
+
if (key !== 'allOf' && key !== 'anyOf' && key !== 'not') {
|
|
1293
|
+
fields.push(key);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return [...new Set(fields)];
|
|
1297
|
+
}
|
|
1298
|
+
// ==========================================================================
|
|
1299
|
+
// Dirty data persistence
|
|
1300
|
+
// ==========================================================================
|
|
1301
|
+
/**
|
|
1302
|
+
* Persist the dirty value-enumeration field indexes + field registry from a
|
|
1303
|
+
* MutationResult. Posting-list durability is owned by the LSM engine
|
|
1304
|
+
* ({@link flushLsm}), so this only writes the JSON value-enumeration surface
|
|
1305
|
+
* the LSM read paths cold-load.
|
|
1306
|
+
*/
|
|
1307
|
+
async persistMutationResult(result) {
|
|
1308
|
+
const promises = [];
|
|
1309
|
+
// Save dirty field indexes
|
|
1310
|
+
for (const field of result.dirtyFieldIndexes) {
|
|
1311
|
+
const json = this.native.saveFieldIndex(field);
|
|
1312
|
+
if (json) {
|
|
1313
|
+
const indexId = `__metadata_field_index__field_${field}`;
|
|
1314
|
+
promises.push(this.storage.saveMetadata(indexId, JSON.parse(json)));
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
// Track new fields
|
|
1318
|
+
for (const field of result.newFields) {
|
|
1319
|
+
this.knownFields.add(field);
|
|
1320
|
+
this.dirtyFields.add(field);
|
|
1321
|
+
}
|
|
1322
|
+
if (result.dirtyFieldRegistry) {
|
|
1323
|
+
promises.push(this.saveFieldRegistry());
|
|
1324
|
+
}
|
|
1325
|
+
// #72 Phase C: the entity-id mapper is the mmap BinaryIdMapper now —
|
|
1326
|
+
// its writes are durable in the mapped files (msync'd at flush), so
|
|
1327
|
+
// there is no per-mutation JSON blob to persist here.
|
|
1328
|
+
await Promise.all(promises);
|
|
1329
|
+
}
|
|
1330
|
+
// ==========================================================================
|
|
1331
|
+
// Cache warming
|
|
1332
|
+
// ==========================================================================
|
|
1333
|
+
async warmCache() {
|
|
1334
|
+
const commonFields = ['noun', 'type', 'service', 'createdAt'];
|
|
1335
|
+
await Promise.all(commonFields.map(async (field) => {
|
|
1336
|
+
try {
|
|
1337
|
+
await this.ensureFieldLoaded(field);
|
|
1338
|
+
}
|
|
1339
|
+
catch {
|
|
1340
|
+
prodLog.debug(`Cache warming: field '${field}' not yet indexed`);
|
|
1341
|
+
}
|
|
1342
|
+
}));
|
|
1343
|
+
await this.warmCacheForTopTypes(3);
|
|
1344
|
+
}
|
|
1345
|
+
async warmCacheForTopTypes(topN = 3) {
|
|
1346
|
+
const topTypes = this.getTopNounTypes(topN);
|
|
1347
|
+
if (topTypes.length === 0)
|
|
1348
|
+
return;
|
|
1349
|
+
for (const type of topTypes) {
|
|
1350
|
+
const typeFields = this.typeFieldAffinity.get(type);
|
|
1351
|
+
if (!typeFields)
|
|
1352
|
+
continue;
|
|
1353
|
+
const topFields = Array.from(typeFields.entries())
|
|
1354
|
+
.sort((a, b) => b[1] - a[1])
|
|
1355
|
+
.slice(0, 5)
|
|
1356
|
+
.map(([field]) => field);
|
|
1357
|
+
if (topFields.length === 0)
|
|
1358
|
+
continue;
|
|
1359
|
+
await Promise.all(topFields.map(async (field) => {
|
|
1360
|
+
try {
|
|
1361
|
+
await this.ensureFieldLoaded(field);
|
|
1362
|
+
}
|
|
1363
|
+
catch {
|
|
1364
|
+
// Silently ignore
|
|
1365
|
+
}
|
|
1366
|
+
}));
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
// ==========================================================================
|
|
1370
|
+
// Lazy load counts
|
|
1371
|
+
// ==========================================================================
|
|
1372
|
+
async lazyLoadCounts() {
|
|
1373
|
+
try {
|
|
1374
|
+
this.totalEntitiesByType.clear();
|
|
1375
|
+
this.entityCountsByTypeFixed.fill(0);
|
|
1376
|
+
this.verbCountsByTypeFixed.fill(0);
|
|
1377
|
+
// Ensure 'noun' field is loaded into Rust
|
|
1378
|
+
await this.ensureFieldLoaded('noun');
|
|
1379
|
+
// Use Rust to get type counts via the noun field. Count natively
|
|
1380
|
+
// (#78 pt2) — getIdsCount returns the mapped-id count without
|
|
1381
|
+
// materializing one UUID string per entity. At 10B this is the
|
|
1382
|
+
// difference between O(1) per type and allocating billions of strings
|
|
1383
|
+
// just to read `.length`.
|
|
1384
|
+
const nounValues = this.native.getFilterValues('noun');
|
|
1385
|
+
for (const typeName of nounValues) {
|
|
1386
|
+
const count = this.native.getIdsCount('noun', JSON.stringify(typeName));
|
|
1387
|
+
if (count > 0) {
|
|
1388
|
+
this.totalEntitiesByType.set(typeName, count);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
prodLog.debug(`Loaded type counts: ${this.totalEntitiesByType.size} types`);
|
|
1392
|
+
}
|
|
1393
|
+
catch (error) {
|
|
1394
|
+
prodLog.debug('Could not load type counts:', error);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
syncTypeCountsToFixed() {
|
|
1398
|
+
for (let i = 0; i < NOUN_TYPE_COUNT; i++) {
|
|
1399
|
+
const type = TypeUtils.getNounFromIndex(i);
|
|
1400
|
+
const count = this.totalEntitiesByType.get(type) || 0;
|
|
1401
|
+
this.entityCountsByTypeFixed[i] = count;
|
|
1402
|
+
}
|
|
1403
|
+
for (let i = 0; i < VERB_TYPE_COUNT; i++) {
|
|
1404
|
+
const type = TypeUtils.getVerbFromIndex(i);
|
|
1405
|
+
const count = this.totalEntitiesByType.get(type) || 0;
|
|
1406
|
+
this.verbCountsByTypeFixed[i] = count;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
syncTypeCountsFromFixed() {
|
|
1410
|
+
for (let i = 0; i < NOUN_TYPE_COUNT; i++) {
|
|
1411
|
+
const count = this.entityCountsByTypeFixed[i];
|
|
1412
|
+
if (count > 0) {
|
|
1413
|
+
const type = TypeUtils.getNounFromIndex(i);
|
|
1414
|
+
this.totalEntitiesByType.set(type, count);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
for (let i = 0; i < VERB_TYPE_COUNT; i++) {
|
|
1418
|
+
const count = this.verbCountsByTypeFixed[i];
|
|
1419
|
+
if (count > 0) {
|
|
1420
|
+
const type = TypeUtils.getVerbFromIndex(i);
|
|
1421
|
+
this.totalEntitiesByType.set(type, count);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
// ==========================================================================
|
|
1426
|
+
// Core query methods
|
|
1427
|
+
// ==========================================================================
|
|
1428
|
+
/**
|
|
1429
|
+
* Serialize a query value to JSON for the native String param. `JSON.stringify`
|
|
1430
|
+
* returns the JS value `undefined` (NOT a string) for `undefined`/functions —
|
|
1431
|
+
* which the napi String param rejects ("Failed to convert Null/Undefined into
|
|
1432
|
+
* String"). Coalesce to the JSON literal `'null'` so the Rust side parses it to
|
|
1433
|
+
* `serde_json::Value::Null`, normalizes, and returns the no-match result (0/[]) —
|
|
1434
|
+
* bug-for-bug compatible with brainy's "no match → empty". (#79 cluster B.)
|
|
1435
|
+
*/
|
|
1436
|
+
serializeValue(value) {
|
|
1437
|
+
const s = JSON.stringify(value);
|
|
1438
|
+
return s === undefined ? 'null' : s;
|
|
1439
|
+
}
|
|
1440
|
+
async getIds(field, value) {
|
|
1441
|
+
await this.ensureFieldLoaded(field);
|
|
1442
|
+
return this.native.getIds(field, this.serializeValue(value));
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Resolve a `where` filter to its matching entity ids.
|
|
1446
|
+
*
|
|
1447
|
+
* @param filter - Brainy field-operator filter (eq / allOf / anyOf / ne /
|
|
1448
|
+
* range / exists / …).
|
|
1449
|
+
* @param opts - OPTIONAL page bound (#75). When `{ limit }` is supplied the
|
|
1450
|
+
* native producer early-stops and returns only the `[offset, offset+limit)`
|
|
1451
|
+
* PREFIX of the sorted match set — it short-circuits the posting-list →
|
|
1452
|
+
* UUID conversion at `offset+limit` instead of materializing the full list
|
|
1453
|
+
* and slicing, which removes the O(N) FFI marshal for a broad-filter
|
|
1454
|
+
* single-page `find()` at billion scale. Brainy calls this with
|
|
1455
|
+
* `{ limit: pageEnd, offset: 0 }` and re-windows the prefix itself
|
|
1456
|
+
* (visibility filter + slice), so the producer honors the literal `offset`
|
|
1457
|
+
* (brainy's `0` ⇒ the `[0, limit)` prefix). Omitting `opts` returns the
|
|
1458
|
+
* FULL match set (back-compat).
|
|
1459
|
+
*/
|
|
1460
|
+
async getIdsForFilter(filter, opts) {
|
|
1461
|
+
if (!filter || Object.keys(filter).length === 0)
|
|
1462
|
+
return [];
|
|
1463
|
+
// Ensure the filter's fields are loaded into the native index.
|
|
1464
|
+
const fields = this.extractFilterFields(filter);
|
|
1465
|
+
await this.ensureFieldsLoaded(fields);
|
|
1466
|
+
// Negation operators (ne / exists:false / missing:true) need the all-ids universe.
|
|
1467
|
+
// The native index self-sources it from the canonical in-memory idMapper — no storage
|
|
1468
|
+
// scan, and correct even when the field was never registered on any entity (R4).
|
|
1469
|
+
// `?? null` (not `|| null`) so a `limit`/`offset` of 0 reaches native intact.
|
|
1470
|
+
return this.native.getIdsForFilter(JSON.stringify(filter), null, opts?.limit ?? null, opts?.offset ?? null);
|
|
1471
|
+
}
|
|
1472
|
+
async getIdsForMultipleFields(fieldValuePairs) {
|
|
1473
|
+
if (fieldValuePairs.length === 0)
|
|
1474
|
+
return [];
|
|
1475
|
+
const fields = fieldValuePairs.map(p => p.field);
|
|
1476
|
+
await this.ensureFieldsLoaded(fields);
|
|
1477
|
+
const pairsJson = JSON.stringify(fieldValuePairs.map(p => ({
|
|
1478
|
+
field: p.field,
|
|
1479
|
+
value: p.value
|
|
1480
|
+
})));
|
|
1481
|
+
return this.native.getIdsForMultipleFields(pairsJson);
|
|
1482
|
+
}
|
|
1483
|
+
async getIdsForTextQuery(query) {
|
|
1484
|
+
await this.ensureFieldLoaded('__words__');
|
|
1485
|
+
const resultJson = this.native.getIdsForTextQuery(query);
|
|
1486
|
+
return JSON.parse(resultJson);
|
|
1487
|
+
}
|
|
1488
|
+
async getSortedIdsForFilter(filter, orderBy, order = 'asc', topK) {
|
|
1489
|
+
const filteredIds = await this.getIdsForFilter(filter);
|
|
1490
|
+
if (filteredIds.length === 0)
|
|
1491
|
+
return [];
|
|
1492
|
+
const idValuePairs = [];
|
|
1493
|
+
for (const id of filteredIds) {
|
|
1494
|
+
const value = await this.getFieldValueForEntity(id, orderBy);
|
|
1495
|
+
idValuePairs.push({ id, value });
|
|
1496
|
+
}
|
|
1497
|
+
idValuePairs.sort((a, b) => {
|
|
1498
|
+
if (a.value == null && b.value == null)
|
|
1499
|
+
return 0;
|
|
1500
|
+
if (a.value == null)
|
|
1501
|
+
return order === 'asc' ? 1 : -1;
|
|
1502
|
+
if (b.value == null)
|
|
1503
|
+
return order === 'asc' ? -1 : 1;
|
|
1504
|
+
if (a.value === b.value)
|
|
1505
|
+
return 0;
|
|
1506
|
+
// Numbers compare numerically; everything else by code-point (UTF-8 byte)
|
|
1507
|
+
// order, matching the native column store and brainy's getSortedIdsForFilter
|
|
1508
|
+
// exactly (and deterministic across environments, unlike `<` for strings).
|
|
1509
|
+
let comparison;
|
|
1510
|
+
if (typeof a.value === 'number' && typeof b.value === 'number') {
|
|
1511
|
+
comparison = a.value < b.value ? -1 : 1;
|
|
1512
|
+
}
|
|
1513
|
+
else {
|
|
1514
|
+
comparison = compareCodePoints(String(a.value), String(b.value));
|
|
1515
|
+
}
|
|
1516
|
+
return order === 'asc' ? comparison : -comparison;
|
|
1517
|
+
});
|
|
1518
|
+
// Honor brainy's page bound (#79): brainy passes topK = offset+limit+hidden
|
|
1519
|
+
// so a broad filter + orderBy returning a small page doesn't ship every
|
|
1520
|
+
// matching sorted id across the FFI boundary into brainy's heap. The full
|
|
1521
|
+
// sort still sees all matches (required to pick the true top-K); slicing
|
|
1522
|
+
// bounds the RETURNED set. (Internal O(matches) value-fetch → native
|
|
1523
|
+
// filteredSortTopK primitive is a tracked perf follow-up, #79.)
|
|
1524
|
+
const bounded = topK != null && topK >= 0 ? idValuePairs.slice(0, topK) : idValuePairs;
|
|
1525
|
+
return bounded.map(p => p.id);
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Get field value for a specific entity (helper for sorted queries).
|
|
1529
|
+
*
|
|
1530
|
+
* Three-path lookup:
|
|
1531
|
+
*
|
|
1532
|
+
* 1. **Bucketed fields** (timestamps) — the native index stores values
|
|
1533
|
+
* rounded to coarser buckets to keep the index compact for range
|
|
1534
|
+
* queries. That bucketing loses precision, so sorting must read the
|
|
1535
|
+
* actual value directly from entity storage via resolveEntityField.
|
|
1536
|
+
*
|
|
1537
|
+
* 2. **Custom fields with no indexed values** — VFS fields like
|
|
1538
|
+
* `modified`/`accessed`, plus any user custom field not currently
|
|
1539
|
+
* present in the native index. Resolved from entity storage via
|
|
1540
|
+
* resolveEntityField, which knows the top-level-vs-metadata
|
|
1541
|
+
* shape contract.
|
|
1542
|
+
*
|
|
1543
|
+
* 3. **Indexed fields** — strings, enums, and low-cardinality ints that
|
|
1544
|
+
* the native index knows about. O(values) lookup.
|
|
1545
|
+
*
|
|
1546
|
+
* Mirrors getFieldValueForEntity in brainy's src/utils/metadataIndex.ts —
|
|
1547
|
+
* keep in lockstep when the entity shape contract evolves.
|
|
1548
|
+
*/
|
|
1549
|
+
async getFieldValueForEntity(entityId, field) {
|
|
1550
|
+
// Path 1: Bucketed fields need the actual value from storage.
|
|
1551
|
+
if (BUCKETED_INDEX_FIELDS.has(field)) {
|
|
1552
|
+
const noun = await this.storage.getNoun(entityId);
|
|
1553
|
+
return noun ? resolveEntityField(noun, field) : undefined;
|
|
1554
|
+
}
|
|
1555
|
+
// Path 3: Scan the native index for this entity's value.
|
|
1556
|
+
await this.ensureFieldLoaded(field);
|
|
1557
|
+
const values = this.native.getFilterValues(field);
|
|
1558
|
+
for (const val of values) {
|
|
1559
|
+
const matchIds = this.native.getIds(field, this.serializeValue(val));
|
|
1560
|
+
if (matchIds.includes(entityId)) {
|
|
1561
|
+
return this.denormalizeValue(val, field);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
// Path 2: Not in the native index — fall back to entity storage.
|
|
1565
|
+
// Covers VFS custom fields (modified, accessed) and user fields
|
|
1566
|
+
// that haven't been inserted into the native index yet.
|
|
1567
|
+
const noun = await this.storage.getNoun(entityId);
|
|
1568
|
+
return noun ? resolveEntityField(noun, field) : undefined;
|
|
1569
|
+
}
|
|
1570
|
+
denormalizeValue(normalized, _field) {
|
|
1571
|
+
const asNumber = Number(normalized);
|
|
1572
|
+
if (!isNaN(asNumber))
|
|
1573
|
+
return asNumber;
|
|
1574
|
+
return normalized;
|
|
1575
|
+
}
|
|
1576
|
+
// ==========================================================================
|
|
1577
|
+
// Filter discovery
|
|
1578
|
+
// ==========================================================================
|
|
1579
|
+
async getFilterValues(field) {
|
|
1580
|
+
await this.ensureFieldLoaded(field);
|
|
1581
|
+
return this.native.getFilterValues(field);
|
|
1582
|
+
}
|
|
1583
|
+
async getFilterFields() {
|
|
1584
|
+
return this.native.getFilterFields();
|
|
1585
|
+
}
|
|
1586
|
+
// ==========================================================================
|
|
1587
|
+
// Mutation methods
|
|
1588
|
+
// ==========================================================================
|
|
1589
|
+
async addToIndex(id, entityOrMetadata, skipFlush = false, deferWrites = false) {
|
|
1590
|
+
// Extract field names from entity to know which fields to load
|
|
1591
|
+
const fieldNames = this.native.extractFieldNames(JSON.stringify(entityOrMetadata));
|
|
1592
|
+
await this.ensureFieldsLoaded(fieldNames);
|
|
1593
|
+
// Execute mutation in Rust (single FFI call)
|
|
1594
|
+
const resultJson = this.native.addToIndex(id, JSON.stringify(entityOrMetadata));
|
|
1595
|
+
const result = JSON.parse(resultJson);
|
|
1596
|
+
// Track new fields in loadedFields
|
|
1597
|
+
for (const field of result.newFields) {
|
|
1598
|
+
this.loadedFields.add(field);
|
|
1599
|
+
this.knownFields.add(field);
|
|
1600
|
+
}
|
|
1601
|
+
// Update TS-side type tracking
|
|
1602
|
+
this.updateTypeTracking(id, entityOrMetadata, 'add');
|
|
1603
|
+
// Populate the column store. The native addToIndex above assigned this
|
|
1604
|
+
// entity's int id, so uuidToInt now resolves. Feed the SAME indexable
|
|
1605
|
+
// field→value map brainy's own MetadataIndexManager feeds its column store,
|
|
1606
|
+
// so cor sorts/filters over an identical column set.
|
|
1607
|
+
const intId = this.native.uuidToInt(id);
|
|
1608
|
+
if (intId !== null) {
|
|
1609
|
+
const fieldsMap = this.extractColumnFields(entityOrMetadata);
|
|
1610
|
+
this.columnStore.addEntity(intId, fieldsMap);
|
|
1611
|
+
}
|
|
1612
|
+
// Persist dirty data (unless deferred for batch operations)
|
|
1613
|
+
if (!deferWrites) {
|
|
1614
|
+
await this.persistMutationResult(result);
|
|
1615
|
+
}
|
|
1616
|
+
// Adaptive auto-flush
|
|
1617
|
+
if (!skipFlush) {
|
|
1618
|
+
for (const field of fieldNames) {
|
|
1619
|
+
this.dirtyFields.add(field);
|
|
1620
|
+
}
|
|
1621
|
+
const timeSinceLastFlush = Date.now() - this.lastFlushTime;
|
|
1622
|
+
const shouldAutoFlush = this.dirtyFields.size >= this.autoFlushThreshold ||
|
|
1623
|
+
(this.dirtyFields.size > 10 && timeSinceLastFlush > 5000);
|
|
1624
|
+
if (shouldAutoFlush) {
|
|
1625
|
+
const startTime = Date.now();
|
|
1626
|
+
await this.flush();
|
|
1627
|
+
const flushTime = Date.now() - startTime;
|
|
1628
|
+
if (flushTime < 50) {
|
|
1629
|
+
this.autoFlushThreshold = Math.min(200, this.autoFlushThreshold * 1.2);
|
|
1630
|
+
}
|
|
1631
|
+
else if (flushTime > 200) {
|
|
1632
|
+
this.autoFlushThreshold = Math.max(20, this.autoFlushThreshold * 0.8);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
// **LSM durability (step 5) — bounded-memtable flush trigger.** Drain the
|
|
1637
|
+
// memtable to a durable SSTable the moment its resident estimate crosses
|
|
1638
|
+
// the budget-tracked threshold, so it never climbs to the admission
|
|
1639
|
+
// ceiling and wedges writers (the whole point of the bounded memtable).
|
|
1640
|
+
// Runs on EVERY mutation, INDEPENDENT of `skipFlush` — `skipFlush` governs
|
|
1641
|
+
// only the field-index JSON flush cadence, whereas the memtable bound is a
|
|
1642
|
+
// hard native-memory safety constraint that must also hold on the
|
|
1643
|
+
// deferred-write rebuild path (`addToIndex(id, md, true, true)` per
|
|
1644
|
+
// entity), where a billion-entity re-index would otherwise grow the
|
|
1645
|
+
// memtable unbounded until admission deadlocked the rebuild. Gated on a
|
|
1646
|
+
// persistent engine (`lsmMetaDir !== null`): the non-persistent fallback
|
|
1647
|
+
// has no SSTable surface, so `flushLsm` cannot drain it and the trigger
|
|
1648
|
+
// would spin no-ops. When the field-index auto-flush above already drained
|
|
1649
|
+
// the memtable this session, `memtableBytes()` reads ~0 and this no-ops.
|
|
1650
|
+
const engine = this.lsmEngine;
|
|
1651
|
+
if (engine !== null &&
|
|
1652
|
+
this.lsmMetaDir !== null &&
|
|
1653
|
+
Number(engine.memtableBytes()) >= this.lsmFlushThresholdBytes) {
|
|
1654
|
+
await this.flushLsm();
|
|
1655
|
+
}
|
|
1656
|
+
this.reportLsmMemoryToResourceManager();
|
|
1657
|
+
}
|
|
1658
|
+
async removeFromIndex(id, metadata) {
|
|
1659
|
+
// Capture the int id BEFORE the native removal (which may drop the mapping)
|
|
1660
|
+
// so the column store can tombstone the same entity.
|
|
1661
|
+
const intId = this.native.uuidToInt(id);
|
|
1662
|
+
if (metadata) {
|
|
1663
|
+
const fieldNames = this.native.extractFieldNames(JSON.stringify(metadata));
|
|
1664
|
+
await this.ensureFieldsLoaded(fieldNames);
|
|
1665
|
+
const resultJson = this.native.removeFromIndex(id, JSON.stringify(metadata));
|
|
1666
|
+
const result = JSON.parse(resultJson);
|
|
1667
|
+
this.updateTypeTracking(id, metadata, 'remove');
|
|
1668
|
+
await this.persistMutationResult(result);
|
|
1669
|
+
}
|
|
1670
|
+
else {
|
|
1671
|
+
// Remove from all known fields (slower path). Ensure every registered
|
|
1672
|
+
// field's value-enumeration index is loaded so the native slow-path
|
|
1673
|
+
// remove (which walks `field_indexes` against the LSM snapshot) sees
|
|
1674
|
+
// the complete candidate set.
|
|
1675
|
+
prodLog.warn(`Removing ID ${id} without metadata requires scanning all fields (slow)`);
|
|
1676
|
+
for (const field of this.knownFields) {
|
|
1677
|
+
await this.ensureFieldLoaded(field);
|
|
1678
|
+
}
|
|
1679
|
+
const resultJson = this.native.removeFromIndex(id);
|
|
1680
|
+
const result = JSON.parse(resultJson);
|
|
1681
|
+
await this.persistMutationResult(result);
|
|
1682
|
+
}
|
|
1683
|
+
// Tombstone in the column store so the entity drops out of sort/filter/range.
|
|
1684
|
+
if (intId !== null) {
|
|
1685
|
+
this.columnStore.removeEntity(intId);
|
|
1686
|
+
}
|
|
1687
|
+
this.reportLsmMemoryToResourceManager();
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Report the LSM engine's current native memory footprint
|
|
1691
|
+
* (memtable + reader cache) to the ResourceManager so the dynamic
|
|
1692
|
+
* per-category rebalance can detect pressure on the 'metadata'
|
|
1693
|
+
* category and shift capacity from underutilized neighbors.
|
|
1694
|
+
*
|
|
1695
|
+
* No-op in JSON mode (no engine attached) and after `close()` has
|
|
1696
|
+
* unregistered the subscriber. Called from `addToIndex` /
|
|
1697
|
+
* `removeFromIndex` on the production hot path — the underlying
|
|
1698
|
+
* `nativeMemoryBytes()` is a single atomic load + sum, sub-microsecond
|
|
1699
|
+
* cost.
|
|
1700
|
+
*
|
|
1701
|
+
* The reportNativeMemory call short-circuits inside ResourceManager
|
|
1702
|
+
* if the subscriber name was already cleared (deregistered between
|
|
1703
|
+
* the mutation and this call), so the unregister race is benign.
|
|
1704
|
+
*/
|
|
1705
|
+
reportLsmMemoryToResourceManager() {
|
|
1706
|
+
if (this.lsmEngine === null || this.lsmSubscriberName === null)
|
|
1707
|
+
return;
|
|
1708
|
+
const bytes = Number(this.lsmEngine.nativeMemoryBytes());
|
|
1709
|
+
ResourceManager.getInstance().reportNativeMemory(this.lsmSubscriberName, bytes);
|
|
1710
|
+
}
|
|
1711
|
+
updateTypeTracking(id, entityOrMetadata, operation) {
|
|
1712
|
+
const nounValue = entityOrMetadata?.type || entityOrMetadata?.noun;
|
|
1713
|
+
if (!nounValue)
|
|
1714
|
+
return;
|
|
1715
|
+
const entityType = String(nounValue).toLowerCase().trim();
|
|
1716
|
+
if (operation === 'add') {
|
|
1717
|
+
const current = this.totalEntitiesByType.get(entityType) || 0;
|
|
1718
|
+
const newCount = current + 1;
|
|
1719
|
+
this.totalEntitiesByType.set(entityType, newCount);
|
|
1720
|
+
try {
|
|
1721
|
+
const nounTypeIndex = TypeUtils.getNounIndex(entityType);
|
|
1722
|
+
this.entityCountsByTypeFixed[nounTypeIndex] = newCount;
|
|
1723
|
+
}
|
|
1724
|
+
catch {
|
|
1725
|
+
// Not a recognized noun type
|
|
1726
|
+
}
|
|
1727
|
+
// Update type-field affinity
|
|
1728
|
+
if (!this.typeFieldAffinity.has(entityType)) {
|
|
1729
|
+
this.typeFieldAffinity.set(entityType, new Map());
|
|
1730
|
+
}
|
|
1731
|
+
const typeFields = this.typeFieldAffinity.get(entityType);
|
|
1732
|
+
const fieldNames = this.native.extractFieldNames(JSON.stringify(entityOrMetadata));
|
|
1733
|
+
for (const field of fieldNames) {
|
|
1734
|
+
const count = typeFields.get(field) || 0;
|
|
1735
|
+
typeFields.set(field, count + 1);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
else if (operation === 'remove') {
|
|
1739
|
+
const current = this.totalEntitiesByType.get(entityType) || 0;
|
|
1740
|
+
if (current > 1) {
|
|
1741
|
+
const newCount = current - 1;
|
|
1742
|
+
this.totalEntitiesByType.set(entityType, newCount);
|
|
1743
|
+
try {
|
|
1744
|
+
const nounTypeIndex = TypeUtils.getNounIndex(entityType);
|
|
1745
|
+
this.entityCountsByTypeFixed[nounTypeIndex] = newCount;
|
|
1746
|
+
}
|
|
1747
|
+
catch { }
|
|
1748
|
+
}
|
|
1749
|
+
else {
|
|
1750
|
+
this.totalEntitiesByType.delete(entityType);
|
|
1751
|
+
this.typeFieldAffinity.delete(entityType);
|
|
1752
|
+
try {
|
|
1753
|
+
const nounTypeIndex = TypeUtils.getNounIndex(entityType);
|
|
1754
|
+
this.entityCountsByTypeFixed[nounTypeIndex] = 0;
|
|
1755
|
+
}
|
|
1756
|
+
catch { }
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
// ==========================================================================
|
|
1761
|
+
// Tokenization (delegates to Rust via extractFieldNames, but also exposed)
|
|
1762
|
+
// ==========================================================================
|
|
1763
|
+
tokenize(text) {
|
|
1764
|
+
if (!text)
|
|
1765
|
+
return [];
|
|
1766
|
+
return text
|
|
1767
|
+
.toLowerCase()
|
|
1768
|
+
.replace(/[^\w\s]/g, ' ')
|
|
1769
|
+
.split(/\s+/)
|
|
1770
|
+
.filter(w => w.length >= 2 && w.length <= 50)
|
|
1771
|
+
.filter((w, i, arr) => arr.indexOf(w) === i);
|
|
1772
|
+
}
|
|
1773
|
+
hashWord(word) {
|
|
1774
|
+
let hash = 2166136261;
|
|
1775
|
+
for (let i = 0; i < word.length; i++) {
|
|
1776
|
+
hash ^= word.charCodeAt(i);
|
|
1777
|
+
hash = Math.imul(hash, 16777619);
|
|
1778
|
+
}
|
|
1779
|
+
return hash | 0;
|
|
1780
|
+
}
|
|
1781
|
+
extractTextContent(data) {
|
|
1782
|
+
if (data === null || data === undefined)
|
|
1783
|
+
return '';
|
|
1784
|
+
if (typeof data === 'string')
|
|
1785
|
+
return data;
|
|
1786
|
+
if (typeof data === 'number' || typeof data === 'boolean')
|
|
1787
|
+
return String(data);
|
|
1788
|
+
if (Array.isArray(data)) {
|
|
1789
|
+
if (data.length > 0 && typeof data[0] === 'number')
|
|
1790
|
+
return '';
|
|
1791
|
+
return data.map(d => this.extractTextContent(d)).filter(Boolean).join(' ');
|
|
1792
|
+
}
|
|
1793
|
+
if (typeof data === 'object') {
|
|
1794
|
+
const skipKeys = new Set(['vector', 'embedding', 'embeddings', 'connections', 'level', 'id']);
|
|
1795
|
+
const texts = [];
|
|
1796
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1797
|
+
if (skipKeys.has(key) || /^\d+$/.test(key))
|
|
1798
|
+
continue;
|
|
1799
|
+
const text = this.extractTextContent(value);
|
|
1800
|
+
if (text)
|
|
1801
|
+
texts.push(text);
|
|
1802
|
+
}
|
|
1803
|
+
return texts.join(' ');
|
|
1804
|
+
}
|
|
1805
|
+
return '';
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Whether a field should be indexed, honoring this index's configured
|
|
1809
|
+
* `excludeFields` / `indexedFields`. Mirrors the Rust `should_index_field`
|
|
1810
|
+
* (and brainy's TS `shouldIndexField`) so the column store indexes exactly the
|
|
1811
|
+
* fields the metadata index does.
|
|
1812
|
+
*
|
|
1813
|
+
* @param field - Fully-qualified field name (dotted for nested).
|
|
1814
|
+
* @returns True if the field is indexable under the current config.
|
|
1815
|
+
*/
|
|
1816
|
+
shouldIndexField(field) {
|
|
1817
|
+
if (this.config.excludeFields.includes(field))
|
|
1818
|
+
return false;
|
|
1819
|
+
if (this.config.indexedFields.length > 0) {
|
|
1820
|
+
return this.config.indexedFields.includes(field);
|
|
1821
|
+
}
|
|
1822
|
+
return true;
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Build the indexable field→value map fed to the column store, identical to
|
|
1826
|
+
* brainy's `MetadataIndexManager.extractIndexableFields()` → `fieldsMap`:
|
|
1827
|
+
*
|
|
1828
|
+
* - Recurses objects; flattens a top-level `metadata` object to the root.
|
|
1829
|
+
* - Maps a top-level `type` field to `noun` (backward-compat with indexes).
|
|
1830
|
+
* - Skips the never-index set (vector/embedding/connections/level/id), purely
|
|
1831
|
+
* numeric keys (array indices), config-excluded fields, and arrays longer
|
|
1832
|
+
* than 10 elements (likely vectors/bulk data).
|
|
1833
|
+
* - Small arrays (≤10) become multi-value fields: one array entry per element.
|
|
1834
|
+
* - Adds `__words__` as a multi-value array of FNV-1a int32 word hashes from
|
|
1835
|
+
* the entity's text content (capped at 5000 words), matching brainy.
|
|
1836
|
+
*
|
|
1837
|
+
* Replicated in TS rather than reusing the Rust extractor because the native
|
|
1838
|
+
* `addToIndex` does not return the extracted pairs and the engine is frozen.
|
|
1839
|
+
*
|
|
1840
|
+
* @param data - The entity or plain-metadata object being indexed.
|
|
1841
|
+
* @returns Field → value (or array of values) map for `columnStore.addEntity`.
|
|
1842
|
+
*/
|
|
1843
|
+
extractColumnFields(data) {
|
|
1844
|
+
const fieldsMap = {};
|
|
1845
|
+
const NEVER_INDEX = new Set(['vector', 'embedding', 'embeddings', 'connections', 'level', 'id']);
|
|
1846
|
+
const pushValue = (field, value) => {
|
|
1847
|
+
const existing = fieldsMap[field];
|
|
1848
|
+
if (Array.isArray(existing)) {
|
|
1849
|
+
existing.push(value);
|
|
1850
|
+
}
|
|
1851
|
+
else if (existing !== undefined) {
|
|
1852
|
+
// A second value for a scalar field → promote to a multi-value array,
|
|
1853
|
+
// preserving insertion order (matches brainy feeding repeated pairs).
|
|
1854
|
+
fieldsMap[field] = [existing, value];
|
|
1855
|
+
}
|
|
1856
|
+
else {
|
|
1857
|
+
fieldsMap[field] = value;
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
const extract = (obj, prefix = '') => {
|
|
1861
|
+
if (!obj || typeof obj !== 'object')
|
|
1862
|
+
return;
|
|
1863
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1864
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
1865
|
+
if (!prefix && NEVER_INDEX.has(key))
|
|
1866
|
+
continue;
|
|
1867
|
+
if (/^\d+$/.test(key))
|
|
1868
|
+
continue;
|
|
1869
|
+
if (!this.shouldIndexField(fullKey))
|
|
1870
|
+
continue;
|
|
1871
|
+
// Flatten a top-level metadata object to the root (no prefix).
|
|
1872
|
+
if (key === 'metadata' && !prefix && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1873
|
+
extract(value, '');
|
|
1874
|
+
continue;
|
|
1875
|
+
}
|
|
1876
|
+
if (Array.isArray(value)) {
|
|
1877
|
+
if (value.length > 10)
|
|
1878
|
+
continue;
|
|
1879
|
+
for (const item of value) {
|
|
1880
|
+
if (item !== null && typeof item !== 'object') {
|
|
1881
|
+
pushValue(fullKey, item);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
else if (value && typeof value === 'object') {
|
|
1886
|
+
extract(value, fullKey);
|
|
1887
|
+
}
|
|
1888
|
+
else if (value !== null && value !== undefined) {
|
|
1889
|
+
const indexField = !prefix && key === 'type' ? 'noun' : fullKey;
|
|
1890
|
+
pushValue(indexField, value);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
};
|
|
1894
|
+
extract(data);
|
|
1895
|
+
// Hybrid text-search words: FNV-1a int32 hashes, multi-value, capped at 5000.
|
|
1896
|
+
const textContent = this.extractTextContent(data);
|
|
1897
|
+
if (textContent) {
|
|
1898
|
+
const MAX_WORDS_PER_ENTITY = 5000;
|
|
1899
|
+
const words = this.tokenize(textContent).slice(0, MAX_WORDS_PER_ENTITY);
|
|
1900
|
+
if (words.length > 0) {
|
|
1901
|
+
fieldsMap.__words__ = words.map(w => this.hashWord(w));
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
return fieldsMap;
|
|
1905
|
+
}
|
|
1906
|
+
// ==========================================================================
|
|
1907
|
+
// Entity counting
|
|
1908
|
+
// ==========================================================================
|
|
1909
|
+
getEntityCountByType(type) {
|
|
1910
|
+
return this.totalEntitiesByType.get(type) || 0;
|
|
1911
|
+
}
|
|
1912
|
+
getTotalEntityCount() {
|
|
1913
|
+
let total = 0;
|
|
1914
|
+
for (const count of this.totalEntitiesByType.values()) {
|
|
1915
|
+
total += count;
|
|
1916
|
+
}
|
|
1917
|
+
return total;
|
|
1918
|
+
}
|
|
1919
|
+
getAllEntityCounts() {
|
|
1920
|
+
return new Map(this.totalEntitiesByType);
|
|
1921
|
+
}
|
|
1922
|
+
getEntityCountByTypeEnum(type) {
|
|
1923
|
+
const index = TypeUtils.getNounIndex(type);
|
|
1924
|
+
return this.entityCountsByTypeFixed[index];
|
|
1925
|
+
}
|
|
1926
|
+
getVerbCountByTypeEnum(type) {
|
|
1927
|
+
const index = TypeUtils.getVerbIndex(type);
|
|
1928
|
+
return this.verbCountsByTypeFixed[index];
|
|
1929
|
+
}
|
|
1930
|
+
getTopNounTypes(n) {
|
|
1931
|
+
const types = [];
|
|
1932
|
+
for (let i = 0; i < NOUN_TYPE_COUNT; i++) {
|
|
1933
|
+
const count = this.entityCountsByTypeFixed[i];
|
|
1934
|
+
if (count > 0) {
|
|
1935
|
+
const type = TypeUtils.getNounFromIndex(i);
|
|
1936
|
+
types.push({ type, count });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
return types.sort((a, b) => b.count - a.count).slice(0, n).map(t => t.type);
|
|
1940
|
+
}
|
|
1941
|
+
getTopVerbTypes(n) {
|
|
1942
|
+
const types = [];
|
|
1943
|
+
for (let i = 0; i < VERB_TYPE_COUNT; i++) {
|
|
1944
|
+
const count = this.verbCountsByTypeFixed[i];
|
|
1945
|
+
if (count > 0) {
|
|
1946
|
+
const type = TypeUtils.getVerbFromIndex(i);
|
|
1947
|
+
types.push({ type, count });
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
return types.sort((a, b) => b.count - a.count).slice(0, n).map(t => t.type);
|
|
1951
|
+
}
|
|
1952
|
+
getAllNounTypeCounts() {
|
|
1953
|
+
const counts = new Map();
|
|
1954
|
+
for (let i = 0; i < NOUN_TYPE_COUNT; i++) {
|
|
1955
|
+
const count = this.entityCountsByTypeFixed[i];
|
|
1956
|
+
if (count > 0) {
|
|
1957
|
+
counts.set(TypeUtils.getNounFromIndex(i), count);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
return counts;
|
|
1961
|
+
}
|
|
1962
|
+
getAllVerbTypeCounts() {
|
|
1963
|
+
const counts = new Map();
|
|
1964
|
+
for (let i = 0; i < VERB_TYPE_COUNT; i++) {
|
|
1965
|
+
const count = this.verbCountsByTypeFixed[i];
|
|
1966
|
+
if (count > 0) {
|
|
1967
|
+
counts.set(TypeUtils.getVerbFromIndex(i), count);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
return counts;
|
|
1971
|
+
}
|
|
1972
|
+
// ==========================================================================
|
|
1973
|
+
// VFS Statistics (roaring bitmap intersection via Rust)
|
|
1974
|
+
// ==========================================================================
|
|
1975
|
+
async getVFSEntityCountByType(type) {
|
|
1976
|
+
await this.ensureFieldsLoaded(['isVFSEntity', 'noun']);
|
|
1977
|
+
const vfsIds = this.native.getIds('isVFSEntity', JSON.stringify(true));
|
|
1978
|
+
const typeIds = this.native.getIds('noun', JSON.stringify(type));
|
|
1979
|
+
const vfsSet = new Set(vfsIds);
|
|
1980
|
+
return typeIds.filter(id => vfsSet.has(id)).length;
|
|
1981
|
+
}
|
|
1982
|
+
async getAllVFSEntityCounts() {
|
|
1983
|
+
await this.ensureFieldsLoaded(['isVFSEntity', 'noun']);
|
|
1984
|
+
const vfsIds = this.native.getIds('isVFSEntity', JSON.stringify(true));
|
|
1985
|
+
if (vfsIds.length === 0)
|
|
1986
|
+
return new Map();
|
|
1987
|
+
const vfsSet = new Set(vfsIds);
|
|
1988
|
+
const result = new Map();
|
|
1989
|
+
for (const type of this.totalEntitiesByType.keys()) {
|
|
1990
|
+
const typeIds = this.native.getIds('noun', JSON.stringify(type));
|
|
1991
|
+
const count = typeIds.filter(id => vfsSet.has(id)).length;
|
|
1992
|
+
if (count > 0)
|
|
1993
|
+
result.set(type, count);
|
|
1994
|
+
}
|
|
1995
|
+
return result;
|
|
1996
|
+
}
|
|
1997
|
+
async getTotalVFSEntityCount() {
|
|
1998
|
+
await this.ensureFieldLoaded('isVFSEntity');
|
|
1999
|
+
// Native count — no per-entity UUID materialization (#78 pt2).
|
|
2000
|
+
return this.native.getIdsCount('isVFSEntity', JSON.stringify(true));
|
|
2001
|
+
}
|
|
2002
|
+
// ==========================================================================
|
|
2003
|
+
// Criteria-based counting
|
|
2004
|
+
// ==========================================================================
|
|
2005
|
+
async getCountForCriteria(field, value) {
|
|
2006
|
+
// Native count — mirrors getIds(field, value).length exactly but allocates
|
|
2007
|
+
// no UUID strings (#78 pt2).
|
|
2008
|
+
await this.ensureFieldLoaded(field);
|
|
2009
|
+
return this.native.getIdsCount(field, this.serializeValue(value));
|
|
2010
|
+
}
|
|
2011
|
+
// ==========================================================================
|
|
2012
|
+
// Flush
|
|
2013
|
+
// ==========================================================================
|
|
2014
|
+
async flush() {
|
|
2015
|
+
// Always save field registry + msync the entity ID mapper — even with
|
|
2016
|
+
// no dirty fields. The field registry is the critical file init() needs
|
|
2017
|
+
// to discover persisted indices. Without it, the metadata index appears
|
|
2018
|
+
// empty after restart.
|
|
2019
|
+
if (this.knownFields.size > 0) {
|
|
2020
|
+
await this.saveFieldRegistry();
|
|
2021
|
+
}
|
|
2022
|
+
// #72 Phase C: msync the mmap BinaryIdMapper FIRST, before the bitmaps /
|
|
2023
|
+
// column segments that reference its ints — so a crash can never leave a
|
|
2024
|
+
// persisted bitmap pointing at an int the mapper has not yet durably
|
|
2025
|
+
// assigned (which would silently drop those rows on reopen).
|
|
2026
|
+
this.idMapperNative?.flush();
|
|
2027
|
+
// LSM durability (steps 3-4): drain the memtable to a durable SSTable +
|
|
2028
|
+
// manifest. Runs AFTER the id-mapper msync above (the SSTable's posting
|
|
2029
|
+
// lists reference entity ints that must be durably assigned first) and
|
|
2030
|
+
// BEFORE the dirty-field early-return below (the memtable can hold
|
|
2031
|
+
// un-persisted writes even when no field index is dirty — e.g. a
|
|
2032
|
+
// deferred-write batch). No-op when the memtable is empty or on the
|
|
2033
|
+
// non-persistent fallback engine.
|
|
2034
|
+
await this.flushLsm();
|
|
2035
|
+
// Flush the column store unconditionally — drains tail buffers to L0 `.cidx`
|
|
2036
|
+
// segments and persists manifests + deleted bitmaps. Must run even when no
|
|
2037
|
+
// field indexes are dirty (e.g. a deferred-write batch buffered column data
|
|
2038
|
+
// without touching the dirty-field set), so orderBy survives a reopen.
|
|
2039
|
+
await this.columnStore.flush();
|
|
2040
|
+
if (this.dirtyFields.size === 0)
|
|
2041
|
+
return;
|
|
2042
|
+
// Save dirty field indexes
|
|
2043
|
+
const promises = [];
|
|
2044
|
+
for (const field of this.dirtyFields) {
|
|
2045
|
+
const json = this.native.saveFieldIndex(field);
|
|
2046
|
+
if (json) {
|
|
2047
|
+
const indexId = `__metadata_field_index__field_${field}`;
|
|
2048
|
+
promises.push(this.storage.saveMetadata(indexId, JSON.parse(json)));
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
await Promise.all(promises);
|
|
2052
|
+
// Save field registry (the mmap mapper was already msync'd above).
|
|
2053
|
+
await this.saveFieldRegistry();
|
|
2054
|
+
this.dirtyFields.clear();
|
|
2055
|
+
this.lastFlushTime = Date.now();
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Persist all state and release the column store's resources. Brainy's
|
|
2059
|
+
* shutdown path calls `metadataIndex.close()` when present; flushing here
|
|
2060
|
+
* guarantees the column store's tail buffers reach disk before the process
|
|
2061
|
+
* exits, so a reopened index can serve `orderBy` from persisted segments.
|
|
2062
|
+
*/
|
|
2063
|
+
async close() {
|
|
2064
|
+
await this.flush();
|
|
2065
|
+
await this.columnStore.close();
|
|
2066
|
+
// P3-5: deregister the ResourceManager subscriber so the rebalance
|
|
2067
|
+
// loop stops calling our callback after shutdown. The engine
|
|
2068
|
+
// itself stays alive as long as `this.native` (via setLsmEngine's
|
|
2069
|
+
// Arc clone) or any held snapshot holds a reference — extra
|
|
2070
|
+
// post-close setAllowedBytes calls would be harmless but we want
|
|
2071
|
+
// the loop to be efficient. Unsubscribing is idempotent; the
|
|
2072
|
+
// returned deregistration function silently no-ops on second call.
|
|
2073
|
+
if (this.unsubscribeFromBudget !== null) {
|
|
2074
|
+
this.unsubscribeFromBudget();
|
|
2075
|
+
this.unsubscribeFromBudget = null;
|
|
2076
|
+
this.lsmSubscriberName = null;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
// ==========================================================================
|
|
2080
|
+
// Stats
|
|
2081
|
+
// ==========================================================================
|
|
2082
|
+
async getStats() {
|
|
2083
|
+
const statsJson = this.native.getStats();
|
|
2084
|
+
const stats = JSON.parse(statsJson);
|
|
2085
|
+
return {
|
|
2086
|
+
totalEntries: stats.totalEntries || 0,
|
|
2087
|
+
totalIds: 0,
|
|
2088
|
+
fieldsIndexed: stats.fieldsIndexed || [],
|
|
2089
|
+
lastRebuild: Date.now(),
|
|
2090
|
+
indexSize: (stats.totalEntries || 0) * 100
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* @description Report which index path a `find({ where: { [field]: ... } })`
|
|
2095
|
+
* clause will hit. Brainy's `brain.explain()` calls this per `where` field so an
|
|
2096
|
+
* operator can see *before* running a query whether the field has any index
|
|
2097
|
+
* entries at all — a `where` on an unindexed field returns `[]` silently, and
|
|
2098
|
+
* `path: 'none'` gives that empty result an explanation.
|
|
2099
|
+
*
|
|
2100
|
+
* The native engine serves all `where` lookups from the column store (the
|
|
2101
|
+
* single source of truth — there is no separate legacy sparse-index path at
|
|
2102
|
+
* query time, unlike brainy's TypeScript baseline). So a field either has
|
|
2103
|
+
* column-store entries (`'column-store'`) or none (`'none'`). The field's
|
|
2104
|
+
* segments are loaded first so `hasField` reflects on-disk state, not just
|
|
2105
|
+
* what has been touched this session.
|
|
2106
|
+
*
|
|
2107
|
+
* @param field - The `where`-clause field name to explain.
|
|
2108
|
+
* @returns The serving path and an operator-facing note.
|
|
2109
|
+
* @example
|
|
2110
|
+
* const { path, notes } = await metadataIndex.explainField('status')
|
|
2111
|
+
* if (path === 'none') console.warn(notes)
|
|
2112
|
+
*/
|
|
2113
|
+
async explainField(field) {
|
|
2114
|
+
await this.ensureFieldLoaded(field);
|
|
2115
|
+
if (this.columnStore.hasField(field)) {
|
|
2116
|
+
return {
|
|
2117
|
+
path: 'column-store',
|
|
2118
|
+
notes: 'O(log n) binary search + roaring bitmap (native Rust column store). Best path.'
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
return {
|
|
2122
|
+
path: 'none',
|
|
2123
|
+
notes: `No index entries for field "${field}". A find({ where: { ${field}: ... } }) ` +
|
|
2124
|
+
`will return an empty result regardless of whether matching entities exist on disk. ` +
|
|
2125
|
+
`Likely causes: (1) the writer registered the field in memory but has not flushed; ` +
|
|
2126
|
+
`(2) the field name does not match what was written (typo or casing); ` +
|
|
2127
|
+
`(3) the field is genuinely absent from all entities. Call requestFlush() on the ` +
|
|
2128
|
+
`writer or call brain.flush() before relying on the result.`
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
// ==========================================================================
|
|
2132
|
+
// Validation
|
|
2133
|
+
// ==========================================================================
|
|
2134
|
+
async validateConsistency() {
|
|
2135
|
+
const entityCount = this.native.entityIdMapperSize();
|
|
2136
|
+
if (entityCount === 0) {
|
|
2137
|
+
return {
|
|
2138
|
+
healthy: true,
|
|
2139
|
+
avgEntriesPerEntity: 0,
|
|
2140
|
+
entityCount: 0,
|
|
2141
|
+
indexEntryCount: 0,
|
|
2142
|
+
recommendation: null
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
// Load all fields to count entries
|
|
2146
|
+
for (const field of this.knownFields) {
|
|
2147
|
+
await this.ensureFieldLoaded(field);
|
|
2148
|
+
}
|
|
2149
|
+
const statsJson = this.native.getStats();
|
|
2150
|
+
const stats = JSON.parse(statsJson);
|
|
2151
|
+
const indexEntryCount = stats.totalEntries || 0;
|
|
2152
|
+
const avgEntriesPerEntity = indexEntryCount / entityCount;
|
|
2153
|
+
const CORRUPTION_THRESHOLD = 100;
|
|
2154
|
+
const healthy = avgEntriesPerEntity <= CORRUPTION_THRESHOLD;
|
|
2155
|
+
let recommendation = null;
|
|
2156
|
+
if (!healthy) {
|
|
2157
|
+
recommendation = `Index corruption detected (${avgEntriesPerEntity.toFixed(1)} avg entries/entity, expected ~30). ` +
|
|
2158
|
+
`Run brain.index.clearAllIndexData() followed by brain.index.rebuild() to repair.`;
|
|
2159
|
+
}
|
|
2160
|
+
return { healthy, avgEntriesPerEntity, entityCount, indexEntryCount, recommendation };
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* O(1)-ish cross-bucket consistency probe (brainy rc.6 matrix 7.7).
|
|
2164
|
+
*
|
|
2165
|
+
* Brainy calls this ONCE on the first read; on `false` it runs
|
|
2166
|
+
* {@link detectAndRepairCorruption}. The check is fully internal — it never
|
|
2167
|
+
* re-reads entity data. It loads a bounded set of value-enumeration field
|
|
2168
|
+
* indexes (so the native sampler can enumerate each field's value buckets),
|
|
2169
|
+
* biased toward `noun` (the canonical single-valued type field where the
|
|
2170
|
+
* staff→timeslot class of phantom lands), then defers to the native
|
|
2171
|
+
* {@link NativeMetadataIndexInstance.probeConsistency} bounded sampler.
|
|
2172
|
+
*
|
|
2173
|
+
* Best-effort: any error (a sampling glitch, a storage hiccup loading a field
|
|
2174
|
+
* index) degrades to `true` so the probe NEVER breaks brainy's read path.
|
|
2175
|
+
*
|
|
2176
|
+
* @returns `true` if the index looks consistent (or the check is
|
|
2177
|
+
* indeterminate), `false` if a cross-bucket phantom was sampled.
|
|
2178
|
+
*/
|
|
2179
|
+
async probeConsistency() {
|
|
2180
|
+
try {
|
|
2181
|
+
// Load value-enumeration field indexes so the native sampler can
|
|
2182
|
+
// enumerate each field's buckets. Bounded to keep the probe cheap: at
|
|
2183
|
+
// most PROBE_MAX_FIELDS small field-index JSON blobs, `noun` first. The
|
|
2184
|
+
// native check itself is O(1)-ish (a bounded sample of ints against a
|
|
2185
|
+
// bounded set of buckets) — it never walks the full index.
|
|
2186
|
+
const PROBE_MAX_FIELDS = 64;
|
|
2187
|
+
let loaded = 0;
|
|
2188
|
+
if (this.knownFields.has('noun')) {
|
|
2189
|
+
await this.ensureFieldLoaded('noun');
|
|
2190
|
+
loaded++;
|
|
2191
|
+
}
|
|
2192
|
+
for (const field of this.knownFields) {
|
|
2193
|
+
if (loaded >= PROBE_MAX_FIELDS)
|
|
2194
|
+
break;
|
|
2195
|
+
if (field === 'noun' || field === '__words__')
|
|
2196
|
+
continue;
|
|
2197
|
+
await this.ensureFieldLoaded(field);
|
|
2198
|
+
loaded++;
|
|
2199
|
+
}
|
|
2200
|
+
return this.native.probeConsistency();
|
|
2201
|
+
}
|
|
2202
|
+
catch (error) {
|
|
2203
|
+
// A probe must never throw into the read path — assume healthy.
|
|
2204
|
+
prodLog.debug(`[probeConsistency] benign sampling error — assuming healthy: ${error}`);
|
|
2205
|
+
return true;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
async detectAndRepairCorruption() {
|
|
2209
|
+
const validation = await this.validateConsistency();
|
|
2210
|
+
if (!validation.healthy) {
|
|
2211
|
+
prodLog.warn(`Index corruption detected (${validation.avgEntriesPerEntity.toFixed(1)} avg entries/entity)`);
|
|
2212
|
+
prodLog.warn('Auto-rebuilding index to repair...');
|
|
2213
|
+
await this.clearAllIndexData();
|
|
2214
|
+
await this.rebuild();
|
|
2215
|
+
const postRebuild = await this.validateConsistency();
|
|
2216
|
+
if (postRebuild.healthy) {
|
|
2217
|
+
prodLog.info(`Index rebuilt successfully (${postRebuild.avgEntriesPerEntity.toFixed(1)} avg entries/entity)`);
|
|
2218
|
+
}
|
|
2219
|
+
else {
|
|
2220
|
+
prodLog.error(`Index still appears corrupted after rebuild (${postRebuild.avgEntriesPerEntity.toFixed(1)} avg entries/entity).`);
|
|
2221
|
+
}
|
|
2222
|
+
// The full rebuild re-indexed every entity from storage (the source of
|
|
2223
|
+
// truth) — each entity lands in exactly its current value bucket, so no
|
|
2224
|
+
// cross-bucket phantom can survive. The targeted purge below is therefore
|
|
2225
|
+
// redundant after a rebuild.
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
// The avg-entries heuristic above catches GROSS corruption (a runaway
|
|
2229
|
+
// entries/entity ratio) but NOT the cross-bucket phantom: one stale posting
|
|
2230
|
+
// that survived a value change barely moves the ratio. Run the targeted
|
|
2231
|
+
// state-level purge so brainy's cold-open self-heal (matrix 7.8b) repairs a
|
|
2232
|
+
// pre-poisoned index at the STATE level, not just masking it at query time.
|
|
2233
|
+
await this.purgeCrossBucketPhantoms();
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* State-level cross-bucket phantom purge (brainy matrix 7.8b).
|
|
2237
|
+
*
|
|
2238
|
+
* Scans the native index for entities whose int sits in more than one value
|
|
2239
|
+
* bucket of a field, then tombstones the stale buckets against each entity's
|
|
2240
|
+
* CURRENT stored value(s) — read straight from storage (brainy's source of
|
|
2241
|
+
* truth), NOT from the index being repaired. A multi-valued field keeps all
|
|
2242
|
+
* its stored values (a no-op); a single-valued phantom is purged down to the
|
|
2243
|
+
* one current value; an orphaned int (entity gone) is evicted entirely.
|
|
2244
|
+
*
|
|
2245
|
+
* Only reached after {@link probeConsistency} flagged the index, so the
|
|
2246
|
+
* full field-index load + scan is acceptable here (unlike the O(1) probe).
|
|
2247
|
+
*/
|
|
2248
|
+
async purgeCrossBucketPhantoms() {
|
|
2249
|
+
// The scan enumerates each field's value buckets from the in-memory
|
|
2250
|
+
// value-enumeration surface, so every known field must be loaded first
|
|
2251
|
+
// (mirrors validateConsistency's load-all).
|
|
2252
|
+
for (const field of this.knownFields) {
|
|
2253
|
+
await this.ensureFieldLoaded(field);
|
|
2254
|
+
}
|
|
2255
|
+
// Cap the scan so a massively-corrupt index produces a bounded list; the
|
|
2256
|
+
// remainder is healed on a subsequent cold-open (probe → repair again).
|
|
2257
|
+
const PHANTOM_SCAN_CAP = 100_000;
|
|
2258
|
+
let candidates;
|
|
2259
|
+
try {
|
|
2260
|
+
candidates = JSON.parse(this.native.detectCrossBucketPhantoms(PHANTOM_SCAN_CAP));
|
|
2261
|
+
}
|
|
2262
|
+
catch (error) {
|
|
2263
|
+
prodLog.warn(`[purgeCrossBucketPhantoms] scan failed (skipping): ${error}`);
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
if (candidates.length === 0)
|
|
2267
|
+
return;
|
|
2268
|
+
prodLog.warn(`[purgeCrossBucketPhantoms] ${candidates.length} cross-bucket candidate(s) — repairing against stored values`);
|
|
2269
|
+
const repairedFields = new Set();
|
|
2270
|
+
let purged = 0;
|
|
2271
|
+
for (const { uuid, field } of candidates) {
|
|
2272
|
+
// The entity's CURRENT value(s) for this field — brainy's source of
|
|
2273
|
+
// truth, read from storage (NOT the index we're repairing). For `noun`
|
|
2274
|
+
// resolveEntityField maps to entity.type; other fields read entity
|
|
2275
|
+
// metadata. An empty result (entity gone / field dropped) keeps nothing,
|
|
2276
|
+
// so the orphan int is evicted from every bucket of the field.
|
|
2277
|
+
let currentValues = [];
|
|
2278
|
+
try {
|
|
2279
|
+
const noun = await this.storage.getNoun(uuid);
|
|
2280
|
+
if (noun) {
|
|
2281
|
+
const raw = resolveEntityField(noun, field);
|
|
2282
|
+
if (raw !== undefined && raw !== null) {
|
|
2283
|
+
currentValues = Array.isArray(raw) ? raw : [raw];
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
catch (error) {
|
|
2288
|
+
prodLog.debug(`[purgeCrossBucketPhantoms] could not read stored ${uuid}.${field}: ${error}`);
|
|
2289
|
+
}
|
|
2290
|
+
try {
|
|
2291
|
+
const didPurge = this.native.repairEntityFieldPhantom(uuid, field, JSON.stringify(currentValues));
|
|
2292
|
+
if (didPurge) {
|
|
2293
|
+
purged++;
|
|
2294
|
+
repairedFields.add(field);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
catch (error) {
|
|
2298
|
+
prodLog.warn(`[purgeCrossBucketPhantoms] repair of ${uuid}.${field} failed: ${error}`);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
// Persist the value-enumeration field indexes the purge mutated so a cold
|
|
2302
|
+
// reopen doesn't re-enumerate an emptied stale value.
|
|
2303
|
+
for (const field of repairedFields) {
|
|
2304
|
+
const json = this.native.saveFieldIndex(field);
|
|
2305
|
+
if (json) {
|
|
2306
|
+
await this.storage.saveMetadata(`__metadata_field_index__field_${field}`, JSON.parse(json));
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
if (purged > 0) {
|
|
2310
|
+
prodLog.info(`[purgeCrossBucketPhantoms] purged ${purged} stale cross-bucket posting set(s) across ${repairedFields.size} field(s)`);
|
|
2311
|
+
// Drain the eviction tombstones to a durable SSTable so the repair
|
|
2312
|
+
// survives a restart even without further writes.
|
|
2313
|
+
await this.flush();
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
// ==========================================================================
|
|
2317
|
+
// Rebuild
|
|
2318
|
+
// ==========================================================================
|
|
2319
|
+
async rebuild() {
|
|
2320
|
+
if (this.skipRebuildOnce) {
|
|
2321
|
+
// getIdMapper().rebuild() (brainy restore() mapper hook) already ran the
|
|
2322
|
+
// full rebuild this cycle; skipping here keeps the entity-id mapper stable
|
|
2323
|
+
// for the concurrent graphIndex.rebuild() in brainy's Promise.all instead
|
|
2324
|
+
// of re-clearing it mid-flight (which silently drops resolved edges). (#68)
|
|
2325
|
+
this.skipRebuildOnce = false;
|
|
2326
|
+
prodLog.debug('NativeMetadataIndex.rebuild(): skipped — getIdMapper().rebuild() already rebuilt this restore cycle');
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
if (this.isRebuilding)
|
|
2330
|
+
return;
|
|
2331
|
+
this.isRebuilding = true;
|
|
2332
|
+
try {
|
|
2333
|
+
prodLog.info('Starting metadata index rebuild with batch processing...');
|
|
2334
|
+
// **LSM durability (step 6) — LSM-aware rebuild teardown.** A rebuild
|
|
2335
|
+
// commonly follows brainy's `restore()`, which WIPES and replaces the
|
|
2336
|
+
// storage tree under the still-live engine. `native.clear()` below
|
|
2337
|
+
// cannot reset the attached engine (it owns its memtable + live-SSTable
|
|
2338
|
+
// set out of `clear()`'s reach), so the engine must be re-opened fresh
|
|
2339
|
+
// against the new tree BEFORE the re-index walk — otherwise its stale
|
|
2340
|
+
// mmap'd readers (which survive the files' unlink) keep serving
|
|
2341
|
+
// pre-restore rows and the stale manifest dangles. {@link
|
|
2342
|
+
// reopenLsmEngineFresh} wipes the stale `_metadata/sstables/*` + manifest
|
|
2343
|
+
// + memtable logs, recreates the memtable dir, opens a fresh
|
|
2344
|
+
// `openWithDir` engine, and re-attaches it. No-op on the non-persistent
|
|
2345
|
+
// fallback. (Supersedes the steps-3+4 minimal memtable-dir recreate —
|
|
2346
|
+
// the wipe+re-open subsumes the `mkdir`.)
|
|
2347
|
+
await this.reopenLsmEngineFresh();
|
|
2348
|
+
// Clear in-memory index state (field registry + value enumeration).
|
|
2349
|
+
// #72 Phase C: native.clear() does NOT reset the shared mmap id-mapper
|
|
2350
|
+
// (it has no in-place clear, and truncating its files while the old mmap
|
|
2351
|
+
// is still mapped would risk the kernel flushing stale MAP_SHARED pages
|
|
2352
|
+
// over fresh data). The mapper persists, so the rebuild's addToIndex
|
|
2353
|
+
// calls re-resolve each surviving UUID to its SAME int via get_or_assign
|
|
2354
|
+
// — ints are stable across rebuild. The column store is still reset +
|
|
2355
|
+
// repopulated below so its segments are rebuilt cleanly from those
|
|
2356
|
+
// (identical) ints. The only residue is orphaned int→uuid entries for
|
|
2357
|
+
// entities dropped by a restore-to-different-snapshot — a bounded space
|
|
2358
|
+
// leak, never a read-correctness issue.
|
|
2359
|
+
this.native.clear();
|
|
2360
|
+
await this.columnStore.reset();
|
|
2361
|
+
this.loadedFields.clear();
|
|
2362
|
+
this.knownFields.clear();
|
|
2363
|
+
this.dirtyFields.clear();
|
|
2364
|
+
this.totalEntitiesByType.clear();
|
|
2365
|
+
this.entityCountsByTypeFixed.fill(0);
|
|
2366
|
+
this.verbCountsByTypeFixed.fill(0);
|
|
2367
|
+
this.typeFieldAffinity.clear();
|
|
2368
|
+
this.unifiedCache.clear('metadata');
|
|
2369
|
+
// Delete existing persisted field indexes from storage
|
|
2370
|
+
prodLog.info('Clearing existing metadata field indexes from storage...');
|
|
2371
|
+
const existingFields = await this.getPersistedFieldList();
|
|
2372
|
+
if (existingFields.length > 0) {
|
|
2373
|
+
for (const field of existingFields) {
|
|
2374
|
+
await this.deletePersistedFieldIndex(field);
|
|
2375
|
+
}
|
|
2376
|
+
try {
|
|
2377
|
+
await this.storage.saveMetadata('__metadata_field_registry__', null);
|
|
2378
|
+
}
|
|
2379
|
+
catch { }
|
|
2380
|
+
prodLog.info(`Cleared ${existingFields.length} field indexes from storage`);
|
|
2381
|
+
}
|
|
2382
|
+
// #72 Phase C: there is no `brainy:entityIdMapper` JSON blob to clear —
|
|
2383
|
+
// the mapper is the mmap BinaryIdMapper, which persists across rebuild.
|
|
2384
|
+
// Rebuild noun + verb metadata indexes via ONE cursor-paginated path for
|
|
2385
|
+
// EVERY storage type (see rebuildEntityMetadata). The previous
|
|
2386
|
+
// local-vs-cloud fork OOM'd / silently TRUNCATED at scale: the local
|
|
2387
|
+
// branch loaded the whole corpus in a single getNouns/getVerbs({limit:1e6})
|
|
2388
|
+
// call (cap 1M), and the cloud branch capped at MAX_ITERATIONS(10000)×
|
|
2389
|
+
// batch(25) ≈ 250K. (#74 — perf-at-all-scales mandate.)
|
|
2390
|
+
const totalNounsProcessed = await this.rebuildEntityMetadata('noun');
|
|
2391
|
+
const totalVerbsProcessed = await this.rebuildEntityMetadata('verb');
|
|
2392
|
+
// Final flush
|
|
2393
|
+
await this.flushRebuildDirty();
|
|
2394
|
+
await this.flush();
|
|
2395
|
+
prodLog.info(`Metadata index rebuild completed! Processed ${totalNounsProcessed} nouns and ${totalVerbsProcessed} verbs`);
|
|
2396
|
+
}
|
|
2397
|
+
finally {
|
|
2398
|
+
this.isRebuilding = false;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
/**
|
|
2402
|
+
* Cursor-paginate every entity of a kind ('noun'|'verb') through the metadata
|
|
2403
|
+
* index during rebuild. ONE streaming path for ALL storage types — replaces
|
|
2404
|
+
* the old local-vs-cloud fork that OOM'd / silently TRUNCATED at scale: the
|
|
2405
|
+
* local branch loaded the whole corpus in a single `getNouns/getVerbs({limit:
|
|
2406
|
+
* 1e6})` call (cap 1M), and the cloud branch capped at MAX_ITERATIONS(10000)×
|
|
2407
|
+
* batch(25) ≈ 250K. Cursor pagination is O(page) RAM and complete at any
|
|
2408
|
+
* scale; it terminates on `hasMore === false` (8.0's cursor is no-rescan), with
|
|
2409
|
+
* an empty-page backstop. (#74 — perf-at-all-scales mandate.)
|
|
2410
|
+
* @returns the number of entities indexed (those carrying metadata).
|
|
2411
|
+
*/
|
|
2412
|
+
async rebuildEntityMetadata(kind) {
|
|
2413
|
+
const PAGE = 1000;
|
|
2414
|
+
let cursor = undefined;
|
|
2415
|
+
let hasMore = true;
|
|
2416
|
+
let processed = 0;
|
|
2417
|
+
let sinceFlush = 0;
|
|
2418
|
+
while (hasMore) {
|
|
2419
|
+
const page = kind === 'noun'
|
|
2420
|
+
? await this.storage.getNouns({ pagination: { limit: PAGE, cursor } })
|
|
2421
|
+
: await this.storage.getVerbs({ pagination: { limit: PAGE, cursor } });
|
|
2422
|
+
const items = page.items;
|
|
2423
|
+
if (items.length === 0)
|
|
2424
|
+
break; // empty page = done (defensive backstop)
|
|
2425
|
+
const ids = items.map((it) => it.id);
|
|
2426
|
+
const md = await this.loadRebuildMetadataBatch(kind, ids);
|
|
2427
|
+
for (const it of items) {
|
|
2428
|
+
const metadata = md.get(it.id);
|
|
2429
|
+
if (metadata) {
|
|
2430
|
+
await this.addToIndex(it.id, metadata, true, true);
|
|
2431
|
+
processed++;
|
|
2432
|
+
if (++sinceFlush >= 5000) {
|
|
2433
|
+
await this.flushRebuildDirty();
|
|
2434
|
+
sinceFlush = 0;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
hasMore = page.hasMore;
|
|
2439
|
+
cursor = page.nextCursor;
|
|
2440
|
+
await this.yieldToEventLoop();
|
|
2441
|
+
}
|
|
2442
|
+
return processed;
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* Batch-load metadata for a page of noun/verb ids — prefers the storage
|
|
2446
|
+
* adapter's batch API, falls back to per-id reads.
|
|
2447
|
+
*/
|
|
2448
|
+
async loadRebuildMetadataBatch(kind, ids) {
|
|
2449
|
+
if (kind === 'noun') {
|
|
2450
|
+
if (this.storage.getMetadataBatch)
|
|
2451
|
+
return this.storage.getMetadataBatch(ids);
|
|
2452
|
+
}
|
|
2453
|
+
else if (this.storage.getVerbMetadataBatch) {
|
|
2454
|
+
return this.storage.getVerbMetadataBatch(ids);
|
|
2455
|
+
}
|
|
2456
|
+
const m = new Map();
|
|
2457
|
+
for (const id of ids) {
|
|
2458
|
+
try {
|
|
2459
|
+
const md = kind === 'noun'
|
|
2460
|
+
? await this.storage.getNounMetadata(id)
|
|
2461
|
+
: await this.storage.getVerbMetadata(id);
|
|
2462
|
+
if (md)
|
|
2463
|
+
m.set(id, md);
|
|
2464
|
+
}
|
|
2465
|
+
catch { }
|
|
2466
|
+
}
|
|
2467
|
+
return m;
|
|
2468
|
+
}
|
|
2469
|
+
/**
|
|
2470
|
+
* During rebuild, msync the durable state accumulated so far.
|
|
2471
|
+
*
|
|
2472
|
+
* Posting lists land in the LSM engine's memtable (drained to durable
|
|
2473
|
+
* SSTables by the step-5 size-flush trigger as the re-index walk proceeds)
|
|
2474
|
+
* and the entity ints land in the mmap id mapper — so the only durable
|
|
2475
|
+
* write to force here is the mapper msync. The value-enumeration field
|
|
2476
|
+
* indexes + the LSM memtable tail are persisted by the final {@link flush}
|
|
2477
|
+
* at the end of the rebuild.
|
|
2478
|
+
*/
|
|
2479
|
+
async flushRebuildDirty() {
|
|
2480
|
+
// #72 Phase C: msync the mmap mapper (no JSON blob to write).
|
|
2481
|
+
this.idMapperNative?.flush();
|
|
2482
|
+
}
|
|
2483
|
+
// ==========================================================================
|
|
2484
|
+
// Clear
|
|
2485
|
+
// ==========================================================================
|
|
2486
|
+
async clearAllIndexData() {
|
|
2487
|
+
prodLog.warn('Clearing ALL metadata index data from storage...');
|
|
2488
|
+
const fields = await this.getPersistedFieldList();
|
|
2489
|
+
for (const field of fields) {
|
|
2490
|
+
await this.deletePersistedFieldIndex(field);
|
|
2491
|
+
}
|
|
2492
|
+
try {
|
|
2493
|
+
await this.storage.saveMetadata('__metadata_field_registry__', null);
|
|
2494
|
+
}
|
|
2495
|
+
catch { }
|
|
2496
|
+
// Clear in-memory index state. #72 Phase C: the shared mmap id-mapper
|
|
2497
|
+
// persists (native.clear() no longer resets it); the rebuild that
|
|
2498
|
+
// follows this repair re-resolves each surviving UUID to its SAME int,
|
|
2499
|
+
// so the reset column store repopulates consistently.
|
|
2500
|
+
this.native.clear();
|
|
2501
|
+
await this.columnStore.reset();
|
|
2502
|
+
this.loadedFields.clear();
|
|
2503
|
+
this.knownFields.clear();
|
|
2504
|
+
this.dirtyFields.clear();
|
|
2505
|
+
this.totalEntitiesByType.clear();
|
|
2506
|
+
this.entityCountsByTypeFixed.fill(0);
|
|
2507
|
+
this.verbCountsByTypeFixed.fill(0);
|
|
2508
|
+
this.typeFieldAffinity.clear();
|
|
2509
|
+
this.unifiedCache.clear('metadata');
|
|
2510
|
+
prodLog.info(`Cleared ${fields.length} field indexes and all in-memory state`);
|
|
2511
|
+
}
|
|
2512
|
+
async getPersistedFieldList() {
|
|
2513
|
+
try {
|
|
2514
|
+
const registry = await this.storage.getMetadata('__metadata_field_registry__');
|
|
2515
|
+
if (!registry?.fields || !Array.isArray(registry.fields))
|
|
2516
|
+
return [];
|
|
2517
|
+
return registry.fields.filter((f) => typeof f === 'string' && f.length > 0);
|
|
2518
|
+
}
|
|
2519
|
+
catch {
|
|
2520
|
+
return [];
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
/**
|
|
2524
|
+
* Delete a field's persisted value-enumeration index
|
|
2525
|
+
* (`__metadata_field_index__field_<field>`) from storage. Called by
|
|
2526
|
+
* {@link rebuild} and {@link clearAllIndexData} to wipe the JSON
|
|
2527
|
+
* value-enumeration surface before re-indexing; the posting lists
|
|
2528
|
+
* themselves are wiped from the LSM engine separately
|
|
2529
|
+
* ({@link reopenLsmEngineFresh}).
|
|
2530
|
+
*/
|
|
2531
|
+
async deletePersistedFieldIndex(field) {
|
|
2532
|
+
try {
|
|
2533
|
+
await this.storage.saveMetadata(`__metadata_field_index__field_${field}`, null);
|
|
2534
|
+
}
|
|
2535
|
+
catch {
|
|
2536
|
+
prodLog.debug(`Could not clear field index for field '${field}'`);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
// ==========================================================================
|
|
2540
|
+
// Field statistics (TS-only analytics)
|
|
2541
|
+
// ==========================================================================
|
|
2542
|
+
async getFieldStatistics() {
|
|
2543
|
+
for (const field of this.knownFields) {
|
|
2544
|
+
if (!this.fieldStats.has(field)) {
|
|
2545
|
+
this.fieldStats.set(field, {
|
|
2546
|
+
cardinality: {
|
|
2547
|
+
uniqueValues: 0,
|
|
2548
|
+
totalValues: 0,
|
|
2549
|
+
distribution: 'uniform',
|
|
2550
|
+
updateFrequency: 0,
|
|
2551
|
+
lastAnalyzed: Date.now()
|
|
2552
|
+
},
|
|
2553
|
+
queryCount: 0,
|
|
2554
|
+
rangeQueryCount: 0,
|
|
2555
|
+
exactQueryCount: 0,
|
|
2556
|
+
avgQueryTime: 0,
|
|
2557
|
+
indexType: 'hash'
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
return new Map(this.fieldStats);
|
|
2562
|
+
}
|
|
2563
|
+
async getFieldCardinality(field) {
|
|
2564
|
+
const stats = this.fieldStats.get(field);
|
|
2565
|
+
return stats ? stats.cardinality : null;
|
|
2566
|
+
}
|
|
2567
|
+
async getFieldsWithCardinality() {
|
|
2568
|
+
const fields = [];
|
|
2569
|
+
for (const [field, stats] of this.fieldStats) {
|
|
2570
|
+
fields.push({
|
|
2571
|
+
field,
|
|
2572
|
+
cardinality: stats.cardinality.uniqueValues,
|
|
2573
|
+
distribution: stats.cardinality.distribution
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
fields.sort((a, b) => a.cardinality - b.cardinality);
|
|
2577
|
+
return fields;
|
|
2578
|
+
}
|
|
2579
|
+
async getOptimalQueryPlan(filters) {
|
|
2580
|
+
const fieldOrder = [];
|
|
2581
|
+
let hasRangeQueries = false;
|
|
2582
|
+
let totalEstimatedCost = 0;
|
|
2583
|
+
for (const [field, value] of Object.entries(filters)) {
|
|
2584
|
+
const stats = this.fieldStats.get(field);
|
|
2585
|
+
if (!stats)
|
|
2586
|
+
continue;
|
|
2587
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2588
|
+
hasRangeQueries = true;
|
|
2589
|
+
}
|
|
2590
|
+
const cardinality = stats.cardinality.uniqueValues;
|
|
2591
|
+
totalEstimatedCost += Math.log2(Math.max(1, cardinality));
|
|
2592
|
+
fieldOrder.push(field);
|
|
2593
|
+
}
|
|
2594
|
+
fieldOrder.sort((a, b) => {
|
|
2595
|
+
const statsA = this.fieldStats.get(a);
|
|
2596
|
+
const statsB = this.fieldStats.get(b);
|
|
2597
|
+
if (!statsA || !statsB)
|
|
2598
|
+
return 0;
|
|
2599
|
+
return statsA.cardinality.uniqueValues - statsB.cardinality.uniqueValues;
|
|
2600
|
+
});
|
|
2601
|
+
return {
|
|
2602
|
+
strategy: hasRangeQueries ? 'hybrid' : 'exact',
|
|
2603
|
+
fieldOrder,
|
|
2604
|
+
estimatedCost: totalEstimatedCost
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
async getFieldsForType(nounType) {
|
|
2608
|
+
const typeFields = this.typeFieldAffinity.get(nounType);
|
|
2609
|
+
const totalEntities = this.totalEntitiesByType.get(nounType);
|
|
2610
|
+
if (!typeFields || !totalEntities)
|
|
2611
|
+
return [];
|
|
2612
|
+
const result = [];
|
|
2613
|
+
for (const [field, count] of typeFields.entries()) {
|
|
2614
|
+
result.push({
|
|
2615
|
+
field,
|
|
2616
|
+
affinity: count / totalEntities,
|
|
2617
|
+
occurrences: count,
|
|
2618
|
+
totalEntities
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
result.sort((a, b) => b.affinity - a.affinity);
|
|
2622
|
+
return result;
|
|
2623
|
+
}
|
|
2624
|
+
// ==========================================================================
|
|
2625
|
+
// Private helpers
|
|
2626
|
+
// ==========================================================================
|
|
2627
|
+
async yieldToEventLoop() {
|
|
2628
|
+
return new Promise(resolve => setImmediate(resolve));
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
//# sourceMappingURL=NativeMetadataIndex.js.map
|