diff --git a/perf/benchmark-lock-ops.mjs b/perf/benchmark-lock-ops.mjs new file mode 100644 index 0000000..2f85aa1 --- /dev/null +++ b/perf/benchmark-lock-ops.mjs @@ -0,0 +1,41 @@ + +import { RelayHealthManager } from '../src/lock-ops.mjs'; + +const healthManager = new RelayHealthManager(); + +// Setup mock relays +const relays = []; +for (let i = 0; i < 100; i++) { + relays.push(`wss://relay-${i}.example.com`); +} + +const config = { + rollingWindowSize: 10, + failureThreshold: 3, + quarantineCooldownMs: 1000, + maxQuarantineCooldownMs: 60000, + snapshotIntervalMs: 10000, + minActiveRelayPool: 5, +}; + +// Populate some metrics +for (const relay of relays) { + // Simulate some outcomes + for (let i = 0; i < 5; i++) { + healthManager.recordOutcome(relay, Math.random() > 0.1, null, Math.random() * 200, config); + } +} + +// Benchmark rankRelays +const start = performance.now(); +const iterations = 10000; + +for (let i = 0; i < iterations; i++) { + healthManager.rankRelays(relays, config); +} + +const end = performance.now(); +const duration = end - start; + +console.log(`RankRelays x ${iterations}: ${duration.toFixed(2)}ms`); +console.log(`Average per call: ${(duration / iterations).toFixed(4)}ms`); diff --git a/src/lock-ops.mjs b/src/lock-ops.mjs index 02b9075..93d0497 100644 --- a/src/lock-ops.mjs +++ b/src/lock-ops.mjs @@ -188,6 +188,8 @@ export class RelayHealthManager { constructor() { this.metricsByRelay = new Map(); this.lastSnapshotAt = 0; + this._metricsVersion = 0; + this._sortedCache = null; } ensureMetrics(relay, config) { @@ -203,31 +205,72 @@ export class RelayHealthManager { lastResultAt: null, }; this.metricsByRelay.set(relay, metrics); + this._metricsVersion += 1; } return metrics; } rankRelays(relays, config, nowMs = Date.now()) { - const summaries = relays.map((relay) => { - const metrics = this.ensureMetrics(relay, config); - const summary = summarizeRelayMetrics(metrics, nowMs); - return { - relay, - summary, - score: computeRelayScore(summary), - }; - }); + for (const relay of relays) { + this.ensureMetrics(relay, config); + } - return summaries.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - if (a.summary.quarantined !== b.summary.quarantined) return a.summary.quarantined ? 1 : -1; - if (a.summary.averageLatencyMs !== b.summary.averageLatencyMs) { - if (a.summary.averageLatencyMs === null) return 1; - if (b.summary.averageLatencyMs === null) return -1; - return a.summary.averageLatencyMs - b.summary.averageLatencyMs; + if ( + !this._sortedCache + || this._sortedCache.version !== this._metricsVersion + || nowMs >= this._sortedCache.validUntil + ) { + let minQuarantineUntil = Infinity; + const entries = []; + + for (const metrics of this.metricsByRelay.values()) { + const summary = summarizeRelayMetrics(metrics, nowMs); + const score = computeRelayScore(summary); + if (metrics.quarantineUntil > nowMs && metrics.quarantineUntil < minQuarantineUntil) { + minQuarantineUntil = metrics.quarantineUntil; + } + entries.push({ + relay: metrics.relay, + summary, + score, + metrics, + }); } - return a.relay.localeCompare(b.relay); - }); + + entries.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (a.summary.quarantined !== b.summary.quarantined) return a.summary.quarantined ? 1 : -1; + if (a.summary.averageLatencyMs !== b.summary.averageLatencyMs) { + if (a.summary.averageLatencyMs === null) return 1; + if (b.summary.averageLatencyMs === null) return -1; + return a.summary.averageLatencyMs - b.summary.averageLatencyMs; + } + return a.relay.localeCompare(b.relay); + }); + + this._sortedCache = { + version: this._metricsVersion, + validUntil: minQuarantineUntil, + entries, + }; + } + + const requestedSet = new Set(relays); + return this._sortedCache.entries + .filter((entry) => requestedSet.has(entry.relay)) + .map((entry) => { + const quarantineRemainingMs = entry.metrics.quarantineUntil > nowMs + ? entry.metrics.quarantineUntil - nowMs + : 0; + return { + relay: entry.relay, + score: entry.score, + summary: { + ...entry.summary, + quarantineRemainingMs, + }, + }; + }); } prioritizeRelays(relays, config, nowMs = Date.now()) { @@ -275,6 +318,7 @@ export class RelayHealthManager { metrics.quarantineUntil = nowMs + metrics.cooldownMs; metrics.cooldownMs = Math.min(config.maxQuarantineCooldownMs, Math.floor(metrics.cooldownMs * 1.5)); } + this._metricsVersion += 1; } collectSnapshot(relays, config, nowMs = Date.now()) { @@ -300,6 +344,8 @@ export class RelayHealthManager { reset() { this.metricsByRelay.clear(); this.lastSnapshotAt = 0; + this._metricsVersion = 0; + this._sortedCache = null; } } diff --git a/test/relay-health-manager.test.mjs b/test/relay-health-manager.test.mjs index a9fe4ce..da65c39 100644 --- a/test/relay-health-manager.test.mjs +++ b/test/relay-health-manager.test.mjs @@ -69,4 +69,75 @@ describe('RelayHealthManager', () => { manager.reset(); assert.strictEqual(manager.metricsByRelay.size, 0); }); + + it('should cache ranked results and invalidate on outcome', () => { + const manager = new RelayHealthManager(); + const config = { + rollingWindowSize: 5, + failureThreshold: 2, + quarantineCooldownMs: 1000, + maxQuarantineCooldownMs: 5000, + minActiveRelayPool: 1, + }; + const now = Date.now(); + + manager.recordOutcome('wss://r1', true, null, 100, config, now); + manager.recordOutcome('wss://r2', true, null, 200, config, now); + + const result1 = manager.rankRelays(['wss://r1', 'wss://r2'], config, now); + // Internal cache should be populated + assert.ok(manager._sortedCache); + assert.strictEqual(manager._sortedCache.version, manager._metricsVersion); + + const result2 = manager.rankRelays(['wss://r1', 'wss://r2'], config, now); + // Should be same objects (deep equality check on content, but references might differ due to map) + // Actually rankRelays returns new objects always. + assert.deepStrictEqual(result1, result2); + + // Modify outcome + manager.recordOutcome('wss://r1', false, 'timeout', 1000, config, now); + // Version should increase + assert.ok(manager._sortedCache.version < manager._metricsVersion); + + const result3 = manager.rankRelays(['wss://r1', 'wss://r2'], config, now); + // Cache should be updated + assert.strictEqual(manager._sortedCache.version, manager._metricsVersion); + assert.notDeepStrictEqual(result1, result3); + }); + + it('should invalidate cache when time passes quarantine expiry', () => { + const manager = new RelayHealthManager(); + const config = { + rollingWindowSize: 5, + failureThreshold: 1, // fails immediately + quarantineCooldownMs: 1000, + maxQuarantineCooldownMs: 5000, + minActiveRelayPool: 1, + }; + let now = 10000; + + manager.recordOutcome('wss://r1', false, 'fail', 100, config, now); + // r1 is quarantined until 11000 + + const result1 = manager.rankRelays(['wss://r1'], config, now); + assert.strictEqual(result1[0].summary.quarantined, true); + assert.strictEqual(manager._sortedCache.validUntil, 11000); + + // Advance time but before expiry + now = 10500; + const result2 = manager.rankRelays(['wss://r1'], config, now); + // Cache should still be valid (version same, now < validUntil) + // Quarantine remaining should be less + assert.strictEqual(result2[0].summary.quarantined, true); + assert.strictEqual(result2[0].summary.quarantineRemainingMs, 500); + + // Advance time past expiry + now = 11001; + const result3 = manager.rankRelays(['wss://r1'], config, now); + // Cache should be invalidated because now >= validUntil + assert.strictEqual(result3[0].summary.quarantined, false); + assert.strictEqual(result3[0].summary.quarantineRemainingMs, 0); + // validUntil should be Infinity (no quarantined relays) + assert.strictEqual(manager._sortedCache.validUntil, Infinity); + }); });