@soulcraft/cor 3.0.14 → 3.0.16
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/dist/graph/NativeGraphAdjacencyIndex.d.ts +7 -0
- package/dist/graph/NativeGraphAdjacencyIndex.js +57 -7
- package/dist/hnsw/NativeDiskAnnWrapper.d.ts +41 -1
- package/dist/hnsw/NativeDiskAnnWrapper.js +251 -2
- package/dist/native/napi.js +21 -1
- package/dist/native/types.d.ts +7 -0
- package/dist/utils/NativeColumnStore.d.ts +6 -0
- package/dist/utils/NativeColumnStore.js +61 -11
- package/dist/utils/NativeMetadataIndex.d.ts +30 -0
- package/dist/utils/NativeMetadataIndex.js +297 -21
- package/dist/utils/binaryIdMapperFactory.js +33 -3
- package/dist/utils/derivedFamilies.d.ts +48 -0
- package/dist/utils/derivedFamilies.js +128 -0
- package/dist/utils/invariantReport.d.ts +29 -0
- package/dist/utils/invariantReport.js +62 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.js +15 -0
- package/native/brainy-native.node +0 -0
- package/native/index.d.ts +23 -1
- package/package.json +2 -2
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
* collation contract (code-point order, matching `compareCodePoints`).
|
|
32
32
|
*/
|
|
33
33
|
import { existsSync } from 'node:fs';
|
|
34
|
+
import { composeInvariantReport } from './invariantReport.js';
|
|
35
|
+
import { declareDerivedFamilies, withFamilyRetirement, COLUMN_FAMILIES } from './derivedFamilies.js';
|
|
34
36
|
import { prodLog } from '@soulcraft/brainy/internals';
|
|
35
37
|
import { ValueType } from './columnStoreTypes.js';
|
|
36
38
|
import { ColumnManifest } from './ColumnManifest.js';
|
|
@@ -145,6 +147,10 @@ export class NativeColumnStore {
|
|
|
145
147
|
'Use brainy FileSystemStorage or another adapter implementing BinaryBlobStorage.');
|
|
146
148
|
}
|
|
147
149
|
this.storage = storage;
|
|
150
|
+
// ADR-004 §7: declare the column family (feature-detected ≥8.3.0) —
|
|
151
|
+
// the one family whose members are written via saveBinaryBlob, so the
|
|
152
|
+
// storage layer's delete-refusal actively bites here.
|
|
153
|
+
await declareDerivedFamilies(storage, COLUMN_FAMILIES);
|
|
148
154
|
// Discover fields by listing manifest files under the column-index prefix.
|
|
149
155
|
let paths;
|
|
150
156
|
try {
|
|
@@ -241,6 +247,43 @@ export class NativeColumnStore {
|
|
|
241
247
|
* would have been invisible.
|
|
242
248
|
* @param field - Field about to be served.
|
|
243
249
|
*/
|
|
250
|
+
/**
|
|
251
|
+
* ADR-004 §6 — the column store's invariant self-report. NEVER throws;
|
|
252
|
+
* bounded (set sizes + per-field segment counts, no IO).
|
|
253
|
+
*/
|
|
254
|
+
async validateInvariants() {
|
|
255
|
+
return composeInvariantReport('column', () => this.storage !== null && this.faultedFields.size === 0, [
|
|
256
|
+
() => ({
|
|
257
|
+
name: 'fields-servable',
|
|
258
|
+
holds: this.faultedFields.size === 0,
|
|
259
|
+
detail: this.faultedFields.size === 0
|
|
260
|
+
? `${this.fieldTypes.size} field(s), none faulted`
|
|
261
|
+
: `${this.faultedFields.size} field(s) UNAVAILABLE after faulted ` +
|
|
262
|
+
`loads (queries throw): ${[...this.faultedFields].join(', ')}`,
|
|
263
|
+
expected: this.fieldTypes.size,
|
|
264
|
+
actual: this.fieldTypes.size - this.faultedFields.size,
|
|
265
|
+
heal: 'rebuild',
|
|
266
|
+
}),
|
|
267
|
+
() => {
|
|
268
|
+
// manifest-residency: every field's manifest-listed segments are
|
|
269
|
+
// attached in the native engine (hasField is the cheap proxy for
|
|
270
|
+
// a field whose segments loaded; per-segment ids are not exposed
|
|
271
|
+
// by the native surface, so the count-level check is the honest
|
|
272
|
+
// bounded version — a faulted segment already trips
|
|
273
|
+
// fields-servable above).
|
|
274
|
+
const unattached = [...this.manifests.keys()].filter((field) => !this.faultedFields.has(field) && !this.native.hasField(field));
|
|
275
|
+
return {
|
|
276
|
+
name: 'manifest-residency',
|
|
277
|
+
holds: unattached.length === 0,
|
|
278
|
+
detail: unattached.length === 0
|
|
279
|
+
? `${this.manifests.size} field manifest(s), all attached`
|
|
280
|
+
: `${unattached.length} field(s) have persisted manifests but no ` +
|
|
281
|
+
`attached native state: ${unattached.join(', ')}`,
|
|
282
|
+
heal: 'rebuild',
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
244
287
|
assertFieldServable(field) {
|
|
245
288
|
if (this.faultedFields.has(field)) {
|
|
246
289
|
throw new Error(`NativeColumnStore: column index for field '${field}' is UNAVAILABLE — ` +
|
|
@@ -464,18 +507,25 @@ export class NativeColumnStore {
|
|
|
464
507
|
// Delete persisted column-index files (manifests + segment/deleted blobs)
|
|
465
508
|
// before dropping the in-memory manifests we need to locate them.
|
|
466
509
|
if (this.storage) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
510
|
+
// Owner-sanctioned retirement (ADR-004 §7): this reset legitimately
|
|
511
|
+
// deletes the family's own members (a rebuild reassigns entity ints,
|
|
512
|
+
// invalidating old segments), so the protection window opens and
|
|
513
|
+
// closes around exactly this loop.
|
|
514
|
+
const storage = this.storage;
|
|
515
|
+
await withFamilyRetirement(storage, COLUMN_FAMILIES[0], async () => {
|
|
516
|
+
for (const [field, manifest] of this.manifests) {
|
|
517
|
+
try {
|
|
518
|
+
await storage.deleteObjectFromPath(manifest.manifestPath());
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
/* manifest already gone */
|
|
522
|
+
}
|
|
523
|
+
for (const seg of manifest.getAllSegments()) {
|
|
524
|
+
await storage.deleteBinaryBlob(segmentBlobKey(field, seg.level, seg.id));
|
|
525
|
+
}
|
|
526
|
+
await storage.deleteBinaryBlob(deletedBlobKey(field));
|
|
476
527
|
}
|
|
477
|
-
|
|
478
|
-
}
|
|
528
|
+
});
|
|
479
529
|
}
|
|
480
530
|
// Drop all native state by replacing the engine, then clear TS-side state.
|
|
481
531
|
// Faults clear too: the rebuild that follows repopulates every field
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { StorageAdapter } from '@soulcraft/brainy';
|
|
19
19
|
import { NounType, VerbType } from '@soulcraft/brainy/types/graphTypes';
|
|
20
|
+
import type { ProviderInvariantReport } from '@soulcraft/brainy';
|
|
20
21
|
import { NativeColumnStore } from './NativeColumnStore.js';
|
|
21
22
|
import type { EntityIdMapperLike } from './columnStoreTypes.js';
|
|
22
23
|
import type { MetadataIndexProvider } from '../providerContracts.js';
|
|
@@ -115,6 +116,14 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
|
|
|
115
116
|
* everything from canonical. Cleared by a completed {@link rebuild}.
|
|
116
117
|
*/
|
|
117
118
|
private strandDetected;
|
|
119
|
+
/**
|
|
120
|
+
* SSTable ids actually REGISTERED into the live engine (cold-open
|
|
121
|
+
* registration + post-flush registration). The `manifest-residency`
|
|
122
|
+
* invariant (ADR-004 §6) compares the persisted manifest's claims
|
|
123
|
+
* against this set — the manifest is a claim; registration is the truth.
|
|
124
|
+
* Cleared on the rebuild's fresh re-open.
|
|
125
|
+
*/
|
|
126
|
+
private lsmRegisteredSstableIds;
|
|
118
127
|
/**
|
|
119
128
|
* **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
|
|
120
129
|
* auto-migrate enabled) and read once at the END of {@link init}, which kicks the
|
|
@@ -803,7 +812,28 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
|
|
|
803
812
|
getAllVFSEntityCounts(): Promise<Map<string, number>>;
|
|
804
813
|
getTotalVFSEntityCount(): Promise<number>;
|
|
805
814
|
getCountForCriteria(field: string, value: any): Promise<number>;
|
|
815
|
+
/**
|
|
816
|
+
* ADR-004 §6 — the provider's self-report of its cross-layer invariants.
|
|
817
|
+
* NEVER throws; bounded (residency + O(1) count reads, one limit-1
|
|
818
|
+
* canonical probe); safe on a live brain. Brainy's
|
|
819
|
+
* `validateIndexConsistency` feature-detects and aggregates this;
|
|
820
|
+
* `repairIndex` maps `heal: 'rebuild'` failures to {@link rebuild}.
|
|
821
|
+
*/
|
|
822
|
+
validateInvariants(): Promise<ProviderInvariantReport>;
|
|
823
|
+
/**
|
|
824
|
+
* Serializes every {@link flush} pass. Two flushes interleaving at the
|
|
825
|
+
* awaits is NOT hypothetical — memory-vm runs a periodic flusher AND
|
|
826
|
+
* brainy's shutdown hook flushes on SIGTERM — and the flushLsm pair
|
|
827
|
+
* both read `nextSstableId(manifest)` before either pushes: SAME id →
|
|
828
|
+
* same blob key → the second write OVERWRITES the first SSTable →
|
|
829
|
+
* postings lost with clean logs and NO replay anomaly (the exact
|
|
830
|
+
* acceptance-#1 signature on CORTEX-RESTART-STRAND, 2026-07-13).
|
|
831
|
+
* Chaining gives each caller its own full pass covering everything
|
|
832
|
+
* written before its call — the semantics a shutdown flush needs.
|
|
833
|
+
*/
|
|
834
|
+
private flushChain;
|
|
806
835
|
flush(): Promise<void>;
|
|
836
|
+
private flushOnce;
|
|
807
837
|
/**
|
|
808
838
|
* Persist all state and release the column store's resources. Brainy's
|
|
809
839
|
* shutdown path calls `metadataIndex.close()` when present; flushing here
|
|
@@ -34,6 +34,8 @@ const BUCKETED_INDEX_FIELDS = new Set([
|
|
|
34
34
|
]);
|
|
35
35
|
import { TypeUtils, NOUN_TYPE_COUNT, VERB_TYPE_COUNT } from '@soulcraft/brainy/types/graphTypes';
|
|
36
36
|
import { loadNativeModule } from '../native/index.js';
|
|
37
|
+
import { composeInvariantReport } from './invariantReport.js';
|
|
38
|
+
import { declareDerivedFamilies, METADATA_FAMILIES } from './derivedFamilies.js';
|
|
37
39
|
import { openOrCreateBinaryIdMapper } from './binaryIdMapperFactory.js';
|
|
38
40
|
import { NativeColumnStore } from './NativeColumnStore.js';
|
|
39
41
|
import { ResourceManager } from '../resource/ResourceManager.js';
|
|
@@ -189,6 +191,14 @@ export class MetadataIndexManager {
|
|
|
189
191
|
* everything from canonical. Cleared by a completed {@link rebuild}.
|
|
190
192
|
*/
|
|
191
193
|
strandDetected = false;
|
|
194
|
+
/**
|
|
195
|
+
* SSTable ids actually REGISTERED into the live engine (cold-open
|
|
196
|
+
* registration + post-flush registration). The `manifest-residency`
|
|
197
|
+
* invariant (ADR-004 §6) compares the persisted manifest's claims
|
|
198
|
+
* against this set — the manifest is a claim; registration is the truth.
|
|
199
|
+
* Cleared on the rebuild's fresh re-open.
|
|
200
|
+
*/
|
|
201
|
+
lsmRegisteredSstableIds = new Set();
|
|
192
202
|
/**
|
|
193
203
|
* **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
|
|
194
204
|
* auto-migrate enabled) and read once at the END of {@link init}, which kicks the
|
|
@@ -485,6 +495,7 @@ export class MetadataIndexManager {
|
|
|
485
495
|
throw new Error(`openLsmEngine: getBinaryBlobPath returned null for persisted SSTable '${key}'`);
|
|
486
496
|
}
|
|
487
497
|
engine.registerSstablePath(path);
|
|
498
|
+
this.lsmRegisteredSstableIds.add(entry.id);
|
|
488
499
|
}
|
|
489
500
|
}
|
|
490
501
|
// Cold-open replay forensics (CORTEX-RESTART-STRAND). The engine's
|
|
@@ -699,6 +710,7 @@ export class MetadataIndexManager {
|
|
|
699
710
|
});
|
|
700
711
|
this.lsmEngine = engine;
|
|
701
712
|
this.lsmManifest = newEmptyLsmManifest();
|
|
713
|
+
this.lsmRegisteredSstableIds.clear();
|
|
702
714
|
this.native.setLsmEngine(engine);
|
|
703
715
|
// Re-apply the current admission ceiling immediately (the subscriber
|
|
704
716
|
// callback only refreshes it on the next rebalance/pressure tick). The
|
|
@@ -783,6 +795,7 @@ export class MetadataIndexManager {
|
|
|
783
795
|
throw new Error(`flushLsm: getBinaryBlobPath returned null for '${key}'`);
|
|
784
796
|
}
|
|
785
797
|
engine.registerSstablePath(path);
|
|
798
|
+
this.lsmRegisteredSstableIds.add(id);
|
|
786
799
|
// (4) Retire the memtable log below the flushed epoch. The `.sst` bytes
|
|
787
800
|
// AND the manifest referencing them are already durable here, so a failure
|
|
788
801
|
// at THIS step is non-fatal per the ordering invariant above: the records
|
|
@@ -1030,15 +1043,29 @@ export class MetadataIndexManager {
|
|
|
1030
1043
|
getUuid: (intId) => native.intToUuid(intId) ?? undefined,
|
|
1031
1044
|
getInt: (uuid) => native.uuidToInt(uuid) ?? undefined,
|
|
1032
1045
|
getOrAssign: (uuid) => {
|
|
1033
|
-
// The native index assigns ids inside addToIndex
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1046
|
+
// The native index assigns ids inside addToIndex; a mapped UUID
|
|
1047
|
+
// resolves to its existing int. An UNMAPPED uuid here is a
|
|
1048
|
+
// canonical-committed entity whose index ops were lost (the
|
|
1049
|
+
// restart-strand class — memory's boot heal hit exactly this,
|
|
1050
|
+
// CORTEX-RESTART-STRAND 2026-07-13): the cold vector rebuild
|
|
1051
|
+
// walks canonical and must be able to MINT the id through the
|
|
1052
|
+
// SAME allocator addToIndex uses (durable via the mapper's delta
|
|
1053
|
+
// log; a later addToIndex reuses it) instead of aborting the
|
|
1054
|
+
// whole heal. The entity's missing metadata postings stay
|
|
1055
|
+
// visible to posted-count-floor — this masks nothing.
|
|
1037
1056
|
const existing = native.uuidToInt(uuid);
|
|
1038
1057
|
if (existing !== null)
|
|
1039
1058
|
return existing;
|
|
1040
|
-
|
|
1041
|
-
`
|
|
1059
|
+
if (typeof native.getOrAssignInt === 'function') {
|
|
1060
|
+
prodLog.warn(`EntityIdMapper.getOrAssign: '${uuid}' had no native id at resolve ` +
|
|
1061
|
+
`time (canonical-committed entity with lost index ops) — assigned ` +
|
|
1062
|
+
`through the canonical allocator; its metadata postings heal via ` +
|
|
1063
|
+
`the posted-count invariant / rebuild.`);
|
|
1064
|
+
return Number(native.getOrAssignInt(uuid));
|
|
1065
|
+
}
|
|
1066
|
+
throw new Error(`EntityIdMapper.getOrAssign: '${uuid}' has no native id yet and the ` +
|
|
1067
|
+
`loaded native binary predates 3.0.16's getOrAssignInt — upgrade ` +
|
|
1068
|
+
`the paired .node build.`);
|
|
1042
1069
|
},
|
|
1043
1070
|
intsIterableToUuids: (ints) => native.intsToUuids([...ints]),
|
|
1044
1071
|
// Brainy 8.0 `restore()` calls this BEFORE the metadata/vector/graph
|
|
@@ -1073,6 +1100,9 @@ export class MetadataIndexManager {
|
|
|
1073
1100
|
// UUID ↔ entity-int allocator. Must run before columnStore.init (which
|
|
1074
1101
|
// resolves ids through getIdMapper()) and before any mutation.
|
|
1075
1102
|
this.wireIdMapper();
|
|
1103
|
+
// ADR-004 §7: declare this provider's protected families (feature-
|
|
1104
|
+
// detected on brainy ≥8.3.0; idempotent upsert; loud-but-non-blocking).
|
|
1105
|
+
await declareDerivedFamilies(this.storage, METADATA_FAMILIES);
|
|
1076
1106
|
// LSM durability (steps 3-4): open the native LSM engine here (not in the
|
|
1077
1107
|
// constructor) so it can be seeded from the on-disk manifest and cold-load
|
|
1078
1108
|
// every persisted SSTable into its live read set — the entire restore,
|
|
@@ -1114,6 +1144,7 @@ export class MetadataIndexManager {
|
|
|
1114
1144
|
prodLog.info('[NativeMetadataIndex] Field registry missing but the LSM engine ' +
|
|
1115
1145
|
'recovered durable state (SSTables/memtable) — serving from it, ' +
|
|
1116
1146
|
'NO rebuild.');
|
|
1147
|
+
this.readinessInitialized = true;
|
|
1117
1148
|
return;
|
|
1118
1149
|
}
|
|
1119
1150
|
// **Tripwire — a rebuild here on a PREVIOUSLY-HEALTHY brain is a bug,
|
|
@@ -1142,11 +1173,21 @@ export class MetadataIndexManager {
|
|
|
1142
1173
|
}
|
|
1143
1174
|
console.warn(`[NativeMetadataIndex] Field registry missing but ${probe.totalCount ?? 'unknown'} entities exist — rebuilding index`);
|
|
1144
1175
|
await this.rebuild();
|
|
1176
|
+
// A completed rebuild IS the readiness determination for this
|
|
1177
|
+
// open (sweep finding: these early exits previously left
|
|
1178
|
+
// readinessInitialized false — a fresh-rebuilt brain reported
|
|
1179
|
+
// not-ready forever, and brainy's gate re-rebuilt every boot).
|
|
1180
|
+
this.readinessInitialized = true;
|
|
1145
1181
|
return;
|
|
1146
1182
|
}
|
|
1183
|
+
// Probe says genuinely empty — an empty brain is READY by contract.
|
|
1184
|
+
this.readinessInitialized = true;
|
|
1147
1185
|
}
|
|
1148
1186
|
catch {
|
|
1149
|
-
// Storage probe failed —
|
|
1187
|
+
// Storage probe failed — treated as empty per current semantics;
|
|
1188
|
+
// readiness stays UNINITIALIZED (conservative: a faulted probe must
|
|
1189
|
+
// not certify an unverified brain as ready — the fault-vs-absent
|
|
1190
|
+
// discrimination for this probe is tracked ADR-004 §4 M-work).
|
|
1150
1191
|
}
|
|
1151
1192
|
return;
|
|
1152
1193
|
}
|
|
@@ -1946,6 +1987,14 @@ export class MetadataIndexManager {
|
|
|
1946
1987
|
const resultJson = this.native.removeFromIndex(id);
|
|
1947
1988
|
const result = JSON.parse(resultJson);
|
|
1948
1989
|
await this.persistMutationResult(result);
|
|
1990
|
+
// Count symmetry (sweep finding; ADR-004 anti-pattern C): this path
|
|
1991
|
+
// has no metadata to know WHICH type to decrement, and skipping the
|
|
1992
|
+
// decrement permanently inflated totalEntitiesByType /
|
|
1993
|
+
// entityCountsByTypeFixed until restart. Instead of guessing,
|
|
1994
|
+
// RECOMPUTE the counters from native ground truth — bounded
|
|
1995
|
+
// (per-type O(1) bitmap-cardinality reads, no id materialization).
|
|
1996
|
+
await this.lazyLoadCounts();
|
|
1997
|
+
this.syncTypeCountsToFixed();
|
|
1949
1998
|
}
|
|
1950
1999
|
// Tombstone in the column store so the entity drops out of sort/filter/range.
|
|
1951
2000
|
if (intId !== null) {
|
|
@@ -2278,7 +2327,195 @@ export class MetadataIndexManager {
|
|
|
2278
2327
|
// ==========================================================================
|
|
2279
2328
|
// Flush
|
|
2280
2329
|
// ==========================================================================
|
|
2330
|
+
/**
|
|
2331
|
+
* ADR-004 §6 — the provider's self-report of its cross-layer invariants.
|
|
2332
|
+
* NEVER throws; bounded (residency + O(1) count reads, one limit-1
|
|
2333
|
+
* canonical probe); safe on a live brain. Brainy's
|
|
2334
|
+
* `validateIndexConsistency` feature-detects and aggregates this;
|
|
2335
|
+
* `repairIndex` maps `heal: 'rebuild'` failures to {@link rebuild}.
|
|
2336
|
+
*/
|
|
2337
|
+
async validateInvariants() {
|
|
2338
|
+
return composeInvariantReport('metadata', () => this.isReady(), [
|
|
2339
|
+
// manifest-residency: every persisted-manifest-named SSTable is
|
|
2340
|
+
// actually registered in the live engine.
|
|
2341
|
+
() => {
|
|
2342
|
+
const manifest = this.lsmManifest;
|
|
2343
|
+
if (manifest === null || this.lsmMetaDir === null) {
|
|
2344
|
+
return {
|
|
2345
|
+
name: 'manifest-residency',
|
|
2346
|
+
holds: true,
|
|
2347
|
+
detail: 'in-RAM engine or fresh brain — no persisted SSTables to verify',
|
|
2348
|
+
heal: 'none',
|
|
2349
|
+
};
|
|
2350
|
+
}
|
|
2351
|
+
const missing = manifest.sstables
|
|
2352
|
+
.map((e) => e.id)
|
|
2353
|
+
.filter((id) => !this.lsmRegisteredSstableIds.has(id));
|
|
2354
|
+
return {
|
|
2355
|
+
name: 'manifest-residency',
|
|
2356
|
+
holds: missing.length === 0,
|
|
2357
|
+
detail: missing.length === 0
|
|
2358
|
+
? `${manifest.sstables.length} manifest-named SSTable(s), all registered`
|
|
2359
|
+
: `manifest names ${manifest.sstables.length} SSTable(s) but ` +
|
|
2360
|
+
`${missing.length} never registered (ids: ${missing.join(', ')}) — ` +
|
|
2361
|
+
`the index serves a subset of its durable postings`,
|
|
2362
|
+
expected: manifest.sstables.length,
|
|
2363
|
+
actual: manifest.sstables.length - missing.length,
|
|
2364
|
+
heal: 'rebuild',
|
|
2365
|
+
};
|
|
2366
|
+
},
|
|
2367
|
+
// posted-count-floor: Σ posted noun counts ≥ canonical noun count.
|
|
2368
|
+
async () => {
|
|
2369
|
+
const getNouns = this.storage.getNouns;
|
|
2370
|
+
if (typeof getNouns !== 'function') {
|
|
2371
|
+
return {
|
|
2372
|
+
name: 'posted-count-floor',
|
|
2373
|
+
holds: true,
|
|
2374
|
+
detail: 'adapter exposes no getNouns — floor unverifiable, skipped',
|
|
2375
|
+
heal: 'none',
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
let canonical;
|
|
2379
|
+
try {
|
|
2380
|
+
const probe = await getNouns.call(this.storage, { pagination: { limit: 1 } });
|
|
2381
|
+
canonical = probe?.totalCount;
|
|
2382
|
+
}
|
|
2383
|
+
catch (error) {
|
|
2384
|
+
return {
|
|
2385
|
+
name: 'posted-count-floor',
|
|
2386
|
+
holds: false,
|
|
2387
|
+
detail: `canonical count probe FAULTED — divergence unverifiable: ${String(error)}`,
|
|
2388
|
+
heal: 'none',
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2391
|
+
if (typeof canonical !== 'number' || !Number.isFinite(canonical) || canonical >= 0xffff_ffff) {
|
|
2392
|
+
return {
|
|
2393
|
+
name: 'posted-count-floor',
|
|
2394
|
+
holds: true,
|
|
2395
|
+
detail: `canonical count not exactly countable (${String(canonical)}) — floor skipped by design`,
|
|
2396
|
+
heal: 'none',
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
const { NounType } = await import('@soulcraft/brainy');
|
|
2400
|
+
let posted = 0;
|
|
2401
|
+
for (const t of Object.values(NounType)) {
|
|
2402
|
+
posted += this.native.getEntityCountByType(t);
|
|
2403
|
+
}
|
|
2404
|
+
return {
|
|
2405
|
+
name: 'posted-count-floor',
|
|
2406
|
+
holds: posted >= canonical,
|
|
2407
|
+
detail: posted >= canonical
|
|
2408
|
+
? `posted ${posted} ≥ canonical ${canonical}`
|
|
2409
|
+
: `canonical holds ${canonical} entities but only ${posted} are ` +
|
|
2410
|
+
`posted — ${canonical - posted} durable-but-unqueryable`,
|
|
2411
|
+
expected: canonical,
|
|
2412
|
+
actual: posted,
|
|
2413
|
+
heal: 'rebuild',
|
|
2414
|
+
};
|
|
2415
|
+
},
|
|
2416
|
+
// replay-clean: the cold open recovered nothing anomalous.
|
|
2417
|
+
() => {
|
|
2418
|
+
const engine = this.lsmEngine;
|
|
2419
|
+
if (!engine || typeof engine.replayStatsJson !== 'function') {
|
|
2420
|
+
return {
|
|
2421
|
+
name: 'replay-clean',
|
|
2422
|
+
holds: true,
|
|
2423
|
+
detail: 'replay stats unavailable (in-RAM engine or pre-3.0.12 binary)',
|
|
2424
|
+
heal: 'none',
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
const s = JSON.parse(engine.replayStatsJson());
|
|
2428
|
+
const unreadable = s.archivesUnreadable ?? 0;
|
|
2429
|
+
const recreated = s.liveLogsRecreated ?? 0;
|
|
2430
|
+
const clean = unreadable === 0 && recreated === 0;
|
|
2431
|
+
return {
|
|
2432
|
+
name: 'replay-clean',
|
|
2433
|
+
holds: clean,
|
|
2434
|
+
detail: clean
|
|
2435
|
+
? 'cold-open replay clean (no unreadable archives, no recreated live logs)'
|
|
2436
|
+
: `cold-open replay anomalies: ${recreated} live log(s) were missing ` +
|
|
2437
|
+
`(recreated), ${unreadable} archive(s) unreadable`,
|
|
2438
|
+
heal: 'rebuild',
|
|
2439
|
+
};
|
|
2440
|
+
},
|
|
2441
|
+
// registry-discoverable: every field this process knows is in the
|
|
2442
|
+
// persisted registry a cold open would discover.
|
|
2443
|
+
async () => {
|
|
2444
|
+
if (this.knownFields.size === 0) {
|
|
2445
|
+
return {
|
|
2446
|
+
name: 'registry-discoverable',
|
|
2447
|
+
holds: true,
|
|
2448
|
+
detail: 'no fields registered yet',
|
|
2449
|
+
heal: 'none',
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
let persisted = [];
|
|
2453
|
+
try {
|
|
2454
|
+
const raw = await this.storage.getMetadata('__metadata_field_registry__');
|
|
2455
|
+
persisted = Array.isArray(raw?.fields)
|
|
2456
|
+
? (raw.fields)
|
|
2457
|
+
: Array.isArray(raw)
|
|
2458
|
+
? raw
|
|
2459
|
+
: [];
|
|
2460
|
+
}
|
|
2461
|
+
catch (error) {
|
|
2462
|
+
return {
|
|
2463
|
+
name: 'registry-discoverable',
|
|
2464
|
+
holds: false,
|
|
2465
|
+
detail: `persisted registry read FAULTED — discoverability unverifiable: ${String(error)}`,
|
|
2466
|
+
heal: 'none',
|
|
2467
|
+
};
|
|
2468
|
+
}
|
|
2469
|
+
const persistedSet = new Set(persisted);
|
|
2470
|
+
const undiscoverable = [...this.knownFields].filter((f) => !persistedSet.has(f));
|
|
2471
|
+
return {
|
|
2472
|
+
name: 'registry-discoverable',
|
|
2473
|
+
holds: undiscoverable.length === 0,
|
|
2474
|
+
detail: undiscoverable.length === 0
|
|
2475
|
+
? `${this.knownFields.size} known field(s), all in the persisted registry`
|
|
2476
|
+
: `${undiscoverable.length} known field(s) missing from the persisted ` +
|
|
2477
|
+
`registry (would be undiscoverable after restart): ${undiscoverable.join(', ')}`,
|
|
2478
|
+
expected: this.knownFields.size,
|
|
2479
|
+
actual: this.knownFields.size - undiscoverable.length,
|
|
2480
|
+
heal: 'repair',
|
|
2481
|
+
};
|
|
2482
|
+
},
|
|
2483
|
+
]);
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* Serializes every {@link flush} pass. Two flushes interleaving at the
|
|
2487
|
+
* awaits is NOT hypothetical — memory-vm runs a periodic flusher AND
|
|
2488
|
+
* brainy's shutdown hook flushes on SIGTERM — and the flushLsm pair
|
|
2489
|
+
* both read `nextSstableId(manifest)` before either pushes: SAME id →
|
|
2490
|
+
* same blob key → the second write OVERWRITES the first SSTable →
|
|
2491
|
+
* postings lost with clean logs and NO replay anomaly (the exact
|
|
2492
|
+
* acceptance-#1 signature on CORTEX-RESTART-STRAND, 2026-07-13).
|
|
2493
|
+
* Chaining gives each caller its own full pass covering everything
|
|
2494
|
+
* written before its call — the semantics a shutdown flush needs.
|
|
2495
|
+
*/
|
|
2496
|
+
flushChain = Promise.resolve();
|
|
2281
2497
|
async flush() {
|
|
2498
|
+
// Flush-during-rebuild guard (CORTEX-RESTART-STRAND, memory-vm's
|
|
2499
|
+
// 80-minute heal): a consumer's periodic flush cycle running DURING a
|
|
2500
|
+
// rebuild drains the growing memtable + rewrites field indexes on
|
|
2501
|
+
// every pass — O(current-index-size) IO per pass, interleaved with the
|
|
2502
|
+
// walk (measured live: 572 msync/s + escalating 29s→45s flush passes).
|
|
2503
|
+
// The rebuild's own completion flush owns durability (canonical is the
|
|
2504
|
+
// source of truth throughout), so a concurrent flush only multiplies
|
|
2505
|
+
// IO. Loud no-op, never silent.
|
|
2506
|
+
if (this.isRebuilding) {
|
|
2507
|
+
prodLog.warn('[NativeMetadataIndex] flush() during an active rebuild — deferred ' +
|
|
2508
|
+
'(the rebuild\'s completion flush owns durability; a concurrent ' +
|
|
2509
|
+
'flush would multiply heal IO).');
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
const run = this.flushChain.then(() => this.flushOnce());
|
|
2513
|
+
// Keep the chain alive on failure — the NEXT flush must still run; the
|
|
2514
|
+
// failure itself propagates to THIS caller below.
|
|
2515
|
+
this.flushChain = run.catch(() => { });
|
|
2516
|
+
return run;
|
|
2517
|
+
}
|
|
2518
|
+
async flushOnce() {
|
|
2282
2519
|
// Always save field registry + msync the entity ID mapper — even with
|
|
2283
2520
|
// no dirty fields. The field registry is the critical file init() needs
|
|
2284
2521
|
// to discover persisted indices. Without it, the metadata index appears
|
|
@@ -2679,7 +2916,10 @@ export class MetadataIndexManager {
|
|
|
2679
2916
|
if (bulkCapable)
|
|
2680
2917
|
this.lsmEngine.setBulkLoad(false);
|
|
2681
2918
|
}
|
|
2682
|
-
// Final flush
|
|
2919
|
+
// Final flush. isRebuilding drops FIRST so the completion flush
|
|
2920
|
+
// passes the flush-during-rebuild guard above; readiness is still
|
|
2921
|
+
// gated by strandDetected until the flush lands and clears it below.
|
|
2922
|
+
this.isRebuilding = false;
|
|
2683
2923
|
await this.flushRebuildDirty();
|
|
2684
2924
|
await this.flush();
|
|
2685
2925
|
prodLog.info(`Metadata index rebuild completed! Processed ${totalNounsProcessed} nouns and ${totalVerbsProcessed} verbs`);
|
|
@@ -2710,13 +2950,27 @@ export class MetadataIndexManager {
|
|
|
2710
2950
|
let processed = 0;
|
|
2711
2951
|
let sinceFlush = 0;
|
|
2712
2952
|
let noMetadata = 0;
|
|
2953
|
+
// Heal observability (CORTEX-RESTART-STRAND, memory's ask): a rebuild
|
|
2954
|
+
// must never be silent for minutes — the operator cannot distinguish
|
|
2955
|
+
// converging from stuck. Every 500 posted entities logs progress with
|
|
2956
|
+
// the sustained rate, so a pathological per-item cost names itself in
|
|
2957
|
+
// the journal in real time.
|
|
2958
|
+
const startedAt = Date.now();
|
|
2959
|
+
let lastProgressAt = 0;
|
|
2960
|
+
const fetchPage = (c) => kind === 'noun'
|
|
2961
|
+
? this.storage.getNouns({ pagination: { limit: PAGE, cursor: c } })
|
|
2962
|
+
: this.storage.getVerbs({ pagination: { limit: PAGE, cursor: c } });
|
|
2963
|
+
// Prefetch pipeline: the NEXT canonical page is fetched while the
|
|
2964
|
+
// CURRENT page indexes, so adapter enumeration latency overlaps the
|
|
2965
|
+
// RAM-speed posting work instead of adding to it.
|
|
2966
|
+
let pending = fetchPage(cursor);
|
|
2713
2967
|
while (hasMore) {
|
|
2714
|
-
const page =
|
|
2715
|
-
? await this.storage.getNouns({ pagination: { limit: PAGE, cursor } })
|
|
2716
|
-
: await this.storage.getVerbs({ pagination: { limit: PAGE, cursor } });
|
|
2968
|
+
const page = await pending;
|
|
2717
2969
|
const items = page.items;
|
|
2718
2970
|
if (items.length === 0)
|
|
2719
2971
|
break; // empty page = done (defensive backstop)
|
|
2972
|
+
if (page.hasMore)
|
|
2973
|
+
pending = fetchPage(page.nextCursor);
|
|
2720
2974
|
const ids = items.map((it) => it.id);
|
|
2721
2975
|
const md = await this.loadRebuildMetadataBatch(kind, ids);
|
|
2722
2976
|
for (const it of items) {
|
|
@@ -2733,6 +2987,12 @@ export class MetadataIndexManager {
|
|
|
2733
2987
|
if (metadata) {
|
|
2734
2988
|
await this.addToIndex(it.id, metadata, true, true);
|
|
2735
2989
|
processed++;
|
|
2990
|
+
if (processed - lastProgressAt >= 500) {
|
|
2991
|
+
lastProgressAt = processed;
|
|
2992
|
+
const elapsedS = (Date.now() - startedAt) / 1000;
|
|
2993
|
+
prodLog.info(`[NativeMetadataIndex] rebuild(${kind}): ${processed} posted ` +
|
|
2994
|
+
`in ${elapsedS.toFixed(1)}s (${(processed / Math.max(elapsedS, 0.001)).toFixed(0)}/s)`);
|
|
2995
|
+
}
|
|
2736
2996
|
if (++sinceFlush >= 5000) {
|
|
2737
2997
|
await this.flushRebuildDirty();
|
|
2738
2998
|
sinceFlush = 0;
|
|
@@ -2743,9 +3003,12 @@ export class MetadataIndexManager {
|
|
|
2743
3003
|
}
|
|
2744
3004
|
}
|
|
2745
3005
|
hasMore = page.hasMore;
|
|
2746
|
-
cursor = page.nextCursor;
|
|
2747
3006
|
await this.yieldToEventLoop();
|
|
2748
3007
|
}
|
|
3008
|
+
const totalS = (Date.now() - startedAt) / 1000;
|
|
3009
|
+
prodLog.info(`[NativeMetadataIndex] rebuild(${kind}): COMPLETE — ${processed} posted ` +
|
|
3010
|
+
`in ${totalS.toFixed(1)}s (${(processed / Math.max(totalS, 0.001)).toFixed(0)}/s)` +
|
|
3011
|
+
(noMetadata > 0 ? `, ${noMetadata} without metadata` : ''));
|
|
2749
3012
|
if (noMetadata > 0) {
|
|
2750
3013
|
prodLog.warn(`[NativeMetadataIndex] rebuild(${kind}): ${noMetadata} enumerated ` +
|
|
2751
3014
|
`entit${noMetadata === 1 ? 'y' : 'ies'} carried no canonical ` +
|
|
@@ -2793,16 +3056,29 @@ export class MetadataIndexManager {
|
|
|
2793
3056
|
else if (this.storage.getVerbMetadataBatch) {
|
|
2794
3057
|
return this.storage.getVerbMetadataBatch(ids);
|
|
2795
3058
|
}
|
|
3059
|
+
// Bounded-concurrency reads (16-way). The reads are independent, and a
|
|
3060
|
+
// SERIAL per-id walk multiplies the adapter's per-op latency by N — on
|
|
3061
|
+
// a slow adapter (memory-vm measured ~1s/op shapes) that turned a
|
|
3062
|
+
// seconds-long heal into ~1s/entity (CORTEX-RESTART-STRAND, the
|
|
3063
|
+
// 52-minute heal). Concurrency divides heal time by ~16 on ANY
|
|
3064
|
+
// adapter; fail-loud is preserved (a faulted read rejects the whole
|
|
3065
|
+
// batch via Promise.all — readRebuildMetadataOrThrow retries once then
|
|
3066
|
+
// throws, never silently excludes).
|
|
2796
3067
|
const m = new Map();
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
3068
|
+
const CONCURRENCY = 16;
|
|
3069
|
+
let next = 0;
|
|
3070
|
+
const workers = Array.from({ length: Math.min(CONCURRENCY, ids.length) }, async () => {
|
|
3071
|
+
for (;;) {
|
|
3072
|
+
const i = next++;
|
|
3073
|
+
if (i >= ids.length)
|
|
3074
|
+
break;
|
|
3075
|
+
const id = ids[i];
|
|
3076
|
+
const md = await this.readRebuildMetadataOrThrow(kind, id);
|
|
3077
|
+
if (md)
|
|
3078
|
+
m.set(id, md);
|
|
3079
|
+
}
|
|
3080
|
+
});
|
|
3081
|
+
await Promise.all(workers);
|
|
2806
3082
|
return m;
|
|
2807
3083
|
}
|
|
2808
3084
|
/**
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* brain pays only for what it uses. This is the constant-RAM,
|
|
16
16
|
* billion-scale store that replaces the resident JSON id-maps (#72).
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
18
|
+
import { statSync } from 'node:fs';
|
|
19
19
|
import { loadNativeModule } from '../native/index.js';
|
|
20
20
|
/** Default storage key for the extendible-hash UUID → int file. */
|
|
21
21
|
export const DEFAULT_UUID_TO_INT_KEY = '_id_mapper/uuid_to_int.mkv';
|
|
@@ -65,10 +65,17 @@ export function openOrCreateBinaryIdMapper(storage, opts = {}) {
|
|
|
65
65
|
// Both files must exist together (paired write semantics). A
|
|
66
66
|
// half-present state is crash corruption surfaced as an error,
|
|
67
67
|
// never silently recreated.
|
|
68
|
+
//
|
|
69
|
+
// Fault-vs-absent (spine ADR-004 §4): the probes below THROW on any stat
|
|
70
|
+
// fault other than a clean ENOENT. `existsSync` returned false on
|
|
71
|
+
// EACCES/EIO/EMFILE too — and a double false routed to `create()`, which
|
|
72
|
+
// TRUNCATES both stores: a transient permissions blip (backup tool,
|
|
73
|
+
// scanner, remount) could wipe the brain's entire UUID↔int allocator.
|
|
74
|
+
// Deciding open-vs-create is only legal on certain knowledge.
|
|
68
75
|
let mapper;
|
|
69
76
|
let created;
|
|
70
|
-
const uuidFileExists =
|
|
71
|
-
const intFileExists =
|
|
77
|
+
const uuidFileExists = durableFilePresent(uuidToIntPath, uuidToIntKey);
|
|
78
|
+
const intFileExists = durableFilePresent(intToUuidPath, intToUuidKey);
|
|
72
79
|
if (uuidFileExists && intFileExists) {
|
|
73
80
|
mapper = NativeBinaryIdMapper.openExisting(config);
|
|
74
81
|
created = false;
|
|
@@ -91,4 +98,27 @@ export function openOrCreateBinaryIdMapper(storage, opts = {}) {
|
|
|
91
98
|
created,
|
|
92
99
|
};
|
|
93
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Fault-discriminating existence probe for the id-mapper stores (spine
|
|
103
|
+
* ADR-004 §4). Returns `true`/`false` only on CERTAIN knowledge; any stat
|
|
104
|
+
* fault other than a clean ENOENT throws — because the caller's "neither
|
|
105
|
+
* file exists" branch invokes `create()`, which TRUNCATES, and a transient
|
|
106
|
+
* EACCES/EIO must never be allowed to masquerade as a fresh brain.
|
|
107
|
+
*/
|
|
108
|
+
function durableFilePresent(path, key) {
|
|
109
|
+
try {
|
|
110
|
+
statSync(path);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const code = error.code;
|
|
115
|
+
if (code === 'ENOENT')
|
|
116
|
+
return false;
|
|
117
|
+
throw new Error(`openOrCreateBinaryIdMapper: existence probe FAULTED for '${key}' ` +
|
|
118
|
+
`(${code ?? 'unknown'}) — refusing to decide open-vs-create on a ` +
|
|
119
|
+
`fault (create() truncates the brain's id allocator; a transient ` +
|
|
120
|
+
`IO/permissions fault must never wipe it). Restore storage health ` +
|
|
121
|
+
`and reopen. Cause: ${String(error)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
94
124
|
//# sourceMappingURL=binaryIdMapperFactory.js.map
|