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")