@soulcraft/cor 3.0.10 → 3.0.12
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/native/types.d.ts
CHANGED
|
@@ -1030,8 +1030,33 @@ export interface NativeLsmEngineInstance {
|
|
|
1030
1030
|
* data lives in both the log and the persisted SSTable, so the log copy is
|
|
1031
1031
|
* safe to retire. No-op for in-memory engines (constructed without
|
|
1032
1032
|
* `openWithDir`).
|
|
1033
|
+
*
|
|
1034
|
+
* Returns the number of records CARRIED FORWARD across the shard-log
|
|
1035
|
+
* rotations — writes that landed between the flush's drain snapshot and
|
|
1036
|
+
* this commit (the wrapper's `await`s on blob + manifest IO let the event
|
|
1037
|
+
* loop run writes in that window). Carried records stay replayable in the
|
|
1038
|
+
* fresh active logs and drain with the next flush; pre-3.0.12 rotations
|
|
1039
|
+
* exiled them into never-replayed archives (CORTEX-RESTART-STRAND).
|
|
1040
|
+
*/
|
|
1041
|
+
commitFlushThrough(throughEpoch: bigint): number;
|
|
1042
|
+
/**
|
|
1043
|
+
* Replay accounting from the memtable's cold open, as JSON:
|
|
1044
|
+
* `{ activeRecords, archiveRecovered, archivesUnreadable, tornTailBytes,
|
|
1045
|
+
* liveLogsRecreated, anomalousShards: string[] }`. Logged loudly at init
|
|
1046
|
+
* when any anomaly is non-zero — the forensic record of what cold-open
|
|
1047
|
+
* recovery found, captured BEFORE any rebuild wipes the evidence.
|
|
1048
|
+
* `liveLogsRecreated > 0` or `archivesUnreadable > 0` is a strand verdict
|
|
1049
|
+
* (unflushed records from a missing live log are unknowable).
|
|
1050
|
+
*/
|
|
1051
|
+
replayStatsJson(): string;
|
|
1052
|
+
/**
|
|
1053
|
+
* Toggle bulk-load mode on the shard logs. `true` suspends the
|
|
1054
|
+
* per-append msyncs — rebuild-from-canonical ONLY (canonical is the
|
|
1055
|
+
* durability source; a crashed rebuild re-runs). `false` restores them
|
|
1056
|
+
* and performs one full msync per shard: the rebuild's single durability
|
|
1057
|
+
* point. Never used on the live write path.
|
|
1033
1058
|
*/
|
|
1034
|
-
|
|
1059
|
+
setBulkLoad(on: boolean): void;
|
|
1035
1060
|
/**
|
|
1036
1061
|
* Update the budget ceiling. Driven by the wrapper's
|
|
1037
1062
|
* `ResourceManager` subscriber callback. Pass `0xffffffffffffffffn`
|
|
@@ -103,6 +103,18 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
|
|
|
103
103
|
* cold-open rebuild gate never skips over an index that has not loaded yet.
|
|
104
104
|
*/
|
|
105
105
|
private readinessInitialized;
|
|
106
|
+
/**
|
|
107
|
+
* A derived-state strand was detected at cold open (CORTEX-CONSOLIDATION-
|
|
108
|
+
* SILENT-DROP, memory-vm 2026-07-12): postings lost while canonical is
|
|
109
|
+
* intact — e.g. memtable log shards deleted out from under the engine (15
|
|
110
|
+
* of 64 on the incident brain), whose histories silently vanish because a
|
|
111
|
+
* missing shard log is recreated empty on next touch. Set by
|
|
112
|
+
* {@link detectDerivedStrand} (archive-orphan census + the
|
|
113
|
+
* |canonical| == |posted| invariant); forces {@link isReady} to report
|
|
114
|
+
* `false` so brainy's cold-open gate runs the one rebuild that re-posts
|
|
115
|
+
* everything from canonical. Cleared by a completed {@link rebuild}.
|
|
116
|
+
*/
|
|
117
|
+
private strandDetected;
|
|
106
118
|
/**
|
|
107
119
|
* **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
|
|
108
120
|
* auto-migrate enabled) and read once at the END of {@link init}, which kicks the
|
|
@@ -518,6 +530,31 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
|
|
|
518
530
|
* @returns `true` when a cold-open rebuild would be redundant.
|
|
519
531
|
*/
|
|
520
532
|
isReady(): boolean;
|
|
533
|
+
/**
|
|
534
|
+
* Detect a derived-state STRAND at cold open: canonical is the source of
|
|
535
|
+
* truth, so any loss in the derived posting state — however it happened —
|
|
536
|
+
* surfaces as one of two signals checked here. Both are cheap (a directory
|
|
537
|
+
* listing + one bitmap-cardinality read + one canonical count probe), run
|
|
538
|
+
* once per open, never on a hot path. Advisory on storage adapters that
|
|
539
|
+
* can't answer (no blob paths / no exact counts): detection skips rather
|
|
540
|
+
* than false-positives. CORTEX-CONSOLIDATION-SILENT-DROP.
|
|
541
|
+
*
|
|
542
|
+
* 1. **Archive-orphan census.** The memtable log rotates
|
|
543
|
+
* `memtable-shard-NN.log` → `*.archive` siblings and always recreates
|
|
544
|
+
* the live file — so an archive WITHOUT its live log proves the live
|
|
545
|
+
* log was deleted (the deleter's signature on memory-vm: 15 of 64
|
|
546
|
+
* shards). A missing shard log silently replays nothing and is
|
|
547
|
+
* recreated empty on next touch; without this census the loss is
|
|
548
|
+
* invisible.
|
|
549
|
+
* 2. **The |canonical| == |posted| invariant.** Every live entity carries
|
|
550
|
+
* exactly one `noun` posting, so the engine's total posted-entity count
|
|
551
|
+
* must equal canonical's noun count. `posted < canonical` = stranded
|
|
552
|
+
* entities (the incident's ~35 unqueryable writes); `posted > canonical`
|
|
553
|
+
* = orphaned postings (egress-guarded at read time, reconciled by the
|
|
554
|
+
* same rebuild). Skipped at/above the u32 saturation point of
|
|
555
|
+
* `getTotalEntityCount` (a saturated count would make equality lie).
|
|
556
|
+
*/
|
|
557
|
+
private detectDerivedStrand;
|
|
521
558
|
/**
|
|
522
559
|
* **#72 Phase C.** Build the shared mmap `BinaryIdMapper` from the
|
|
523
560
|
* brain's `_id_mapper/*` storage paths and inject it into the Rust
|
|
@@ -852,6 +889,15 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
|
|
|
852
889
|
* @returns the number of entities indexed (those carrying metadata).
|
|
853
890
|
*/
|
|
854
891
|
private rebuildEntityMetadata;
|
|
892
|
+
/**
|
|
893
|
+
* Rebuild-path verify-read for a single entity's canonical metadata.
|
|
894
|
+
* Retries once on a thrown read, then rethrows — a rebuild must NEVER
|
|
895
|
+
* silently exclude an entity because a storage read faulted (that
|
|
896
|
+
* completes the rebuild under-posted and clears the strand verdict
|
|
897
|
+
* over missing data). A clean `null`/`undefined` result is returned
|
|
898
|
+
* as `null` — a genuinely metadata-less record, the caller counts it.
|
|
899
|
+
*/
|
|
900
|
+
private readRebuildMetadataOrThrow;
|
|
855
901
|
/**
|
|
856
902
|
* Batch-load metadata for a page of noun/verb ids — prefers the storage
|
|
857
903
|
* adapter's batch API, falls back to per-id reads.
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Buffer exchange pattern: TS loads data from storage, passes JSON to Rust.
|
|
16
16
|
* Rust operates in-memory, returns serialized state for TS to persist.
|
|
17
17
|
*/
|
|
18
|
-
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
import { prodLog, getGlobalCache, FieldTypeInference, resolveEntityField } from '@soulcraft/brainy/internals';
|
|
21
21
|
import { compareCodePoints } from './collation.js';
|
|
@@ -177,6 +177,18 @@ export class MetadataIndexManager {
|
|
|
177
177
|
* cold-open rebuild gate never skips over an index that has not loaded yet.
|
|
178
178
|
*/
|
|
179
179
|
readinessInitialized = false;
|
|
180
|
+
/**
|
|
181
|
+
* A derived-state strand was detected at cold open (CORTEX-CONSOLIDATION-
|
|
182
|
+
* SILENT-DROP, memory-vm 2026-07-12): postings lost while canonical is
|
|
183
|
+
* intact — e.g. memtable log shards deleted out from under the engine (15
|
|
184
|
+
* of 64 on the incident brain), whose histories silently vanish because a
|
|
185
|
+
* missing shard log is recreated empty on next touch. Set by
|
|
186
|
+
* {@link detectDerivedStrand} (archive-orphan census + the
|
|
187
|
+
* |canonical| == |posted| invariant); forces {@link isReady} to report
|
|
188
|
+
* `false` so brainy's cold-open gate runs the one rebuild that re-posts
|
|
189
|
+
* everything from canonical. Cleared by a completed {@link rebuild}.
|
|
190
|
+
*/
|
|
191
|
+
strandDetected = false;
|
|
180
192
|
/**
|
|
181
193
|
* **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
|
|
182
194
|
* auto-migrate enabled) and read once at the END of {@link init}, which kicks the
|
|
@@ -475,6 +487,35 @@ export class MetadataIndexManager {
|
|
|
475
487
|
engine.registerSstablePath(path);
|
|
476
488
|
}
|
|
477
489
|
}
|
|
490
|
+
// Cold-open replay forensics (CORTEX-RESTART-STRAND). The engine's
|
|
491
|
+
// open replayed the active shard logs AND recovered any records a
|
|
492
|
+
// prior session exiled into archives (pre-carry-forward rotations,
|
|
493
|
+
// mid-rotation crashes). Surface every anomaly LOUDLY here — this
|
|
494
|
+
// line is the evidence that survives even if a rebuild later wipes
|
|
495
|
+
// the on-disk state.
|
|
496
|
+
if (metaDir !== null && typeof this.lsmEngine.replayStatsJson === 'function') {
|
|
497
|
+
try {
|
|
498
|
+
const stats = JSON.parse(this.lsmEngine.replayStatsJson());
|
|
499
|
+
if (stats.archiveRecovered > 0 ||
|
|
500
|
+
stats.archivesUnreadable > 0 ||
|
|
501
|
+
stats.tornTailBytes > 0) {
|
|
502
|
+
prodLog.error(`[NativeMetadataIndex] COLD-OPEN RECOVERY: replayed ` +
|
|
503
|
+
`${stats.activeRecords} active-log record(s); RECOVERED ` +
|
|
504
|
+
`${stats.archiveRecovered} record(s) exiled into archives by a ` +
|
|
505
|
+
`prior session; ${stats.archivesUnreadable} unreadable ` +
|
|
506
|
+
`archive(s); ${stats.tornTailBytes} torn-tail byte(s). ` +
|
|
507
|
+
`Recovered records are served and drain with the next flush. ` +
|
|
508
|
+
`Per-shard: ${stats.anomalousShards.join(' | ')}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
prodLog.warn('[NativeMetadataIndex] replay-forensics read failed (non-fatal):', error);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
else if (metaDir !== null) {
|
|
516
|
+
prodLog.warn('[NativeMetadataIndex] replay forensics unavailable — the loaded ' +
|
|
517
|
+
'native binary predates 3.0.12; upgrade the paired .node build.');
|
|
518
|
+
}
|
|
478
519
|
}
|
|
479
520
|
/**
|
|
480
521
|
* **LSM durability (steps 3-4) — the sync part.** Register the
|
|
@@ -754,7 +795,18 @@ export class MetadataIndexManager {
|
|
|
754
795
|
// flushed data is safely in the new SSTable. Warn + continue rather than
|
|
755
796
|
// abort the rebuild — the next clean flush retires the log.
|
|
756
797
|
try {
|
|
757
|
-
engine.commitFlushThrough(result.lastDurableEpoch);
|
|
798
|
+
const carried = engine.commitFlushThrough(result.lastDurableEpoch);
|
|
799
|
+
// Writes that landed during THIS flush's blob/manifest awaits (the
|
|
800
|
+
// event loop runs between the drain and this commit) were carried
|
|
801
|
+
// into the fresh active logs — they stay replayable and drain with
|
|
802
|
+
// the next flush. Logged for the write-ack audit trail: pre-3.0.12
|
|
803
|
+
// rotations exiled exactly these records into never-replayed
|
|
804
|
+
// archives (CORTEX-RESTART-STRAND).
|
|
805
|
+
if (typeof carried === 'number' && carried > 0) {
|
|
806
|
+
prodLog.info(`flushLsm: carried ${carried} concurrent-write record(s) across ` +
|
|
807
|
+
`the shard-log rotation for SSTable ${id} — they drain with the ` +
|
|
808
|
+
`next flush.`);
|
|
809
|
+
}
|
|
758
810
|
}
|
|
759
811
|
catch (error) {
|
|
760
812
|
prodLog.warn(`flushLsm: commitFlushThrough deferred for SSTable ${id} (it + the ` +
|
|
@@ -1101,6 +1153,13 @@ export class MetadataIndexManager {
|
|
|
1101
1153
|
await this.warmCache();
|
|
1102
1154
|
await this.lazyLoadCounts();
|
|
1103
1155
|
this.syncTypeCountsToFixed();
|
|
1156
|
+
// Strand detection (CORTEX-CONSOLIDATION-SILENT-DROP): the LSM can load
|
|
1157
|
+
// "durable state" that is silently MISSING postings — deleted shard logs
|
|
1158
|
+
// replay nothing and are recreated empty on next touch, so the index
|
|
1159
|
+
// serves healthy-while-broken. Detect it here, before the readiness
|
|
1160
|
+
// contract reports ready, so brainy's gate heals via rebuild-from-
|
|
1161
|
+
// canonical instead of trusting a partial index.
|
|
1162
|
+
await this.detectDerivedStrand();
|
|
1104
1163
|
// **#18 migration kick — LAST, after the index is fully constructed.** If the
|
|
1105
1164
|
// epoch guard flagged a large stale brain (outcome 4), start the coordinated
|
|
1106
1165
|
// blocking migration NOW: `start()` flips the lock (`isMigrating()` → true)
|
|
@@ -1145,8 +1204,140 @@ export class MetadataIndexManager {
|
|
|
1145
1204
|
return false;
|
|
1146
1205
|
if (this.knownFields.size > 0 && !this.lsmHasDurableState())
|
|
1147
1206
|
return false;
|
|
1207
|
+
// A detected strand (postings lost while canonical is intact) means a
|
|
1208
|
+
// rebuild is NOT redundant — see {@link detectDerivedStrand}.
|
|
1209
|
+
if (this.strandDetected)
|
|
1210
|
+
return false;
|
|
1148
1211
|
return true;
|
|
1149
1212
|
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Detect a derived-state STRAND at cold open: canonical is the source of
|
|
1215
|
+
* truth, so any loss in the derived posting state — however it happened —
|
|
1216
|
+
* surfaces as one of two signals checked here. Both are cheap (a directory
|
|
1217
|
+
* listing + one bitmap-cardinality read + one canonical count probe), run
|
|
1218
|
+
* once per open, never on a hot path. Advisory on storage adapters that
|
|
1219
|
+
* can't answer (no blob paths / no exact counts): detection skips rather
|
|
1220
|
+
* than false-positives. CORTEX-CONSOLIDATION-SILENT-DROP.
|
|
1221
|
+
*
|
|
1222
|
+
* 1. **Archive-orphan census.** The memtable log rotates
|
|
1223
|
+
* `memtable-shard-NN.log` → `*.archive` siblings and always recreates
|
|
1224
|
+
* the live file — so an archive WITHOUT its live log proves the live
|
|
1225
|
+
* log was deleted (the deleter's signature on memory-vm: 15 of 64
|
|
1226
|
+
* shards). A missing shard log silently replays nothing and is
|
|
1227
|
+
* recreated empty on next touch; without this census the loss is
|
|
1228
|
+
* invisible.
|
|
1229
|
+
* 2. **The |canonical| == |posted| invariant.** Every live entity carries
|
|
1230
|
+
* exactly one `noun` posting, so the engine's total posted-entity count
|
|
1231
|
+
* must equal canonical's noun count. `posted < canonical` = stranded
|
|
1232
|
+
* entities (the incident's ~35 unqueryable writes); `posted > canonical`
|
|
1233
|
+
* = orphaned postings (egress-guarded at read time, reconciled by the
|
|
1234
|
+
* same rebuild). Skipped at/above the u32 saturation point of
|
|
1235
|
+
* `getTotalEntityCount` (a saturated count would make equality lie).
|
|
1236
|
+
*/
|
|
1237
|
+
async detectDerivedStrand() {
|
|
1238
|
+
if (!this.lsmEngine || !this.lsmMetaDir)
|
|
1239
|
+
return;
|
|
1240
|
+
// 0. Replay-stats verdict (3.0.12). The native open now RECREATES a
|
|
1241
|
+
// missing live log and recovers what its archives hold, so the
|
|
1242
|
+
// filename census below can no longer see the deleted-live-log
|
|
1243
|
+
// signature — the native layer reports it instead. Either condition
|
|
1244
|
+
// is a strand verdict: a recreated-with-archives shard may have held
|
|
1245
|
+
// UNFLUSHED records that lived only in the missing live log
|
|
1246
|
+
// (unknowable — rebuild is the only honest answer), and an
|
|
1247
|
+
// unreadable archive means potentially-exiled records were
|
|
1248
|
+
// unreachable to recovery.
|
|
1249
|
+
try {
|
|
1250
|
+
const engine = this.lsmEngine;
|
|
1251
|
+
if (typeof engine.replayStatsJson === 'function') {
|
|
1252
|
+
const stats = JSON.parse(engine.replayStatsJson());
|
|
1253
|
+
const recreated = stats.liveLogsRecreated ?? 0;
|
|
1254
|
+
const unreadable = stats.archivesUnreadable ?? 0;
|
|
1255
|
+
if (recreated > 0 || unreadable > 0) {
|
|
1256
|
+
this.strandDetected = true;
|
|
1257
|
+
prodLog.error(`[NativeMetadataIndex] STRAND: cold-open replay reports ` +
|
|
1258
|
+
`${recreated} shard live log(s) MISSING (recreated; archives ` +
|
|
1259
|
+
`recovered what they held, but unflushed records from the ` +
|
|
1260
|
+
`missing logs are unknowable) and ${unreadable} unreadable ` +
|
|
1261
|
+
`archive(s). Reporting not-ready so the cold-open gate ` +
|
|
1262
|
+
`rebuilds from canonical. Per-shard: ` +
|
|
1263
|
+
`${(stats.anomalousShards ?? []).join(' | ')}`);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
// Verdict probe is advisory — a stats read failure must not block open.
|
|
1269
|
+
}
|
|
1270
|
+
// 1. Archive-orphan census over the memtable shard logs.
|
|
1271
|
+
try {
|
|
1272
|
+
const memtableDir = join(this.lsmMetaDir, 'memtable');
|
|
1273
|
+
if (existsSync(memtableDir)) {
|
|
1274
|
+
const files = readdirSync(memtableDir);
|
|
1275
|
+
const live = new Set(files.filter((f) => /^memtable-shard-\d{2}\.log$/.test(f)));
|
|
1276
|
+
const orphaned = [];
|
|
1277
|
+
for (const f of files) {
|
|
1278
|
+
const m = /^(memtable-shard-\d{2}\.log)\..+\.archive$/.exec(f);
|
|
1279
|
+
if (m && !live.has(m[1]) && !orphaned.includes(m[1])) {
|
|
1280
|
+
orphaned.push(m[1]);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
if (orphaned.length > 0) {
|
|
1284
|
+
this.strandDetected = true;
|
|
1285
|
+
prodLog.error(`[NativeMetadataIndex] STRAND: ${orphaned.length} memtable shard ` +
|
|
1286
|
+
`log(s) have archive rotations but the live log is MISSING ` +
|
|
1287
|
+
`(${orphaned.join(', ')}) — the live logs were deleted and their ` +
|
|
1288
|
+
`posting history is gone. Reporting not-ready so the cold-open ` +
|
|
1289
|
+
`gate rebuilds from canonical.`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
catch {
|
|
1294
|
+
// Census is advisory — an unreadable dir must not block open.
|
|
1295
|
+
}
|
|
1296
|
+
// 2. |canonical| == |posted| invariant.
|
|
1297
|
+
try {
|
|
1298
|
+
const getNouns = this.storage.getNouns;
|
|
1299
|
+
if (typeof getNouns === 'function') {
|
|
1300
|
+
const probe = await getNouns.call(this.storage, { pagination: { limit: 1 } });
|
|
1301
|
+
const canonical = probe?.totalCount;
|
|
1302
|
+
if (typeof canonical === 'number' &&
|
|
1303
|
+
Number.isFinite(canonical) &&
|
|
1304
|
+
canonical < 0xffff_ffff) {
|
|
1305
|
+
// Posted count = Σ per-type snapshot cardinalities over brainy's
|
|
1306
|
+
// CLOSED 42-noun vocabulary (wire-stable, validated at the storage
|
|
1307
|
+
// boundary — no type outside it can exist in canonical).
|
|
1308
|
+
// getEntityCountByType reads the LSM SNAPSHOT (memtable + SSTables),
|
|
1309
|
+
// so it is reopen-true; getTotalEntityCount is NOT (it counts the
|
|
1310
|
+
// in-process field registry, which is empty at cold open). O(42)
|
|
1311
|
+
// bitmap-cardinality reads, no id materialization — scale-safe.
|
|
1312
|
+
// Raw (unmapped) cardinality is acceptable here: at cold open there
|
|
1313
|
+
// is no mapper-eviction churn to inflate it.
|
|
1314
|
+
const { NounType } = await import('@soulcraft/brainy');
|
|
1315
|
+
let posted = 0;
|
|
1316
|
+
for (const t of Object.values(NounType)) {
|
|
1317
|
+
posted += this.native.getEntityCountByType(t);
|
|
1318
|
+
}
|
|
1319
|
+
// LOWER-BOUND invariant, not equality: the adapter's totalCount
|
|
1320
|
+
// EXCLUDES system-routed entities (e.g. the VFS root) while the
|
|
1321
|
+
// index posts them, so healthy brains sit at posted ≥ canonical.
|
|
1322
|
+
// posted < canonical therefore proves lost postings. A strand
|
|
1323
|
+
// smaller than the (1–2 entity) system skew is masked here and
|
|
1324
|
+
// falls to the archive census above; the exact invariant (a
|
|
1325
|
+
// persisted posted-count stamp) is CORTEX-BILLION-SCALE-SPINE work.
|
|
1326
|
+
if (posted < canonical) {
|
|
1327
|
+
this.strandDetected = true;
|
|
1328
|
+
prodLog.error(`[NativeMetadataIndex] STRAND: canonical holds ${canonical} ` +
|
|
1329
|
+
`entities but the index has postings for only ${posted} — ` +
|
|
1330
|
+
`${canonical - posted}+ entities are durable in canonical but ` +
|
|
1331
|
+
`UNQUERYABLE (lost postings). Reporting not-ready so the ` +
|
|
1332
|
+
`cold-open gate rebuilds from canonical.`);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
// Invariant probe is advisory — a failed canonical read must not block open.
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1150
1341
|
// ==========================================================================
|
|
1151
1342
|
// Storage I/O helpers
|
|
1152
1343
|
// ==========================================================================
|
|
@@ -2463,12 +2654,39 @@ export class MetadataIndexManager {
|
|
|
2463
2654
|
// branch loaded the whole corpus in a single getNouns/getVerbs({limit:1e6})
|
|
2464
2655
|
// call (cap 1M), and the cloud branch capped at MAX_ITERATIONS(10000)×
|
|
2465
2656
|
// batch(25) ≈ 250K. (#74 — perf-at-all-scales mandate.)
|
|
2466
|
-
|
|
2467
|
-
|
|
2657
|
+
// Bulk-load mode for the re-post walk: canonical is the durability
|
|
2658
|
+
// source during a rebuild (a crash re-runs it), so the per-append
|
|
2659
|
+
// shard-log msyncs — ~2 synchronous disk flushes per posting record,
|
|
2660
|
+
// the mechanism behind memory-vm's MEASURED ~1.3-2 items/s (30 min
|
|
2661
|
+
// for 2,354 entities on e2-standard-2, CORTEX-RESTART-STRAND) — are
|
|
2662
|
+
// suspended and replaced by ONE full msync per shard at the end of
|
|
2663
|
+
// the walk (setBulkLoad(false), inside finally so a failed rebuild
|
|
2664
|
+
// can never leave the live write path in deferred mode).
|
|
2665
|
+
const bulkCapable = this.lsmEngine !== null &&
|
|
2666
|
+
typeof this.lsmEngine.setBulkLoad ===
|
|
2667
|
+
'function';
|
|
2668
|
+
if (bulkCapable)
|
|
2669
|
+
this.lsmEngine.setBulkLoad(true);
|
|
2670
|
+
let totalNounsProcessed;
|
|
2671
|
+
let totalVerbsProcessed;
|
|
2672
|
+
try {
|
|
2673
|
+
totalNounsProcessed = await this.rebuildEntityMetadata('noun');
|
|
2674
|
+
totalVerbsProcessed = await this.rebuildEntityMetadata('verb');
|
|
2675
|
+
}
|
|
2676
|
+
finally {
|
|
2677
|
+
// Leaving bulk mode performs the walk's single durability point
|
|
2678
|
+
// (one full msync per shard log).
|
|
2679
|
+
if (bulkCapable)
|
|
2680
|
+
this.lsmEngine.setBulkLoad(false);
|
|
2681
|
+
}
|
|
2468
2682
|
// Final flush
|
|
2469
2683
|
await this.flushRebuildDirty();
|
|
2470
2684
|
await this.flush();
|
|
2471
2685
|
prodLog.info(`Metadata index rebuild completed! Processed ${totalNounsProcessed} nouns and ${totalVerbsProcessed} verbs`);
|
|
2686
|
+
// A completed rebuild re-posted every canonical entity into a freshly
|
|
2687
|
+
// re-opened engine (reopenLsmEngineFresh wiped the stale shard set) —
|
|
2688
|
+
// any cold-open strand verdict is now satisfied.
|
|
2689
|
+
this.strandDetected = false;
|
|
2472
2690
|
}
|
|
2473
2691
|
finally {
|
|
2474
2692
|
this.isRebuilding = false;
|
|
@@ -2491,6 +2709,7 @@ export class MetadataIndexManager {
|
|
|
2491
2709
|
let hasMore = true;
|
|
2492
2710
|
let processed = 0;
|
|
2493
2711
|
let sinceFlush = 0;
|
|
2712
|
+
let noMetadata = 0;
|
|
2494
2713
|
while (hasMore) {
|
|
2495
2714
|
const page = kind === 'noun'
|
|
2496
2715
|
? await this.storage.getNouns({ pagination: { limit: PAGE, cursor } })
|
|
@@ -2501,7 +2720,16 @@ export class MetadataIndexManager {
|
|
|
2501
2720
|
const ids = items.map((it) => it.id);
|
|
2502
2721
|
const md = await this.loadRebuildMetadataBatch(kind, ids);
|
|
2503
2722
|
for (const it of items) {
|
|
2504
|
-
|
|
2723
|
+
// An id enumerated by canonical but absent from the batch result
|
|
2724
|
+
// gets an individual verify-read: a batch adapter may return a
|
|
2725
|
+
// partial map on an internal fault, and silently skipping the
|
|
2726
|
+
// entity would complete the rebuild UNDER-POSTED — the exact
|
|
2727
|
+
// damage the strand detector exists to catch, laundered by its
|
|
2728
|
+
// own healer (the 01:31 memory-vm rebuild's failure shape). The
|
|
2729
|
+
// verify-read retries once, then FAILS THE REBUILD loudly; a
|
|
2730
|
+
// clean null (a genuinely metadata-less record) is counted +
|
|
2731
|
+
// reported, never silently dropped.
|
|
2732
|
+
const metadata = md.get(it.id) ?? (await this.readRebuildMetadataOrThrow(kind, it.id));
|
|
2505
2733
|
if (metadata) {
|
|
2506
2734
|
await this.addToIndex(it.id, metadata, true, true);
|
|
2507
2735
|
processed++;
|
|
@@ -2510,13 +2738,49 @@ export class MetadataIndexManager {
|
|
|
2510
2738
|
sinceFlush = 0;
|
|
2511
2739
|
}
|
|
2512
2740
|
}
|
|
2741
|
+
else {
|
|
2742
|
+
noMetadata++;
|
|
2743
|
+
}
|
|
2513
2744
|
}
|
|
2514
2745
|
hasMore = page.hasMore;
|
|
2515
2746
|
cursor = page.nextCursor;
|
|
2516
2747
|
await this.yieldToEventLoop();
|
|
2517
2748
|
}
|
|
2749
|
+
if (noMetadata > 0) {
|
|
2750
|
+
prodLog.warn(`[NativeMetadataIndex] rebuild(${kind}): ${noMetadata} enumerated ` +
|
|
2751
|
+
`entit${noMetadata === 1 ? 'y' : 'ies'} carried no canonical ` +
|
|
2752
|
+
`metadata (verified by direct read) and were not indexed — ` +
|
|
2753
|
+
`expected only for metadata-less legacy records.`);
|
|
2754
|
+
}
|
|
2518
2755
|
return processed;
|
|
2519
2756
|
}
|
|
2757
|
+
/**
|
|
2758
|
+
* Rebuild-path verify-read for a single entity's canonical metadata.
|
|
2759
|
+
* Retries once on a thrown read, then rethrows — a rebuild must NEVER
|
|
2760
|
+
* silently exclude an entity because a storage read faulted (that
|
|
2761
|
+
* completes the rebuild under-posted and clears the strand verdict
|
|
2762
|
+
* over missing data). A clean `null`/`undefined` result is returned
|
|
2763
|
+
* as `null` — a genuinely metadata-less record, the caller counts it.
|
|
2764
|
+
*/
|
|
2765
|
+
async readRebuildMetadataOrThrow(kind, id) {
|
|
2766
|
+
const read = () => kind === 'noun'
|
|
2767
|
+
? this.storage.getNounMetadata(id)
|
|
2768
|
+
: this.storage.getVerbMetadata(id);
|
|
2769
|
+
try {
|
|
2770
|
+
return (await read()) ?? null;
|
|
2771
|
+
}
|
|
2772
|
+
catch {
|
|
2773
|
+
try {
|
|
2774
|
+
return (await read()) ?? null;
|
|
2775
|
+
}
|
|
2776
|
+
catch (error) {
|
|
2777
|
+
throw new Error(`[NativeMetadataIndex] rebuild(${kind}): canonical metadata read ` +
|
|
2778
|
+
`for '${id}' faulted twice — failing the rebuild rather than ` +
|
|
2779
|
+
`silently excluding the entity (an under-posted rebuild is the ` +
|
|
2780
|
+
`strand bug reintroduced by its own healer). Cause: ${String(error)}`);
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2520
2784
|
/**
|
|
2521
2785
|
* Batch-load metadata for a page of noun/verb ids — prefers the storage
|
|
2522
2786
|
* adapter's batch API, falls back to per-id reads.
|
|
@@ -2531,14 +2795,13 @@ export class MetadataIndexManager {
|
|
|
2531
2795
|
}
|
|
2532
2796
|
const m = new Map();
|
|
2533
2797
|
for (const id of ids) {
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
catch { }
|
|
2798
|
+
// Retry-once-then-throw (readRebuildMetadataOrThrow): a faulted
|
|
2799
|
+
// read must fail the rebuild loudly, never silently exclude the
|
|
2800
|
+
// entity — a swallowed fault here completed the rebuild
|
|
2801
|
+
// under-posted (the strand bug re-created by its own healer).
|
|
2802
|
+
const md = await this.readRebuildMetadataOrThrow(kind, id);
|
|
2803
|
+
if (md)
|
|
2804
|
+
m.set(id, md);
|
|
2542
2805
|
}
|
|
2543
2806
|
return m;
|
|
2544
2807
|
}
|
package/docs/snapshot-safety.md
CHANGED
|
@@ -128,7 +128,13 @@ is the reference implementation.
|
|
|
128
128
|
tail offset; live writers append past that offset without
|
|
129
129
|
affecting what the snapshot reader sees. On compaction
|
|
130
130
|
commit the delta is renamed to a `.archive` sibling and a
|
|
131
|
-
fresh
|
|
131
|
+
fresh delta replaces it, carrying forward any record newer
|
|
132
|
+
than the commit watermark — a record appended while the
|
|
133
|
+
commit's durability IO was in flight stays replayable in the
|
|
134
|
+
fresh delta, never exiled to the archive. Opens also scan
|
|
135
|
+
retained archives for records above the watermark and
|
|
136
|
+
recover them, so even a rotation interrupted between its two
|
|
137
|
+
renames loses nothing.
|
|
132
138
|
|
|
133
139
|
3. **Atomic head pointer** (`head`) — a tiny text file named by
|
|
134
140
|
`PointerFile` (`diskann::pointer_file`). Names the current
|
package/native/index.d.ts
CHANGED
|
@@ -1260,8 +1260,34 @@ export declare class NativeLsmEngine {
|
|
|
1260
1260
|
*
|
|
1261
1261
|
* No-op for in-memory engines (constructed without
|
|
1262
1262
|
* `openWithDir`).
|
|
1263
|
-
|
|
1264
|
-
|
|
1263
|
+
*
|
|
1264
|
+
* Returns the number of records carried forward across the
|
|
1265
|
+
* shard-log rotations — writes that landed between the flush's
|
|
1266
|
+
* drain snapshot and this commit (the wrapper's async
|
|
1267
|
+
* durability-IO window; the JS event loop runs writes there).
|
|
1268
|
+
* They stay replayable in the fresh active logs and drain with
|
|
1269
|
+
* the next flush. The wrapper logs non-zero counts for the
|
|
1270
|
+
* write-ack audit trail.
|
|
1271
|
+
*/
|
|
1272
|
+
commitFlushThrough(throughEpoch: bigint): number
|
|
1273
|
+
/**
|
|
1274
|
+
* Toggle bulk-load mode on the shard logs. `true` suspends the
|
|
1275
|
+
* per-append msyncs — for rebuild-from-canonical ONLY (canonical
|
|
1276
|
+
* is the durability source; a crashed rebuild re-runs). `false`
|
|
1277
|
+
* restores them and performs one full msync per shard: the
|
|
1278
|
+
* rebuild's single durability point. Never used on the live
|
|
1279
|
+
* write path, whose ack contract requires the per-append msync.
|
|
1280
|
+
*/
|
|
1281
|
+
setBulkLoad(on: boolean): void
|
|
1282
|
+
/**
|
|
1283
|
+
* Replay accounting from the memtable's cold open, as JSON:
|
|
1284
|
+
* `{ activeRecords, archiveRecovered, archivesUnreadable,
|
|
1285
|
+
* tornTailBytes, anomalousShards: string[] }`. The wrapper logs
|
|
1286
|
+
* this at init when any anomaly is non-zero — the forensic
|
|
1287
|
+
* record of what cold-open recovery found, captured BEFORE any
|
|
1288
|
+
* rebuild wipes the evidence (CORTEX-RESTART-STRAND).
|
|
1289
|
+
*/
|
|
1290
|
+
replayStatsJson(): string
|
|
1265
1291
|
/** `"u32"` or `"u64"`. */
|
|
1266
1292
|
idSpace(): string
|
|
1267
1293
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulcraft/cor",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.12",
|
|
4
4
|
"description": "Native Rust acceleration for Brainy — SIMD distance, vector quantization, zero-copy mmap, native embeddings. Free tier for storage, Pro license for compute acceleration.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@napi-rs/cli": "^3.0.0",
|
|
85
|
-
"@soulcraft/brainy": "8.2.
|
|
85
|
+
"@soulcraft/brainy": "8.2.5",
|
|
86
86
|
"@types/node": "^22.0.0",
|
|
87
87
|
"pg": "^8.21.0",
|
|
88
88
|
"tsx": "^4.21.0",
|