libsql

One place that configures SQLite: WAL, synchronous=NORMAL, and four profiles that state a connection's intent

Overview

libsql is the single seam that decides how every SQLite connection in the project is configured, at src/libs/libsql/. It is Core tier: its public header names a connection by string rather than taking a QSqlDatabase, so it pulls in Qt Core and nothing else and a host tool can depend on it. helpbuild, which generates the help search index during codegen, does exactly that.

It provides one class:

API Purpose
SqlTuning::apply(db, profile) Apply the house pragma set to an open connection
SqlTuning::apply(connectionName, profile) The same, for a caller that has only the name
SqlTuning::busyTimeoutMs / cacheSizeKiB What a profile asks for, so tests can assert it

Why this exists

Ten call sites opened SQLite by hand and each answered the configuration question differently. Three set journal_mode=WAL and foreign_keys=ON, one set WAL and a 30-second busy_timeout, and six set nothing at all.

None of them set synchronous, which is the setting that actually costs the time.

What the defaults are, measured rather than assumed

The driver we ship carries SQLite 3.51.0 compiled into libqsqlite.so — ldd links no system libsqlite3, so the host's own copy is irrelevant to what runs. Measured on both kits in the rotation, Qt 6.8.2 (the docker builder) and Qt 6.10.1 (this workstation); they agree on every value below. Reading the compile options out of that binary:

Option Value Consequence
DEFAULT_SYNCHRONOUS 2 FULL — an fsync on every commit
DEFAULT_WAL_SYNCHRONOUS 2 FULL in WAL mode too, so WAL alone does not fix it
DEFAULT_CACHE_SIZE -2000 A 2 MB page cache
DEFAULT_MMAP_SIZE 0 Memory-mapped I/O off
ENABLE_FTS5 present Full-text search is available and creatable
THREADSAFE 1

SECURE_DELETE is not among them. Older Qt kits compiled their bundled SQLite with it, which zero-fills freed content on every delete and costs write throughput for a guarantee almost nothing asks for. It is the first thing to suspect when Qt SQLite is slow, and on this kit it is already gone — testSqlTuning::testSecureDeleteIsNotCompiledIn fails if a kit upgrade brings it back, rather than the tree quietly getting slower.

Why synchronous=NORMAL is the headline

Under WAL, NORMAL fsyncs at checkpoints instead of at every commit. The durability difference is narrow and specific: a power loss or OS crash may lose the most recent transactions, but the database is not corrupted — WAL's atomicity does not depend on this setting.

FULL buys only "the last commit survives a power cut", which is not a promise any store in this project makes to its callers, and it is paid for on every single commit.

Profiles

A profile states what a connection is for, so a call site declares intent instead of copying a pragma list it will not maintain.

Profile Applies For
ReadWrite WAL, synchronous=NORMAL, busy timeout, foreign keys, temp_store=MEMORY, 16 MiB cache The default — every ordinary store
ReadOnly Everything except journal mode and synchronous A database opened to be queried and never written
BulkLoad ReadWrite plus a 64 MiB cache and wal_autocheckpoint=10000 A writer inserting a great deal in one run
Deterministic page_size=4096, journal_mode=OFF, synchronous=OFF, memory temp store, large cache A build artifact that must be byte-identical every run

ReadOnly exists because switching a read-only file to WAL writes to it, and on a genuinely read-only file that fails — noisily on some platforms, silently on others. HelpSearch opens the bundled index with QSQLITE_OPEN_READONLY, so its profile has to match its connection.

Deterministic is the opposite of the others in one respect, and deliberately so. helpbuild generates search.sqlite during codegen; a file that differed run to run would defeat ccache and make a reproducible build unreproducible. The tool writes a fresh file it can always regenerate, so durability buys nothing while journal bytes vary. It also pins page_size, which only takes effect before the first table is created, and leaves foreign_keys alone so that adopting the profile cannot change which inserts succeed.

Call it after every open

Everything here is per connection, not per database, and SQLite forgets it when the connection closes. That matters most in the thread-local reopen paths — libopal's history and conversation stores open a fresh connection per thread — which is exactly where a hand-written pragma list gets forgotten.

A call site with a measured reason for a different busy timeout passes one: liblab waits 30 seconds because its workload genuinely contends that long, and that decision is worth keeping rather than flattening into the house default.

Non-SQLite drivers

apply() returns true and does nothing for any driver that is not QSQLITE, so a multi-driver call site — liblab reaches PostgreSQL too — calls it unconditionally rather than branching first.

Verification is by asking back

A PRAGMA that returns no error has not necessarily taken effect; several are advisory. journal_mode in particular reports the mode it stayed in when it refuses, which it does on a network filesystem or a read-only file. So apply() reads the answer back and returns false when the connection is not configured the way the caller asked, and testSqlTuning asserts every setting by querying it rather than by trusting the call.

In this section
Topics
reference libsql sqlite pragma performance