feat(quartz): add EventInterner so deserialized events share canonical instances

Process-wide interner that canonicalises Event instances by id —
whenever the same event id is decoded twice (relay duplicates,
projection seeds, FS re-reads, etc.), every consumer sees the same
object reference. Backed by weak references so entries vanish once
no live consumer (projection slot, UI state) holds the event.

Shape:
- expect class EventInterner in nip01Core/core/, with a Default
  process-wide instance.
- jvmAndroid actual: ConcurrentHashMap<HexKey, WeakReference<Event>>
  pre-sized to 5_000 (load factor 0.75). On-access cleanup atomically
  removes dead entries via map.remove(key, ref). intern() uses
  putIfAbsent + retry-on-dead-canonical to be race-safe.
- apple / linux actuals: passthrough (no canonicalisation). Kotlin/
  Native has weak refs but no built-in concurrent map; rather than
  ship a half-baked impl on Apple targets we skip canonicalisation
  there. Can be revisited if iOS wants the memory savings.

Wire-up:
- Event.fromJson now routes through EventInterner.Default.intern,
  so every deserialised event becomes canonical for free. In-process
  events (signer.sign(...) etc.) aren't auto-interned — the
  canonicaliser pays off for events that re-occur from multiple
  sources, which signed-locally events don't.

Tests (jvmTest):
- internReturnsFirstInstance / internCollapsesDuplicates:
  identity is preserved across equivalent decodes.
- getReturnsLiveEntry / getReturnsNullForUnknownId.
- getEvictsDeadEntries: weak ref cleared by GC, on-access
  cleanup drops the entry.
- draftChurnDoesNotLeak: 100 distinct drafts churn through the
  interner with no strong refs; map shrinks back to ~0 after GC.
- defaultInstanceIsShared: the global Default interner is process-wide.
- 7/7 pass; 240/240 store + projection tests still green.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
This commit is contained in:
Claude
2026-04-30 13:27:33 +00:00
parent ae6343cd73
commit e909866d26
6 changed files with 356 additions and 1 deletions
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.core
/**
* Apple actual: passthrough. Kotlin/Native has weak refs but no
* built-in concurrent map; rather than ship a half-baked impl, we
* skip canonicalisation entirely on Apple targets and let
* [Event.fromJson] return whatever the deserializer produced. Can
* be revisited if Amethyst's iOS port wants the memory savings.
*/
actual class EventInterner {
actual fun intern(event: Event): Event = event
actual fun get(id: HexKey): Event? = null
actual fun size(): Int = 0
actual fun clear() {}
actual companion object {
actual val Default: EventInterner = EventInterner()
}
}
@@ -67,7 +67,7 @@ open class Event(
fun toJson(): String = OptimizedJsonMapper.toJson(this)
companion object {
fun fromJson(json: String): Event = OptimizedJsonMapper.fromJson(json)
fun fromJson(json: String): Event = EventInterner.Default.intern(OptimizedJsonMapper.fromJson(json))
fun fromJsonOrNull(json: String) =
try {
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.core
/**
* Process-wide interner that canonicalises [Event] instances by id so
* every consumer (relay client, store deserialization, projections,
* tests) sees the same object reference for the same event id.
*
* Backed by weak references — entries vanish when no live consumer
* holds the event, so the cache only grows as long as projections /
* UI state actually need the events. Sized for ~5000 hot events; the
* underlying map resizes if usage exceeds that.
*
* Use [intern] on every event arrival path. The first occurrence wins
* and becomes canonical; subsequent equivalent decodes return that
* canonical instance.
*
* Platforms without weak references fall back to a passthrough that
* returns [event] unchanged — no canonicalisation, but no leaks
* either.
*/
expect class EventInterner() {
/**
* Returns the canonical [Event] for [event]'s id. If a live
* canonical instance already exists for this id, returns it;
* otherwise stores [event] as the new canonical and returns it.
*
* Equivalence is by event id only — callers must trust the id
* was content-derived (signed events satisfy this).
*/
fun intern(event: Event): Event
/** Returns the canonical [Event] for [id] if one is live, else null. */
fun get(id: HexKey): Event?
/** Number of map entries (including dead weak refs not yet cleaned). */
fun size(): Int
/** Drop every entry. Mostly useful for tests. */
fun clear()
companion object {
/** Process-wide default interner used by [Event.fromJson]. */
val Default: EventInterner
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.core
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap
/**
* JVM/Android actual: ConcurrentHashMap of WeakReference, pre-sized
* for the expected hot working set. Dead entries are reclaimed
* lazily on access (see [intern] and [get]) — when a weak ref's
* referent has been GC'd, the map slot is removed atomically and
* replaced if a fresh event takes its place.
*/
actual class EventInterner {
private val cache = ConcurrentHashMap<HexKey, WeakReference<Event>>(INITIAL_CAPACITY, LOAD_FACTOR)
actual fun intern(event: Event): Event {
// Fast path: existing canonical is still alive.
cache[event.id]?.get()?.let { return it }
// Race-safe install: putIfAbsent ensures only one writer
// wins. If another thread beat us, return whatever they put
// (resolving the rare case where their ref was already GC'd
// by retrying through the slow path).
while (true) {
val ref = WeakReference(event)
val existing = cache.putIfAbsent(event.id, ref)
if (existing == null) return event
val canonical = existing.get()
if (canonical != null) return canonical
// Existing entry's referent was GC'd; drop it and retry.
cache.remove(event.id, existing)
}
}
actual fun get(id: HexKey): Event? {
val ref = cache[id] ?: return null
val event = ref.get()
if (event != null) return event
// Self-cleaning: drop the dead entry so the map doesn't bloat.
cache.remove(id, ref)
return null
}
actual fun size(): Int = cache.size
actual fun clear() {
cache.clear()
}
actual companion object {
actual val Default: EventInterner = EventInterner()
private const val INITIAL_CAPACITY = 5_000
private const val LOAD_FACTOR = 0.75f
}
}
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.core
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
class EventInternerTest {
private val signer = NostrSignerSync()
private lateinit var interner: EventInterner
@BeforeTest
fun setUp() {
Secp256k1Instance
interner = EventInterner()
}
@AfterTest
fun tearDown() {
interner.clear()
}
@Test
fun internReturnsFirstInstance() {
val original = signer.sign(TextNoteEvent.build("hello", createdAt = 100))
val canonical = interner.intern(original)
assertSame(original, canonical)
assertEquals(1, interner.size())
}
@Test
fun internCollapsesDuplicates() {
val a = signer.sign(TextNoteEvent.build("hello", createdAt = 100))
// Round-trip through OptimizedJsonMapper directly (NOT
// Event.fromJson, which would already intern via Default) so
// we get a non-identical-but-equal event for our isolated
// interner to deduplicate.
val b = OptimizedJsonMapper.fromJson(a.toJson()) as TextNoteEvent
assertEquals(a.id, b.id)
val firstCanonical = interner.intern(a)
val secondCanonical = interner.intern(b)
assertSame(firstCanonical, secondCanonical)
assertSame(a, secondCanonical)
assertEquals(1, interner.size())
}
@Test
fun getReturnsLiveEntry() {
val a = signer.sign(TextNoteEvent.build("hello", createdAt = 100))
interner.intern(a)
assertSame(a, interner.get(a.id))
}
@Test
fun getReturnsNullForUnknownId() {
assertNull(interner.get("0".repeat(64)))
}
@Test
fun getEvictsDeadEntries() {
// Confine the event to a helper so no strong ref leaks into
// this method's stack frame after it returns. The interner's
// WeakReference is then the only thing holding it.
val id = internAndDiscard(interner)
for (i in 0..40) {
System.gc()
Thread.sleep(20)
if (interner.get(id) == null) break
}
assertNull(interner.get(id))
assertEquals(0, interner.size(), "dead entry should be evicted on access")
}
private fun internAndDiscard(interner: EventInterner): HexKey {
val ev = signer.sign(TextNoteEvent.build("hello", createdAt = 100))
interner.intern(ev)
return ev.id
}
@Test
fun draftChurnDoesNotLeak() {
// 100 distinct drafts, no strong references retained.
val ids = ArrayList<HexKey>(100)
for (i in 0 until 100) {
val e = signer.sign(TextNoteEvent.build("draft-$i", createdAt = 1_000L + i))
ids.add(e.id)
interner.intern(e)
}
// Force GC + sweep — calling get() on every id triggers
// the on-access cleanup path.
for (i in 0..40) {
System.gc()
Thread.sleep(20)
ids.forEach { interner.get(it) }
if (interner.size() == 0) break
}
assertTrue(interner.size() <= 5, "expected most drafts to be evicted, got ${interner.size()}")
}
@Test
fun defaultInstanceIsShared() {
val a = signer.sign(TextNoteEvent.build("hello", createdAt = 100))
val canonical = EventInterner.Default.intern(a)
// A second intern call from anywhere returns the same canonical.
val b = EventInterner.Default.intern(a)
assertSame(canonical, b)
EventInterner.Default.clear()
}
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.core
/** Linux native actual: passthrough. See `EventInterner.apple.kt`. */
actual class EventInterner {
actual fun intern(event: Event): Event = event
actual fun get(id: HexKey): Event? = null
actual fun size(): Int = 0
actual fun clear() {}
actual companion object {
actual val Default: EventInterner = EventInterner()
}
}