feat(relayBench): deep resumable --download for million-event corpora

The corpus downloader previously sampled ~100k events max (40-page cap
per kind bucket) and held everything in memory with no failure recovery.
Rework it for full-depth timeline pulls:

- page the latest events newest-first with an inclusive until cursor
  (id-dedup absorbs the same-second overlap) instead of kind buckets
- no page cap: keep paging until the --limit target is met
- stream every unique event to an on-disk NDJSON spill instead of RAM
- checkpoint the pagination cursor per relay; interrupted downloads
  resume where they left off
- reconnect with exponential backoff on socket drops/timeouts
- filter deterministically droppable events (kind-5 deletions are ~40%
  of a live firehose, ephemerals, oversize) at page time so they never
  count toward the download goal

Verified with a 1M-event pull from relay.damus.io (~2.1 GB raw,
4100 pages, ~22 min through a proxy) feeding a full geode vs strfry
run; corpus prepared to exactly 1,000,000 events, fingerprint
141e746599d901f5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
This commit is contained in:
Claude
2026-07-04 03:52:54 +00:00
parent ba9db9e2cb
commit 8b29c06c13
3 changed files with 215 additions and 58 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ two runs are comparable only when fingerprints match.
| **real dump** | `--real` | The `quartz` test fixture `nostr_vitor_startup_data.json.gz` — ~31k unique real events from 2024 with a rich kind mix (notes, chats, DMs, zaps, reports, communities). | | **real dump** | `--real` | The `quartz` test fixture `nostr_vitor_startup_data.json.gz` — ~31k unique real events from 2024 with a rich kind mix (notes, chats, DMs, zaps, reports, communities). |
| **contact lists** | `--corpus contact-lists.gz --limit 100000 --max-event-bytes 1048576 --max-tags 20000` | 2.1M real kind-3 contact lists (heavy events, ~1.3 kB avg, up to 100+ kB). Grab it with `pip install gdown && gdown 1yyC93xY9sDsEsa351ZAMhtAXwBUh3LYT`. Raising the size/tag caps reconfigures strfry to match, so both relays still accept the full stream. | | **contact lists** | `--corpus contact-lists.gz --limit 100000 --max-event-bytes 1048576 --max-tags 20000` | 2.1M real kind-3 contact lists (heavy events, ~1.3 kB avg, up to 100+ kB). Grab it with `pip install gdown && gdown 1yyC93xY9sDsEsa351ZAMhtAXwBUh3LYT`. Raising the size/tag caps reconfigures strfry to match, so both relays still accept the full stream. |
| **any dump** | `--corpus FILE` | NDJSON or a single JSON array, gzipped or plain (sniffed by magic bytes). | | **any dump** | `--corpus FILE` | NDJSON or a single JSON array, gzipped or plain (sniffed by magic bytes). |
| **fresh download** | `--download [urls]` | Pages recent events out of public relays (damus/nos.lol/primal by default). | | **fresh download** | `--download [urls]` | Pages the latest events out of public relays newest-first (damus/nos.lol/primal by default), `--limit N` deep — built for million-event pulls: streams to an on-disk spill, checkpoints the pagination cursor, resumes interrupted downloads, reconnects on drops. E.g. `--download wss://relay.damus.io --limit 1000000`. |
Every source goes through the same preparation: dedup by id, drop unsigned Every source goes through the same preparation: dedup by id, drop unsigned
events (NIP-17 rumors), kind-5 deletions and ephemerals (order-dependent or events (NIP-17 rumors), kind-5 deletions and ephemerals (order-dependent or
@@ -231,6 +231,7 @@ private fun loadCorpus(
options.cacheDir, options.cacheDir,
http, http,
log, log,
options.maxEventBytes,
) )
else -> else ->
CorpusSource.synthetic( CorpusSource.synthetic(
@@ -21,28 +21,42 @@
package com.vitorpamplona.relaybench.corpus package com.vitorpamplona.relaybench.corpus
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.relaybench.NostrSocket import com.vitorpamplona.relaybench.NostrSocket
import kotlinx.coroutines.async import kotlinx.coroutines.delay
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.io.File import java.io.File
import java.io.FileWriter
import java.io.Writer
import java.util.concurrent.TimeUnit
/** /**
* Assembles a fresh real-world corpus by paginating recent events out of * Assembles a fresh real-world corpus by paginating the *latest* events out
* public relays. Complements the checked-in dump: use this when the corpus * of public relays — newest first, no kind filter, `until`-paged all the way
* should reflect *today's* event mix. The result goes through the same * down until the target is met. Complements the checked-in dump: use this
* [CorpusSource.prepare] pipeline (dedup, verify, filter) as every other * when the corpus should reflect today's live event mix.
* source, then is cached as NDJSON. *
* Built to survive million-event pulls over flaky links:
* - every unique event is appended to an on-disk NDJSON spill as it
* arrives, so nothing is held in memory during the download;
* - pagination progress (`until` cursor per relay) is checkpointed next to
* the spill, so a crashed or interrupted run resumes where it left off;
* - the socket is reconnected with backoff on timeouts/drops.
*
* The finished spill goes through the same [CorpusSource.prepare] pipeline
* (dedup, verify, filter, sort) as every other source, then is cached as
* NDJSON + manifest.
*/ */
object CorpusDownloader { object CorpusDownloader {
val DEFAULT_RELAYS = listOf("wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net") val DEFAULT_RELAYS = listOf("wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net")
private val KIND_BUCKETS = listOf(listOf(0, 3), listOf(1), listOf(6, 7), listOf(9735), listOf(30023, 1111)) /** Stay inside the strictest common relay page cap (damus: max_limit 500). */
private const val PAGE_LIMIT = 500
private const val PAGE_TIMEOUT_MS = 45_000L
private const val MAX_CONSECUTIVE_FAILURES = 10
private val mapper = jacksonObjectMapper() private val mapper = jacksonObjectMapper()
fun download( fun download(
@@ -51,82 +65,224 @@ object CorpusDownloader {
cacheDir: File, cacheDir: File,
http: OkHttpClient, http: OkHttpClient,
log: (String) -> Unit, log: (String) -> Unit,
maxEventBytes: Int = CorpusSource.DEFAULT_MAX_EVENT_BYTES,
): Corpus { ): Corpus {
val key = "corpus-download-${relayUrls.hashCode()}-n$target.ndjson" val tag = relayUrls.joinToString("+") { hostOf(it) }
val cached = File(cacheDir, key) val key = "corpus-download-$tag-n$target"
val cached = File(cacheDir, "$key.ndjson")
if (cached.exists()) { if (cached.exists()) {
log(" reusing downloaded corpus ${cached.name}") log(" reusing downloaded corpus ${cached.name}")
return CorpusIO.read(cached, source = "download:${relayUrls.joinToString(",")}") return CorpusIO.read(cached, source = "download:${relayUrls.joinToString(",")}")
} }
val perRelay = (target * 2 / relayUrls.size).coerceAtLeast(500) cacheDir.mkdirs()
val collected = LinkedHashMap<String, Event>(target * 2) val spill = File(cacheDir, "$key.raw.ndjson")
runBlocking { val checkpoint = File(cacheDir, "$key.progress.json")
relayUrls
.map { url ->
async {
runCatching { downloadFrom(url, perRelay, log) }
.onFailure { log(" ! $url failed: ${it.message}") }
.getOrDefault(emptyList())
}
}.awaitAll()
}.flatten().forEach { collected.putIfAbsent(it.id, it) }
log(" downloaded ${collected.size} unique events from ${relayUrls.size} relays") // Deterministically droppable events (kind-5 deletions — ~40% of a
val corpus = CorpusSource.prepare(collected.values.toList(), target, "download:${relayUrls.joinToString(",")}", log) // live firehose! — ephemerals, oversize) are filtered at page time
// and never count toward the goal, so only a small headroom is left
// for what preparation alone can catch (invalid sigs, canonical-size
// edge cases, cross-relay duplicates).
val needed = if (relayUrls.size == 1) target + target / 25 else target * 2
val ids = HashSet<String>(needed * 2)
val cursors = HashMap<String, Long>()
if (spill.exists()) {
spill.useLines { lines ->
lines.filter { it.isNotBlank() }.forEach { line ->
runCatching { mapper.readTree(line) }
.getOrNull()
?.get("id")
?.asText()
?.let { ids.add(it) }
}
}
if (checkpoint.exists()) {
runCatching { mapper.readTree(checkpoint.readText()) }.getOrNull()?.fields()?.forEach { (url, until) ->
cursors[url] = until.asLong()
}
}
log(" resuming download: ${ids.size} events already spilled to ${spill.name}")
}
val ws =
http
.newBuilder()
.pingInterval(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
spill.appendText("") // ensure the file exists even if the relay yields nothing
FileWriter(spill, true).buffered(1 shl 16).use { out ->
runBlocking {
// Even split across relays (the last one may top up the rest)
// so a multi-relay corpus actually mixes sources.
val perRelay = (needed + relayUrls.size - 1) / relayUrls.size
relayUrls.forEachIndexed { i, url ->
val goal = if (i == relayUrls.lastIndex) needed else minOf(needed, ids.size + perRelay)
if (ids.size < goal) {
downloadFrom(url, goal, ids, out, cursors, checkpoint, ws, maxEventBytes, log)
}
}
}
}
log(" downloaded ${ids.size} unique events from ${relayUrls.size} relay(s)")
if (ids.size < needed) {
log(" ! relays exhausted below the $needed-event goal — preparing what we have")
}
log(" preparing (dedup, filter, verify signatures)…")
val raw = CorpusIO.read(spill).events
val corpus = CorpusSource.prepare(raw, target, "download:${relayUrls.joinToString(",")}", log)
CorpusIO.write(cached, corpus) CorpusIO.write(cached, corpus)
checkpoint.delete()
log(" cached prepared corpus to ${cached.path}")
return corpus return corpus
} }
private fun hostOf(url: String) =
url
.removePrefix("wss://")
.removePrefix("ws://")
.trimEnd('/')
.replace(Regex("[^A-Za-z0-9.-]"), "_")
/**
* Pages `{"limit":500,"until":T}` down the relay's timeline, appending
* unique events to [out]. The `until` cursor stays *inclusive* of the
* oldest seen second (id-dedup absorbs the overlap) so events sharing a
* created_at are not skipped; only a full page of nothing-new at the same
* cursor — >500 events in one second — forces the cursor past it.
*/
private suspend fun downloadFrom( private suspend fun downloadFrom(
url: String, url: String,
target: Int, goal: Int,
ids: MutableSet<String>,
out: Writer,
cursors: MutableMap<String, Long>,
checkpoint: File,
http: OkHttpClient,
maxEventBytes: Int,
log: (String) -> Unit, log: (String) -> Unit,
): List<Event> { ) {
val http = OkHttpClient.Builder().build() var until: Long? = cursors[url]
val socket = NostrSocket.connect(http, url) var socket: NostrSocket? = null
val events = ArrayList<Event>(target) var failures = 0
var pages = 0
var added = 0
val startedAt = System.nanoTime()
fun saveCheckpoint() {
until?.let { cursors[url] = it }
runCatching { checkpoint.writeText(mapper.writeValueAsString(cursors)) }
}
try { try {
for (kinds in KIND_BUCKETS) { while (ids.size < goal) {
var until: Long? = null val sock =
var pages = 0 socket ?: runCatching { NostrSocket.connect(http, url) }.getOrElse {
val quota = target / KIND_BUCKETS.size failures++
var got = 0 if (failures > MAX_CONSECUTIVE_FAILURES) {
while (got < quota && pages < 40) { log(" ! $url: giving up after $failures consecutive failures (${it.message})")
val filter = Filter(kinds = kinds, limit = 500, until = until) return
val page = requestPage(socket, "dl-${kinds.first()}-$pages", filter) ?: break }
if (page.isEmpty()) break delay(minOf(1000L shl (failures - 1), 30_000L))
events += page null
got += page.size } ?: continue
until = page.minOf { it.createdAt } - 1 socket = sock
pages++
val filter = Filter(limit = PAGE_LIMIT, until = until)
val page = requestPage(sock, "dl-$pages", filter)
if (page == null) {
// Timeout or dead socket: reconnect and retry the same cursor.
runCatching { sock.disconnect() }
socket = null
failures++
if (failures > MAX_CONSECUTIVE_FAILURES) {
log(" ! $url: giving up after $failures consecutive page failures")
return
}
delay(minOf(1000L shl (failures - 1), 30_000L))
continue
}
failures = 0
pages++
if (page.isEmpty()) break // end of the relay's timeline
var fresh = 0
for (event in page) {
// Skip what preparation would deterministically drop, so
// they never count toward the goal: deletions, ephemerals
// (never queryable), events over the size cap.
if (event.kind == 5 || event.kind in 20000..29999) continue
if (event.json.length > maxEventBytes) continue
if (ids.add(event.id)) {
out.write(event.json)
out.write("\n")
fresh++
}
}
added += fresh
val oldest = page.minOf { it.createdAt }
until =
if (fresh == 0 && until != null && oldest >= until!!) {
// >PAGE_LIMIT events in this second and we have them all.
until!! - 1
} else {
oldest
}
out.flush()
saveCheckpoint()
if (page.size < PAGE_LIMIT && fresh == 0) break // exhausted
if (pages % 50 == 0) {
val elapsed = (System.nanoTime() - startedAt) / 1e9
val rate = added / elapsed
val remaining = (goal - ids.size).coerceAtLeast(0)
val eta = if (rate > 0) (remaining / rate).toLong() else -1
log(
" $url: ${ids.size} events (${"%.0f".format(rate)}/s, page $pages, " +
"cursor $until, eta ${if (eta >= 0) "${eta / 60}m${eta % 60}s" else "?"})",
)
} }
} }
} finally { } finally {
socket.disconnect() runCatching { socket?.disconnect() }
http.dispatcher.executorService.shutdown()
} }
log(" $url: ${events.size} events") log(" $url: $added events in $pages pages")
return events
} }
/** One REQ page; null on timeout or socket close. */ private class PagedEvent(
val id: String,
val kind: Int,
val createdAt: Long,
val json: String,
)
/** One REQ page; null on timeout or socket close (caller reconnects). */
private suspend fun requestPage( private suspend fun requestPage(
socket: NostrSocket, socket: NostrSocket,
subId: String, subId: String,
filter: Filter, filter: Filter,
): List<Event>? = ): List<PagedEvent>? =
withTimeoutOrNull(30_000) { withTimeoutOrNull(PAGE_TIMEOUT_MS) {
socket.req(subId, filter.toJson()) if (!socket.req(subId, filter.toJson())) return@withTimeoutOrNull null
val page = ArrayList<Event>(filter.limit ?: 500) val page = ArrayList<PagedEvent>(filter.limit ?: PAGE_LIMIT)
for (raw in socket.incoming) { for (raw in socket.incoming) {
val node = runCatching { mapper.readTree(raw) }.getOrNull() ?: continue val node = runCatching { mapper.readTree(raw) }.getOrNull() ?: continue
when (node[0]?.asText()) { when (node[0]?.asText()) {
"EVENT" -> "EVENT" ->
if (node[1]?.asText() == subId) { if (node[1]?.asText() == subId) {
runCatching { OptimizedJsonMapper.fromJson(node[2].toString()) } val event = node[2] ?: continue
.getOrNull() val id = event["id"]?.asText() ?: continue
?.let { page.add(it) } val kind = event["kind"]?.asInt() ?: continue
val createdAt = event["created_at"]?.asLong() ?: continue
page.add(PagedEvent(id, kind, createdAt, event.toString()))
} }
"EOSE", "CLOSED" -> "EOSE", "CLOSED" ->
if (node[1]?.asText() == subId) { if (node[1]?.asText() == subId) {
@@ -135,6 +291,6 @@ object CorpusDownloader {
} }
} }
} }
page null // channel closed without EOSE — socket died mid-page
} }
} }