diff --git a/README.md b/README.md index 901dd31..77b9626 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,6 @@ libraries. It works with any of the available implementations of AndroidX SQLite; see their documentation for more information. -> [!IMPORTANT] -> If you are using the Bundled or Native implementation, and there will be multithreaded access to the database, -> then you **must** create the driver with the `SQLITE_OPEN_FULLMUTEX` flag: -> -> ```kotlin -> Database( -> AndroidxSqliteDriver( -> createConnection = { name -> -> val openFlags = SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE or SQLITE_OPEN_FULLMUTEX -> BundledSQLiteDriver().open(name, openFlags) -> }, -> ... -> ) -> ) -> ``` -> -> If you are certain that there won't be any multithreaded access to the database, -> you can choose to omit `SQLITE_OPEN_FULLMUTEX`, and pass `isAccessMultithreaded = false` -> to `AndroidxSqliteDriver` for a (very) small performance boost. - ## Gradle ```kotlin @@ -100,5 +80,41 @@ Database( It will handle calling the `create` and `migrate` functions on your schema for you, and keep track of the database's version. +## Connection Pooling + +By default, one connection will be used for both reading and writing, and only one thread can acquire that connection at a time. +If you have WAL enabled, you could (and should) set the amount of pooled reader connections that will be used: + +```kotlin +AndroidxSqliteDriver( + ..., + readerConnections = 4, + ..., +) +``` + +On Android you can defer to the system to determine how many reader connections there should be[1]: + +```kotlin +// Based on SQLiteGlobal.getWALConnectionPoolSize() +fun getWALConnectionPoolSize() { + val resources = Resources.getSystem() + val resId = + resources.getIdentifier("db_connection_pool_size", "integer", "android") + return if (resId != 0) { + resources.getInteger(resId) + } else { + 2 + } +} +``` + +See [WAL & Dispatchers] for more information about how to configure dispatchers to use for reads and writes. + +> [!NOTE] +> In-Memory and temporary databases will always use 0 reader connections i.e. there will be a single connection + +[1]: https://blog.p-y.wtf/parallelism-with-android-sqlite#heading-secondary-connections [AndroidX Kotlin Multiplatform SQLite]: https://developer.android.com/kotlin/multiplatform/sqlite [SQLDelight]: https://github.com/sqldelight/sqldelight +[WAL & Dispatchers]: https://blog.p-y.wtf/parallelism-with-android-sqlite#heading-wal-amp-dispatchers diff --git a/library/build.gradle.kts b/library/build.gradle.kts index dfb5fc2..ddf48a0 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -33,6 +33,8 @@ kotlin { api(libs.androidx.sqlite) api(libs.cashapp.sqldelight.runtime) + + implementation(libs.kotlinx.coroutines.core) } commonTest.dependencies { diff --git a/library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteDriver.kt b/library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteDriver.kt index af2340c..eb5a989 100644 --- a/library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteDriver.kt +++ b/library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteDriver.kt @@ -15,7 +15,6 @@ import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.db.SqlPreparedStatement import app.cash.sqldelight.db.SqlSchema import kotlinx.atomicfu.atomic -import kotlinx.atomicfu.locks.ReentrantLock import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized @@ -38,8 +37,8 @@ public class AndroidxSqliteDriver( createConnection: (String) -> SQLiteConnection, databaseType: AndroidxSqliteDatabaseType, private val schema: SqlSchema>, - cacheSize: Int = DEFAULT_CACHE_SIZE, - private val isAccessMultithreaded: Boolean = true, + readerConnections: Int = 0, + private val cacheSize: Int = DEFAULT_CACHE_SIZE, private val migrateEmptySchema: Boolean = false, private val onConfigure: ConfigurableDatabase.() -> Unit = {}, private val onCreate: SqlDriver.() -> Unit = {}, @@ -51,8 +50,8 @@ public class AndroidxSqliteDriver( driver: SQLiteDriver, databaseType: AndroidxSqliteDatabaseType, schema: SqlSchema>, + readerConnections: Int = 0, cacheSize: Int = DEFAULT_CACHE_SIZE, - isAccessMultithreaded: Boolean = true, migrateEmptySchema: Boolean = false, onConfigure: ConfigurableDatabase.() -> Unit = {}, onCreate: SqlDriver.() -> Unit = {}, @@ -63,8 +62,8 @@ public class AndroidxSqliteDriver( createConnection = driver::open, databaseType = databaseType, schema = schema, + readerConnections = readerConnections, cacheSize = cacheSize, - isAccessMultithreaded = isAccessMultithreaded, migrateEmptySchema = migrateEmptySchema, onConfigure = onConfigure, onCreate = onCreate, @@ -76,29 +75,41 @@ public class AndroidxSqliteDriver( @Suppress("NonBooleanPropertyPrefixedWithIs") private val isFirstInteraction = atomic(true) - private val connection by lazy { - createConnection( - when(databaseType) { + private val connectionPool by lazy { + ConnectionPool( + createConnection = createConnection, + name = when(databaseType) { is AndroidxSqliteDatabaseType.File -> databaseType.databaseFilePath AndroidxSqliteDatabaseType.Memory -> ":memory:" AndroidxSqliteDatabaseType.Temporary -> "" }, + maxReaders = when(databaseType) { + is AndroidxSqliteDatabaseType.File -> readerConnections + AndroidxSqliteDatabaseType.Memory -> 0 + AndroidxSqliteDatabaseType.Temporary -> 0 + }, ) } private val transactions = TransactionsThreadLocal() - private val explicitTransactionLock = ReentrantLock() - - private val statements = object : LruCache(cacheSize) { - override fun entryRemoved( - evicted: Boolean, - key: Int, - oldValue: AndroidxStatement, - newValue: AndroidxStatement?, + + private val statementsCaches = mutableMapOf>() + + private fun getStatementsCache(connection: SQLiteConnection): LruCache = + statementsCaches.getOrPut( + connection, ) { - if(evicted) oldValue.close() + object : LruCache(cacheSize) { + override fun entryRemoved( + evicted: Boolean, + key: Int, + oldValue: AndroidxStatement, + newValue: AndroidxStatement?, + ) { + if(evicted) oldValue.close() + } + } } - } private var skipStatementsCache = true @@ -135,12 +146,15 @@ public class AndroidxSqliteDriver( createOrMigrateIfNeeded() val enclosing = transactions.get() - val transaction = Transaction(enclosing) + val transactionConnection = when(enclosing) { + null -> connectionPool.acquireWriterConnection() + else -> (enclosing as Transaction).connection + } + val transaction = Transaction(enclosing, transactionConnection) transactions.set(transaction) if(enclosing == null) { - if(isAccessMultithreaded) explicitTransactionLock.lock() - connection.execSQL("BEGIN IMMEDIATE") + transactionConnection.execSQL("BEGIN IMMEDIATE") } return QueryResult.Value(transaction) @@ -148,8 +162,9 @@ public class AndroidxSqliteDriver( override fun currentTransaction(): Transacter.Transaction? = transactions.get() - public inner class Transaction( + private inner class Transaction( override val enclosingTransaction: Transacter.Transaction?, + val connection: SQLiteConnection, ) : Transacter.Transaction() { override fun endTransaction(successful: Boolean): QueryResult { if(enclosingTransaction == null) { @@ -159,9 +174,8 @@ public class AndroidxSqliteDriver( } else { connection.execSQL("ROLLBACK") } - } - finally { - if(isAccessMultithreaded) explicitTransactionLock.unlock() + } finally { + connectionPool.releaseWriterConnection() } } transactions.set(enclosingTransaction) @@ -171,23 +185,23 @@ public class AndroidxSqliteDriver( private fun execute( identifier: Int?, - createStatement: () -> AndroidxStatement, + connection: SQLiteConnection, + createStatement: (SQLiteConnection) -> AndroidxStatement, binders: (SqlPreparedStatement.() -> Unit)?, result: AndroidxStatement.() -> T, ): QueryResult.Value { - createOrMigrateIfNeeded() - + val statementsCache = if(!skipStatementsCache) getStatementsCache(connection) else null var statement: AndroidxStatement? = null - if(identifier != null && !skipStatementsCache) { - statement = statements[identifier] + if(identifier != null && statementsCache != null) { + statement = statementsCache[identifier] // remove temporarily from the cache if(statement != null) { - statements.remove(identifier) + statementsCache.remove(identifier) } } if(statement == null) { - statement = createStatement() + statement = createStatement(connection) } try { if(binders != null) { @@ -196,7 +210,7 @@ public class AndroidxSqliteDriver( return QueryResult.Value(statement.result()) } finally { if(identifier != null && !skipStatementsCache) { - statements.put(identifier, statement)?.close() + statementsCache?.put(identifier, statement)?.close() statement.reset() } else { statement.close() @@ -209,8 +223,34 @@ public class AndroidxSqliteDriver( sql: String, parameters: Int, binders: (SqlPreparedStatement.() -> Unit)?, - ): QueryResult = - execute(identifier, { AndroidxPreparedStatement(connection.prepare(sql)) }, binders, { execute() }) + ): QueryResult { + createOrMigrateIfNeeded() + + val transaction = currentTransaction() + if(transaction == null) { + val writerConnection = connectionPool.acquireWriterConnection() + try { + return execute( + identifier = identifier, + connection = writerConnection, + createStatement = { AndroidxPreparedStatement(it.prepare(sql)) }, + binders = binders, + result = { execute() }, + ) + } finally { + connectionPool.releaseWriterConnection() + } + } else { + val connection = (transaction as Transaction).connection + return execute( + identifier = identifier, + connection = connection, + createStatement = { AndroidxPreparedStatement(it.prepare(sql)) }, + binders = binders, + result = { execute() }, + ) + } + } override fun executeQuery( identifier: Int?, @@ -218,13 +258,42 @@ public class AndroidxSqliteDriver( mapper: (SqlCursor) -> QueryResult, parameters: Int, binders: (SqlPreparedStatement.() -> Unit)?, - ): QueryResult.Value = - execute(identifier, { AndroidxQuery(sql, connection, parameters) }, binders) { executeQuery(mapper) } + ): QueryResult.Value { + createOrMigrateIfNeeded() + + val transaction = currentTransaction() + if(transaction == null) { + val connection = connectionPool.acquireReaderConnection() + try { + return execute( + identifier = identifier, + connection = connection, + createStatement = { AndroidxQuery(sql, it, parameters) }, + binders = binders, + result = { executeQuery(mapper) }, + ) + } finally { + connectionPool.releaseReaderConnection(connection) + } + } else { + val connection = (transaction as Transaction).connection + return execute( + identifier = identifier, + connection = connection, + createStatement = { AndroidxQuery(sql, it, parameters) }, + binders = binders, + result = { executeQuery(mapper) }, + ) + } + } override fun close() { - statements.snapshot().values.forEach { it.close() } - statements.evictAll() - return connection.close() + statementsCaches.forEach { (_, cache) -> + cache.snapshot().values.forEach { it.close() } + cache.evictAll() + } + statementsCaches.clear() + connectionPool.close() } private val createOrMigrateLock = SynchronizedObject() @@ -237,11 +306,16 @@ public class AndroidxSqliteDriver( ConfigurableDatabase(this).onConfigure() - val currentVersion = connection.prepare("PRAGMA user_version").use { getVersion -> - when { - getVersion.step() -> getVersion.getLong(0) - else -> 0 + val writerConnection = connectionPool.acquireWriterConnection() + val currentVersion = try { + writerConnection.prepare("PRAGMA user_version").use { getVersion -> + when { + getVersion.step() -> getVersion.getLong(0) + else -> 0 + } } + } finally { + connectionPool.releaseWriterConnection() } if(currentVersion == 0L && !migrateEmptySchema || currentVersion < schema.version) { @@ -258,10 +332,9 @@ public class AndroidxSqliteDriver( 0L -> onCreate() else -> onUpdate(currentVersion, schema.version) } - connection.prepare("PRAGMA user_version = ${schema.version}").use { it.step() } + writerConnection.prepare("PRAGMA user_version = ${schema.version}").use { it.step() } } - } - else { + } else { skipStatementsCache = false } diff --git a/library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/ConnectionPool.kt b/library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/ConnectionPool.kt new file mode 100644 index 0000000..a269f15 --- /dev/null +++ b/library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/ConnectionPool.kt @@ -0,0 +1,73 @@ +package com.eygraber.sqldelight.androidx.driver + +import androidx.sqlite.SQLiteConnection +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class ConnectionPool( + private val createConnection: (String) -> SQLiteConnection, + private val name: String, + private val maxReaders: Int = 4, +) { + private val writerConnection: SQLiteConnection by lazy { createConnection(name) } + private val writerMutex = Mutex() + + private val readerChannel = Channel(capacity = maxReaders) + private val readerConnections = List(maxReaders) { lazy { createConnection(name) } } + + /** + * Acquires the writer connection, blocking if it's currently in use. + * @return The writer SQLiteConnection + */ + fun acquireWriterConnection() = runBlocking { + writerMutex.lock() + writerConnection + } + + /** + * Releases the writer connection (mutex unlocks automatically). + */ + fun releaseWriterConnection() { + writerMutex.unlock() + } + + /** + * Acquires a reader connection, blocking if none are available. + * @return A reader SQLiteConnection + */ + fun acquireReaderConnection() = when(maxReaders) { + 0 -> acquireWriterConnection() + else -> runBlocking { + val firstUninitialized = readerConnections.firstOrNull { !it.isInitialized() } + firstUninitialized?.value ?: readerChannel.receive() + } + } + + /** + * Releases a reader connection back to the pool. + * @param connection The SQLiteConnection to release + */ + fun releaseReaderConnection(connection: SQLiteConnection) = when(maxReaders) { + 0 -> releaseWriterConnection() + else -> runBlocking { + readerChannel.send(connection) + } + } + + /** + * Closes all connections in the pool. + */ + fun close() { + runBlocking { + writerMutex.withLock { + writerConnection.close() + } + readerConnections.forEach { reader -> + if(reader.isInitialized()) reader.value.close() + } + readerChannel.close() + } + } +} diff --git a/library/src/jvmTest/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteCommonTests.jvm.kt b/library/src/jvmTest/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteCommonTests.jvm.kt index 8b7b618..23e2bb2 100644 --- a/library/src/jvmTest/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteCommonTests.jvm.kt +++ b/library/src/jvmTest/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteCommonTests.jvm.kt @@ -3,9 +3,6 @@ package com.eygraber.sqldelight.androidx.driver import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import androidx.sqlite.driver.bundled.SQLITE_OPEN_CREATE -import androidx.sqlite.driver.bundled.SQLITE_OPEN_FULLMUTEX -import androidx.sqlite.driver.bundled.SQLITE_OPEN_READWRITE import app.cash.sqldelight.Transacter import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -24,7 +21,7 @@ actual class CommonEphemeralTest : AndroidxSqliteEphemeralTest() actual fun androidxSqliteTestDriver(): SQLiteDriver = BundledSQLiteDriver() actual fun androidxSqliteTestCreateConnection(): (String) -> SQLiteConnection = { name -> - BundledSQLiteDriver().open(name, SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE or SQLITE_OPEN_FULLMUTEX) + BundledSQLiteDriver().open(name) } @Suppress("InjectDispatcher") diff --git a/library/src/nativeTest/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteCommonTests.native.kt b/library/src/nativeTest/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteCommonTests.native.kt index 80a39dd..271445b 100644 --- a/library/src/nativeTest/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteCommonTests.native.kt +++ b/library/src/nativeTest/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteCommonTests.native.kt @@ -3,9 +3,6 @@ package com.eygraber.sqldelight.androidx.driver import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import androidx.sqlite.driver.bundled.SQLITE_OPEN_CREATE -import androidx.sqlite.driver.bundled.SQLITE_OPEN_FULLMUTEX -import androidx.sqlite.driver.bundled.SQLITE_OPEN_READWRITE import app.cash.sqldelight.Transacter import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -29,7 +26,7 @@ actual class CommonEphemeralTest : AndroidxSqliteEphemeralTest() actual fun androidxSqliteTestDriver(): SQLiteDriver = BundledSQLiteDriver() actual fun androidxSqliteTestCreateConnection(): (String) -> SQLiteConnection = { name -> - BundledSQLiteDriver().open(name, SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE or SQLITE_OPEN_FULLMUTEX) + BundledSQLiteDriver().open(name) } @Suppress("InjectDispatcher")