Merge pull request #1053 from PR0M3TH3AN/codex/add-retries-to-batchfetchprofiles-call

Improve profile hydration retry diagnostics
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-12-29 21:27:08 -05:00
commit 42ef3ddd46
2 files changed
+144 -22

No files matched your search

+59 -22
View File
@@ -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;
+85
View File
@@ -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 () => {