From f6322400506509077ea7d0aac73418f69752ee83 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 11 Nov 2025 13:33:54 +0100 Subject: [PATCH] make search more robust --- src/components/Embedded/EmbeddedNote.tsx | 96 ++++++++--- src/pages/secondary/NotePage/NotFound.tsx | 103 ++++++++---- src/services/client.service.ts | 192 ++++++++++++++++++---- 3 files changed, 302 insertions(+), 89 deletions(-) diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 6892a39..29017d0 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -1,5 +1,5 @@ import { Skeleton } from '@/components/ui/skeleton' -import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { useFetchEvent } from '@/hooks' import { normalizeUrl } from '@/lib/url' import { cn } from '@/lib/utils' @@ -106,30 +106,36 @@ function EmbeddedNoteNotFound({ // Calculate which external relays would be tried useEffect(() => { const getExternalRelays = async () => { - let relays: string[] = [] + // Get all relays that have already been tried (BIG_RELAY_URLS + FAST_READ_RELAY_URLS) + // These are the relays used in the initial fetch + const alreadyTriedRelaysSet = new Set() + ;[...BIG_RELAY_URLS, ...FAST_READ_RELAY_URLS].forEach(url => { + const normalized = normalizeUrl(url) + if (normalized) alreadyTriedRelaysSet.add(normalized) + }) + + let hintRelays: string[] = [] let extractedHexEventId: string | null = null + // Parse relay hints and author from bech32 ID if (!/^[0-9a-f]{64}$/.test(noteId)) { try { const { type, data } = nip19.decode(noteId) if (type === 'nevent') { extractedHexEventId = data.id - if (data.relays) relays.push(...data.relays) + if (data.relays) hintRelays.push(...data.relays) if (data.author) { const authorRelayList = await client.fetchRelayList(data.author) - relays.push(...authorRelayList.write.slice(0, 6)) + hintRelays.push(...authorRelayList.write.slice(0, 6)) } } else if (type === 'naddr') { - if (data.relays) relays.push(...data.relays) + if (data.relays) hintRelays.push(...data.relays) const authorRelayList = await client.fetchRelayList(data.pubkey) - relays.push(...authorRelayList.write.slice(0, 6)) + hintRelays.push(...authorRelayList.write.slice(0, 6)) } else if (type === 'note') { extractedHexEventId = data } - // Normalize and deduplicate relays - relays = relays.map(url => normalizeUrl(url) || url) - relays = Array.from(new Set(relays)) } catch (err) { logger.error('Failed to parse external relays', { error: err, noteId }) } @@ -139,25 +145,40 @@ function EmbeddedNoteNotFound({ setHexEventId(extractedHexEventId) + // Get relays where this event was seen const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : [] - relays.push(...seenOn) + hintRelays.push(...seenOn) - // Normalize all relays first - let normalizedRelays = relays.map(url => normalizeUrl(url) || url).filter(Boolean) - normalizedRelays = Array.from(new Set(normalizedRelays)) + // Normalize all hint relays + const normalizedHints = hintRelays + .map(url => normalizeUrl(url)) + .filter((url): url is string => Boolean(url)) - // If no external relays from hints, try SEARCHABLE_RELAY_URLS as fallback - // Filter out relays that overlap with BIG_RELAY_URLS (already tried first) - if (normalizedRelays.length === 0) { - const searchableRelays = SEARCHABLE_RELAY_URLS - .map(url => normalizeUrl(url) || url) - .filter((url): url is string => Boolean(url)) - .filter(relay => !BIG_RELAY_URLS.includes(relay)) - normalizedRelays.push(...searchableRelays) - } + // Combine hints with SEARCHABLE_RELAY_URLS (always include as fallback) + // Normalize SEARCHABLE_RELAY_URLS for comparison + const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS + .map(url => normalizeUrl(url)) + .filter((url): url is string => Boolean(url)) + + // Combine all potential relays (hints + searchable) + const allPotentialRelays = new Set([...normalizedHints, ...normalizedSearchableRelays]) + + // Filter out relays that were already tried + const externalRelays = Array.from(allPotentialRelays).filter( + relay => !alreadyTriedRelaysSet.has(relay) + ) // Deduplicate final relay list - setExternalRelays(Array.from(new Set(normalizedRelays))) + setExternalRelays(externalRelays) + + logger.debug('External relays calculated', { + noteId, + hintRelaysCount: normalizedHints.length, + searchableRelaysCount: normalizedSearchableRelays.length, + alreadyTriedCount: alreadyTriedRelaysSet.size, + externalRelaysCount: externalRelays.length, + externalRelays: externalRelays.slice(0, 10) // Log first 10 + }) } getExternalRelays() @@ -166,14 +187,37 @@ function EmbeddedNoteNotFound({ const handleTryExternalRelays = async () => { if (!hexEventId || isSearchingExternal) return + if (externalRelays.length === 0) { + logger.warn('No external relays to search', { noteId, hexEventId }) + setTriedExternal(true) + return + } + setIsSearchingExternal(true) try { + logger.info('Searching external relays', { + noteId, + hexEventId, + relayCount: externalRelays.length, + relays: externalRelays.slice(0, 5) // Log first 5 relays + }) + const event = await client.fetchEventWithExternalRelays(hexEventId, externalRelays) - if (event && onEventFound) { - onEventFound(event) + + if (event) { + logger.info('Event found on external relay', { noteId, hexEventId }) + if (onEventFound) { + onEventFound(event) + } + } else { + logger.info('Event not found on external relays', { + noteId, + hexEventId, + relayCount: externalRelays.length + }) } } catch (error) { - logger.error('External relay fetch failed', { error, noteId }) + logger.error('External relay fetch failed', { error, noteId, hexEventId, externalRelays }) } finally { setIsSearchingExternal(false) setTriedExternal(true) diff --git a/src/pages/secondary/NotePage/NotFound.tsx b/src/pages/secondary/NotePage/NotFound.tsx index e98e54e..eab3016 100644 --- a/src/pages/secondary/NotePage/NotFound.tsx +++ b/src/pages/secondary/NotePage/NotFound.tsx @@ -1,6 +1,6 @@ import ClientSelect from '@/components/ClientSelect' import { Button } from '@/components/ui/button' -import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' import client from '@/services/client.service' import { AlertCircle, Search } from 'lucide-react' @@ -27,10 +27,15 @@ export default function NotFound({ if (!bech32Id) return const getExternalRelays = async () => { - // Get all relays that would be tried in tiers 1-3 (already tried) - const alreadyTriedRelays: string[] = await client.getAlreadyTriedRelays() + // Get all relays that have already been tried (BIG_RELAY_URLS + FAST_READ_RELAY_URLS) + // These are the relays used in the initial fetch + const alreadyTriedRelaysSet = new Set() + ;[...BIG_RELAY_URLS, ...FAST_READ_RELAY_URLS].forEach(url => { + const normalized = normalizeUrl(url) + if (normalized) alreadyTriedRelaysSet.add(normalized) + }) - let externalRelays: string[] = [] + let hintRelays: string[] = [] let extractedHexEventId: string | null = null // Parse relay hints and author from bech32 ID @@ -40,23 +45,20 @@ export default function NotFound({ if (type === 'nevent') { extractedHexEventId = data.id - if (data.relays) externalRelays.push(...data.relays) + if (data.relays) hintRelays.push(...data.relays) if (data.author) { const authorRelayList = await client.fetchRelayList(data.author) - externalRelays.push(...authorRelayList.write.slice(0, 6)) + hintRelays.push(...authorRelayList.write.slice(0, 6)) } } else if (type === 'naddr') { - if (data.relays) externalRelays.push(...data.relays) + if (data.relays) hintRelays.push(...data.relays) const authorRelayList = await client.fetchRelayList(data.pubkey) - externalRelays.push(...authorRelayList.write.slice(0, 6)) + hintRelays.push(...authorRelayList.write.slice(0, 6)) } else if (type === 'note') { extractedHexEventId = data } - // Normalize and deduplicate external relays - externalRelays = externalRelays.map(url => normalizeUrl(url) || url) - externalRelays = Array.from(new Set(externalRelays)) } catch (err) { - logger.error('Failed to parse external relays', { error: err, bech32Id }) + logger.error('Failed to parse external relays', { error: err, bech32Id }) } } else { extractedHexEventId = bech32Id @@ -64,28 +66,40 @@ export default function NotFound({ setHexEventId(extractedHexEventId) + // Get relays where this event was seen const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : [] - externalRelays.push(...seenOn) - - // Normalize all relays first - let normalizedRelays = externalRelays.map(url => normalizeUrl(url) || url).filter(Boolean) - normalizedRelays = Array.from(new Set(normalizedRelays)) - - // If no external relays from hints, try SEARCHABLE_RELAY_URLS as fallback - // Filter out relays that overlap with BIG_RELAY_URLS (already tried first) - if (normalizedRelays.length === 0) { - const searchableRelays = SEARCHABLE_RELAY_URLS - .map(url => normalizeUrl(url) || url) - .filter((url): url is string => Boolean(url)) - .filter(relay => !BIG_RELAY_URLS.includes(relay)) - normalizedRelays.push(...searchableRelays) - } + hintRelays.push(...seenOn) + + // Normalize all hint relays + const normalizedHints = hintRelays + .map(url => normalizeUrl(url)) + .filter((url): url is string => Boolean(url)) + + // Combine hints with SEARCHABLE_RELAY_URLS (always include as fallback) + // Normalize SEARCHABLE_RELAY_URLS for comparison + const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS + .map(url => normalizeUrl(url)) + .filter((url): url is string => Boolean(url)) - // Filter out relays that were already tried in tiers 1-3 - const newRelays = normalizedRelays.filter(relay => !alreadyTriedRelays.includes(relay)) + // Combine all potential relays (hints + searchable) + const allPotentialRelays = new Set([...normalizedHints, ...normalizedSearchableRelays]) + + // Filter out relays that were already tried + const externalRelays = Array.from(allPotentialRelays).filter( + relay => !alreadyTriedRelaysSet.has(relay) + ) // Deduplicate final relay list - setExternalRelays(Array.from(new Set(newRelays))) + setExternalRelays(externalRelays) + + logger.debug('External relays calculated (NotFound)', { + bech32Id, + hintRelaysCount: normalizedHints.length, + searchableRelaysCount: normalizedSearchableRelays.length, + alreadyTriedCount: alreadyTriedRelaysSet.size, + externalRelaysCount: externalRelays.length, + externalRelays: externalRelays.slice(0, 10) // Log first 10 + }) } getExternalRelays() @@ -94,14 +108,37 @@ export default function NotFound({ const handleTryExternalRelays = async () => { if (!hexEventId || isSearchingExternal) return + if (externalRelays.length === 0) { + logger.warn('No external relays to search (NotFound)', { bech32Id, hexEventId }) + setTriedExternal(true) + return + } + setIsSearchingExternal(true) try { + logger.info('Searching external relays (NotFound)', { + bech32Id, + hexEventId, + relayCount: externalRelays.length, + relays: externalRelays.slice(0, 5) // Log first 5 relays + }) + const event = await client.fetchEventWithExternalRelays(hexEventId, externalRelays) - if (event && onEventFound) { - onEventFound(event) + + if (event) { + logger.info('Event found on external relay (NotFound)', { bech32Id, hexEventId }) + if (onEventFound) { + onEventFound(event) + } + } else { + logger.info('Event not found on external relays (NotFound)', { + bech32Id, + hexEventId, + relayCount: externalRelays.length + }) } } catch (error) { - logger.error('External relay fetch failed', { error, bech32Id, hexEventId }) + logger.error('External relay fetch failed (NotFound)', { error, bech32Id, hexEventId, externalRelays }) } finally { setIsSearchingExternal(false) setTriedExternal(true) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 2a1ff6c..53864b8 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -6,6 +6,7 @@ import { isReplaceableEvent } from '@/lib/event' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' +import logger from '@/lib/logger' import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag' import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url' @@ -753,65 +754,156 @@ class ClientService extends EventTarget { set.add(relay) } - private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) { + private async query( + urls: string[], + filter: Filter | Filter[], + onevent?: (evt: NEvent) => void, + options?: { eoseTimeout?: number; globalTimeout?: number } + ) { + const eoseTimeout = options?.eoseTimeout ?? 500 // Default 500ms after EOSE + const globalTimeout = options?.globalTimeout ?? 10000 // Default 10s global timeout + const isExternalSearch = eoseTimeout > 1000 // Consider it external search if timeout > 1s + + if (isExternalSearch) { + logger.info('query: Starting external relay search', { + relayCount: urls.length, + relays: urls, + eoseTimeout, + globalTimeout, + filter: Array.isArray(filter) ? filter : [filter] + }) + } + return await new Promise((resolve) => { const events: NEvent[] = [] - let hasEosed = false let resolveTimeout: ReturnType | null = null + let allEosed = false + let eoseTime: number | null = null + let eventCount = 0 + + let globalTimeoutId: ReturnType | null = null const resolveWithEvents = () => { if (resolveTimeout) { clearTimeout(resolveTimeout) resolveTimeout = null } + if (globalTimeoutId) { + clearTimeout(globalTimeoutId) + globalTimeoutId = null + } + const duration = eoseTime ? Date.now() - eoseTime : 0 + if (isExternalSearch) { + logger.info('query: Resolving external search', { + eventsFound: events.length, + eventCount, + allEosed, + timeSinceEose: duration + }) + } sub.close() resolve(events) } const sub = this.subscribe(urls, filter, { onevent(evt) { + eventCount++ + if (isExternalSearch && eventCount <= 3) { + logger.info('query: Received event', { + eventId: evt.id.substring(0, 8), + eventCount, + timeSinceEose: eoseTime ? Date.now() - eoseTime : null + }) + } onevent?.(evt) events.push(evt) - // If we got events, clear any timeout - we're making progress - if (resolveTimeout) { - clearTimeout(resolveTimeout) - resolveTimeout = null + + // Check if we're looking for a specific event ID (limit: 1 with ids filter) + const filters = Array.isArray(filter) ? filter : [filter] + const hasIdFilter = filters.some(f => f.ids && f.ids.length > 0) + const hasLimitOne = filters.some(f => f.limit === 1) + + // If we're searching for a specific event and found it, we can resolve early + // But wait a bit (100ms) in case duplicate events arrive + if (hasIdFilter && hasLimitOne && events.length > 0 && allEosed) { + // We've found the event and received EOSE, wait a short moment then resolve + if (resolveTimeout) { + clearTimeout(resolveTimeout) + } + resolveTimeout = setTimeout(() => { + resolveWithEvents() + }, 100) // Short delay to catch any duplicate events } }, oneose: (eosed) => { if (eosed) { - hasEosed = true - // Wait a bit more after EOSE to ensure we got all events + // When eosed is true, it means all relays have finished (either sent EOSE or failed to connect) + allEosed = true + eoseTime = Date.now() + if (isExternalSearch) { + logger.info('query: Received EOSE from all relays', { + eventsSoFar: events.length, + eventCount, + willWait: eoseTimeout + }) + } + // Clear any existing timeout + if (resolveTimeout) { + clearTimeout(resolveTimeout) + } + // Wait longer after all relays send EOSE to allow searchable relays to finish searching + // For searchable relays, they may send EOSE quickly but still need time to search their database + // Important: We keep the subscription open during this timeout so we can receive events resolveTimeout = setTimeout(() => { resolveWithEvents() - }, 500) + }, eoseTimeout) } }, - onclose: () => { - // Only resolve immediately on close if we've received EOSE or have events - // Otherwise, wait a bit to see if more events come - if (hasEosed || events.length > 0) { - if (resolveTimeout) { - clearTimeout(resolveTimeout) + onclose: (url, reason) => { + if (isExternalSearch) { + logger.info('query: Relay connection closed', { url, reason, eventsSoFar: events.length, allEosed }) + } + // If we've received EOSE, we have a timeout set - let it handle resolution + // This gives searchable relays time to search their databases + if (allEosed) { + // Don't resolve immediately - let the EOSE timeout handle it + // This allows searchable relays to continue searching even if connections close + return + } + + // If we have events but no EOSE yet, we might want to wait a bit more + // But if connections are closing, we should resolve + if (events.length > 0) { + // We have events, but haven't received EOSE from all relays + // Wait a short time to see if more events come, then resolve + if (!resolveTimeout) { + resolveTimeout = setTimeout(() => { + resolveWithEvents() + }, 1000) // Wait 1 second for more events } - resolve(events) } else { - // Wait up to 3 seconds for events if connection closes early - resolveTimeout = setTimeout(() => { - resolve(events) - }, 3000) + // No events and no EOSE - connection closed early + // Wait a bit to see if events arrive, but not too long + if (!resolveTimeout) { + resolveTimeout = setTimeout(() => { + resolveWithEvents() + }, 2000) // Wait 2 seconds for events + } } } }) - // Fallback timeout: resolve after 10 seconds max to prevent hanging - setTimeout(() => { - if (resolveTimeout) { - clearTimeout(resolveTimeout) + // Fallback timeout: resolve after globalTimeout to prevent hanging + globalTimeoutId = setTimeout(() => { + if (isExternalSearch) { + logger.info('query: Global timeout reached', { + eventsFound: events.length, + eventCount, + allEosed + }) } - sub.close() - resolve(events) - }, 10000) + resolveWithEvents() + }, globalTimeout) }) } @@ -820,14 +912,23 @@ class ClientService extends EventTarget { filter: Filter | Filter[], { onevent, - cache = false + cache = false, + eoseTimeout, + globalTimeout }: { onevent?: (evt: NEvent) => void cache?: boolean + eoseTimeout?: number + globalTimeout?: number } = {} ) { const relays = Array.from(new Set(urls)) - const events = await this.query(relays.length > 0 ? relays : BIG_RELAY_URLS, filter, onevent) + const events = await this.query( + relays.length > 0 ? relays : BIG_RELAY_URLS, + filter, + onevent, + { eoseTimeout, globalTimeout } + ) if (cache) { events.forEach((evt) => { this.addEventToCache(evt) @@ -1749,8 +1850,39 @@ class ClientService extends EventTarget { } async fetchEventWithExternalRelays(eventId: string, externalRelays: string[]) { + if (!externalRelays || externalRelays.length === 0) { + logger.warn('fetchEventWithExternalRelays: No external relays provided', { eventId }) + return undefined + } + + logger.info('fetchEventWithExternalRelays: Starting search', { + eventId: eventId.substring(0, 8), + relayCount: externalRelays.length, + relays: externalRelays + }) + // Use external relays for fetching the event - const events = await this.fetchEvents(externalRelays, { ids: [eventId], limit: 1 }) + // For searchable relays, we want to give them more time to search their database + // Use a longer EOSE timeout (10 seconds) to allow searchable relays to complete their search + // and a longer global timeout (20 seconds) to ensure we wait long enough + const startTime = Date.now() + const events = await this.fetchEvents( + externalRelays, + { ids: [eventId], limit: 1 }, + { + eoseTimeout: 10000, // Wait 10 seconds after all EOSE (searchable relays need time to search) + globalTimeout: 20000 // 20 second global timeout + } + ) + const duration = Date.now() - startTime + + logger.info('fetchEventWithExternalRelays: Search completed', { + eventId: eventId.substring(0, 8), + relayCount: externalRelays.length, + eventsFound: events.length, + durationMs: duration + }) + return events[0] }