diff --git a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt index 41fd2157..6b450326 100644 --- a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt +++ b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt @@ -76,16 +76,13 @@ class SqliteEventStore( connection.transaction { val existing = findByEventId(event.metadata.eventId) check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } - val globalSeqVal = nextGlobalSequence() - val sessionSeqVal = nextSessionSequence(event.metadata.sessionId) - val s = StoredEvent( + val (globalSeqVal, sessionSeqVal) = insertAssigningSequences(event) + StoredEvent( metadata = event.metadata, sequence = globalSeqVal, sessionSequence = sessionSeqVal, payload = event.payload, ) - insert(s) - s } } } @@ -109,16 +106,13 @@ class SqliteEventStore( events.map { event -> val existing = findByEventId(event.metadata.eventId) check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" } - val globalSeqVal = nextGlobalSequence() - val sessionSeqVal = nextSessionSequence(sessionId) - val s = StoredEvent( + val (globalSeqVal, sessionSeqVal) = insertAssigningSequences(event) + StoredEvent( metadata = event.metadata, sequence = globalSeqVal, sessionSequence = sessionSeqVal, payload = event.payload, ) - insert(s) - s } } } @@ -240,29 +234,16 @@ class SqliteEventStore( */ private inline fun withConnection(block: () -> T): T = synchronized(connection) { block() } - private fun nextGlobalSequence(): Long = - connection.prepareStatement( - "SELECT COALESCE(MAX(sequence), 0) FROM events" - ).use { ps -> - ps.executeQuery().use { rs -> - rs.next() - rs.getLong(1) + 1 - } - } - - 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 - } - } - + /** + * Inserts [event], letting SQLite assign both the global `sequence` and the per-session + * `session_sequence` as `MAX+1` subqueries evaluated inside the INSERT itself, and RETURNs the + * assigned values. SQLite serializes writers (one write transaction at a time), so the subqueries + * are atomic against other processes on the same DB — closing the CLI+server MAX+1 race that the + * old read-then-insert had (the unique index on `sequence` is the backstop). Within one appendAll + * transaction, each INSERT sees the prior rows, so a batch increments correctly. + */ @Suppress("MagicNumber") - private fun insert(event: StoredEvent) { + private fun insertAssigningSequences(event: NewEvent): Pair = connection.prepareStatement( """ INSERT INTO events ( @@ -275,21 +256,28 @@ class SqliteEventStore( causation_id, correlation_id, 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(), ).use { ps -> ps.setString(1, event.metadata.eventId.value) ps.setString(2, event.metadata.sessionId.value) - ps.setLong(3, event.sequence) - ps.setLong(4, event.sessionSequence) - ps.setString(5, event.metadata.timestamp.toString()) - ps.setInt(6, event.metadata.schemaVersion) - ps.setString(7, event.metadata.causationId?.value) - ps.setString(8, event.metadata.correlationId?.value) - ps.setString(9, jsonSerializer.serialize(event.payload)) - ps.executeUpdate() + ps.setString(3, event.metadata.sessionId.value) + ps.setString(4, event.metadata.timestamp.toString()) + ps.setInt(5, event.metadata.schemaVersion) + ps.setString(6, event.metadata.causationId?.value) + ps.setString(7, event.metadata.correlationId?.value) + ps.setString(8, jsonSerializer.serialize(event.payload)) + ps.executeQuery().use { rs -> + rs.next() + rs.getLong("sequence") to rs.getLong("session_sequence") + } } - } private fun findByEventId(eventId: EventId): StoredEvent? = connection.prepareStatement(