perf(dm): coalesce concurrent decrypts of the same DM event

Multiple subscribers (history list + notifications) decrypt the same DM event
at once; before the in-memory cache fills, each hit the serialized nip-07
extension, multiplying round-trips per message. Cache the in-flight decrypt
promise by event id so concurrent callers share one decrypt.

Harness: cold-load extension decrypt calls drop from ~50-60 to exactly 20 for 20
DMs (1 per message); warm reload stays 0 via the persisted cache. Combined with
the earlier ordering/sequential and persist-cache fixes, legacy DM decryption
went from ~241 extension calls (20 DMs) to 20 cold / 0 warm.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-12 19:56:36 -04:00
1 parent 8171fcb347
commit 39aa81fb23
1 file changed
+46 -23
+46 -23
View File
@@ -577,6 +577,8 @@ export class NostrClient {
},
});
this.dmDecryptCache = new LRUCache({ maxSize: DM_DECRYPT_CACHE_LIMIT });
// event id -> in-flight decrypt promise (coalesces concurrent decrypts).
this.dmDecryptInFlight = new Map();
this.dmDecryptor = null;
this.dmDecryptorPromise = null;
this.isInitialized = false;
@@ -1373,32 +1375,53 @@ export class NostrClient {
// fall through to a fresh decrypt
}
}
}
const decryptDM = await this.ensureDmDecryptor();
const context = await this.buildDmDecryptContext(actorPubkey);
let result;
try {
result = await decryptDM(event, context);
} catch (error) {
devLogger.warn("[nostr] DM decryptor threw unexpectedly.", {
error: sanitizeDecryptError(error),
event: summarizeDmEventForLog(event),
});
throw error;
}
if (result?.ok && eventId) {
this.dmDecryptCache.set(eventId, result);
try {
dmPlaintextCache.set(eventId, JSON.stringify(result));
} catch (_) {
// result not serializable — skip persisting
// Coalesce concurrent decrypts of the SAME event. Multiple subscribers
// (history list, notifications) often process an event at once; without
// this they each hit the (serialized, slow) extension before the cache
// fills, multiplying extension round-trips per message.
const inFlight = this.dmDecryptInFlight.get(eventId);
if (inFlight) {
return inFlight;
}
}
return result;
const decryptPromise = (async () => {
const decryptDM = await this.ensureDmDecryptor();
const context = await this.buildDmDecryptContext(actorPubkey);
let result;
try {
result = await decryptDM(event, context);
} catch (error) {
devLogger.warn("[nostr] DM decryptor threw unexpectedly.", {
error: sanitizeDecryptError(error),
event: summarizeDmEventForLog(event),
});
throw error;
}
if (result?.ok && eventId) {
this.dmDecryptCache.set(eventId, result);
try {
dmPlaintextCache.set(eventId, JSON.stringify(result));
} catch (_) {
// result not serializable — skip persisting
}
}
return result;
})();
if (eventId) {
this.dmDecryptInFlight.set(eventId, decryptPromise);
}
try {
return await decryptPromise;
} finally {
if (eventId) {
this.dmDecryptInFlight.delete(eventId);
}
}
}
/**