Skip to content

Bug fixes for 2.6.0 - #131

Merged
qiaoyuang merged 7 commits into
release/2.6.0from
feature/bug-fixes-2.6.0
Oct 13, 2026
Merged

qiaoyuang merged 7 commits into
release/2.6.0from
feature/bug-fixes-2.6.0

Conversation

@qiaoyuang

@qiaoyuang qiaoyuang commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Bug fixes for 2.6.0, one commit per fix, each with a test and a CHANGELOG.md entry, plus a roadmap item. More fixes of this cycle may be added to this PR.

Fixes

  • B1: the default lookasideSlotSize and lookasideSlotCount, 0, which the documentation said left SQLite's default configuration, disabled SQLite's lookaside memory allocator on Kotlin/Native and on Android 9 and higher, while the JVM and lower versions of Android kept the default. Their default is now -1, which leaves SQLite's default configuration on every platform, in DatabaseConfiguration and DSLDBConfiguration; 0 still disables the allocator. (972867f)
  • B2: on Android, busyTimeout never took effect, so SQLite waited 2500 ms, as the framework sets it. On Android 9 and higher it was given to the deprecated setIdleConnectionTimeout, which SQLiteOpenHelper drops anyway. It is now set with PRAGMA busy_timeout: from Android 13 on through execPerConnectionSQL, on every connection, and below on the database's only connection. (cb25ba5)
  • B3: on the JVM and Kotlin/Native, a blocking database { } scope on another thread could roll back the TEMP table and triggers that count the changes of observed queries, as the connection is locked per statement rather than per transaction. The next write then failed with "no such table", and once another table was observed, the lost table's changes were no longer counted. The tracker now checks for its TEMP objects before reading the counts, creates whatever is gone, and counts a change of those tables; a failure to tell observed queries is added to the statement's failure instead of replacing it. The docs now say to write through suspendedScope while queries are observed. (77bba18)
  • B4: the example of the guide to concurrency safety called database suspendScope { }, which doesn't exist, in a non-suspend function. (dd4d3ba)
  • B5: transactions began DEFERRED on the JVM and Kotlin/Native and EXCLUSIVE on Android, so on the JVM and Kotlin/Native a transaction that read before it wrote failed at once with "database is locked" while another connection wrote, without waiting for busyTimeout. They now begin IMMEDIATE on every platform; a read-only connection on the JVM and Kotlin/Native begins a deferred one. The JVM driver now begins and ends transactions with SQL, as sqlite-jdbc begins another transaction of its configured kind right after a commit, which an IMMEDIATE one couldn't do while another connection writes. (4a22c6f)
  • B6: when SQLite rolled a transaction back itself, for a constraint with ON CONFLICT ROLLBACK, a trigger's RAISE(ROLLBACK) or some I/O errors, ending it failed with "cannot rollback - no transaction is active", and withTransaction, and so the DSL's transaction { }, threw that instead of the failure that caused the rollback, on every platform. The block's failure is now thrown, with the failure to end the transaction added as a suppressed exception. (ae4d7eb)

None changes source compatibility.

Roadmap

  • F66: table version tokens (versioned { }, +, awaitChange, onChange), the primitive a self-invalidating reader such as a PagingSource needs, designed with the MyMovie project; F13 now builds on it. (caf0fa1)

Testing

  • sqllin-driver: jvmTest (10 tests) and testAndroidHostTest on Robolectric API 26 and 37 (21 tests) pass locally. sqllin-dsl-test: jvmTest (94 tests) and testAndroidHostTest (188 tests) pass. The intermediate state after B3 was tested on its own too.
  • Each new test was checked against the old code: testBusyTimeout returned 2500 on Android API 26 and 37, testLookasideConfig returned (0, 0), testObserveRestoresTracking missed the insert after the tracker's row was gone, testImmediateTransaction let another connection write during a transaction on the JVM, and testRolledBackBySQLite got "cannot rollback - no transaction is active" instead of the constraint or trigger failure on the JVM and on Android API 26 and 37.
  • The native tests have not run, including the new testLookaside. The development machine is an Intel Mac: the tests link for macosArm64 and iosSimulatorArm64 and compile for linuxX64 and mingwX64. CI on this PR is their first run.
  • Not covered: the Android 9 to 12 path of B2 (OpenParams with the rawQuery PRAGMA), which runs the same rawQuery code as API 26, and a lost trigger without a lost row in B3, which the same check handles but which the DSL can't produce without a schema change.

🤖 Generated with Claude Code

qiaoyuang and others added 7 commits October 12, 2026 10:48
busyTimeout never took effect on Android: SQLite waited 2500 ms, as
the framework sets it, for a lock that another connection held. On
Android 9 and higher, it was given to OpenParams' deprecated
setIdleConnectionTimeout, which means how long a connection may be idle
before the framework closes it, and which SQLiteOpenHelper drops anyway,
as the OpenParams.Builder it copies the parameters into doesn't copy
that one, on every version from Android 9 to 16.

It is now set with PRAGMA busy_timeout, when the database is configured.
From Android 13 on, a database in WAL mode reads through several
connections, so execPerConnectionSQL runs it on each of them, and on
those opened later; it only runs a PRAGMA that returns a row, as this
one does, from Android 13 on. Before, a database that SQLlin opens has a
single connection, which runs it through rawQuery.

