Browse Source

make search more robust

imwald
Silberengel 4 months ago
parent
commit
f632240050
  1. 100
      src/components/Embedded/EmbeddedNote.tsx
  2. 105
      src/pages/secondary/NotePage/NotFound.tsx
  3. 192
      src/services/client.service.ts

100
src/components/Embedded/EmbeddedNote.tsx

@ -1,5 +1,5 @@
import { Skeleton } from '@/components/ui/skeleton' 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 { useFetchEvent } from '@/hooks'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -106,30 +106,36 @@ function EmbeddedNoteNotFound({
// Calculate which external relays would be tried // Calculate which external relays would be tried
useEffect(() => { useEffect(() => {
const getExternalRelays = async () => { 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<string>()
;[...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 let extractedHexEventId: string | null = null
// Parse relay hints and author from bech32 ID
if (!/^[0-9a-f]{64}$/.test(noteId)) { if (!/^[0-9a-f]{64}$/.test(noteId)) {
try { try {
const { type, data } = nip19.decode(noteId) const { type, data } = nip19.decode(noteId)
if (type === 'nevent') { if (type === 'nevent') {
extractedHexEventId = data.id extractedHexEventId = data.id
if (data.relays) relays.push(...data.relays) if (data.relays) hintRelays.push(...data.relays)
if (data.author) { if (data.author) {
const authorRelayList = await client.fetchRelayList(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') { } 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) 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') { } else if (type === 'note') {
extractedHexEventId = data extractedHexEventId = data
} }
// Normalize and deduplicate relays
relays = relays.map(url => normalizeUrl(url) || url)
relays = Array.from(new Set(relays))
} catch (err) { } catch (err) {
logger.error('Failed to parse external relays', { error: err, noteId }) logger.error('Failed to parse external relays', { error: err, noteId })
} }
@ -139,25 +145,40 @@ function EmbeddedNoteNotFound({
setHexEventId(extractedHexEventId) setHexEventId(extractedHexEventId)
// Get relays where this event was seen
const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : [] const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : []
relays.push(...seenOn) hintRelays.push(...seenOn)
// Normalize all relays first // Normalize all hint relays
let normalizedRelays = relays.map(url => normalizeUrl(url) || url).filter(Boolean) const normalizedHints = hintRelays
normalizedRelays = Array.from(new Set(normalizedRelays)) .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) // Combine hints with SEARCHABLE_RELAY_URLS (always include as fallback)
if (normalizedRelays.length === 0) { // Normalize SEARCHABLE_RELAY_URLS for comparison
const searchableRelays = SEARCHABLE_RELAY_URLS const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS
.map(url => normalizeUrl(url) || url) .map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url)) .filter((url): url is string => Boolean(url))
.filter(relay => !BIG_RELAY_URLS.includes(relay))
normalizedRelays.push(...searchableRelays) // 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 // 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() getExternalRelays()
@ -166,14 +187,37 @@ function EmbeddedNoteNotFound({
const handleTryExternalRelays = async () => { const handleTryExternalRelays = async () => {
if (!hexEventId || isSearchingExternal) return if (!hexEventId || isSearchingExternal) return
if (externalRelays.length === 0) {
logger.warn('No external relays to search', { noteId, hexEventId })
setTriedExternal(true)
return
}
setIsSearchingExternal(true) setIsSearchingExternal(true)
try { 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) 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) { } catch (error) {
logger.error('External relay fetch failed', { error, noteId }) logger.error('External relay fetch failed', { error, noteId, hexEventId, externalRelays })
} finally { } finally {
setIsSearchingExternal(false) setIsSearchingExternal(false)
setTriedExternal(true) setTriedExternal(true)

105
src/pages/secondary/NotePage/NotFound.tsx

@ -1,6 +1,6 @@
import ClientSelect from '@/components/ClientSelect' import ClientSelect from '@/components/ClientSelect'
import { Button } from '@/components/ui/button' 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 { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
import { AlertCircle, Search } from 'lucide-react' import { AlertCircle, Search } from 'lucide-react'
@ -27,10 +27,15 @@ export default function NotFound({
if (!bech32Id) return if (!bech32Id) return
const getExternalRelays = async () => { const getExternalRelays = async () => {
// Get all relays that would be tried in tiers 1-3 (already tried) // Get all relays that have already been tried (BIG_RELAY_URLS + FAST_READ_RELAY_URLS)
const alreadyTriedRelays: string[] = await client.getAlreadyTriedRelays() // These are the relays used in the initial fetch
const alreadyTriedRelaysSet = new Set<string>()
let externalRelays: string[] = [] ;[...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 let extractedHexEventId: string | null = null
// Parse relay hints and author from bech32 ID // Parse relay hints and author from bech32 ID
@ -40,23 +45,20 @@ export default function NotFound({
if (type === 'nevent') { if (type === 'nevent') {
extractedHexEventId = data.id extractedHexEventId = data.id
if (data.relays) externalRelays.push(...data.relays) if (data.relays) hintRelays.push(...data.relays)
if (data.author) { if (data.author) {
const authorRelayList = await client.fetchRelayList(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') { } 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) 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') { } else if (type === 'note') {
extractedHexEventId = data extractedHexEventId = data
} }
// Normalize and deduplicate external relays
externalRelays = externalRelays.map(url => normalizeUrl(url) || url)
externalRelays = Array.from(new Set(externalRelays))
} catch (err) { } catch (err) {
logger.error('Failed to parse external relays', { error: err, bech32Id }) logger.error('Failed to parse external relays', { error: err, bech32Id })
} }
} else { } else {
extractedHexEventId = bech32Id extractedHexEventId = bech32Id
@ -64,28 +66,40 @@ export default function NotFound({
setHexEventId(extractedHexEventId) setHexEventId(extractedHexEventId)
// Get relays where this event was seen
const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : [] const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : []
externalRelays.push(...seenOn) hintRelays.push(...seenOn)
// Normalize all relays first // Normalize all hint relays
let normalizedRelays = externalRelays.map(url => normalizeUrl(url) || url).filter(Boolean) const normalizedHints = hintRelays
normalizedRelays = Array.from(new Set(normalizedRelays)) .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)
}
// Filter out relays that were already tried in tiers 1-3 // Combine hints with SEARCHABLE_RELAY_URLS (always include as fallback)
const newRelays = normalizedRelays.filter(relay => !alreadyTriedRelays.includes(relay)) // 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 // 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() getExternalRelays()
@ -94,14 +108,37 @@ export default function NotFound({
const handleTryExternalRelays = async () => { const handleTryExternalRelays = async () => {
if (!hexEventId || isSearchingExternal) return if (!hexEventId || isSearchingExternal) return
if (externalRelays.length === 0) {
logger.warn('No external relays to search (NotFound)', { bech32Id, hexEventId })
setTriedExternal(true)
return
}
setIsSearchingExternal(true) setIsSearchingExternal(true)
try { 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) 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) { } catch (error) {
logger.error('External relay fetch failed', { error, bech32Id, hexEventId }) logger.error('External relay fetch failed (NotFound)', { error, bech32Id, hexEventId, externalRelays })
} finally { } finally {
setIsSearchingExternal(false) setIsSearchingExternal(false)
setTriedExternal(true) setTriedExternal(true)

192
src/services/client.service.ts

@ -6,6 +6,7 @@ import {
isReplaceableEvent isReplaceableEvent
} from '@/lib/event' } from '@/lib/event'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag' import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag'
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
@ -753,65 +754,156 @@ class ClientService extends EventTarget {
set.add(relay) 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<NEvent[]>((resolve) => { return await new Promise<NEvent[]>((resolve) => {
const events: NEvent[] = [] const events: NEvent[] = []
let hasEosed = false
let resolveTimeout: ReturnType<typeof setTimeout> | null = null let resolveTimeout: ReturnType<typeof setTimeout> | null = null
let allEosed = false
let eoseTime: number | null = null
let eventCount = 0
let globalTimeoutId: ReturnType<typeof setTimeout> | null = null
const resolveWithEvents = () => { const resolveWithEvents = () => {
if (resolveTimeout) { if (resolveTimeout) {
clearTimeout(resolveTimeout) clearTimeout(resolveTimeout)
resolveTimeout = null 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() sub.close()
resolve(events) resolve(events)
} }
const sub = this.subscribe(urls, filter, { const sub = this.subscribe(urls, filter, {
onevent(evt) { 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) onevent?.(evt)
events.push(evt) events.push(evt)
// If we got events, clear any timeout - we're making progress
if (resolveTimeout) { // Check if we're looking for a specific event ID (limit: 1 with ids filter)
clearTimeout(resolveTimeout) const filters = Array.isArray(filter) ? filter : [filter]
resolveTimeout = null 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) => { oneose: (eosed) => {
if (eosed) { if (eosed) {
hasEosed = true // When eosed is true, it means all relays have finished (either sent EOSE or failed to connect)
// Wait a bit more after EOSE to ensure we got all events 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(() => { resolveTimeout = setTimeout(() => {
resolveWithEvents() resolveWithEvents()
}, 500) }, eoseTimeout)
} }
}, },
onclose: () => { onclose: (url, reason) => {
// Only resolve immediately on close if we've received EOSE or have events if (isExternalSearch) {
// Otherwise, wait a bit to see if more events come logger.info('query: Relay connection closed', { url, reason, eventsSoFar: events.length, allEosed })
if (hasEosed || events.length > 0) { }
if (resolveTimeout) { // If we've received EOSE, we have a timeout set - let it handle resolution
clearTimeout(resolveTimeout) // 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 { } else {
// Wait up to 3 seconds for events if connection closes early // No events and no EOSE - connection closed early
resolveTimeout = setTimeout(() => { // Wait a bit to see if events arrive, but not too long
resolve(events) if (!resolveTimeout) {
}, 3000) resolveTimeout = setTimeout(() => {
resolveWithEvents()
}, 2000) // Wait 2 seconds for events
}
} }
} }
}) })
// Fallback timeout: resolve after 10 seconds max to prevent hanging // Fallback timeout: resolve after globalTimeout to prevent hanging
setTimeout(() => { globalTimeoutId = setTimeout(() => {
if (resolveTimeout) { if (isExternalSearch) {
clearTimeout(resolveTimeout) logger.info('query: Global timeout reached', {
eventsFound: events.length,
eventCount,
allEosed
})
} }
sub.close() resolveWithEvents()
resolve(events) }, globalTimeout)
}, 10000)
}) })
} }
@ -820,14 +912,23 @@ class ClientService extends EventTarget {
filter: Filter | Filter[], filter: Filter | Filter[],
{ {
onevent, onevent,
cache = false cache = false,
eoseTimeout,
globalTimeout
}: { }: {
onevent?: (evt: NEvent) => void onevent?: (evt: NEvent) => void
cache?: boolean cache?: boolean
eoseTimeout?: number
globalTimeout?: number
} = {} } = {}
) { ) {
const relays = Array.from(new Set(urls)) 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) { if (cache) {
events.forEach((evt) => { events.forEach((evt) => {
this.addEventToCache(evt) this.addEventToCache(evt)
@ -1749,8 +1850,39 @@ class ClientService extends EventTarget {
} }
async fetchEventWithExternalRelays(eventId: string, externalRelays: string[]) { 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 // 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] return events[0]
} }

Loading…
Cancel
Save