diff --git a/tools/xover_eras.ts b/tools/xover_eras.ts new file mode 100644 index 0000000..3b203e9 --- /dev/null +++ b/tools/xover_eras.ts @@ -0,0 +1,65 @@ +/** + * Does the news server answer XOVER faster for some eras of a group than for + * others? Each connection fetches chunks round-robin from several starting + * points (eras) of one group, at the same time, so every era sees the same + * server load; per era: seconds per command, articles/s per connection, + * compressed bytes per article (LOG.md 2026-10-09 (6)). + * + * node --env-file=.env --import=@poppinss/ts-exec tools/xover_eras.ts ,,… [--connections 2] [--seconds 600] [--chunk 100000] + */ +import { unzipSync } from 'node:zlib' +import { XoverClient } from '#lib/nntp/index' + +const [group, starts] = process.argv.slice(2) +const flag = (name: string, fallback: number) => { + const i = process.argv.indexOf(name) + return i === -1 ? fallback : Number(process.argv[i + 1]) +} +const connections = flag('--connections', 2) +const seconds = flag('--seconds', 600) +const chunk = flag('--chunk', 100_000) +const eras = starts.split(',').map((s) => ({ start: Number(s), next: Number(s), posted: '', chunks: [] as { ms: number; articles: number; bytes: number }[] })) + +const until = Date.now() + seconds * 1000 +async function connection(index: number) { + const env = process.env + const client = await XoverClient.connect({ + host: env.NNTP_HOST!, + port: Number(env.NNTP_PORT ?? 563), + user: env.NNTP_USER, + password: env.NNTP_PASSWORD, + secure: (env.NNTP_SECURE ?? 'true') !== 'false', + compress: true, + timeoutMs: 300_000, + }) + await client.group(group) + for (let step = index; Date.now() < until; step++) { + const era = eras[step % eras.length] + const from = era.next + era.next += chunk + const at = performance.now() + const raw = await client.xoverRaw(from, from + chunk - 1) + const ms = performance.now() - at + const text = unzipSync(raw).toString('utf8') + const lines = text.split('\n').filter((l) => l.includes('\t')) + if (!era.posted && lines[0]) era.posted = lines[0].split('\t')[3] ?? '' + era.chunks.push({ ms, articles: lines.length, bytes: raw.length }) + console.log(JSON.stringify({ connection: index, start: era.start, from, seconds: +(ms / 1000).toFixed(2), articles: lines.length, bytes: raw.length })) + } + client.close() +} + +await Promise.all(Array.from({ length: connections }, (_, i) => connection(i))) + +for (const era of eras) { + const ms = era.chunks.reduce((n, c) => n + c.ms, 0) + const articles = era.chunks.reduce((n, c) => n + c.articles, 0) + const bytes = era.chunks.reduce((n, c) => n + c.bytes, 0) + const sorted = era.chunks.map((c) => c.ms).sort((a, b) => a - b) + const median = sorted[Math.floor(sorted.length / 2)] ?? 0 + console.log( + `${group} from ${era.start} (${era.posted}): ${era.chunks.length} chunks, ` + + `${(ms / era.chunks.length / 1000).toFixed(2)} s a chunk (median ${(median / 1000).toFixed(2)}), ` + + `${Math.round(articles / (ms / 1000)).toLocaleString('en')} articles/s per connection, ${(bytes / Math.max(1, articles)).toFixed(1)} B an article` + ) +}