From e909866d26dd95774c512f0a9f11de8595be4498 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 13:27:33 +0000 Subject: [PATCH] feat(quartz): add EventInterner so deserialized events share canonical instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> 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 --- .../nip01Core/core/EventInterner.apple.kt | 42 ++++++ .../quartz/nip01Core/core/Event.kt | 2 +- .../quartz/nip01Core/core/EventInterner.kt | 65 +++++++++ .../core/EventInterner.jvmAndroid.kt | 76 ++++++++++ .../nip01Core/core/EventInternerTest.kt | 136 ++++++++++++++++++ .../nip01Core/core/EventInterner.linux.kt | 36 +++++ 6 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.apple.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.jvmAndroid.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInternerTest.kt create mode 100644 quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.linux.kt diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.apple.kt new file mode 100644 index 000000000..4dc306f84 --- /dev/null +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.apple.kt @@ -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() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt index 906773852..576fe6813 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt @@ -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 { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.kt new file mode 100644 index 000000000..c0be04c21 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.kt @@ -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 + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.jvmAndroid.kt new file mode 100644 index 000000000..f728608ac --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.jvmAndroid.kt @@ -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>(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 + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInternerTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInternerTest.kt new file mode 100644 index 000000000..f55f79cd8 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInternerTest.kt @@ -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(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() + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.linux.kt new file mode 100644 index 000000000..38df76c6e --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/EventInterner.linux.kt @@ -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() + } +}