Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 141 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,39 +110,166 @@ have been introduced during the migration.

## 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:
SQLite supports several concurrency models that can significantly impact your application's performance. This driver
provides flexible connection pooling through the `AndroidxSqliteConcurrencyModel` interface.

### Available Concurrency Models

#### 1. SingleReaderWriter

The simplest model with one connection handling all operations:

```kotlin
AndroidxSqliteDriver(
...,
readerConnections = 4,
...,
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.SingleReaderWriter
)
```

On Android you can defer to the system to determine how many reader connections there should be<sup>[1]</sup>:
**Best for:**

- Simple applications with minimal database usage
- Testing and development
- When memory usage is a primary concern
- Single-threaded applications

#### 2. MultipleReaders

Dedicated reader connections for read-only access:

```kotlin
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.MultipleReaders(
readerCount = 3 // Number of concurrent reader connections
)
)
```

**Best for:**

- Read-only applications (analytics dashboards, reporting tools)
- Data visualization and content browsing applications
- Scenarios where all writes happen externally (data imports, ETL processes)
- Applications that only query pre-populated databases

**Important:** This model is designed for **read-only access**. No write operations (INSERT, UPDATE, DELETE) should be
performed. If you need write capabilities, use `MultipleReadersSingleWriter` in WAL mode instead.

#### 3. MultipleReadersSingleWriter (Recommended)

The most flexible model that adapts based on journal mode:

```kotlin
AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true, // Enable WAL mode for true concurrency
walCount = 4, // Reader connections when WAL is enabled
nonWalCount = 0 // Reader connections when WAL is disabled
)
)
```

**Best for:**

- Most production applications
- Mixed read/write workloads
- When you want to leverage WAL mode benefits
- Applications requiring optimal performance

### WAL Mode Benefits

- **True Concurrency**: Readers and writers don't block each other
- **Better Performance**: Concurrent operations improve throughput
- **Consistency**: ACID properties are maintained (when `PRAGMA synchronous = FULL` is used)
- **Scalability**: Handles higher concurrent load

### Choosing Reader Connection Count

The optimal number of reader connections depends on your use case:

```kotlin
// Conservative (default)
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 4,
nonWalCount = 0,
)

// High-concurrency applications
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 8
)

// Memory-conscious applications
AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = 2
)
```

### Platform-Specific Configuration

On Android, you can use system-determined connection pool sizes:

```kotlin
// Based on SQLiteGlobal.getWALConnectionPoolSize()
fun getWALConnectionPoolSize() {
fun getWALConnectionPoolSize(): Int {
val resources = Resources.getSystem()
val resId =
resources.getIdentifier("db_connection_pool_size", "integer", "android")
val resId = resources.getIdentifier("db_connection_pool_size", "integer", "android")
return if (resId != 0) {
resources.getInteger(resId)
} else {
2
2 // Fallback default
}
}

AndroidxSqliteConfiguration(
concurrencyModel = AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter(
isWal = true,
walCount = getWALConnectionPoolSize(),
nonWalCount = 0,
)
)
```

See [WAL & Dispatchers] for more information about how to configure dispatchers to use for reads and writes.
### Performance Considerations

| Model | Memory Usage | Read Concurrency | Write Capability | Best Use Case |
|---------------------------------------|--------------|------------------|------------------|--------------------|
| SingleReaderWriter | Lowest | None | Full | Simple apps |
| MultipleReaders | Medium | Excellent | None (read-only) | Read-only apps |
| MultipleReadersSingleWriter (WAL) | Higher | Excellent | Full | Production |
| MultipleReadersSingleWriter (non-WAL) | Medium | Limited | Full | Legacy/constrained |

### Special Database Types

> [!NOTE]
> In-Memory and temporary databases will always use 0 reader connections i.e. there will be a single connection
> In-Memory and temporary databases automatically use `SingleReaderWriter` model regardless of configuration, as
> connection pooling provides no benefit for these database types.

### Journal Mode

If `PRAGMA journal_mode = ...` is used, the connection pool will:

1. Acquire the writer connection
2. Acquire all reader connections
3. Close all reader connections
4. Run the `PRAGMA` statement
5. Recreate the reader connections

This ensures all connections use the same journal mode and prevents inconsistencies.

### Best Practices

1. **Start with defaults**: Uses `MultipleReadersSingleWriter` in WAL mode
2. **Monitor performance**: Profile your specific workload to determine optimal reader count
3. **Consider memory**: Each connection has overhead - balance performance vs memory usage
4. **Test thoroughly**: Verify your concurrency model works under expected load
5. **Platform differences**: Android may have different optimal settings than JVM/Native

See [WAL & Dispatchers] for more information about how to configure dispatchers to use for reads and writes.

[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
[Write-Ahead Logging]: https://sqlite.org/wal.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.eygraber.sqldelight.androidx.driver.integration

import app.cash.sqldelight.coroutines.asFlow
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConcurrencyModel.MultipleReadersSingleWriter
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType
import kotlinx.coroutines.delay
Expand All @@ -23,7 +24,10 @@ class AndroidxSqliteConcurrencyIntegrationTest : AndroidxSqliteIntegrationTest()
// having 2 readers instead of the default 4 makes it more
// likely to have concurrent readers using the same cached statement
configuration = AndroidxSqliteConfiguration(
readerConnectionsCount = 2,
concurrencyModel = MultipleReadersSingleWriter(
isWal = true,
walCount = 2,
),
)

launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ abstract class AndroidxSqliteIntegrationTest {

@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
private fun readDispatcher(): CoroutineDispatcher? = when {
configuration.readerConnectionsCount >= 1 -> newFixedThreadPoolContext(
nThreads = configuration.readerConnectionsCount,
configuration.concurrencyModel.readerCount >= 1 -> newFixedThreadPoolContext(
nThreads = configuration.concurrencyModel.readerCount,
name = "db-reads",
)
else -> null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.eygraber.sqldelight.androidx.driver

/**
* Defines the concurrency model for SQLite database connections, controlling how many
* reader and writer connections are maintained in the connection pool.
*
* SQLite supports different concurrency models depending on the journal mode and application needs:
* - Single connection for simple use cases
* - Multiple readers with WAL (Write-Ahead Logging) for better read concurrency
* - Configurable reader counts for fine-tuned performance
*
* @property readerCount The number of reader connections to maintain in the pool
*/
public sealed interface AndroidxSqliteConcurrencyModel {
public val readerCount: Int

/**
* Single connection model - one connection handles both reads and writes.
*
* This is the simplest and most conservative approach, suitable for:
* - Applications with low concurrency requirements
* - Simple database operations
* - Testing scenarios
* - When database contention is not a concern
*
* **Performance characteristics:**
* - Lowest memory overhead
* - No connection pooling complexity
* - Sequential read/write operations only
* - Suitable for single-threaded or low-concurrency scenarios
*/
public data object SingleReaderWriter : AndroidxSqliteConcurrencyModel {
override val readerCount: Int = 0
}

/**
* Multiple readers model - allows concurrent read operations only.
*
* This model creates a pool of dedicated reader connections for read-only access.
* **No write operations should be performed** when using this model.
*
* **Use cases:**
* - Read-only applications (analytics dashboards, reporting tools)
* - Data visualization and content browsing applications
* - Scenarios where all writes happen externally (e.g., data imports)
* - Applications that only query pre-populated databases
*
* **Performance characteristics:**
* - Excellent read concurrency
* - Higher memory overhead due to connection pooling
* - No write capability - reads only
* - Optimal for read-heavy workloads with no database modifications
*
* **Important:** This model is designed for read-only access. If your application
* needs to perform any write operations (INSERT, UPDATE, DELETE, schema changes),
* use `MultipleReadersSingleWriter` in WAL mode instead.
*
* @param readerCount Number of reader connections to maintain (typically 2-8)
*/
public data class MultipleReaders(
override val readerCount: Int,
) : AndroidxSqliteConcurrencyModel

/**
* Multiple readers with single writer model - optimized for different journal modes.
*
* This is the most flexible model that adapts its behavior based on whether
* Write-Ahead Logging (WAL) mode is enabled:
*
* **WAL Mode (isWal = true):**
* - Enables true concurrent reads and writes
* - Readers don't block writers and vice versa
* - Best performance for mixed read/write workloads
* - Uses `walCount` reader connections
*
* **Non-WAL Mode (isWal = false):**
* - Falls back to traditional SQLite locking
* - Reads and writes are still serialized
* - Uses `nonWalCount` reader connections (typically 0)
*
* **Recommended configuration:**
* ```kotlin
* // For WAL mode
* MultipleReadersSingleWriter(
* isWal = true,
* walCount = 4 // Good default for most applications
* )
*
* // For non-WAL mode
* MultipleReadersSingleWriter(
* isWal = false,
* nonWalCount = 0 // Single connection is often sufficient
* )
* ```
*
* @param isWal Whether WAL (Write-Ahead Logging) journal mode is enabled
* @param nonWalCount Number of reader connections when WAL is disabled (default: 0)
* @param walCount Number of reader connections when WAL is enabled (default: 4)
*/
public data class MultipleReadersSingleWriter(
public val isWal: Boolean,
public val nonWalCount: Int = 0,
public val walCount: Int = 4,
) : AndroidxSqliteConcurrencyModel {
override val readerCount: Int = when {
isWal -> walCount
else -> nonWalCount
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@ package com.eygraber.sqldelight.androidx.driver

import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlCursor
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlPreparedStatement

public class AndroidxSqliteConfigurableDriver(
private val driver: AndroidxSqliteDriver,
private val driver: SqlDriver,
) {
public fun setForeignKeyConstraintsEnabled(isForeignKeyConstraintsEnabled: Boolean) {
driver.setForeignKeyConstraintsEnabled(isForeignKeyConstraintsEnabled)
val foreignKey = if(isForeignKeyConstraintsEnabled) "ON" else "OFF"
executePragma("foreign_keys = $foreignKey")
}

public fun setJournalMode(journalMode: SqliteJournalMode) {
driver.setJournalMode(journalMode)
executePragma("journal_mode = ${journalMode.value}")
}

public fun setSync(sync: SqliteSync) {
driver.setSync(sync)
executePragma("synchronous = ${sync.value}")
}

public fun executePragma(
Expand All @@ -27,10 +29,10 @@ public class AndroidxSqliteConfigurableDriver(
driver.execute(null, "PRAGMA $pragma;", parameters, binders)
}

public fun <T> executePragmaQuery(
public fun <R> executePragmaQuery(
pragma: String,
mapper: (SqlCursor) -> QueryResult<T>,
mapper: (SqlCursor) -> QueryResult<R>,
parameters: Int = 0,
binders: (SqlPreparedStatement.() -> Unit)? = null,
): QueryResult.Value<T> = driver.executeQuery(null, "PRAGMA $pragma;", mapper, parameters, binders)
): QueryResult<R> = driver.executeQuery(null, "PRAGMA $pragma;", mapper, parameters, binders)
}
Loading