diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt index 58b0b23310..ea5f9aad21 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt @@ -96,15 +96,22 @@ class SyncCoverageFile( if (!file.isFile) return runCatching { val root = Json.parseToJsonElement(file.readText()).jsonObject + // The file's key is the joined form, decoded by the class that + // mints it — this layer never has to know the separator. A key it + // cannot read names no pair and is dropped, which costs one + // upstream's re-walk rather than the whole file. coverage.restore( - root.mapValues { (_, v) -> - val o = v.jsonObject - SyncCoverage.Band( - spansOf(o), - o["complete"]?.jsonPrimitive?.boolean ?: false, - o["fullAt"]?.jsonPrimitive?.long ?: 0L, - ) - }, + root.entries + .mapNotNull { (k, v) -> + val key = SyncCoverage.BandKey.decode(k) ?: return@mapNotNull null + val o = v.jsonObject + key to + SyncCoverage.Band( + spansOf(o), + o["complete"]?.jsonPrimitive?.boolean ?: false, + o["fullAt"]?.jsonPrimitive?.long ?: 0L, + ) + }.toMap(), ) }.onFailure { Log.w("SyncCoverageFile") { "could not read ${file.path} (${it.message}); starting fresh" } @@ -142,7 +149,7 @@ class SyncCoverageFile( buildJsonObject { coverage.export().forEach { (key, band) -> put( - key, + key.encode(), buildJsonObject { // min/max are the outer edges across every // kind, and are written for two readers: a diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt index 3eb54ae9fd..c8e2e3aed8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt @@ -49,8 +49,10 @@ import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap * occasional and self-heal on the next filter change or full re-walk. * * Persistence is the caller's: [export] the map on a schedule and [restore] - * it at startup. [onChange] fires whenever a band changes, so a persistence - * layer can mark itself dirty without polling. + * it at startup, both keyed by [BandKey] so a file layer can lay the two + * halves out however it likes without having to know how a key is spelled. + * [onChange] fires whenever a band changes, so a persistence layer can mark + * itself dirty without polling. * * Not to be confused with the `relay.client.paging` package: its * `RelayLoadingCursors` are in-memory POSITIONS for demand-driven UI paging @@ -65,6 +67,41 @@ class SyncCoverage( private val now: () -> Long = { TimeUtils.now() }, private val onChange: () -> Unit = {}, ) { + /** + * What one band is about: the relay's url, and the filter as [Filter.toJson] + * renders it. + * + * A pair, not a joined string, for two reasons. A persistence layer needs + * the halves — one that lays its file out by relay, or by filter, or by + * both, had to split the key back apart, and the separator was folklore it + * could only learn by reading this class. And on the hot path a joined key + * COPIES the filter's json on every lookup: `legs()` runs once per relay + * per cycle, an author-scoped filter's json runs to tens of thousands of + * characters, and a fan-out over thousands of relays paid that copy — plus + * a fresh hash over all of it, since a newly built string has none cached — + * on every one of them. A pair hashes the two halves it already holds. + * + * [encode] and [decode] are the joined form, kept HERE so a file that wants + * one key per line still gets the separator from the class that mints it. + * A normalized relay url contains no space, which is what makes splitting + * at the first one exact. + */ + data class BandKey( + val relay: String, + val filter: String, + ) { + fun encode(): String = "$relay $filter" + + companion object { + /** The inverse of [encode], or null for a key that names no pair. */ + fun decode(key: String): BandKey? { + val at = key.indexOf(' ') + if (at <= 0 || at == key.length - 1) return null + return BandKey(key.substring(0, at), key.substring(at + 1)) + } + } + } + /** A covered `created_at` interval, inclusive at both ends. */ data class Span( val min: Long, @@ -115,7 +152,7 @@ class SyncCoverage( } } - private val bands = ConcurrentMap() + private val bands = ConcurrentMap() // filter -> its canonical json. Filter.toJson() runs to tens of thousands // of characters for author-scoped filters, and a fan-out keys once per @@ -379,10 +416,10 @@ class SyncCoverage( fun size(): Int = bands.size() /** A point-in-time copy of every band, for a persistence layer to write out. */ - fun export(): Map = bands.snapshot() + fun export(): Map = bands.snapshot() /** Load previously [export]ed bands, e.g. at startup. */ - fun restore(entries: Map) { + fun restore(entries: Map) { for ((key, band) in entries) bands[key] = band } @@ -395,7 +432,7 @@ class SyncCoverage( private fun key( url: NormalizedRelayUrl, filter: Filter, - ): String { + ): BandKey { val fingerprint = fingerprints[filter] ?: filter.toJson().also { @@ -404,7 +441,7 @@ class SyncCoverage( // pays the toJson each time instead of growing the heap. if (fingerprints.size() < MAX_FINGERPRINTS) fingerprints[filter] = it } - return "${url.url} $fingerprint" + return BandKey(url.url, fingerprint) } // One line per process, not per walk: the point is to tell a caller it has diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt index 3c26e1891e..abf1113f02 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt @@ -354,6 +354,26 @@ class SyncCoverageTest { assertEquals(1_700_002_000L, band.maxCreatedAt) } + @Test + fun `a band key round-trips through the joined form a file writes`() { + // A file that wants one key per line joins and splits with these, so + // the separator stays in the class that mints the key instead of being + // rediscovered by every persistence layer downstream. + val c = SyncCoverage() + c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + val key = c.export().keys.single() + + assertEquals(relay.url, key.relay) + assertEquals(profiles.toJson(), key.filter) + assertEquals(key, SyncCoverage.BandKey.decode(key.encode())) + + // A key naming no pair is refused rather than read as a relay with an + // empty filter, which would key a band nothing can ever look up. + assertNull(SyncCoverage.BandKey.decode("no-space-here")) + assertNull(SyncCoverage.BandKey.decode(" {\"kinds\":[0]}")) + assertNull(SyncCoverage.BandKey.decode("wss://relay.example/ ")) + } + @Test fun `onChange fires when a band changes so persistence can mark dirty`() { var changes = 0