The guides to getting started listed busyTimeout, and in Chinese
journalMode, among the parameters that only work on Android 9 and
higher; only inMemory and the lookaside parameters do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The default lookasideSlotSize and lookasideSlotCount, 0, which the
documentation said left SQLite's default configuration, disabled the
lookaside memory allocator on Kotlin/Native and on Android 9 and higher,
as sqlite3_db_config and setLookasideConfig take 0 to disable it, while
the JVM, where sqlite-jdbc can't set it, and lower versions of Android
kept SQLite's default configuration.

Their default is now -1, in DatabaseConfiguration and DSLDBConfiguration,
which leaves SQLite's default configuration on every platform, as the
Android framework's own default does; 0 still disables the allocator.
The Android driver only calls setLookasideConfig when both values are 0
or more, as it rejects negative ones. The examples of the guides show
the new defaults, which they explain, and the guides name
sqlite3_db_config() as what sqlite-jdbc doesn't support, rather than
sqlite3_config(). The error of the Kotlin/Native driver had a stray '%'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F66 is the primitive that a reader which has to invalidate itself, as a
PagingSource does, needs: versioned { } returns the results of a
read-only scope with an opaque token of the versions of the tables it
read, captured under the lock that the read runs under, after the
tables are tracked, so that no change between the read and the capture
is missed. Tokens combine with +, taking the earlier version of a table,
and reject the tokens of another Database. awaitChange suspends until a
table changes, and onChange registers a one-shot callback, run after
the writing scope releases the lock, returning null when the token is
already stale, as it registers before it checks.

It comes from the design discussed with the MyMovie project, which
found that results, as observe gives, don't fit a PagingSource, and
that a version read without the read can't be sound, as a table only
has one once it is tracked. F13 now builds on it, with the callback
held weakly by the registry, so that a source that Pager abandons
doesn't stay registered, and the three traps of offset paging that the
project reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the JVM and Kotlin/Native, the connection is locked for each
statement rather than for a transaction, so a blocking database { }
scope on another thread can have its transaction open while the tracker
of observed queries creates its TEMP table and triggers. They are
rolled back with that transaction, while the tracker still counts their
tables as tracked: the next write failed with "no such table", and once
another table was observed, which created the TEMP table again, the
changes of the lost table were no longer counted. Android isn't
affected, as a transaction holds the primary connection there.

Before it reads the counts, the tracker now checks that the TEMP table
exists and holds a row for each tracked table, and that each tracked
table has its three triggers, in a transaction, as on Android a query
outside one may run on a reader connection. It creates again whatever
is gone, and counts a change of those tables above the version last
read, as their changes may have been missed. A failure to tell the
observed queries of a change is added to the failure of the statement
that made it, rather than taking its place.

The guide to observed queries, and the documentation of invoke and
observe, now say to write through suspendedScope while queries are
observed, as they run on another thread, and that a blocking scope
isn't serialized with them, as the guide to concurrency safety already
said of sharing a database between threads. The test stands for the
rollback by removing the row of a table from the TEMP table, and by
dropping it, through an entity of the TEMP table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The example called database suspendScope { }, which doesn't exist, in
a function that wasn't a suspend one, in English and in Chinese. It is
database suspendedScope { }, in a suspend function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Transactions began DEFERRED on the JVM and Kotlin/Native, and EXCLUSIVE
on Android. So on the JVM and Kotlin/Native, a transaction that read
before it wrote failed at once with "database is locked" while another
connection wrote, as SQLite doesn't wait for busyTimeout where waiting
could deadlock, while on Android it waited.

They now begin IMMEDIATE on every platform, taking the write lock at
once, for which SQLite waits as long as busyTimeout. On Android,
beginTransactionNonExclusive does it, which is what EXCLUSIVE did in
WAL mode, and in DELETE mode lets other connections read until the
transaction commits. On the JVM and Kotlin/Native, a read-only
connection begins a deferred transaction, as it can't take the write
lock. The JVM driver now begins and ends transactions with SQL, in
auto-commit mode: sqlite-jdbc begins another transaction of its
configured kind as soon as one is committed or rolled back, which an
IMMEDIATE one couldn't do while another connection writes, so that a
COMMIT that succeeded would fail.

F27, choosing how a transaction begins, no longer says that the
platforms differ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SQLite rolls some transactions back itself: for a constraint with ON
CONFLICT ROLLBACK, which onConflict = OnConflict.ROLLBACK declares, for
a trigger's RAISE(ROLLBACK), and for some I/O errors. Ending such a
transaction then failed too, with "cannot rollback - no transaction is
active", and withTransaction threw that failure from its finally
block, in place of the one that made SQLite roll it back, on every
platform, and so did the DSL's transaction { }, which runs through it.

withTransaction now throws the block's failure, with the failure to end
the transaction added to it as a suppressed exception. It keeps ending
the transaction in a finally block, as a non-local return from the
block, which an inline function allows, has to end it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@qiaoyuang
qiaoyuang merged commit c830bce into release/2.6.0 Oct 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant