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:
+58
-38
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user