Quartz: give SyncCoverage's persistence a typed band key

`export`/`restore` handed back `Map<String, Band>` where the string was
the INTERNAL key — `"<relay-url> <filter-json>"`. 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.
This commit is contained in:
Claude
2026-08-07 23:46:30 +00:00
parent cfc57d66c9
commit 3f087e5c60
3 changed files with 80 additions and 16 deletions
@@ -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
@@ -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<String, Band>()
private val bands = ConcurrentMap<BandKey, Band>()
// 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<String, Band> = bands.snapshot()
fun export(): Map<BandKey, Band> = bands.snapshot()
/** Load previously [export]ed bands, e.g. at startup. */
fun restore(entries: Map<String, Band>) {
fun restore(entries: Map<BandKey, Band>) {
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
@@ -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