fix(persistence): assign event sequences atomically inside INSERT (#56)
nextGlobalSequence()/nextSessionSequence() computed MAX+1 with two extra SELECT round-trips, guarded only by an in-process mutex — two processes on the same correx.db (CLI + server) could read the same MAX and collide on the unique index. Compute both the global sequence and session_sequence as MAX+1 subqueries inside the INSERT and RETURNING the assigned values. SQLite serializes writers, so the subqueries are atomic across processes; the unique index is the backstop. Also removes the two pre-SELECTs per append.
This commit is contained in:
+30
-42
@@ -76,16 +76,13 @@ class SqliteEventStore(
|
|||||||
connection.transaction {
|
connection.transaction {
|
||||||
val existing = findByEventId(event.metadata.eventId)
|
val existing = findByEventId(event.metadata.eventId)
|
||||||
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
|
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
|
||||||
val globalSeqVal = nextGlobalSequence()
|
val (globalSeqVal, sessionSeqVal) = insertAssigningSequences(event)
|
||||||
val sessionSeqVal = nextSessionSequence(event.metadata.sessionId)
|
StoredEvent(
|
||||||
val s = StoredEvent(
|
|
||||||
metadata = event.metadata,
|
metadata = event.metadata,
|
||||||
sequence = globalSeqVal,
|
sequence = globalSeqVal,
|
||||||
sessionSequence = sessionSeqVal,
|
sessionSequence = sessionSeqVal,
|
||||||
payload = event.payload,
|
payload = event.payload,
|
||||||
)
|
)
|
||||||
insert(s)
|
|
||||||
s
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,16 +106,13 @@ class SqliteEventStore(
|
|||||||
events.map { event ->
|
events.map { event ->
|
||||||
val existing = findByEventId(event.metadata.eventId)
|
val existing = findByEventId(event.metadata.eventId)
|
||||||
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
|
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
|
||||||
val globalSeqVal = nextGlobalSequence()
|
val (globalSeqVal, sessionSeqVal) = insertAssigningSequences(event)
|
||||||
val sessionSeqVal = nextSessionSequence(sessionId)
|
StoredEvent(
|
||||||
val s = StoredEvent(
|
|
||||||
metadata = event.metadata,
|
metadata = event.metadata,
|
||||||
sequence = globalSeqVal,
|
sequence = globalSeqVal,
|
||||||
sessionSequence = sessionSeqVal,
|
sessionSequence = sessionSeqVal,
|
||||||
payload = event.payload,
|
payload = event.payload,
|
||||||
)
|
)
|
||||||
insert(s)
|
|
||||||
s
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,29 +234,16 @@ class SqliteEventStore(
|
|||||||
*/
|
*/
|
||||||
private inline fun <T> withConnection(block: () -> T): T = synchronized(connection) { block() }
|
private inline fun <T> withConnection(block: () -> T): T = synchronized(connection) { block() }
|
||||||
|
|
||||||
private fun nextGlobalSequence(): Long =
|
/**
|
||||||
connection.prepareStatement(
|
* Inserts [event], letting SQLite assign both the global `sequence` and the per-session
|
||||||
"SELECT COALESCE(MAX(sequence), 0) FROM events"
|
* `session_sequence` as `MAX+1` subqueries evaluated inside the INSERT itself, and RETURNs the
|
||||||
).use { ps ->
|
* assigned values. SQLite serializes writers (one write transaction at a time), so the subqueries
|
||||||
ps.executeQuery().use { rs ->
|
* are atomic against other processes on the same DB — closing the CLI+server MAX+1 race that the
|
||||||
rs.next()
|
* old read-then-insert had (the unique index on `sequence` is the backstop). Within one appendAll
|
||||||
rs.getLong(1) + 1
|
* transaction, each INSERT sees the prior rows, so a batch increments correctly.
|
||||||
}
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
private fun nextSessionSequence(sessionId: SessionId): Long =
|
|
||||||
connection.prepareStatement(
|
|
||||||
"SELECT COALESCE(MAX(session_sequence), 0) FROM events WHERE session_id = ?"
|
|
||||||
).use { ps ->
|
|
||||||
ps.setString(1, sessionId.value)
|
|
||||||
ps.executeQuery().use { rs ->
|
|
||||||
rs.next()
|
|
||||||
rs.getLong(1) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("MagicNumber")
|
@Suppress("MagicNumber")
|
||||||
private fun insert(event: StoredEvent) {
|
private fun insertAssigningSequences(event: NewEvent): Pair<Long, Long> =
|
||||||
connection.prepareStatement(
|
connection.prepareStatement(
|
||||||
"""
|
"""
|
||||||
INSERT INTO events (
|
INSERT INTO events (
|
||||||
@@ -275,21 +256,28 @@ class SqliteEventStore(
|
|||||||
causation_id,
|
causation_id,
|
||||||
correlation_id,
|
correlation_id,
|
||||||
payload
|
payload
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (
|
||||||
|
?, ?,
|
||||||
|
(SELECT COALESCE(MAX(sequence), 0) + 1 FROM events),
|
||||||
|
(SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM events WHERE session_id = ?),
|
||||||
|
?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
|
RETURNING sequence, session_sequence
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
).use { ps ->
|
).use { ps ->
|
||||||
ps.setString(1, event.metadata.eventId.value)
|
ps.setString(1, event.metadata.eventId.value)
|
||||||
ps.setString(2, event.metadata.sessionId.value)
|
ps.setString(2, event.metadata.sessionId.value)
|
||||||
ps.setLong(3, event.sequence)
|
ps.setString(3, event.metadata.sessionId.value)
|
||||||
ps.setLong(4, event.sessionSequence)
|
ps.setString(4, event.metadata.timestamp.toString())
|
||||||
ps.setString(5, event.metadata.timestamp.toString())
|
ps.setInt(5, event.metadata.schemaVersion)
|
||||||
ps.setInt(6, event.metadata.schemaVersion)
|
ps.setString(6, event.metadata.causationId?.value)
|
||||||
ps.setString(7, event.metadata.causationId?.value)
|
ps.setString(7, event.metadata.correlationId?.value)
|
||||||
ps.setString(8, event.metadata.correlationId?.value)
|
ps.setString(8, jsonSerializer.serialize(event.payload))
|
||||||
ps.setString(9, jsonSerializer.serialize(event.payload))
|
ps.executeQuery().use { rs ->
|
||||||
ps.executeUpdate()
|
rs.next()
|
||||||
|
rs.getLong("sequence") to rs.getLong("session_sequence")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun findByEventId(eventId: EventId): StoredEvent? =
|
private fun findByEventId(eventId: EventId): StoredEvent? =
|
||||||
connection.prepareStatement(
|
connection.prepareStatement(
|
||||||
|
|||||||
Reference in New Issue
Block a user