Skip to content

sqflite_watchos: SQL runs on a background isolate - #38

Merged
MAUstaoglu merged 2 commits into
mainfrom
sqflite-off-ui-isolate
Oct 30, 2026
Merged

MAUstaoglu merged 2 commits into
mainfrom
sqflite-off-ui-isolate

Conversation

@MAUstaoglu

@MAUstaoglu MAUstaoglu commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Part of #20: SQL moves off the UI isolate.

Design. One background isolate per isolate that uses the factory. It follows sqflite_common_ffi's databaseFactoryFfi model, and lib/src/worker.dart is adapted from its isolate code (with the BSD-2 notice).

  • The first call starts the isolate. The handler and every open database, transaction and cursor live there.
  • Each call crosses as a message with an id. Calls keep their order, and a call queued behind a transaction does not block its COMMIT.
  • A SqfliteDatabaseException crosses as data, keeping its message, details, result code and transactionClosed, and is rebuilt as the same type.
  • The worker ends when its creator ends: an exit listener posts the stop. The worker then closes its databases, releasing their locks. Its answer port keeps the creator alive only while an answer is due, so a helper isolate that used sqflite can end on its own.
  • After a crash or a stop, calls fail as database_closed, and none hangs.

Behaviour that changes, also in the README and CHANGELOG:

  • A long query no longer holds up a frame.
  • The 2 s busy wait now blocks the worker, not the UI. Two connections to one file opened from the same isolate still cannot wait each other out, so keep singleInstance: true.
  • An argument the worker cannot copy fails the whole call, as a method channel's codec does on iOS.
  • The test hook libraryOverride becomes libraryPathOverride. The package is unpublished, so there is no deprecation.

Tests. 60 host tests pass, 21 of them new. They show that:

  • a timer ticks while a 3M-row count runs;
  • errors survive the boundary, field by field;
  • transactions and queued calls work across it;
  • stop, crash, early stop and lazy start behave as specified;
  • a worker does not outlive an Isolate.run or a helper isolate.

Each of nine mutations of the worker fails at least one test.

Simulator (watchOS 27.0, Series 12 46 mm). The first commit, merged with #37, passed upstream's integration test (14 tests), and upstream's example ran its "Raw tests" page with no failure in the console. The final commit passed the same checks: the integration test (14 tests), the example starting with no error in its console (first frame 0.7 s), and its "Raw tests" page.

Not yet: a physical watch. That stays on #20, along with the busy test's SIGPROF note, which is not proven on Linux.

An independent review confirmed eight low-severity findings in the first commit. The second commit fixes them all.

Issue #20, item 1, spec 0024. Every call ran on the calling isolate, the
UI isolate in an app, so a long query held up a frame and the 2-second
lock wait froze the UI.

* lib/src/worker.dart runs the handler on one long-lived isolate, as
  sqflite_common_ffi's databaseFactoryFfi does: the first call starts it,
  every database shares it, and the databases, transactions and cursors
  live there. Each call crosses as a message with an id, and every answer
  comes back on one port. Calls made before the isolate is up keep their
  order, and a call queued behind a transaction does not hold up the call
  that ends it. It is adapted from sqflite_common_ffi's isolate_io.dart and
  method_call.dart and carries their BSD-2-Clause notice.
* A SqfliteDatabaseException crosses as data (message, details, result
  code, transactionClosed) and is built again on the caller's side, so
  sqflite_common sees the same error. A file error or an ArgumentError is
  copied as it is, as Isolate.run copies one. An argument that cannot be
  sent fails its call as a SqfliteDatabaseException.
* SqfliteWatchos.libraryPathOverride, a path the isolate opens, replaces
  libraryOverride, since a DynamicLibrary cannot cross isolates.
  resetForTesting returns a Future: the isolate closes its databases and
  ends. sqliteVersion stays synchronous, and registerWith binds nothing
  until the first call.
* The busy test's comment names the likelier cause of the 39 ms wait on
  Ubuntu: flutter test runs the Dart profiler, which samples threads with
  SIGPROF on Linux and so ends SQLite's busy-handler sleeps early. On macOS
  the test also checks that the test isolate ticks while the worker waits.
* README, CHANGELOG (0.0.1, unpublished, edited in place) and
  PORTING_REPORT describe the new model and what the lock wait blocks.

Checked: 52 host tests on macOS (39 as before, 13 new). The two tick
checks fail on the old code (0 ticks). Mutations that drop
transactionClosed, send other errors as text, skip closing the databases,
or await each call in turn each fail tests. flutter analyze, dart format,
check_words, check_versions, check_test_names, symcheck and
check_readme_snippets pass. Not run: CI's Ubuntu job, the Simulator and a
physical watch.
From the review of the background isolate, spec 0024. All eight findings
were low after verification.

* The worker ends with the isolate that started it. When the worker's
  port arrives, that isolate adds it as an exit listener of its own, and
  the null exit message stops the worker as a stop does: it closes every
  database and ends. The answer port is a RawReceivePort that keeps the
  isolate alive only while an answer is due, a call is unsent or a stop
  has not finished, so an isolate that used sqflite can end on its own.
  Before, every isolate that used the factory left a worker behind, and
  an Isolate.spawn helper never ended.
* SqfliteWatchosWorker gets two @VisibleForTesting members: isStarted,
  and crashForTesting, which ends the isolate with an error it does not
  catch.
* New worker tests:
  - an isolate ended by that error fails a call queued behind an open
    transaction with isDatabaseClosedError() and the error's text;
  - stop() completes only on the isolate's exit, after every answer;
  - a stop made with calls in flight, or before the isolate's port
    arrived, answers those calls first and still closes their databases;
  - the isolate starts on the first call, and building the factory
    starts none;
  - an argument that cannot be sent fails a whole batch;
  - an Isolate.run session leaves no worker once it ends, and an
    Isolate.spawn helper that used the factory ends on its own.
* The stop test's matcher is the handler's own cancel error, and the
  lock checks write through a connection with busy_timeout 0, so a lock
  still held fails at once.
* An argument that cannot be sent keeps failing the whole call, as a
  method channel's codec fails it on iOS. The CHANGELOG and worker.dart
  now say so, and that its details hold the call's SQL and arguments but
  no database, and a batch's none.
* Upstream's integration test passed on the watchOS 27.0 Simulator with
  SQL on the background isolate (the owner's run of 2026-10-30), so
  PORTING_REPORT and the CHANGELOG record it.

Checked: 60 host tests on macOS (42 and 18; 8 new). Each of these changes
to the worker fails tests: leaking the calls an ended isolate never
answered, ignoring its error report, closing without cancelling, a stop()
that resolves early, a stop that fails the calls already made, a stop
before the port that is dropped, an eager start, no exit listener, and an
answer port that always keeps its isolate alive. flutter analyze, dart
format, check_words, check_versions, check_test_names, symcheck and
check_readme_snippets pass.
@MAUstaoglu
MAUstaoglu merged commit afed080 into main Oct 30, 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