7 changed files with 261 additions and 1 deletions
@ -0,0 +1,107 @@
@@ -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 @@
@@ -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