7 changed files with 261 additions and 1 deletions
@ -0,0 +1,107 @@ |
|||||||
|
import { RELAY_POOL_IDLE_SWEEP_INTERVAL_MS, RELAY_POOL_SOCKET_IDLE_MS } from '@/constants' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
import { canonicalRelaySessionKey, normalizeAnyRelayUrl } from '@/lib/url' |
||||||
|
import type { SimplePool } from 'nostr-tools' |
||||||
|
|
||||||
|
type HasActiveSubsFn = (canonicalRelayKey: string) => boolean |
||||||
|
|
||||||
|
let pool: SimplePool | null = null |
||||||
|
let hasActiveSubs: HasActiveSubsFn | null = null |
||||||
|
let sweepTimer: ReturnType<typeof setInterval> | null = null |
||||||
|
const lastActivityMs = new Map<string, number>() |
||||||
|
|
||||||
|
function canon(url: string): string { |
||||||
|
return canonicalRelaySessionKey(normalizeAnyRelayUrl(url) || url.trim()) |
||||||
|
} |
||||||
|
|
||||||
|
/** Mark relay URL as recently used (connect, REQ, publish). */ |
||||||
|
export function touchRelayPoolActivity(url: string): void { |
||||||
|
const key = canon(url) |
||||||
|
if (!key) return |
||||||
|
lastActivityMs.set(key, Date.now()) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Wire idle socket sweeps to the app pool. Safe to call once from {@link ClientService} constructor. |
||||||
|
*/ |
||||||
|
export function initRelayPoolIdle(nextPool: SimplePool, nextHasActiveSubs: HasActiveSubsFn): void { |
||||||
|
pool = nextPool |
||||||
|
hasActiveSubs = nextHasActiveSubs |
||||||
|
if (sweepTimer != null) return |
||||||
|
sweepTimer = setInterval(() => { |
||||||
|
sweepIdleRelayPoolSockets() |
||||||
|
}, RELAY_POOL_IDLE_SWEEP_INTERVAL_MS) |
||||||
|
} |
||||||
|
|
||||||
|
/** Close connected sockets that have no active SUBs and exceeded {@link RELAY_POOL_SOCKET_IDLE_MS}. */ |
||||||
|
export function sweepIdleRelayPoolSockets(): void { |
||||||
|
if (!pool || !hasActiveSubs) return |
||||||
|
const now = Date.now() |
||||||
|
let status: Map<string, boolean> |
||||||
|
try { |
||||||
|
status = pool.listConnectionStatus() |
||||||
|
} catch { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const toClose: string[] = [] |
||||||
|
for (const [url, connected] of status) { |
||||||
|
if (!connected) continue |
||||||
|
const key = canon(url) |
||||||
|
if (!key) continue |
||||||
|
if (hasActiveSubs(key)) continue |
||||||
|
const last = lastActivityMs.get(key) ?? 0 |
||||||
|
if (now - last < RELAY_POOL_SOCKET_IDLE_MS) continue |
||||||
|
toClose.push(normalizeAnyRelayUrl(url) || url) |
||||||
|
} |
||||||
|
|
||||||
|
if (toClose.length === 0) return |
||||||
|
try { |
||||||
|
pool.close(toClose) |
||||||
|
logger.debug('[RelayPoolIdle] closed idle sockets', { count: toClose.length, relays: toClose }) |
||||||
|
} catch { |
||||||
|
/* ignore */ |
||||||
|
} |
||||||
|
for (const url of toClose) { |
||||||
|
lastActivityMs.delete(canon(url)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Close specific relays when they are connected, have no active SUBs, and are not session-parked hot. */ |
||||||
|
export function closeRelayPoolSocketsIfIdle(urls: readonly string[]): void { |
||||||
|
if (!pool || !hasActiveSubs || urls.length === 0) return |
||||||
|
let status: Map<string, boolean> |
||||||
|
try { |
||||||
|
status = pool.listConnectionStatus() |
||||||
|
} catch { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const toClose: string[] = [] |
||||||
|
for (const raw of urls) { |
||||||
|
const key = canon(raw) |
||||||
|
if (!key || hasActiveSubs(key)) continue |
||||||
|
const normalized = normalizeAnyRelayUrl(raw) || raw |
||||||
|
const connected = [...status.entries()].some( |
||||||
|
([u, ok]) => ok && canon(u) === key |
||||||
|
) |
||||||
|
if (connected) toClose.push(normalized) |
||||||
|
} |
||||||
|
if (toClose.length === 0) return |
||||||
|
try { |
||||||
|
pool.close(toClose) |
||||||
|
logger.debug('[RelayPoolIdle] closed sockets after slow-park', { relays: toClose }) |
||||||
|
} catch { |
||||||
|
/* ignore */ |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function resetRelayPoolIdleForTests(): void { |
||||||
|
if (sweepTimer != null) { |
||||||
|
clearInterval(sweepTimer) |
||||||
|
sweepTimer = null |
||||||
|
} |
||||||
|
pool = null |
||||||
|
hasActiveSubs = null |
||||||
|
lastActivityMs.clear() |
||||||
|
} |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
import { describe, expect, it, beforeEach } from 'vitest' |
||||||
|
import { relaySessionStrikes } from './relay-strikes' |
||||||
|
import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service' |
||||||
|
|
||||||
|
function row( |
||||||
|
url: string, |
||||||
|
outcome: RelayOpTerminalRow['outcome'], |
||||||
|
msFromBatchStart: number |
||||||
|
): RelayOpTerminalRow { |
||||||
|
return { cmdIndex: 0, relayUrl: url, outcome, msFromBatchStart } |
||||||
|
} |
||||||
|
|
||||||
|
describe('relaySessionStrikes.observeSubscribeBatch', () => { |
||||||
|
beforeEach(() => { |
||||||
|
relaySessionStrikes.reset() |
||||||
|
}) |
||||||
|
|
||||||
|
it('session-parks a relay much slower than batch median after two slow waves', () => { |
||||||
|
const slow = 'wss://slow.example.com/' |
||||||
|
const fast = 'wss://fast.example.com/' |
||||||
|
|
||||||
|
relaySessionStrikes.observeSubscribeBatch([ |
||||||
|
row(fast, 'eose', 400), |
||||||
|
row(slow, 'eose', 12_000) |
||||||
|
]) |
||||||
|
expect(relaySessionStrikes.isReadHttpSkipped(slow)).toBe(false) |
||||||
|
|
||||||
|
relaySessionStrikes.observeSubscribeBatch([ |
||||||
|
row(fast, 'eose', 500), |
||||||
|
row(slow, 'eose', 11_000) |
||||||
|
]) |
||||||
|
expect(relaySessionStrikes.isReadHttpSkipped(slow)).toBe(true) |
||||||
|
expect(relaySessionStrikes.isReadHttpSkipped(fast)).toBe(false) |
||||||
|
}) |
||||||
|
|
||||||
|
it('clears slow parking on fast EOSE via recordReadSuccess', () => { |
||||||
|
const url = 'wss://recover.example.com/' |
||||||
|
relaySessionStrikes.observeSubscribeBatch([row(url, 'eose', 15_000)]) |
||||||
|
relaySessionStrikes.observeSubscribeBatch([row(url, 'eose', 14_000)]) |
||||||
|
expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(true) |
||||||
|
relaySessionStrikes.recordReadSuccess(url) |
||||||
|
expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(false) |
||||||
|
}) |
||||||
|
}) |
||||||
Loading…
Reference in new issue