Skip to content

Commit 0a5d0c1

Browse files
committed
fix(#632): two-phase upgrade - separate LLM enrichment from DB writes
- Extract prepareEntry() for LLM enrichment (no lock) - Extract applyEnrichedEntry() for DB writes (within single lock) - Phase 1: Promise.all for parallel LLM enrichment - Phase 2: Single runWithFileLock for all DB writes - Reduces lock contention from N to 1 per batch
1 parent 3697ed5 commit 0a5d0c1

File tree

1 file changed

+74
-15
lines changed

1 file changed

+74
-15
lines changed

src/memory-upgrader.ts

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,9 @@ export class MemoryUpgrader {
257257
return result;
258258
}
259259

260-
// Process in batches
260+
// Process in batches using two-phase approach (fix #632)
261+
// Phase 1: LLM enrichment - NO lock (parallel)
262+
// Phase 2: Single lock for ALL DB writes
261263
const toProcess = limit
262264
? legacyMemories.slice(0, limit)
263265
: legacyMemories;
@@ -268,16 +270,49 @@ export class MemoryUpgrader {
268270
`memory-upgrader: processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(toProcess.length / batchSize)} (${batch.length} memories)`,
269271
);
270272

271-
for (const entry of batch) {
272-
try {
273-
await this.upgradeEntry(entry, noLlm);
274-
result.upgraded++;
275-
} catch (err) {
276-
const errMsg = `Failed to upgrade ${entry.id}: ${String(err)}`;
277-
result.errors.push(errMsg);
278-
this.log(`memory-upgrader: ERROR — ${errMsg}`);
273+
// Phase 1: LLM enrichment (parallel, no lock)
274+
const enrichedEntries = await Promise.all(
275+
batch.map(async (entry) => {
276+
try {
277+
const enriched = await this.prepareEntry(entry, noLlm);
278+
return { entry, enriched, error: null };
279+
} catch (err) {
280+
const errMsg = `Failed to enrich ${entry.id}: ${String(err)}`;
281+
this.log(`memory-upgrader: ERROR — ${errMsg}`);
282+
return { entry, enriched: null, error: errMsg };
283+
}
284+
})
285+
);
286+
287+
// Phase 2: Single lock for ALL DB writes
288+
// Note: runWithFileLock is optional (exists in real store, not in test mocks)
289+
const doInLock = async <T>(fn: () => Promise<T>): Promise<T> => {
290+
if (typeof (this.store as any).runWithFileLock === "function") {
291+
return (this.store as any).runWithFileLock(fn);
279292
}
280-
}
293+
// No lock in test environment
294+
return fn();
295+
};
296+
297+
await doInLock(async () => {
298+
for (const { entry, enriched, error } of enrichedEntries) {
299+
if (error) {
300+
result.errors.push(error);
301+
continue;
302+
}
303+
if (!enriched) {
304+
continue;
305+
}
306+
try {
307+
await this.applyEnrichedEntry(entry, enriched);
308+
result.upgraded++;
309+
} catch (err) {
310+
const errMsg = `Failed to apply ${entry.id}: ${String(err)}`;
311+
result.errors.push(errMsg);
312+
this.log(`memory-upgrader: ERROR — ${errMsg}`);
313+
}
314+
}
315+
});
281316

282317
// Progress report
283318
this.log(
@@ -292,12 +327,13 @@ export class MemoryUpgrader {
292327
}
293328

294329
/**
295-
* Upgrade a single legacy memory entry.
330+
* Phase 1: Prepare enriched metadata (LLM enrichment, NO lock).
331+
* Extracts LLM logic from upgradeEntry for two-phase approach (fix #632).
296332
*/
297-
private async upgradeEntry(
333+
private async prepareEntry(
298334
entry: MemoryEntry,
299335
noLlm: boolean,
300-
): Promise<void> {
336+
): Promise<{ enriched: Pick<EnrichedMetadata, "l0_abstract" | "l1_overview" | "l2_content">; newCategory: MemoryCategory }> {
301337
// Step 1: Reverse-map category
302338
let newCategory = reverseMapCategory(entry.category, entry.text);
303339

@@ -344,7 +380,19 @@ export class MemoryUpgrader {
344380
enriched = simpleEnrich(entry.text, newCategory);
345381
}
346382

347-
// Step 3: Build enriched metadata
383+
return { enriched, newCategory };
384+
}
385+
386+
/**
387+
* Phase 2: Apply enriched entry to DB (within lock).
388+
*/
389+
private async applyEnrichedEntry(
390+
entry: MemoryEntry,
391+
prepared: { enriched: Pick<EnrichedMetadata, "l0_abstract" | "l1_overview" | "l2_content">; newCategory: MemoryCategory },
392+
): Promise<void> {
393+
const { enriched, newCategory } = prepared;
394+
395+
// Build enriched metadata
348396
const existingMeta = entry.metadata ? (() => {
349397
try { return JSON.parse(entry.metadata!); } catch { return {}; }
350398
})() : {};
@@ -366,13 +414,24 @@ export class MemoryUpgrader {
366414
upgraded_at: Date.now(),
367415
};
368416

369-
// Step 4: Update the memory entry
417+
// Update the memory entry
370418
await this.store.update(entry.id, {
371419
// Update text to L0 abstract for better search indexing
372420
text: enriched.l0_abstract,
373421
metadata: stringifySmartMetadata(newMetadata),
374422
});
375423
}
424+
425+
/**
426+
* Upgrade a single legacy memory entry (legacy method, kept for backward compatibility).
427+
*/
428+
private async upgradeEntry(
429+
entry: MemoryEntry,
430+
noLlm: boolean,
431+
): Promise<void> {
432+
const { enriched, newCategory } = await this.prepareEntry(entry, noLlm);
433+
await this.applyEnrichedEntry(entry, { enriched, newCategory });
434+
}
376435
}
377436

378437
// ============================================================================

0 commit comments

Comments
 (0)