aboutsummaryrefslogtreecommitdiffstats
path: root/tools/relay-budget-test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-02 09:26:55 -0700
committerAdam Soltys <[email protected]>2026-11-02 09:26:55 -0700
commit42a76a883d23221cd73e10d8649d8ca28f6aed39 (patch)
treef0d60ed755dc45e51312a3c5f8e8e0207618a284 /tools/relay-budget-test.js
parent84460457619857ea6cb678bb0a7b2918c222014a (diff)
downloadcoinosv3-42a76a883d23221cd73e10d8649d8ca28f6aed39.tar.gz
coinosv3-42a76a883d23221cd73e10d8649d8ca28f6aed39.zip
The feed asks the relays for less, and asks it in fewer REQs. Measured from the laptop (2026-11-02, after the resume fix), an open of Following sent some 560 REQs in twenty seconds across 57 relays, over a hundred each to damus and primal, and damus answered the home IP "banned: too many rate-limit violations". Four sources, all cut: a quoted note's author (a face tapped, a reply's target) had their relay list asked for one REQ per author per relay — six quotes on screen sent eighteen — and those asks now wait 80 ms and go out as one REQ through the feed's batched fetch, which also remembers who has no list; the live subscriptions were closed and reopened at the end of every refresh pass (seventeen REQs over eight relays, twice per open) and now stay up while the relays and authors they cover are unchanged, the head they count fresh posts against read at each event; the follows' reports were fetched afresh at every reload (an in-memory clock) and now honour the clock the device saved, asking only for what came since; and every one-shot REQ was held open on our own relay until the slowest relay in the ask had answered — the library closes them all together — so the fast one carried each for up to five seconds. One-shot queries now go out per relay (queryOn, queryStreamOn, queryManyOn, the profile and inbox lookups), each closed on that relay's own EOSE, with no more than ten open on any relay at once: the rest wait their turn, and a turn that comes after the ask's own wait is skipped. tools/relay-budget-test.js runs the feed against a fast and a slow fake relay and checks each of these
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6TFgisGKUDhdStLUvs
Diffstat (limited to 'tools/relay-budget-test.js')
-rw-r--r--tools/relay-budget-test.js154
1 files changed, 154 insertions, 0 deletions
diff --git a/tools/relay-budget-test.js b/tools/relay-budget-test.js
new file mode 100644
index 0000000..600f60b
--- /dev/null
+++ b/tools/relay-budget-test.js
@@ -0,0 +1,154 @@
+// What a Following feed asks of a relay, and how. Measured from the laptop
+// (2026-11-02) an open of Following sent some 560 REQs in twenty seconds:
+// a relay list asked per quoted note's author (one REQ per author per
+// relay), the live subscriptions closed and reopened at the end of every
+// refresh pass, the follows' reports fetched afresh at every reload, and
+// every one-shot REQ held open on our fast relay until the slowest relay
+// in the ask had answered. damus answered the home IP "banned: too many
+// rate-limit violations". Now: relay lists go out in one REQ per batch,
+// unchanged live subscriptions stay up, the reports sync honours the saved
+// clock, each relay's REQ closes on its own EOSE, and no relay carries more
+// than ten one-shot REQs at once. Run: bun tools/relay-budget-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { neventEncode } from 'nostr-tools/nip19';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const html = await buildHtml({ minify: true, pwa: false });
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = () => Math.floor(Date.now() / 1000);
+const post = (sk, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now() - ago, tags, content }, sk);
+// six people the follow quotes, each with one note on the relay
+const quoters = Array.from({ length: 6 }, () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; });
+const quotedNotes = quoters.map((q, i) => post(q.sk, `quoted thought ${i}`, 7200 + i));
+const stored = [...quotedNotes, post(SK, 'morning one', 3600), post(SK, 'morning two', 3000)];
+const quotePosts = quotedNotes.map((n, i) => post(SK, `look at this ${i} nostr:${neventEncode({ id: n.id, author: n.pubkey })}`, 1200 + i * 10, [['q', n.id, '', n.pubkey]]));
+stored.push(...quotePosts);
+const cached = [...quotePosts, ...stored.filter((e) => e.pubkey === AUTHOR && !quotePosts.includes(e))].sort((a, b) => b.created_at - a.created_at);
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since);
+
+// two relays on one server: /fast answers at once, /slow holds its EOSE
+const SLOW_MS = 3000;
+const log = []; // { t, relay, kind: 'REQ'|'CLOSE'|'EOSE', id, filters }
+const sockets = new Set();
+let maxOpen = { fast: 0, slow: 0 };
+const server = Bun.serve({ port: 0, fetch: (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/fast' || path === '/slow') return srv.upgrade(req, { data: { relay: path.slice(1), subs: new Map() } }) ? undefined : new Response('ws', { status: 426 });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+}, websocket: {
+ open(ws) { sockets.add(ws); },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ const relay = ws.data.relay;
+ if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; }
+ if (m[0] === 'REQ') {
+ const filters = m.slice(2);
+ ws.data.subs.set(m[1], filters);
+ maxOpen[relay] = Math.max(maxOpen[relay], ws.data.subs.size);
+ log.push({ t: Date.now(), relay, kind: 'REQ', id: m[1], filters });
+ const answer = () => {
+ if (!ws.data.subs.has(m[1])) return;
+ for (const e of stored) if (filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ log.push({ t: Date.now(), relay, kind: 'EOSE', id: m[1] });
+ };
+ if (relay === 'slow') setTimeout(answer, SLOW_MS); else answer();
+ } else if (m[0] === 'CLOSE') { ws.data.subs.delete(m[1]); log.push({ t: Date.now(), relay, kind: 'CLOSE', id: m[1] }); }
+ },
+ close(ws) { sockets.delete(ws); },
+} });
+const PORT = server.port;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 390, height: 844 });
+await page.evaluateOnNewDocument((port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) {
+ if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/fast`;
+ else if (/relay\.damus\.io/.test(String(url))) url = `ws://localhost:${port}/slow`;
+ return p ? new Real(url, p) : new Real(url);
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+}, PORT);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const reqs = (relay, pred) => log.filter((x) => x.kind === 'REQ' && x.relay === relay && pred(x.filters));
+const isLive = (fs) => fs.some((f) => f.authors && f.since && !f.limit && f.kinds && f.kinds.includes(1));
+// a subscription meant to stay up: since with no limit, or limit 0 (the DM inbox)
+const isSub = (fs) => fs.some((f) => (f.since && !f.limit) || f.limit === 0);
+
+try {
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Early Bird', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, cached]);
+ log.length = 0; maxOpen = { fast: 0, slow: 0 };
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ const t0 = Date.now();
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the quote posts are on screen', await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 6, null, 20000));
+ await sleep(9000); // the refresh pass, the quotes' lookups, the reports sync
+
+ console.log('\n[relay lists for the quoted notes\' authors]');
+ const qpks = new Set(quoters.map((q) => q.pk));
+ const listAsks = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds.length === 1 && f.kinds[0] === 10002 && (f.authors || []).some((a) => qpks.has(a))));
+ const covered = new Set(listAsks.flatMap((x) => x.filters.flatMap((f) => f.authors || [])).filter((a) => qpks.has(a)));
+ check('asked for in one REQ, not one per author', listAsks.length === 1 && covered.size === 6, listAsks.length + ' REQ(s) covering ' + covered.size + ' of 6 authors');
+
+ console.log('\n[the live subscriptions]');
+ const live = reqs('fast', isLive);
+ const liveClosed = live.filter((x) => log.some((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === x.id));
+ check('opened once and kept across the refresh', live.length === 1 && liveClosed.length === 0, live.length + ' live REQs, ' + liveClosed.length + ' closed');
+
+ console.log('\n[one-shot REQs]');
+ const fastReqs = reqs('fast', (fs) => !isSub(fs));
+ const lag = [];
+ for (const r of fastReqs) {
+ const eose = log.find((y) => y.kind === 'EOSE' && y.relay === 'fast' && y.id === r.id);
+ const close = log.find((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === r.id && y.t >= r.t);
+ if (eose && close) { lag.push(close.t - eose.t); if (close.t - eose.t > 1500) console.log(' late close ' + (close.t - eose.t) + ' ms: ' + JSON.stringify(r.filters).slice(0, 160)); }
+ }
+ const slowLag = lag.filter((x) => x > 1500).length;
+ check('closed on the fast relay\'s own EOSE, not the slow relay\'s', lag.length >= 10 && slowLag === 0, lag.length + ' closed, ' + slowLag + ' later than 1.5 s (slowest ' + Math.max(0, ...lag) + ' ms; the slow relay holds EOSE ' + SLOW_MS + ' ms)');
+ const slowReqs = reqs('slow', () => true).length;
+ check('no more than ten one-shot REQs open on a relay at once', maxOpen.slow <= 10 + 5, slowReqs + ' REQs to the slow relay, at most ' + maxOpen.slow + ' open at once (fast: ' + maxOpen.fast + ')');
+ const totalFast = reqs('fast', () => true).length;
+ console.log(' ' + totalFast + ' REQs to the fast relay in ' + ((Date.now() - t0) / 1000).toFixed(0) + ' s');
+
+ console.log('\n[a reload within the half hour]');
+ const reportsBefore = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length;
+ log.length = 0;
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await sleep(6000);
+ const reportsAfter = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length;
+ check('the follows\' reports are not fetched again', reportsBefore >= 1 && reportsAfter === 0, reportsBefore + ' REQ(s) on the first open, ' + reportsAfter + ' after the reload');
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the feed asks the relays for less, and asks it in fewer REQs' : '\n❌ failed');
+process.exit(ok ? 0 : 1);