From 3c6e397984781419f3d85b042592b6ae262c6fe1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:19:00 -0500 Subject: [PATCH] Improve profile hydration retry diagnostics --- js/services/commentThreadService.js | 81 ++++++++++++++++++------- tests/comment-thread-service.test.mjs | 85 +++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 22 deletions(-) diff --git a/js/services/commentThreadService.js b/js/services/commentThreadService.js index 3cb9805e..21dff452 100644 --- a/js/services/commentThreadService.js +++ b/js/services/commentThreadService.js @@ -9,6 +9,8 @@ import { FEATURE_IMPROVED_COMMENT_FETCHING } from "../constants.js"; const ROOT_PARENT_KEY = "__root__"; const DEFAULT_INITIAL_LIMIT = 40; const DEFAULT_HYDRATION_DEBOUNCE_MS = 25; +const PROFILE_FETCH_MAX_ATTEMPTS = 3; +const PROFILE_FETCH_BACKOFF_MS = 50; const COMMENT_CACHE_PREFIX = "bitvid:comments:"; const COMMENT_CACHE_TTL_MS = 5 * 60 * 1000; const COMMENT_CACHE_VERSION = 2; @@ -874,31 +876,66 @@ export default class CommentThreadService { return; } - const hydrationPromise = Promise.resolve( - this.batchFetchProfiles(pubkeys), - ) - .catch((error) => { - if (this.logger?.warn) { - this.logger.warn( - "[commentThread] Profile hydration failed:", - error, - ); - } - this.emitError(error); - }) - .then(() => { - pubkeys.forEach((pubkey) => { - const profile = this.getProfileFromCache(pubkey); - if (profile) { - this.profileCache.set(pubkey, profile); + const pubkeyListLog = pubkeys.join(", ") || "(empty profile batch)"; + const hydrationPromise = (async () => { + try { + let attempt = 0; + let lastError = null; + + while (attempt < PROFILE_FETCH_MAX_ATTEMPTS) { + try { + await Promise.resolve(this.batchFetchProfiles(pubkeys)); + break; + } catch (error) { + lastError = error; + const attemptLabel = `${attempt + 1}/${PROFILE_FETCH_MAX_ATTEMPTS}`; + if (this.logger?.dev?.warn) { + this.logger.dev.warn( + `[commentThread] Profile hydration attempt ${attemptLabel} failed for pubkeys: ${pubkeyListLog}.`, + error, + ); + } + + attempt += 1; + if (attempt >= PROFILE_FETCH_MAX_ATTEMPTS) { + throw lastError; + } + + const backoffMs = PROFILE_FETCH_BACKOFF_MS * attempt; + logDev( + this.logger?.dev, + `[commentThread] Retrying profile hydration in ${backoffMs}ms for pubkeys: ${pubkeyListLog}.`, + ); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); } - }); - }) - .finally(() => { - if (this.profileHydrationPromise === hydrationPromise) { - this.profileHydrationPromise = null; + } + } catch (error) { + const errorMessage = `[commentThread] Profile hydration failed for pubkeys: ${pubkeyListLog}.`; + if (this.logger?.user?.warn) { + this.logger.user.warn(errorMessage, error); + } else if (this.logger?.warn) { + this.logger.warn(errorMessage, error); + } + + if (this.logger?.dev?.warn && this.logger.dev !== this.logger.user) { + this.logger.dev.warn(errorMessage, error); + } + + this.emitError(error); + return; + } + + pubkeys.forEach((pubkey) => { + const profile = this.getProfileFromCache(pubkey); + if (profile) { + this.profileCache.set(pubkey, profile); } }); + })().finally(() => { + if (this.profileHydrationPromise === hydrationPromise) { + this.profileHydrationPromise = null; + } + }); this.profileHydrationPromise = hydrationPromise; await hydrationPromise; diff --git a/tests/comment-thread-service.test.mjs b/tests/comment-thread-service.test.mjs index 3c3b2666..c538d5b4 100644 --- a/tests/comment-thread-service.test.mjs +++ b/tests/comment-thread-service.test.mjs @@ -416,6 +416,91 @@ test("CommentThreadService normalizes mixed-case pubkeys during hydration", asyn ); }); +test("CommentThreadService retries profile hydration before succeeding", async () => { + const profiles = new Map(); + const hydrationCalls = []; + let attempts = 0; + + const service = new CommentThreadService({ + getProfileCacheEntry: (pubkey) => profiles.get(pubkey), + batchFetchProfiles: async (pubkeys) => { + hydrationCalls.push([...pubkeys]); + attempts += 1; + if (attempts === 1) { + throw new Error("temporary hydration failure"); + } + pubkeys.forEach((pubkey) => + profiles.set(pubkey, { name: `Profile ${pubkey}` }), + ); + }, + hydrationDebounceMs: 0, + }); + + service.queueProfileForHydration("pk-retry-1"); + service.queueProfileForHydration("pk-retry-2"); + + for (let attempt = 0; attempt < 5 && !service.profileHydrationPromise; attempt += 1) { + await tick(10); + } + await service.waitForProfileHydration(); + + assert.equal(attempts, 2, "hydration should retry failed batches once"); + assert.deepEqual( + hydrationCalls, + [ + ["pk-retry-1", "pk-retry-2"], + ["pk-retry-1", "pk-retry-2"], + ], + "hydration should reuse the same batch across retries", + ); + assert.ok(service.getProfile("pk-retry-1")); + assert.ok(service.getProfile("pk-retry-2")); +}); + +test( + "CommentThreadService surfaces profile hydration failures after retries", + async () => { + const errors = []; + const userWarnings = []; + const devWarnings = []; + let attempts = 0; + + const service = new CommentThreadService({ + batchFetchProfiles: async (pubkeys) => { + attempts += 1; + throw new Error(`hydration failed attempt ${attempts}`); + }, + logger: { + warn: (...args) => userWarnings.push(args), + dev: { warn: (...args) => devWarnings.push(args) }, + user: { warn: (...args) => userWarnings.push(args) }, + }, + hydrationDebounceMs: 0, + }); + + service.setCallbacks({ onError: (error) => errors.push(error) }); + service.queueProfileForHydration("pk-retry-fail"); + + await tick(200); + await service.waitForProfileHydration(); + + assert.equal(attempts, 3, "hydration should respect the retry limit"); + assert.equal(errors.length, 1, "final failures should emit an error"); + assert.ok( + userWarnings.some(([message]) => + typeof message === "string" && message.includes("pk-retry-fail"), + ), + "user-visible warnings should include the failed pubkeys", + ); + assert.ok( + devWarnings.some(([message]) => + typeof message === "string" && message.includes("attempt 1/3") + ), + "dev warnings should log the retry attempts", + ); + }, +); + test( "listVideoComments accepts builder events without parent ids and filters replies", async () => {