mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Merge pull request #673 from PR0M3TH3AN/codex/update-batchfetchprofiles-for-resilient-processing
Handle partial relay profile fetch failures
This commit is contained in:
3 files changed
+301
-60
No files matched your search
@@ -87,6 +87,7 @@ import { resolveVideoPointer } from "./utils/videoPointer.js";
|
||||
import { isValidMagnetUri } from "./utils/magnetValidators.js";
|
||||
import { dedupeToNewestByRoot } from "./utils/videoDeduper.js";
|
||||
import { buildServiceWorkerFallbackStatus } from "./utils/serviceWorkerFallbackMessages.js";
|
||||
import { batchFetchProfilesFromRelays } from "./utils/profileBatchFetcher.js";
|
||||
import {
|
||||
getVideoRootIdentifier,
|
||||
applyRootTimestampToVideosMap,
|
||||
@@ -2279,67 +2280,15 @@ class Application {
|
||||
}
|
||||
|
||||
async batchFetchProfiles(authorSet) {
|
||||
const pubkeys = Array.from(authorSet);
|
||||
if (!pubkeys.length) return;
|
||||
|
||||
const toFetch = [];
|
||||
|
||||
pubkeys.forEach((pubkey) => {
|
||||
const cacheEntry = this.getProfileCacheEntry(pubkey);
|
||||
if (cacheEntry) {
|
||||
this.updateProfileInDOM(pubkey, cacheEntry.profile);
|
||||
} else {
|
||||
toFetch.push(pubkey);
|
||||
}
|
||||
return batchFetchProfilesFromRelays({
|
||||
authorSet,
|
||||
getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey),
|
||||
setProfileCacheEntry: (pubkey, profile) =>
|
||||
this.setProfileCacheEntry(pubkey, profile),
|
||||
updateProfileInDOM: (pubkey, profile) =>
|
||||
this.updateProfileInDOM(pubkey, profile),
|
||||
hex64Regex: HEX64_REGEX,
|
||||
});
|
||||
|
||||
if (!toFetch.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [0],
|
||||
authors: toFetch,
|
||||
limit: toFetch.length,
|
||||
};
|
||||
|
||||
try {
|
||||
// Query each relay
|
||||
const results = await Promise.all(
|
||||
nostrClient.relays.map((relayUrl) =>
|
||||
nostrClient.pool.list([relayUrl], [filter])
|
||||
)
|
||||
);
|
||||
const allProfileEvents = results.flat();
|
||||
|
||||
// Keep only the newest per author
|
||||
const newestEvents = new Map();
|
||||
for (const evt of allProfileEvents) {
|
||||
if (
|
||||
!newestEvents.has(evt.pubkey) ||
|
||||
evt.created_at > newestEvents.get(evt.pubkey).created_at
|
||||
) {
|
||||
newestEvents.set(evt.pubkey, evt);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the cache & DOM
|
||||
for (const [pubkey, evt] of newestEvents.entries()) {
|
||||
try {
|
||||
const data = JSON.parse(evt.content);
|
||||
const profile = {
|
||||
name: data.name || data.display_name || "Unknown",
|
||||
picture: data.picture || "assets/svg/default-profile.svg",
|
||||
};
|
||||
this.setProfileCacheEntry(pubkey, profile);
|
||||
this.updateProfileInDOM(pubkey, profile);
|
||||
} catch (err) {
|
||||
devLogger.error("Profile parse error:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
devLogger.error("Batch profile fetch error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
updateProfileInDOM(pubkey, profile) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// js/utils/profileBatchFetcher.js
|
||||
|
||||
import { nostrClient } from "../nostr.js";
|
||||
import { devLogger } from "./logger.js";
|
||||
|
||||
const DEFAULT_PROFILE_IMAGE = "assets/svg/default-profile.svg";
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
export async function batchFetchProfilesFromRelays({
|
||||
authorSet,
|
||||
getProfileCacheEntry,
|
||||
setProfileCacheEntry,
|
||||
updateProfileInDOM,
|
||||
nostr = nostrClient,
|
||||
logger = devLogger,
|
||||
hex64Regex = HEX64_REGEX,
|
||||
defaultProfileImage = DEFAULT_PROFILE_IMAGE,
|
||||
} = {}) {
|
||||
if (typeof getProfileCacheEntry !== "function") {
|
||||
throw new TypeError("getProfileCacheEntry callback is required");
|
||||
}
|
||||
if (typeof setProfileCacheEntry !== "function") {
|
||||
throw new TypeError("setProfileCacheEntry callback is required");
|
||||
}
|
||||
if (typeof updateProfileInDOM !== "function") {
|
||||
throw new TypeError("updateProfileInDOM callback is required");
|
||||
}
|
||||
|
||||
const pubkeys = Array.isArray(authorSet)
|
||||
? authorSet
|
||||
: Array.from(authorSet || []);
|
||||
if (!pubkeys.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toFetch = [];
|
||||
|
||||
pubkeys.forEach((pubkey) => {
|
||||
const normalizedPubkey =
|
||||
typeof pubkey === "string" ? pubkey.trim() : "";
|
||||
const hasValidPubkey =
|
||||
normalizedPubkey && hex64Regex.test(normalizedPubkey);
|
||||
|
||||
if (!hasValidPubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheEntry = getProfileCacheEntry(normalizedPubkey);
|
||||
if (cacheEntry) {
|
||||
updateProfileInDOM(normalizedPubkey, cacheEntry.profile);
|
||||
} else {
|
||||
toFetch.push(normalizedPubkey);
|
||||
}
|
||||
});
|
||||
|
||||
if (!toFetch.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nostr?.pool || typeof nostr.pool.list !== "function") {
|
||||
logger.warn(
|
||||
"[batchFetchProfiles] Relay pool is not ready; skipping profile fetch.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [0],
|
||||
authors: toFetch,
|
||||
limit: toFetch.length,
|
||||
};
|
||||
|
||||
const newestProfiles = new Map();
|
||||
|
||||
const applyProfileEvent = (evt, relayUrl) => {
|
||||
if (!evt || typeof evt !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const pubkey =
|
||||
typeof evt.pubkey === "string" && hex64Regex.test(evt.pubkey)
|
||||
? evt.pubkey
|
||||
: null;
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAt = Number.isFinite(evt.created_at) ? evt.created_at : 0;
|
||||
const previous = newestProfiles.get(pubkey);
|
||||
if (previous && previous.createdAt >= createdAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(evt.content);
|
||||
const profile = {
|
||||
name: data?.name || data?.display_name || "Unknown",
|
||||
picture: data?.picture || defaultProfileImage,
|
||||
};
|
||||
|
||||
newestProfiles.set(pubkey, { createdAt, profile });
|
||||
setProfileCacheEntry(pubkey, profile);
|
||||
updateProfileInDOM(pubkey, profile);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[batchFetchProfiles] Profile parse error for ${pubkey} from ${relayUrl}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const relayPromises = (Array.isArray(nostr.relays) ? nostr.relays : []).map(
|
||||
(relayUrl) =>
|
||||
nostr.pool
|
||||
.list([relayUrl], [filter])
|
||||
.then((events) => ({ relayUrl, events })),
|
||||
);
|
||||
|
||||
if (!relayPromises.length) {
|
||||
logger.warn(
|
||||
"[batchFetchProfiles] No relays configured; skipping profile fetch.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(relayPromises);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const relayUrl =
|
||||
result.status === "fulfilled"
|
||||
? result.value.relayUrl
|
||||
: nostr.relays[index];
|
||||
|
||||
if (result.status === "fulfilled") {
|
||||
const events = Array.isArray(result.value.events)
|
||||
? result.value.events
|
||||
: [];
|
||||
if (!events.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newestByPubkey = new Map();
|
||||
for (const evt of events) {
|
||||
if (!evt || typeof evt !== "object") {
|
||||
continue;
|
||||
}
|
||||
const pubkey =
|
||||
typeof evt.pubkey === "string" && hex64Regex.test(evt.pubkey)
|
||||
? evt.pubkey
|
||||
: null;
|
||||
if (!pubkey) {
|
||||
continue;
|
||||
}
|
||||
const createdAt = Number.isFinite(evt.created_at)
|
||||
? evt.created_at
|
||||
: 0;
|
||||
const prior = newestByPubkey.get(pubkey);
|
||||
if (!prior || createdAt > prior.createdAt) {
|
||||
newestByPubkey.set(pubkey, { createdAt, event: evt });
|
||||
}
|
||||
}
|
||||
|
||||
newestByPubkey.forEach(({ event }) => {
|
||||
applyProfileEvent(event, relayUrl);
|
||||
});
|
||||
} else if (relayUrl) {
|
||||
logger.warn(
|
||||
`[batchFetchProfiles] Failed to fetch profiles from relay ${relayUrl}:`,
|
||||
result.reason,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { nostrClient } from "../js/nostr.js";
|
||||
import { batchFetchProfilesFromRelays } from "../js/utils/profileBatchFetcher.js";
|
||||
|
||||
const FAST_RELAY = "wss://fast.example";
|
||||
const FAIL_RELAY = "wss://fail.example";
|
||||
const VALID_PUBKEY = "a".repeat(64);
|
||||
const CACHED_PUBKEY = "b".repeat(64);
|
||||
|
||||
function createProfileEvent({ pubkey, createdAt, content }) {
|
||||
return {
|
||||
id: `${pubkey}:${createdAt}`,
|
||||
pubkey,
|
||||
created_at: createdAt,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
}
|
||||
|
||||
test("batchFetchProfiles handles fast and failing relays", async () => {
|
||||
const cache = new Map();
|
||||
const updates = [];
|
||||
const setCalls = [];
|
||||
const authorSet = new Set([
|
||||
VALID_PUBKEY,
|
||||
CACHED_PUBKEY,
|
||||
"",
|
||||
null,
|
||||
undefined,
|
||||
"npubInvalid",
|
||||
]);
|
||||
|
||||
const getProfileCacheEntry = (pubkey) => {
|
||||
if (pubkey === CACHED_PUBKEY) {
|
||||
return { profile: { name: "Cached", picture: "cached.png" } };
|
||||
}
|
||||
return cache.get(pubkey) ?? null;
|
||||
};
|
||||
|
||||
const setProfileCacheEntry = (pubkey, profile) => {
|
||||
cache.set(pubkey, { profile });
|
||||
setCalls.push({ pubkey, profile });
|
||||
};
|
||||
|
||||
const updateProfileInDOM = (pubkey, profile) => {
|
||||
updates.push({ pubkey, profile });
|
||||
};
|
||||
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
? [...nostrClient.relays]
|
||||
: [];
|
||||
const originalPool = nostrClient.pool;
|
||||
|
||||
const poolListCalls = [];
|
||||
|
||||
nostrClient.relays = [FAST_RELAY, FAIL_RELAY];
|
||||
nostrClient.pool = {
|
||||
list: (relays, filters) => {
|
||||
poolListCalls.push({ relays, filters });
|
||||
const relayUrl = Array.isArray(relays) ? relays[0] : undefined;
|
||||
if (relayUrl === FAST_RELAY) {
|
||||
return Promise.resolve([
|
||||
createProfileEvent({
|
||||
pubkey: VALID_PUBKEY,
|
||||
createdAt: 200,
|
||||
content: { name: "Fast", picture: "fast.png" },
|
||||
}),
|
||||
createProfileEvent({
|
||||
pubkey: VALID_PUBKEY,
|
||||
createdAt: 150,
|
||||
content: { name: "Old", picture: "old.png" },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
if (relayUrl === FAIL_RELAY) {
|
||||
return Promise.reject(new Error("relay timed out"));
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await batchFetchProfilesFromRelays({
|
||||
authorSet,
|
||||
getProfileCacheEntry,
|
||||
setProfileCacheEntry,
|
||||
updateProfileInDOM,
|
||||
});
|
||||
} finally {
|
||||
nostrClient.relays = originalRelays;
|
||||
nostrClient.pool = originalPool;
|
||||
}
|
||||
|
||||
assert.equal(poolListCalls.length, 2, "expected a query per relay");
|
||||
|
||||
const [fastCall] = poolListCalls;
|
||||
assert.deepEqual(
|
||||
fastCall.filters[0].authors,
|
||||
[VALID_PUBKEY],
|
||||
"should query only uncached, valid pubkeys",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
updates[0],
|
||||
{ pubkey: CACHED_PUBKEY, profile: { name: "Cached", picture: "cached.png" } },
|
||||
"cached profiles should hydrate immediately",
|
||||
);
|
||||
|
||||
assert.equal(updates.length, 2, "fast relay should update immediately");
|
||||
assert.deepEqual(
|
||||
updates[1],
|
||||
{ pubkey: VALID_PUBKEY, profile: { name: "Fast", picture: "fast.png" } },
|
||||
"newest relay profile should render",
|
||||
);
|
||||
|
||||
assert.deepEqual(setCalls, [
|
||||
{ pubkey: VALID_PUBKEY, profile: { name: "Fast", picture: "fast.png" } },
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user