From 64e12734581b378e473776b4c42c337da5458edb Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Tue, 17 Feb 2026 03:20:52 -0600 Subject: [PATCH] Race relays on first EOSE with grace timer for remaining Replace the round-robin single-relay approach with proper racing: - Queries are sent to ALL read relays simultaneously - First relay to return EOSE starts a 1.5s grace timer - Remaining relays that respond within the grace window have their events merged into the result set - If all relays finish before the timer, returns immediately - Events are deduplicated via NSet (handles replaceable events correctly) - Publishing and req() still delegate to the pool unchanged Co-authored-by: shakespeare.diy --- src/components/NostrProvider.tsx | 114 ++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 11 deletions(-) diff --git a/src/components/NostrProvider.tsx b/src/components/NostrProvider.tsx index 5295fb84..b9c5fdc0 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -1,9 +1,100 @@ import React, { useEffect, useRef } from 'react'; -import { NostrEvent, NostrFilter, NPool, NRelay1 } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter, NPool, NRelay1, NSet } from '@nostrify/nostrify'; +import type { NRelay } from '@nostrify/nostrify'; import { NostrContext } from '@nostrify/react'; import { useQueryClient } from '@tanstack/react-query'; import { useAppContext } from '@/hooks/useAppContext'; +/** Grace period (ms) given to remaining relays after the first EOSE. */ +const EOSE_GRACE_MS = 1500; + +/** + * Wraps an NPool so that `query()` races individual relays: + * - Sends the request to every read relay simultaneously. + * - As soon as the first relay finishes (EOSE), starts a short grace timer. + * - When the timer fires (or all relays finish), merges + deduplicates results and returns. + * - Callers still get events from *all* relays that responded in time. + */ +class RacingPool implements NRelay { + constructor(private pool: NPool, private getReadRelays: () => string[]) {} + + /** Publish — delegates straight to the pool (fans out to all write relays). */ + event(event: NostrEvent, opts?: { signal?: AbortSignal }) { + return this.pool.event(event, opts); + } + + /** Subscribe — delegates to pool (keeps original all-relay-EOSE behaviour). */ + req(filters: NostrFilter[], opts?: { signal?: AbortSignal }) { + return this.pool.req(filters, opts); + } + + /** Query with first-EOSE racing. */ + async query(filters: NostrFilter[], opts?: { signal?: AbortSignal }): Promise { + const relayUrls = this.getReadRelays(); + + // 0-1 relays: no racing needed + if (relayUrls.length <= 1) { + return this.pool.query(filters, opts); + } + + const controller = new AbortController(); + const outerSignal = opts?.signal; + + // If the caller aborts, propagate to our internal controller + if (outerSignal) { + if (outerSignal.aborted) { + controller.abort(); + } else { + outerSignal.addEventListener('abort', () => controller.abort(), { once: true }); + } + } + + const allEvents = new NSet(); + let firstEoseFired = false; + let graceTimeout: ReturnType | undefined; + let resolved = false; + + return new Promise((resolve) => { + const finish = () => { + if (resolved) return; + resolved = true; + if (graceTimeout) clearTimeout(graceTimeout); + controller.abort(); + // NSet deduplicates and keeps latest replaceable events + resolve([...allEvents].sort((a, b) => b.created_at - a.created_at)); + }; + + let pending = relayUrls.length; + + for (const url of relayUrls) { + const relay = this.pool.relay(url); + relay.query(filters, { signal: controller.signal }).then((events) => { + if (resolved) return; + for (const e of events) allEvents.add(e); + + if (!firstEoseFired) { + firstEoseFired = true; + // Start grace timer for remaining relays + graceTimeout = setTimeout(finish, EOSE_GRACE_MS); + } + + pending--; + if (pending <= 0) finish(); + }).catch(() => { + // Relay failed or was aborted — just count it as done + pending--; + if (pending <= 0) finish(); + }); + } + }); + } + + /** Proxy helper methods through to the underlying pool. */ + relay(url: string) { return this.pool.relay(url); } + group(urls: string[]) { return this.pool.group(urls); } + close() { return this.pool.close(); } +} + interface NostrProviderProps { children: React.ReactNode; } @@ -14,7 +105,8 @@ const NostrProvider: React.FC = (props) => { const queryClient = useQueryClient(); - // Create NPool instance only once + // Create instances only once + const racingPool = useRef(undefined); const pool = useRef(undefined); // Use refs so the pool always has the latest data @@ -26,9 +118,6 @@ const NostrProvider: React.FC = (props) => { queryClient.invalidateQueries({ queryKey: ['nostr'] }); }, [config.relayMetadata, queryClient]); - // Round-robin index for distributing reads across relays - const rrIndex = useRef(0); - // Initialize NPool only once if (!pool.current) { pool.current = new NPool({ @@ -38,15 +127,13 @@ const NostrProvider: React.FC = (props) => { reqRouter(filters: NostrFilter[]) { const routes = new Map(); - // Pick a single read relay (round-robin) so queries resolve on first EOSE + // Route to all read relays const readRelays = relayMetadata.current.relays .filter(r => r.read) .map(r => r.url); - if (readRelays.length > 0) { - const idx = rrIndex.current % readRelays.length; - rrIndex.current = idx + 1; - routes.set(readRelays[idx], filters); + for (const url of readRelays) { + routes.set(url, filters); } return routes; @@ -60,10 +147,15 @@ const NostrProvider: React.FC = (props) => { return [...new Set(writeRelays)]; }, }); + + racingPool.current = new RacingPool( + pool.current, + () => relayMetadata.current.relays.filter(r => r.read).map(r => r.url), + ); } return ( - + {children} );