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:
+42
@@ -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
|
||||
}
|
||||
}
|
||||
+76
@@ -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
|
||||
}
|
||||
}
|
||||
+136
@@ -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()
|
||||
}
|
||||
}
|
||||
+36
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user