Skip to content

Commit fef7a36

Browse files
authored
fix(ai): add Memory Core repair dry-run report (#14005) (#14006)
1 parent cdb7d7e commit fef7a36

2 files changed

Lines changed: 95 additions & 19 deletions

File tree

ai/scripts/maintenance/defragChromaDB.mjs

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ const __filename = fileURLToPath(import.meta.url);
6767
const __dirname = path.dirname(__filename);
6868
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
6969
export const LOCAL_AI_CONFIG_FILE = path.join(PROJECT_ROOT, 'ai', 'config.mjs');
70-
const DEFRAG_STATE_DIR = path.join(PROJECT_ROOT, '.neo-ai-data', 'maintenance', 'defrag-state');
70+
const DEFRAG_STATE_DIR = path.join(PROJECT_ROOT, '.neo-ai-data', 'maintenance', 'defrag-state');
7171
const MEMORY_CORE_UNSAFE_MESSAGE =
7272
'Memory Core defrag is disabled until a safe multi-collection shadow/parking promotion exists. ' +
7373
'MC is an irreplaceable store; use backup/restore or a purpose-built repair lane instead of delete/recreate defrag.';
@@ -325,7 +325,7 @@ export async function cleanOldBackups(backupDir, retention = resolveDefragSnapsh
325325
*/
326326
async function getDirSize(dir) {
327327
const files = await fs.readdir(dir, {withFileTypes: true});
328-
let size = 0;
328+
let size = 0;
329329

330330
for (const file of files) {
331331
const filePath = path.join(dir, file.name);
@@ -669,14 +669,16 @@ export async function rewriteCollectionViaShadowPromotion({
669669
* @param {Object} options.embeddingFunction Chroma embedding function (dummy, for raw-vector moves).
670670
* @param {String} options.statePath Durable defrag-state marker path.
671671
* @param {Object} [options.stateBase={}] Stable fields written into every phase marker.
672+
* @param {Boolean} [options.dryRun=false] True extracts/re-embeds and reports counts without shadow promotion or state-marker writes.
672673
* @param {Function} [options.auditFn=auditChromaVectorCoverage] Enumeration seam (test injection).
673674
* @param {Function} [options.extractFn=extractMemoryCoreCollectionData] Extract + re-embed seam.
674675
* @param {Function} [options.promoteFn=rewriteCollectionViaShadowPromotion] Shadow-promotion seam.
675676
* @param {Function} [options.clearStateFn=clearDefragState] Clears the durable marker on a fully successful repair.
676677
* @param {Function} [options.writeStateFn=writeDefragState] Rewrites the explicit aborted marker on a partial repair.
677678
* @param {Function} [options.log=console.log] Log sink.
678-
* @returns {Promise<{results: Object[]}>} Per collection: `{collectionName, promotion, counts}` on success
679-
* or `{collectionName, aborted: true, unrecoverable, counts}` when fail-loud aborts the promotion.
679+
* @returns {Promise<{results: Object[]}>} Per collection: `{collectionName, promotion, counts}` on success,
680+
* `{collectionName, dryRun: true, counts}` on clean dry-run, or
681+
* `{collectionName, aborted: true, unrecoverable, counts}` when fail-loud aborts the promotion/report.
680682
*/
681683
export async function repairMemoryCoreCollectionsViaFullEnumeration({
682684
client,
@@ -687,6 +689,7 @@ export async function repairMemoryCoreCollectionsViaFullEnumeration({
687689
embeddingFunction,
688690
statePath,
689691
stateBase = {},
692+
dryRun = false,
690693
auditFn = auditChromaVectorCoverage,
691694
extractFn = extractMemoryCoreCollectionData,
692695
promoteFn = rewriteCollectionViaShadowPromotion,
@@ -708,9 +711,21 @@ export async function repairMemoryCoreCollectionsViaFullEnumeration({
708711
throw new Error(`repairMemoryCoreCollectionsViaFullEnumeration: no coverage row for '${collectionName}' — refusing to promote a collection the enumeration never saw.`);
709712
}
710713

711-
const {allIds, missingVectorIds} = cov,
712-
collection = await client.getCollection({name: collectionName, embeddingFunction}),
713-
{data, unrecoverable, counts} = await extractFn({collection, allIds, missingVectorIds, embedFn});
714+
const {allIds, missingVectorIds} = cov,
715+
collection = await client.getCollection({name: collectionName, embeddingFunction}),
716+
{data, unrecoverable, counts} = await extractFn({collection, allIds, missingVectorIds, embedFn});
717+
718+
if (dryRun) {
719+
if (unrecoverable.length > 0) {
720+
log(` ⚠️ '${collectionName}': DRY-RUN found ${unrecoverable.length} unrecoverable row(s) (document-less / metadata-absent) — no promotion attempted. Counts: ${JSON.stringify(counts)}`);
721+
results.push({collectionName, dryRun: true, aborted: true, unrecoverable, counts});
722+
continue;
723+
}
724+
725+
log(` 🧪 '${collectionName}': DRY-RUN extraction/re-embed succeeded; no promotion attempted. Counts: ${JSON.stringify(counts)}`);
726+
results.push({collectionName, dryRun: true, counts});
727+
continue;
728+
}
714729

715730
// Fail-loud: never promote a collection that lost rows to unrecoverable extraction.
716731
if (unrecoverable.length > 0) {
@@ -727,7 +742,7 @@ export async function repairMemoryCoreCollectionsViaFullEnumeration({
727742
// wrote durable per-phase markers, so a fully successful repair MUST clear the marker — else the next run aborts
728743
// as DEFRAG_INCOMPLETE_STATE. An aborted/partial repair instead rewrites an explicit aborted marker so
729744
// assertNoIncompleteDefragState blocks rerun with an accurate diagnostic, not a stale mid-phase marker.
730-
if (statePath) {
745+
if (statePath && !dryRun) {
731746
if (anyRepairAborted(results)) {
732747
await writeStateFn({statePath, state: {
733748
...stateBase,
@@ -777,6 +792,7 @@ async function defragChromaDB() {
777792
.description('Defragment ChromaDB instances by rewriting data and cleaning orphaned files.')
778793
.requiredOption('-t, --target <name>', 'Database target (knowledge-base, memory-core)')
779794
.option('--allow-memory-core', 'Opt in to the Memory Core repair-defrag path (default: fails closed)')
795+
.option('--dry-run', 'For memory-core, run full enumeration/extraction report without shadow promotion')
780796
.parse(process.argv);
781797

782798
const options = program.opts();
@@ -848,23 +864,30 @@ async function defragChromaDB() {
848864
// defrag state marker on clean success (or rewrites an explicit aborted marker on a partial repair)
849865
// before this branch returns ahead of the KB path.
850866
if (targetName === 'memory-core') {
851-
console.log(`\n3️⃣ Memory Core repair-defrag: full-enumeration extract + re-embed + shadow-promote...`);
867+
const dryRun = options.dryRun === true;
868+
869+
console.log(dryRun
870+
? `\n3️⃣ Memory Core repair-defrag DRY-RUN: full-enumeration extract + re-embed report (no shadow promotion)...`
871+
: `\n3️⃣ Memory Core repair-defrag: full-enumeration extract + re-embed + shadow-promote...`);
852872
const {default: TextEmbeddingService} = await import('../../services/memory-core/TextEmbeddingService.mjs');
853-
const {results} = await repairMemoryCoreCollectionsViaFullEnumeration({
873+
const {results} = await repairMemoryCoreCollectionsViaFullEnumeration({
854874
client,
855875
collections : config.collections,
856876
snapshotPath : path.join(backupPath, 'chroma.sqlite3'),
857877
persistDir : backupPath,
858878
embedFn : docs => TextEmbeddingService.embedTexts(docs, config.embeddingProvider),
859879
embeddingFunction: dummyEf,
860880
statePath,
861-
stateBase : {targetName}
881+
stateBase : {targetName},
882+
dryRun
862883
});
863884

864885
for (const result of results) {
865886
console.log(result.aborted
866-
? ` ⚠️ ${result.collectionName}: ABORTED — ${result.unrecoverable.length} unrecoverable row(s); counts ${JSON.stringify(result.counts)}`
867-
: ` ✅ ${result.collectionName}: repaired + promoted; counts ${JSON.stringify(result.counts)}`);
887+
? ` ⚠️ ${result.collectionName}: ${dryRun ? 'DRY-RUN WOULD ABORT' : 'ABORTED'}${result.unrecoverable.length} unrecoverable row(s); counts ${JSON.stringify(result.counts)}`
888+
: dryRun
889+
? ` 🧪 ${result.collectionName}: dry-run report clean; no promotion; counts ${JSON.stringify(result.counts)}`
890+
: ` ✅ ${result.collectionName}: repaired + promoted; counts ${JSON.stringify(result.counts)}`);
868891
}
869892

870893
const finalSize = await getDirSize(DB_PATH);
@@ -874,16 +897,18 @@ async function defragChromaDB() {
874897
// (mirrors the KB extractionErrors / hasRestoreErrors -> process.exit(1) discipline below).
875898
if (anyRepairAborted(results)) {
876899
const abortedNames = results.filter(result => result.aborted).map(result => result.collectionName);
877-
console.error(`❌ Memory Core repair aborted for ${abortedNames.join(', ')} (unrecoverable rows) — NOT a successful repair; resolve the unrecoverable rows and re-run. Counts logged above.`);
900+
console.error(dryRun
901+
? `❌ Memory Core repair dry-run found unrecoverable rows for ${abortedNames.join(', ')} — no promotion was attempted; resolve the unrecoverable rows before running the mutating repair. Counts logged above.`
902+
: `❌ Memory Core repair aborted for ${abortedNames.join(', ')} (unrecoverable rows) — NOT a successful repair; resolve the unrecoverable rows and re-run. Counts logged above.`);
878903
process.exit(1);
879904
}
880905
return;
881906
}
882907

883908
// 3. Extract All Data (Multi-Collection)
884909
console.log(`\n3️⃣ Fetching data from all collections...`);
885-
const buffer = {};
886-
let extractionErrors = false;
910+
const buffer = {};
911+
let extractionErrors = false;
887912

888913
for (const colName of config.collections) {
889914
console.log(` Processing collection: ${colName}`);
@@ -900,7 +925,7 @@ async function defragChromaDB() {
900925

901926
// 3.1 Fetch all IDs first (avoids HNSW index to prevent "Error finding id")
902927
const allIds = [];
903-
let offset = 0;
928+
let offset = 0;
904929
while (true) {
905930
const batch = await collection.get({limit: 2000, offset, include: []});
906931
if (batch.ids.length === 0) break;

test/playwright/unit/ai/scripts/maintenance/defragMemoryCoreRepair.spec.mjs

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ test.describe('repairMemoryCoreCollectionsViaFullEnumeration (#13634 AC4)', () =
6262
});
6363

6464
test('fail-loud: unrecoverable rows abort that collection promotion (no silent drop)', async () => {
65-
const coverage = {collections: [{name: 'mc-memory', allIds: ['a', 'b'], missingVectorIds: ['b']}]},
65+
const coverage = {collections: [{name: 'mc-memory', allIds: ['a', 'b'], missingVectorIds: ['b']}]},
6666
extractResults = {
6767
'mc-memory': {data: {ids: ['a'], embeddings: [[1]], documents: [''], metadatas: [{}]}, unrecoverable: ['b'], counts: {total: 2, intact: 1, reEmbedded: 0, unrecoverable: 1}}
6868
},
@@ -89,6 +89,57 @@ test.describe('repairMemoryCoreCollectionsViaFullEnumeration (#13634 AC4)', () =
8989
expect(calls.writeState[0].state.aborted).toEqual(['mc-memory']);
9090
});
9191

92+
test('dry-run reports clean extraction without promotion or state-marker writes', async () => {
93+
const coverage = {collections: [{name: 'mc-memory', allIds: ['a', 'b'], missingVectorIds: ['b']}]},
94+
extractResults = {
95+
'mc-memory': {data: {ids: ['a', 'b'], embeddings: [[1], [2]], documents: ['', 'doc'], metadatas: [{}, {}]}, unrecoverable: [], counts: {total: 2, intact: 1, reEmbedded: 1, unrecoverable: 0}}
96+
},
97+
{calls, client, auditFn, extractFn, promoteFn, clearStateFn, writeStateFn} = makeSeams({coverage, extractResults});
98+
99+
const {results} = await repairMemoryCoreCollectionsViaFullEnumeration({
100+
client, collections: ['mc-memory'], snapshotPath: '/snap', persistDir: '/persist',
101+
embedFn, embeddingFunction, statePath: '/state', dryRun: true,
102+
auditFn, extractFn, promoteFn, clearStateFn, writeStateFn, log: () => {}
103+
});
104+
105+
expect(calls.audit[0].includeFullIds).toBe(true);
106+
expect(calls.extract).toHaveLength(1);
107+
expect(calls.promote).toHaveLength(0);
108+
expect(calls.clearState).toHaveLength(0);
109+
expect(calls.writeState).toHaveLength(0);
110+
expect(results).toEqual([{
111+
collectionName: 'mc-memory',
112+
dryRun : true,
113+
counts : {total: 2, intact: 1, reEmbedded: 1, unrecoverable: 0}
114+
}]);
115+
});
116+
117+
test('dry-run reports unrecoverable rows without writing an aborted state marker', async () => {
118+
const coverage = {collections: [{name: 'mc-memory', allIds: ['a', 'b'], missingVectorIds: ['b']}]},
119+
extractResults = {
120+
'mc-memory': {data: {ids: ['a'], embeddings: [[1]], documents: [''], metadatas: [{}]}, unrecoverable: ['b'], counts: {total: 2, intact: 1, reEmbedded: 0, unrecoverable: 1}}
121+
},
122+
{calls, client, auditFn, extractFn, promoteFn, clearStateFn, writeStateFn} = makeSeams({coverage, extractResults});
123+
124+
const {results} = await repairMemoryCoreCollectionsViaFullEnumeration({
125+
client, collections: ['mc-memory'], snapshotPath: '/snap', persistDir: '/persist',
126+
embedFn, embeddingFunction, statePath: '/state', dryRun: true,
127+
auditFn, extractFn, promoteFn, clearStateFn, writeStateFn, log: () => {}
128+
});
129+
130+
expect(calls.promote).toHaveLength(0);
131+
expect(calls.clearState).toHaveLength(0);
132+
expect(calls.writeState).toHaveLength(0);
133+
expect(results[0]).toMatchObject({
134+
collectionName: 'mc-memory',
135+
dryRun : true,
136+
aborted : true,
137+
unrecoverable : ['b'],
138+
counts : {total: 2, intact: 1, reEmbedded: 0, unrecoverable: 1}
139+
});
140+
expect(anyRepairAborted(results)).toBe(true);
141+
});
142+
92143
test('throws when the enumeration returns no coverage row for a requested collection', async () => {
93144
const {client, auditFn, extractFn, promoteFn} = makeSeams({coverage: {collections: []}, extractResults: {}});
94145

@@ -99,7 +150,7 @@ test.describe('repairMemoryCoreCollectionsViaFullEnumeration (#13634 AC4)', () =
99150
});
100151

101152
test('omitting statePath skips all state-marker ops (no marker lifecycle without a path)', async () => {
102-
const coverage = {collections: [{name: 'mc-memory', allIds: ['a'], missingVectorIds: []}]},
153+
const coverage = {collections: [{name: 'mc-memory', allIds: ['a'], missingVectorIds: []}]},
103154
extractResults = {
104155
'mc-memory': {data: {ids: ['a'], embeddings: [[1]], documents: [''], metadatas: [{}]}, unrecoverable: [], counts: {total: 1, intact: 1, reEmbedded: 0, unrecoverable: 0}}
105156
},

0 commit comments

Comments
 (0)