fix(persistence): serialize SqliteEventStore reads with append transaction (#52)

Reads (read/readFrom/lastSequence/allEvents/allSessionIds/lastGlobalSequence)
shared the single JDBC Connection with append's transaction unsynchronized.
The coroutine appendMutex only excludes append-vs-append; a read on the caller
thread could hit the connection mid-transaction (setAutoCommit(false)..commit)
and corrupt tx state — SQLite JDBC is not thread-safe. Guard every connection
access with a shared JVM monitor (synchronized(connection)) via withConnection,
so reads and the IO-thread append transaction are mutually exclusive.
This commit is contained in:
2026-07-12 13:17:38 +04:00
parent bb73bc9371
commit 759828c0f5
@@ -72,19 +72,21 @@ class SqliteEventStore(
appendMutex.withLock { appendMutex.withLock {
artifactStore.flushBefore { artifactStore.flushBefore {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
stored = connection.transaction { stored = withConnection {
val existing = findByEventId(event.metadata.eventId) connection.transaction {
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } val existing = findByEventId(event.metadata.eventId)
val globalSeqVal = nextGlobalSequence() check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
val sessionSeqVal = nextSessionSequence(event.metadata.sessionId) val globalSeqVal = nextGlobalSequence()
val s = StoredEvent( val sessionSeqVal = nextSessionSequence(event.metadata.sessionId)
metadata = event.metadata, val s = StoredEvent(
sequence = globalSeqVal, metadata = event.metadata,
sessionSequence = sessionSeqVal, sequence = globalSeqVal,
payload = event.payload, sessionSequence = sessionSeqVal,
) payload = event.payload,
insert(s) )
s insert(s)
s
}
} }
} }
} }
@@ -102,20 +104,22 @@ class SqliteEventStore(
appendMutex.withLock { appendMutex.withLock {
artifactStore.flushBefore { artifactStore.flushBefore {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
stored = connection.transaction { stored = withConnection {
events.map { event -> connection.transaction {
val existing = findByEventId(event.metadata.eventId) events.map { event ->
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } val existing = findByEventId(event.metadata.eventId)
val globalSeqVal = nextGlobalSequence() check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
val sessionSeqVal = nextSessionSequence(sessionId) val globalSeqVal = nextGlobalSequence()
val s = StoredEvent( val sessionSeqVal = nextSessionSequence(sessionId)
metadata = event.metadata, val s = StoredEvent(
sequence = globalSeqVal, metadata = event.metadata,
sessionSequence = sessionSeqVal, sequence = globalSeqVal,
payload = event.payload, sessionSequence = sessionSeqVal,
) payload = event.payload,
insert(s) )
s insert(s)
s
}
} }
} }
} }
@@ -127,7 +131,7 @@ class SqliteEventStore(
return stored return stored
} }
override fun read(sessionId: SessionId): List<StoredEvent> = override fun read(sessionId: SessionId): List<StoredEvent> = withConnection {
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT * FROM events SELECT * FROM events
@@ -144,8 +148,9 @@ class SqliteEventStore(
} }
} }
} }
}
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = withConnection {
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT * FROM events SELECT * FROM events
@@ -164,8 +169,9 @@ class SqliteEventStore(
} }
} }
} }
}
override fun lastSequence(sessionId: SessionId): Long? = override fun lastSequence(sessionId: SessionId): Long? = withConnection {
connection.prepareStatement( connection.prepareStatement(
"SELECT MAX(session_sequence) FROM events WHERE session_id = ?" "SELECT MAX(session_sequence) FROM events WHERE session_id = ?"
).use { ps -> ).use { ps ->
@@ -175,6 +181,7 @@ class SqliteEventStore(
else null else null
} }
} }
}
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = override fun subscribe(sessionId: SessionId): Flow<StoredEvent> =
subscriptions.computeIfAbsent(sessionId) { MutableSharedFlow(replay = 0, extraBufferCapacity = 64) } subscriptions.computeIfAbsent(sessionId) { MutableSharedFlow(replay = 0, extraBufferCapacity = 64) }
@@ -183,17 +190,19 @@ class SqliteEventStore(
override suspend fun lastGlobalSequence(): Long = override suspend fun lastGlobalSequence(): Long =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
connection.prepareStatement( withConnection {
"SELECT COALESCE(MAX(sequence), 0) FROM events" connection.prepareStatement(
).use { ps -> "SELECT COALESCE(MAX(sequence), 0) FROM events"
ps.executeQuery().use { rs -> ).use { ps ->
rs.next() ps.executeQuery().use { rs ->
rs.getLong(1) rs.next()
rs.getLong(1)
}
} }
} }
} }
override fun allEvents(): Sequence<StoredEvent> = override fun allEvents(): Sequence<StoredEvent> = withConnection {
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT * FROM events SELECT * FROM events
@@ -208,8 +217,9 @@ class SqliteEventStore(
} }
} }
}.asSequence() }.asSequence()
}
override fun allSessionIds(): Set<SessionId> = override fun allSessionIds(): Set<SessionId> = withConnection {
mutableSetOf<SessionId>().apply { mutableSetOf<SessionId>().apply {
connection.prepareStatement("SELECT DISTINCT session_id FROM events").use { ps -> connection.prepareStatement("SELECT DISTINCT session_id FROM events").use { ps ->
ps.executeQuery().use { rs -> ps.executeQuery().use { rs ->
@@ -217,9 +227,19 @@ class SqliteEventStore(
} }
} }
} }
}
// ---------- helpers ---------- // ---------- helpers ----------
/**
* Serializes all JDBC access to the shared [connection] on a single JVM monitor. Reads run on the
* caller thread while append's transaction runs on Dispatchers.IO; the coroutine [appendMutex] only
* excludes append-vs-append, so without this a read could hit the connection mid-transaction
* (SQLite JDBC is not thread-safe → corrupted tx state). ponytail: store-wide lock, one session at a
* time is fine here; shard per-connection only if read throughput ever matters.
*/
private inline fun <T> withConnection(block: () -> T): T = synchronized(connection) { block() }
private fun nextGlobalSequence(): Long = private fun nextGlobalSequence(): Long =
connection.prepareStatement( connection.prepareStatement(
"SELECT COALESCE(MAX(sequence), 0) FROM events" "SELECT COALESCE(MAX(sequence), 0) FROM events"