refactor(quartz): intern only on store reads, inject per-store interner
The previous commit interned at Event.fromJson, which is too early — the deserializer can produce events with manipulated id fields that don't match their content. Move interning to the post-validation boundary: events are only canonicalised when read back from durable storage, where they were valid when written. - Revert Event.fromJson to plain OptimizedJsonMapper.fromJson. - Revert ObservableEventStore.insert interning. Substituting the caller's event for a previously-cached one with the same id but potentially different sig was a write-side surprise; we leave the caller's instance alone. - Intern at SQLiteStatement.toEvent (read deserialization). - Intern at FsEventStore.readEvent (FS reads). The interner is also now constructor-injected through every store layer (SQLiteEventStore, EventStore, QueryBuilder, FsEventStore) defaulting to EventInterner.Default. BaseDBTest gives each parallel forEachDB store its own EventInterner — without this, parallel test runs that sign "same id, different sig" events through the shared Default would see the first run's sig leak across all DBs. All 240 store + projection + interner tests pass. https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
This commit is contained in:
@@ -67,7 +67,7 @@ open class Event(
|
|||||||
fun toJson(): String = OptimizedJsonMapper.toJson(this)
|
fun toJson(): String = OptimizedJsonMapper.toJson(this)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromJson(json: String): Event = EventInterner.Default.intern(OptimizedJsonMapper.fromJson(json))
|
fun fromJson(json: String): Event = OptimizedJsonMapper.fromJson(json)
|
||||||
|
|
||||||
fun fromJsonOrNull(json: String) =
|
fun fromJsonOrNull(json: String) =
|
||||||
try {
|
try {
|
||||||
|
|||||||
+3
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
|||||||
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.EventInterner
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
@@ -36,8 +37,9 @@ class EventStore(
|
|||||||
dbName: String? = "events.db",
|
dbName: String? = "events.db",
|
||||||
val relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(),
|
val relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(),
|
||||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||||
|
val interner: EventInterner = EventInterner.Default,
|
||||||
) : IEventStore {
|
) : IEventStore {
|
||||||
val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy)
|
val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy, interner = interner)
|
||||||
|
|
||||||
override suspend fun insert(event: Event) = store.insertEvent(event)
|
override suspend fun insert(event: Event) = store.insertEvent(event)
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
|||||||
import androidx.sqlite.SQLiteConnection
|
import androidx.sqlite.SQLiteConnection
|
||||||
import androidx.sqlite.SQLiteStatement
|
import androidx.sqlite.SQLiteStatement
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.EventInterner
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||||
@@ -35,6 +36,7 @@ class QueryBuilder(
|
|||||||
val fts: FullTextSearchModule,
|
val fts: FullTextSearchModule,
|
||||||
val hasher: (db: SQLiteConnection) -> TagNameValueHasher,
|
val hasher: (db: SQLiteConnection) -> TagNameValueHasher,
|
||||||
val indexStrategy: IndexingStrategy,
|
val indexStrategy: IndexingStrategy,
|
||||||
|
val interner: EventInterner = EventInterner.Default,
|
||||||
) {
|
) {
|
||||||
// ------------
|
// ------------
|
||||||
// Main methods
|
// Main methods
|
||||||
@@ -232,7 +234,9 @@ class QueryBuilder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T : Event> SQLiteStatement.toEvent() =
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T : Event> SQLiteStatement.toEvent(): T {
|
||||||
|
val event =
|
||||||
EventFactory.create<T>(
|
EventFactory.create<T>(
|
||||||
getText(0),
|
getText(0),
|
||||||
getText(1),
|
getText(1),
|
||||||
@@ -242,6 +246,11 @@ class QueryBuilder(
|
|||||||
getText(5),
|
getText(5),
|
||||||
getText(6),
|
getText(6),
|
||||||
)
|
)
|
||||||
|
// Events read from durable storage were valid when written, so
|
||||||
|
// interning their reconstruction is safe. Multiple projections
|
||||||
|
// re-querying the same id end up sharing one Event instance.
|
||||||
|
return interner.intern(event) as T
|
||||||
|
}
|
||||||
|
|
||||||
private fun SQLiteStatement.toRawEvent() =
|
private fun SQLiteStatement.toRawEvent() =
|
||||||
RawEvent(
|
RawEvent(
|
||||||
|
|||||||
+3
@@ -25,6 +25,7 @@ import androidx.sqlite.SQLiteDriver
|
|||||||
import androidx.sqlite.SQLiteException
|
import androidx.sqlite.SQLiteException
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.EventInterner
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||||
@@ -41,6 +42,7 @@ class SQLiteEventStore(
|
|||||||
val relay: NormalizedRelayUrl? = null,
|
val relay: NormalizedRelayUrl? = null,
|
||||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||||
val numReaders: Int = 4,
|
val numReaders: Int = 4,
|
||||||
|
val interner: EventInterner = EventInterner.Default,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val DATABASE_VERSION = 2
|
const val DATABASE_VERSION = 2
|
||||||
@@ -68,6 +70,7 @@ class SQLiteEventStore(
|
|||||||
fullTextSearchModule,
|
fullTextSearchModule,
|
||||||
seedModule::hasher,
|
seedModule::hasher,
|
||||||
indexStrategy,
|
indexStrategy,
|
||||||
|
interner,
|
||||||
)
|
)
|
||||||
|
|
||||||
val modules =
|
val modules =
|
||||||
|
|||||||
+7
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.EventInterner
|
||||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -63,6 +64,12 @@ open class BaseDBTest {
|
|||||||
EventStore(
|
EventStore(
|
||||||
dbName = null,
|
dbName = null,
|
||||||
indexStrategy = indexStrategy,
|
indexStrategy = indexStrategy,
|
||||||
|
// Each store gets its own interner so
|
||||||
|
// parallel forEachDB runs don't share a
|
||||||
|
// canonical Event for the same id —
|
||||||
|
// tests sign with random sigs that
|
||||||
|
// would otherwise cross-pollinate.
|
||||||
|
interner = EventInterner(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.fs
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.EventInterner
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
|
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
|
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
|
||||||
@@ -75,6 +76,7 @@ open class FsEventStore(
|
|||||||
* verification re-canonicalises — so format is purely a UX choice.
|
* verification re-canonicalises — so format is purely a UX choice.
|
||||||
*/
|
*/
|
||||||
private val eventToJson: (Event) -> String = Event::toJson,
|
private val eventToJson: (Event) -> String = Event::toJson,
|
||||||
|
private val interner: EventInterner = EventInterner.Default,
|
||||||
) : IEventStore {
|
) : IEventStore {
|
||||||
private val layout = FsLayout(root)
|
private val layout = FsLayout(root)
|
||||||
private val hasher: TagNameValueHasher
|
private val hasher: TagNameValueHasher
|
||||||
@@ -508,7 +510,10 @@ open class FsEventStore(
|
|||||||
val p = layout.canonical(id)
|
val p = layout.canonical(id)
|
||||||
if (!p.exists()) return null
|
if (!p.exists()) return null
|
||||||
return try {
|
return try {
|
||||||
Event.fromJson(p.readText())
|
// Events on disk were valid when written, so interning
|
||||||
|
// their reconstruction is safe and lets multiple readers
|
||||||
|
// of the same id share one Event instance.
|
||||||
|
interner.intern(Event.fromJson(p.readText()))
|
||||||
} catch (_: java.nio.file.NoSuchFileException) {
|
} catch (_: java.nio.file.NoSuchFileException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user