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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user