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.