Repository navigation
In WAL mode, plain reads and read-only transactions take the primary connection and block writers #99
Description
Summary
In WAL mode, SQLiteConnectionPool is meant to serve reads from non-primary connections so that writers on the single primary connection don't wait for them. In sqlcipher-android, readers take the primary connection in two common cases where android.database.sqlite would give them a non-primary one:
- A read without a transaction gets the primary connection whenever the primary is free and no non-primary connection is idle: at startup and whenever all opened non-primary connections are busy.
beginTransactionReadOnly()starts anEXCLUSIVEtransaction on the primary connection. That also covers the read-only transactions Room starts throughSQLCipherDriver(BEGIN DEFERRED TRANSACTION).
In both cases a writer waits until the reader finishes, although under WAL the two could run concurrently.
Related: #98. A slow fillWindow on a large cursor holds whatever connection it got, so when it lands on the primary connection, writers wait for the whole cursor fill.
Versions
- sqlcipher-android 4.19.1 (
cipher_versionreports4.19.0 community). - Android emulator
sdk_gphone64_arm64, API 36.
Where it happens (v4.19.1)
1. The pool prefers the primary connection for reads. See SQLiteConnectionPool.waitForConnection() in sqlcipher/src/main/java/net/zetetic/database/sqlcipher/SQLiteConnectionPool.java:
if ((mAvailablePrimaryConnection != null && mAvailableNonPrimaryConnections.isEmpty()) || wantPrimaryConnection) {
connection = tryAcquirePrimaryConnectionLocked(connectionFlags);
if (connection != null) {
return connection;
}
}
if (!wantPrimaryConnection) {
connection = tryAcquireNonPrimaryConnectionLocked(sql, connectionFlags);
...This came with dfecce3 ("Uses primary connection if available prior to using/creating secondary connections", v4.7.0). Its comment says the goal is to skip opening a non-primary connection for the first reads after opening the database. But mAvailableNonPrimaryConnections.isEmpty() is true not only before the pool is initialized, but also whenever every opened non-primary connection is in use. A new non-primary connection is opened only while the primary is busy. So under concurrent reads, one reader regularly ends up on the primary connection, and every writer queues behind it.
AOSP (frameworks/base/core/java/android/database/sqlite/SQLiteConnectionPool.java) tries a non-primary connection first and falls back to the primary only when a non-primary one can't be acquired or opened:
if (!wantPrimaryConnection) {
connection = tryAcquireNonPrimaryConnectionLocked(sql, connectionFlags);
}
if (connection == null) {
connection = tryAcquirePrimaryConnectionLocked(connectionFlags);
}2. Read-only transactions are exclusive. See sqlcipher/src/main/java/net/zetetic/database/sqlcipher/SQLiteDatabase.java:
public void beginTransactionReadOnly() {
beginTransaction();
}beginTransaction() runs SQLiteSession.beginTransaction(TRANSACTION_MODE_EXCLUSIVE, ..., getThreadDefaultConnectionFlags(false)), i.e. on the primary connection. SQLiteDatabase never uses TRANSACTION_MODE_DEFERRED.
The driver path behaves the same way (from reading the source, not covered by the repro below). Room starts a read-only transaction with execSQL("BEGIN DEFERRED TRANSACTION"). SQLCipherStatement (sqlcipher/src/main/java/net/zetetic/database/sqlcipher/driver/SQLCipherStatement.java) runs it through rawQuery, and SQLiteSession.executeSpecial() turns any BEGIN into TRANSACTION_MODE_EXCLUSIVE. Since SQLiteProgram marks BEGIN as not read-only, the transaction gets the primary connection. Because SQLCipherDriver.hasConnectionPool() returns true, Room uses PassthroughConnectionPool and doesn't keep its own reader connections, so there is no reader connection Room could use instead.
In the framework:
- Since API 35,
SQLiteDatabase.beginTransactionReadOnly()starts aDEFERREDtransaction with read-only connection flags, so it can run on a non-primary connection. androidx.sqlite'sFrameworkSQLiteDatabase.beginTransactionReadOnly()callsSQLiteSession.beginTransaction(TRANSACTION_MODE_DEFERRED, listener, 0, null)via reflection on any API level, without the primary-affinity flag, with the same effect.
Minimal repro
One instrumented test runs the same scenarios on android.database.sqlite and on sqlcipher-android, both opened with ENABLE_WRITE_AHEAD_LOGGING on table t(id INTEGER PRIMARY KEY, v INTEGER).
- No Room and no pragmas are involved. Each run checks that
PRAGMA journal_modereturnswalon both databases. - sqlcipher-android uses an empty password, so opening a new connection doesn't pay for key derivation and encryption plays no part.
Reader threads start first. 50 ms later the test thread runs one INSERT and measures how long it takes.
- coldSingleReader: a fresh database and one slow plain
SELECT(a recursive CTE counting to 3 000 000). - warmTwoReaders: a fresh database where one non-primary connection was opened beforehand (a read on another thread while the test thread holds a write transaction). Then two slow plain
SELECTs run concurrently. This shows the problem isn't limited to startup. - readOnlyTransaction: one thread holds
beginTransactionReadOnly()for 500 ms after a shortSELECT.
Setup: sqlcipher-android 4.19.1, arm64 emulator, API 36. Each number is the median of 3 runs on a fresh database, in ms. reader is how long the slowest reader ran; writer wait is how long the INSERT took.
| Scenario | framework reader | framework writer wait | sqlcipher reader | sqlcipher writer wait |
|---|---|---|---|---|
| coldSingleReader | 304.8 | 0.1 | 325.2 | 274.7 |
| warmTwoReaders | 283.7 | 0.1 | 330.0 | 277.6 |
| readOnlyTransaction | 502.3 | 0.2 | 503.3 | 453.9 |
- With the framework, the
INSERTnever waits (≤ 0.2 ms) in any scenario. - With sqlcipher-android, the
INSERTwaits for the rest of the read in all three. The reader started 50 ms earlier, so a wait of reader time minus about 50 ms means the writer blocked for the reader's whole remaining run. warmTwoReadersblocks the writer just like the cold case: an already opened non-primary connection doesn't help once it is busy.
Full test
import android.content.Context
import android.database.Cursor
import android.os.Build
import android.util.Log
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import android.database.sqlite.SQLiteDatabase as FrameworkDatabase
import net.zetetic.database.sqlcipher.SQLiteDatabase as CipherDatabase
@RunWith(AndroidJUnit4::class)
class PrimaryConnectionReproTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private val executor = Executors.newCachedThreadPool()
private interface Engine {
fun query(sql: String, vararg args: Any): Cursor
fun write(value: Long)
fun beginTransaction()
fun beginTransactionReadOnly()
fun endTransaction()
fun close()
}
@Test
fun writerWaitsForReaders() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM)
System.loadLibrary("sqlcipher")
val engines = listOf("framework" to ::framework, "sqlcipher" to ::cipher)
engines.forEach { (name, open) ->
report(name, open, "coldSingleReader") { engine -> writerWait(engine, listOf { slowRead(engine) }) }
report(name, open, "warmTwoReaders") { engine ->
openNonPrimaryConnection(engine)
writerWait(engine, listOf({ slowRead(engine) }, { slowRead(engine) }))
}
report(name, open, "readOnlyTransaction") { engine ->
writerWait(engine, listOf { readOnlyTransaction(engine) })
}
}
executor.shutdown()
Log.i(TAG, "versions sqlcipher=${cipherVersion()} device=${Build.MODEL} api=${Build.VERSION.SDK_INT}")
}
private fun report(name: String, open: () -> Engine, scenario: String, block: (Engine) -> Pair<Long, Long>) {
val runs = List(RUNS) {
val engine = open()
try {
requireWal(engine)
block(engine)
} finally {
engine.close()
}
}
val reader = runs.map { it.first }.sorted()[RUNS / 2]
val writer = runs.map { it.second }.sorted()[RUNS / 2]
Log.i(TAG, "RESULT engine=$name scenario=$scenario " +
"readerMs=${ms(reader)} writerWaitMs=${ms(writer)}")
}
private fun writerWait(engine: Engine, readers: List<() -> Unit>): Pair<Long, Long> {
val started = CountDownLatch(readers.size)
val futures = readers.map { reader ->
executor.submit<Long> {
started.countDown()
val start = System.nanoTime()
reader()
System.nanoTime() - start
}
}
started.await()
Thread.sleep(START_DELAY_MS)
val start = System.nanoTime()
engine.write(System.nanoTime())
val wait = System.nanoTime() - start
val reader = futures.maxOf { it.get() }
return reader to wait
}
private fun requireWal(engine: Engine) {
engine.query("PRAGMA journal_mode").use { cursor ->
check(cursor.moveToFirst())
check(cursor.getString(0).equals("wal", ignoreCase = true)) { "journal_mode=${cursor.getString(0)}" }
}
}
private fun slowRead(engine: Engine) {
engine.query(SLOW_SELECT).use { cursor ->
check(cursor.moveToFirst())
check(cursor.getLong(0) == SLOW_ROWS)
}
}
private fun readOnlyTransaction(engine: Engine) {
engine.beginTransactionReadOnly()
try {
engine.query("SELECT count(*) FROM t").use { it.moveToFirst() }
Thread.sleep(HOLD_MS)
} finally {
engine.endTransaction()
}
}
private fun openNonPrimaryConnection(engine: Engine) {
engine.beginTransaction()
try {
executor.submit { engine.query("SELECT count(*) FROM t").use { it.moveToFirst() } }.get()
} finally {
engine.endTransaction()
}
}
private fun framework(): Engine {
val file = freshFile("primary-framework.db")
val db = FrameworkDatabase.openDatabase(
file.path,
null,
FrameworkDatabase.CREATE_IF_NECESSARY or FrameworkDatabase.ENABLE_WRITE_AHEAD_LOGGING
)
db.execSQL(CREATE_TABLE)
return object : Engine {
override fun query(sql: String, vararg args: Any): Cursor =
db.rawQuery(sql, args.map { it.toString() }.toTypedArray())
override fun write(value: Long) = db.execSQL(INSERT, arrayOf(value))
override fun beginTransaction() = db.beginTransaction()
override fun beginTransactionReadOnly() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
db.beginTransactionReadOnly()
} else {
error("beginTransactionReadOnly requires API 35")
}
}
override fun endTransaction() = db.endTransaction()
override fun close() = db.close()
}
}
private fun cipher(): Engine {
val file = freshFile("primary-sqlcipher.db")
val db = CipherDatabase.openDatabase(
file.path,
"",
null,
CipherDatabase.CREATE_IF_NECESSARY or CipherDatabase.ENABLE_WRITE_AHEAD_LOGGING,
null
)
db.execSQL(CREATE_TABLE)
return object : Engine {
override fun query(sql: String, vararg args: Any): Cursor = db.rawQuery(sql, *args)
override fun write(value: Long) = db.execSQL(INSERT, arrayOf(value))
override fun beginTransaction() = db.beginTransaction()
override fun beginTransactionReadOnly() = db.beginTransactionReadOnly()
override fun endTransaction() = db.endTransaction()
override fun close() = db.close()
}
}
private fun freshFile(name: String): File {
context.deleteDatabase(name)
return context.getDatabasePath(name).also { it.parentFile?.mkdirs() }
}
private fun cipherVersion(): String {
val db = CipherDatabase.openDatabase(":memory:", "", null, CipherDatabase.CREATE_IF_NECESSARY, null)
return db.rawQuery("PRAGMA cipher_version").use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else "?"
}.also { db.close() }
}
private fun ms(nanos: Long) = "%.1f".format(nanos / 1e6)
private companion object {
const val TAG = "PrimaryConnectionRepro"
const val CREATE_TABLE = "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL)"
const val INSERT = "INSERT INTO t (v) VALUES (?)"
const val SLOW_ROWS = 3_000_000L
const val SLOW_SELECT =
"WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < $SLOW_ROWS) " +
"SELECT count(*) FROM c"
const val HOLD_MS = 500L
const val START_DELAY_MS = 50L
const val RUNS = 3
}
}Expected
In WAL mode, a read without a transaction and a read-only transaction should not block writers as long as the pool can still open a non-primary connection, as with android.database.sqlite.
Possible fixes
- Use the primary connection for reads only before any non-primary connection exists. This keeps the startup optimization from dfecce3. One option is to check that no non-primary connection has been opened yet (none available and none acquired) instead of
mAvailableNonPrimaryConnections.isEmpty(). Another is to return to the AOSP order: non-primary first, primary as a fallback. - Make
beginTransactionReadOnly()deferred and read-only:getThreadSession().beginTransaction(TRANSACTION_MODE_DEFERRED, listener, getThreadDefaultConnectionFlags(true), null), as AOSP does since API 35. - In the driver, map
BEGIN DEFERRED TRANSACTIONtobeginTransactionReadOnly(),BEGIN IMMEDIATEtobeginTransactionNonExclusive(), andBEGIN EXCLUSIVEtobeginTransaction(), instead of running the statement throughrawQuery.androidx.sqlite'sSupportSQLiteDriverdoes this. This only helps together with the previous fix: todaybeginTransactionReadOnly()is the samebeginTransaction().
Workaround
- Keep reads short, e.g. page large reads with
LIMIT, so they hold the primary connection only briefly. - Override
beginTransactionReadOnly()in aSupportSQLiteDatabasewrapper. This needs package-privategetThreadSession(), i.e. a helper class innet.zetetic.database.sqlcipheror reflection. It covers only theSupportSQLiteDatabasepath, notSQLCipherDriver.
The read-path preference can't be changed from outside the library.
Activity