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:
2026-07-12 13:19:50 +04:00
parent 759828c0f5
commit 65d5e9de83
@@ -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(