Skip to content

In WAL mode, plain reads and read-only transactions take the primary connection and block writers #99

Open

Description

@al-kuz-swe

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:

  1. 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.
  2. beginTransactionReadOnly() starts an EXCLUSIVE transaction on the primary connection. That also covers the read-only transactions Room starts through SQLCipherDriver (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_version reports 4.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 a DEFERRED transaction with read-only connection flags, so it can run on a non-primary connection.
  • androidx.sqlite's FrameworkSQLiteDatabase.beginTransactionReadOnly() calls SQLiteSession.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_mode returns wal on 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 short SELECT.

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 INSERT never waits (≤ 0.2 ms) in any scenario.
  • With sqlcipher-android, the INSERT waits 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.
  • warmTwoReaders blocks 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 TRANSACTION to beginTransactionReadOnly(), BEGIN IMMEDIATE to beginTransactionNonExclusive(), and BEGIN EXCLUSIVE to beginTransaction(), instead of running the statement through rawQuery. androidx.sqlite's SupportSQLiteDriver does this. This only helps together with the previous fix: today beginTransactionReadOnly() is the same beginTransaction().

Workaround

  • Keep reads short, e.g. page large reads with LIMIT, so they hold the primary connection only briefly.
  • Override beginTransactionReadOnly() in a SupportSQLiteDatabase wrapper. This needs package-private getThreadSession(), i.e. a helper class in net.zetetic.database.sqlcipher or reflection. It covers only the SupportSQLiteDatabase path, not SQLCipherDriver.

The read-path preference can't be changed from outside the library.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions