perf(subscriptions): cut redundant cold-login list fetches

Two redundancies were inflating the login REQ burst (kind 30000=19 storm in
the real-env console — the cause of the slow hashtags, KNOWN_BUGS #0),
isolated with the channel-sim harness's REQ-signature + timing capture:

1. Duplicate trigger: ProfileModalController.handleAuthLogin called
   subscriptions.loadSubscriptions() AND userBlocks.loadBlocks() DIRECTLY at
   login, on top of the auth coordinator's gated list loads — a second,
   ungated relay round-trip for each. Route them through ensureLoaded()
   instead so they coalesce with the coordinator's in-flight load. Harness:
   subscriptions updateFromRelays now fires ONCE at login instead of 3×
   (≈3× fewer kind-30000 fetches in prod, where each is multiplied by the
   relay set).

2. Fetch/decrypt coupling: a decrypt retry re-ran the full relay fetch even
   though only signer availability had changed. Cache the fetched ciphertext
   (_pendingDecryptEvent) and have scheduleDecryptRetry re-decrypt it via a
   cachedDecryptEvent fast-path in updateFromRelays — no redundant relay
   query per retry.

Validated: MODE=overload (channel drops) still decrypts blocks +
subscriptions + hashtags (DMs OK); full unit suite green; subscription loop
+ aggregation tests still pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-16 10:26:26 -04:00
1 parent 10ea4f6a22
commit 0db4f0a603
3 files changed
+103 -63

No files matched your search

+89 -59
View File
@@ -398,6 +398,9 @@ class SubscriptionsManager {
this.subsEventCreatedAt = null;
// Last live-subscription event id we triggered a refresh for (loop guard).
this._lastHandledListEventId = null;
// Most recently fetched list ciphertext, reused by decrypt retries so they
// don't re-query relays.
this._pendingDecryptEvent = null;
this.currentUserPubkey = null;
this.uiReady = false;
this.dataReady = false;
@@ -517,7 +520,13 @@ class SubscriptionsManager {
if (this.currentUserPubkey && this.currentUserPubkey !== normalized) {
return;
}
this.updateFromRelays(normalized, options).catch((retryError) => {
// Reuse the already-fetched ciphertext so the retry only re-runs the
// DECRYPT (no redundant relay fetch). Fall back to a full fetch only if we
// somehow have no pending event cached.
const retryOptions = this._pendingDecryptEvent
? { ...options, cachedDecryptEvent: this._pendingDecryptEvent }
: options;
this.updateFromRelays(normalized, retryOptions).catch((retryError) => {
userLogger.warn("[SubscriptionsManager] Decryption retry failed:", retryError);
});
}, retryDelayMs);
@@ -573,79 +582,95 @@ class SubscriptionsManager {
);
}
}
this.ensureSubscriptionListSubscription(normalizedUserPubkey, relayUrls);
// DECRYPT-RETRY FAST PATH: a retry re-uses the ciphertext we already
// fetched rather than re-querying relays. The relay data hasn't changed
// between attempts — only signer availability has — so re-fetching on
// every retry just floods the cold-login burst with redundant kind-30000
// REQs (KNOWN_BUGS #0). Only the initial load (and a genuinely new live
// event) hits the network.
let hadCachedSnapshot = false;
let mergedEvents = [];
const retryEvent =
options?.cachedDecryptEvent && options.cachedDecryptEvent.id
? options.cachedDecryptEvent
: null;
// Use incremental fetch helper
const cachedSnapshot = parseCachedSubscriptionSnapshot(
profileCache.getProfileData(normalizedUserPubkey, "subscriptions"),
);
const hadCachedSnapshot = cachedSnapshot.hasSnapshot;
const shouldForceFullFetch = !cachedSnapshot.hasSnapshot;
const incrementalSince = shouldForceFullFetch
? 0
: computeIncrementalSinceWithOverlap(cachedSnapshot.createdAt, 1);
if (retryEvent) {
mergedEvents = [retryEvent];
} else {
this.ensureSubscriptionListSubscription(normalizedUserPubkey, relayUrls);
// Fetch user and session actor subscription lists in parallel to reduce
// total relay wait time during login.
const fetchPromises = [
nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: incrementalSince,
timeoutMs: 12000,
}),
];
const normalizedSessionActorPubkey = normalizeNostrPubkey(
nostrClient?.sessionActor?.pubkey,
);
const shouldFetchSessionActor =
normalizedSessionActorPubkey &&
normalizedSessionActorPubkey !== normalizedUserPubkey;
if (shouldFetchSessionActor) {
const sessionCachedSnapshot = parseCachedSubscriptionSnapshot(
profileCache.getProfileData(
normalizedSessionActorPubkey,
"subscriptions",
),
// Use incremental fetch helper
const cachedSnapshot = parseCachedSubscriptionSnapshot(
profileCache.getProfileData(normalizedUserPubkey, "subscriptions"),
);
const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot;
const sessionSince = shouldForceSessionFetch
hadCachedSnapshot = cachedSnapshot.hasSnapshot;
const shouldForceFullFetch = !cachedSnapshot.hasSnapshot;
const incrementalSince = shouldForceFullFetch
? 0
: computeIncrementalSinceWithOverlap(sessionCachedSnapshot.createdAt, 1);
fetchPromises.push(
: computeIncrementalSinceWithOverlap(cachedSnapshot.createdAt, 1);
// Fetch user and session actor subscription lists in parallel to reduce
// total relay wait time during login.
const fetchPromises = [
nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedSessionActorPubkey,
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: sessionSince,
since: incrementalSince,
timeoutMs: 12000,
}),
];
const normalizedSessionActorPubkey = normalizeNostrPubkey(
nostrClient?.sessionActor?.pubkey,
);
}
const shouldFetchSessionActor =
normalizedSessionActorPubkey &&
normalizedSessionActorPubkey !== normalizedUserPubkey;
if (shouldFetchSessionActor) {
const sessionCachedSnapshot = parseCachedSubscriptionSnapshot(
profileCache.getProfileData(
normalizedSessionActorPubkey,
"subscriptions",
),
);
const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot;
const sessionSince = shouldForceSessionFetch
? 0
: computeIncrementalSinceWithOverlap(sessionCachedSnapshot.createdAt, 1);
fetchPromises.push(
nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedSessionActorPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: sessionSince,
timeoutMs: 12000,
}),
);
}
const fetchResults = await Promise.all(fetchPromises);
let events = fetchResults[0] || [];
if (shouldFetchSessionActor && fetchResults[1]?.length) {
events = events.concat(fetchResults[1]);
}
const fetchResults = await Promise.all(fetchPromises);
let events = fetchResults[0] || [];
if (shouldFetchSessionActor && fetchResults[1]?.length) {
events = events.concat(fetchResults[1]);
}
const mergedEvents = [];
if (events.length) {
const deduped = new Map();
for (const event of events) {
if (!event || typeof event !== "object" || !event.id) {
continue;
if (events.length) {
const deduped = new Map();
for (const event of events) {
if (!event || typeof event !== "object" || !event.id) {
continue;
}
const existing = deduped.get(event.id);
if (!existing || (event.created_at ?? 0) > (existing.created_at ?? 0)) {
deduped.set(event.id, event);
}
}
const existing = deduped.get(event.id);
if (!existing || (event.created_at ?? 0) > (existing.created_at ?? 0)) {
deduped.set(event.id, event);
}
mergedEvents.push(...deduped.values());
}
mergedEvents.push(...deduped.values());
}
if (!mergedEvents.length) {
@@ -692,6 +717,10 @@ class SubscriptionsManager {
return;
}
// Cache the ciphertext so a decrypt retry can re-attempt WITHOUT another
// relay round-trip (see the retry fast-path above).
this._pendingDecryptEvent = newest;
// Any event returned here is effectively "new information" (or re-fetched full state).
// fetchListIncrementally already handles the "newer than last sync" logic per relay,
// and we persist event metadata for additional cache context.
@@ -870,6 +899,7 @@ class SubscriptionsManager {
this.subsEventId = null;
this.subsEventCreatedAt = null;
this._lastHandledListEventId = null;
this._pendingDecryptEvent = null;
this.currentUserPubkey = null;
this.uiReady = false;
this.dataReady = false;
+13 -3
View File
@@ -5157,12 +5157,22 @@ export class ProfileModalController {
this.renderSavedProfiles();
// Trigger aggressive parallel fetches
// Warm blocks + subscriptions, but go through ensureLoaded() (not the raw
// loadBlocks/loadSubscriptions) so these COALESCE with the auth coordinator's
// login-time list loads instead of issuing a second, ungated fetch. The
// direct calls here each kicked off an independent relay round-trip,
// doubling the cold-login kind-30000/10000 REQ burst (KNOWN_BUGS #0).
if (activePubkey) {
if (this.services.userBlocks) {
if (this.services.userBlocks?.ensureLoaded) {
this.services.userBlocks.ensureLoaded(activePubkey).catch(noop);
} else if (this.services.userBlocks?.loadBlocks) {
this.services.userBlocks.loadBlocks(activePubkey).catch(noop);
}
if (this.subscriptionsService) {
if (this.subscriptionsService?.ensureLoaded) {
this.subscriptionsService
.ensureLoaded(activePubkey, { allowPermissionPrompt: false })
.catch(noop);
} else if (this.subscriptionsService?.loadSubscriptions) {
this.subscriptionsService
.loadSubscriptions(activePubkey, { allowPermissionPrompt: false })
.catch(noop);
+1 -1
View File
@@ -226,7 +226,7 @@ async function main() {
if (text.includes("nip07-channel-unresponsive")) signals.channelUnresponsive += 1;
if (text.includes("DM decryption helpers are unavailable")) signals.dmHelpersUnavailable += 1;
if (DEBUG && /UserBlockList|Hashtag|Subscriptions|Decryption|signer|channel|direct message/i.test(text)) {
console.log("PAGE>", text.slice(0, 200));
console.log("PAGE>", text.slice(0, 500));
}
};