From 3f087e5c6080f09c1b3294c9f8716ef131c7b998 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 23:46:30 +0000 Subject: [PATCH] Quartz: give SyncCoverage's persistence a typed band key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `export`/`restore` handed back `Map` where the string was the INTERNAL key — `" "`. That is fine for a file layer that writes the key back verbatim, and nothing else. A layer that wants its own layout — one object per relay, or per filter, or nested by both — had to split the key apart, and the separator was folklore it could only learn by reading this class. Two of them now do. So the key is a pair, with the joined form kept here as `encode`/`decode` for a file that does want one key per line. geode keeps its format byte-for-byte and stops pattern-matching on somebody else's string. It is also faster on the path that matters. `key()` built a new string per lookup, so a `legs()` over a fan-out COPIED the filter's json — tens of thousands of characters for an author-scoped filter — once per relay per cycle, then hashed all of it, since a freshly built string carries no cached hash. The pair hashes two halves it already holds: the url, and the fingerprint instance the cache above it already returns. No behaviour change: the same pairs key the same bands, a file written before this reads back through `decode`, and the format on disk is untouched. --- .../geode/mirror/SyncCoverageFile.kt | 25 +++++---- .../relay/client/accessories/SyncCoverage.kt | 51 ++++++++++++++++--- .../client/accessories/SyncCoverageTest.kt | 20 ++++++++ 3 files changed, 80 insertions(+), 16 deletions(-) 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