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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-17 03:20:52 -06:00
parent cf193abfd9
commit 64e1273458
+103 -11
View File
@@ -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<NostrEvent[]> {
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<typeof setTimeout> | undefined;
let resolved = false;
return new Promise<NostrEvent[]>((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<NostrProviderProps> = (props) => {
const queryClient = useQueryClient();
// Create NPool instance only once
// Create instances only once
const racingPool = useRef<RacingPool | undefined>(undefined);
const pool = useRef<NPool | undefined>(undefined);
// Use refs so the pool always has the latest data
@@ -26,9 +118,6 @@ const NostrProvider: React.FC<NostrProviderProps> = (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<NostrProviderProps> = (props) => {
reqRouter(filters: NostrFilter[]) {
const routes = new Map<string, NostrFilter[]>();
// 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<NostrProviderProps> = (props) => {
return [...new Set(writeRelays)];
},
});
racingPool.current = new RacingPool(
pool.current,
() => relayMetadata.current.relays.filter(r => r.read).map(r => r.url),
);
}
return (
<NostrContext.Provider value={{ nostr: pool.current }}>
<NostrContext.Provider value={{ nostr: racingPool.current! }}>
{children}
</NostrContext.Provider>
);