Sessions and transactions topic
Streaming and execution
get() materializes a complete result. stream() keeps a database cursor open
and fetches the next batch when the consumer requests more rows:
await for (final user in db.user
.orderBy((u) => [u.id.asc()])
.stream(batchSize: 128)) {
await exportUser(user);
}
await for pauses while its body awaits. With listen, pause/resume the
subscription yourself; an asynchronous onData callback does not create demand
control. toList() deliberately accumulates the whole stream in the consumer.
The default batch size is 128. SQLite advances a native prepared cursor in its
background isolate. PostgreSQL declares a server cursor and issues bounded
FETCH statements. Neither adapter first materializes all root rows in Dart.
The SQL plan can still sort or aggregate before it returns its first row.
Relations are loaded once per root batch on the same connection, including
nested selections and per-parent limits. Memory includes the selected related
rows: use relation take() when a parent can have a large collection. Root
batch size alone does not bound that collection.
A stream holds one connection until completion or cancellation. Outside an explicit transaction it opens a read transaction, committing after normal exhaustion and rolling back on early exit or failure. PostgreSQL uses a non-holdable, forward-only cursor. For a consistent snapshot across root and relationship queries, stream inside a transaction with the required isolation:
await db.transaction((tx) async {
await for (final row in tx.user.stream()) {
await consume(row);
}
}, options: const PostgresTransaction(
isolation: Isolation.repeatableRead,
readOnly: true,
));
Finish/cancel subscriptions inside a transaction or leased session callback.
An escaping active stream is stopped and the callback fails with
TRANSACTION.UNAWAITED or SESSION.UNAWAITED. Database.close() also stops
active cursors before releasing its driver. A leased connection executes one
statement at a time; overlapping statements fail with SESSION.BUSY.
Concurrent close() callers all await the same drain and driver shutdown.
Connection acquisition
final options = ExecutionOptions(
acquireTimeout: const Duration(seconds: 1),
timeout: const Duration(seconds: 5),
cancellation: token,
);
final rows = await db.user.select((u) => u.email).get(options: options);
await db.transaction((tx) async {
await tx.user.create(email: 'within-a-lease@example.com');
}, acquire: const AcquisitionOptions(timeout: Duration(seconds: 2)));
await db.session((session) async {
// This session already owns its connection.
}, acquire: AcquisitionOptions(
timeout: const Duration(seconds: 2),
cancellation: acquisitionToken,
));
ExecutionOptions.acquireTimeout spans waiting for the driver to supply a
connection, including connection establishment and initialization. It ends before
SQL starts. The same query cancellation token covers both waiting and statement
execution. Stream and watch options propagate this acquisition bound; batch
inserts apply it before opening their transaction. An existing leased session
needs no further acquisition.
AcquisitionOptions is the corresponding session/transaction entry setting.
Its cancellation token affects acquisition only: after the callback begins it
does not interrupt that callback or its statements. It is not a transaction
execution deadline. Non-positive timeouts and already-cancelled requests are
rejected before queuing.
A Dart Future timeout does
not cancel its source computation. Expired acquisitions report CONNECTION.TIMEOUT; requested cancellation reports
OPERATION.CANCELLED. The ORM does not merely abandon a Future: a late driver
lease is returned without entering the query, mutation or transaction callback.
A monotonic clock also checks the deadline at entry, so a delayed Dart Timer
cannot allow expired work to start. The underlying pool can retain its reservation
until it grants a lease or fails; the ORM does not pretend that it can remove an
entry from a driver queue that exposes no removal API. Database.close() waits
for these reservations to drain. A caller holding a session must still release it.
PostgreSQL has separate driver settings:
| Setting | Default | Scope |
|---|---|---|
PostgresOptions.connectTimeout |
10 seconds | Establishing a new connection |
PostgresOptions.poolTimeout |
30 seconds | Native driver's pool queue waits |
PostgresOptions.queryTimeout |
30 seconds | Each SQL statement |
ExecutionOptions.acquireTimeout |
unset | Entire acquisition for this operation |
The per-operation bound can shorten acquisition; it does not enlarge the native
pool limit. Borrowed pools retain their owner's connection and queue settings.
Driver timeouts before callback entry are classified as CONNECTION.TIMEOUT;
a TimeoutException thrown by application code after entry is preserved.
SQLite's busyTimeout controls database lock waits, not its ORM lease queue.
Neither backend's connection settings substitute for a total transaction deadline.
Cancellation and statement deadlines
final token = CancellationToken();
final options = ExecutionOptions(
cancellation: token,
timeout: const Duration(seconds: 5),
);
final pending = db.user.where((u) => u.email.eq(email)).get(options: options);
// Another event can call token.cancel().
final users = await pending;
Options are accepted by query get/first/single/count/exists/stream, mutation
execute, returning get/single/first, createRow, batch execution/returning,
and Database.execute. They propagate into relation loads and batch chunks.
Generated named create/patch shortcuts currently use default execution
options; use the underlying insert/update builder for explicit options.
timeout applies to each SQL statement or cursor fetch. It excludes time spent
waiting for a connection and consuming already fetched rows. It is not a total
transaction deadline. Use acquireTimeout for the separate acquisition phase.
capabilities.statementTimeout reports statement deadline support, while
capabilities.cancellation reports token-based interruption. MySQL/MariaDB support
the former by discarding their connection; they do not support the latter.
Cancellation while acquiring a connection now completes promptly and prevents
the SQL callback from ever entering, including when the driver grants a lease
later. Cancellation of an executing statement still awaits database cleanup.
A token is a sticky request, not proof that a write was rolled back. Always await
the operation's result: a completed statement may win the cancellation race and
return success. OPERATION.TIMEOUT reports an expired statement deadline; it is
not proof of rollback. MySQL/MariaDB discard the socket and close the driver, so a
write's outcome can remain unknown and further work needs a newly opened driver.
Connection loss and uncertain commits likewise require checking the outcome.
Writes are retried only inside an explicitly opted-in
transaction, subject to the confirmed-rollback rules below.
SQLite uses sqlite3_interrupt from the exact native asset backing the open
database. capabilities.cancellation is false if that build does not export the
symbol. Such builds reject cancellation/deadlines instead of merely abandoning
a running future. In particular, the default Linux build of sqlite3 3.6.0 hides
that symbol: statement cancellation, execution deadlines and bounded transaction
retries are unavailable there. Ordinary reads, writes, transactions and streaming
remain available. When distributing a native application, build with its native
assets:
dart build cli --target=bin/app.dart --output=build/app
Distribute the complete bundle directory, including its native libraries.
dart compile exe alone does not bundle dependency build-hook assets; see
Dart CLI builds.
PostgreSQL opens a separate control connection only when cancellation is
requested and calls pg_cancel_backend for the leased backend. It waits for
control cleanup before permitting another statement. This may temporarily use
one connection beyond the pool limit. The control role must be allowed to cancel
the original backend; the default uses the same connection credentials.
The deadline also covers the first backend PID lookup. Cancellation before that lookup completes discards the connection without sending the application SQL. Successful lookups are cached for reuse of the same physical connection.
Owned PostgreSQL drivers default to a 30-second statement deadline, configurable
with PostgresOptions.queryTimeout. PostgresDriver.borrow needs a
cancellationConnection factory for cancellation or deadlines, and accepts its
own optional queryTimeout. The borrowed pool's statement timeout is not used:
the ORM controls cancellation completion itself. Closing a borrowed ORM driver
does not close the caller's pool.
An interrupted statement marks an explicit transaction failed even if its error is caught. Cancellation between batch chunks also fails the transaction, so catching that error cannot commit earlier chunks. Use a savepoint for recoverable work. Cancelling a stream while a PostgreSQL fetch is executing can abort that transaction; ending a stream between fetches just closes its cursor.
onQuery reports execute, cursorOpen, cursorFetch and cursorClose events.
Fetch events include actual batch row counts; they carry the original query SQL
for attribution. Parameters are counted but their values are not recorded.
Optional onAcquire and onDecode callbacks separately report lease acquisition
and synchronous ORM result processing. See plans and observations
for their scopes, inheritance and limits, and non-executing query.inspect().
Transaction deadlines and failure outcomes
final cancel = CancellationToken();
await db.transaction((tx) async {
await tx.user.create(email: 'one@example.com');
await tx.savepoint((child) async {
await child.user.create(email: 'two@example.com');
});
},
acquire: const AcquisitionOptions(timeout: Duration(seconds: 1)),
timeout: const Duration(seconds: 5),
cancellation: cancel,
);
transaction(timeout: ...) starts after acquiring its connection, immediately
before BEGIN. It covers BEGIN, application callback time, all statements and
savepoints, cursor consumption, and COMMIT. It does not restart for each query or
savepoint. A monotonic check prevents new SQL or COMMIT after a CPU-bound callback
has exceeded the deadline even when its Timer could not run promptly. The
transaction cancellation token also cancels acquisition; acquire.cancellation
continues to apply only to acquisition.
Expiration rejects new operations, cancels active SQL and cursors, drains pending work, and rolls back using the underlying connection without the expired token. The outer Future completes after this cleanup. Cleanup can exceed the requested duration: stopping a statement, waiting for SQLite's busy handler, and confirming rollback take time. A driver without actual statement cancellation rejects these options before starting the transaction.
Dart cannot forcibly stop an arbitrary asynchronous callback or interrupt Dart code currently monopolizing its isolate. A callback waiting on another Future may resume later; its expired database view rejects further operations. Its late errors are observed. External effects performed by callback code are not rolled back by the ORM. Use transaction/outbox patterns for effects that must follow committed data.
Successful COMMIT acknowledgement wins a cancellation race. Failure outcomes are explicit:
| Outcome | Error |
|---|---|
| Transaction deadline expires before confirmed commit | TRANSACTION.TIMEOUT |
| Transaction cancellation during execution | TRANSACTION.CANCELLED |
| Cancellation while still acquiring | OPERATION.CANCELLED |
| Shorter statement timeout | OPERATION.TIMEOUT |
| Database has already ended the transaction | TRANSACTION.ENDED for further calls; the enclosing transaction fails |
| Rollback cannot be confirmed | TRANSACTION.ROLLBACK; the connection is discarded |
| COMMIT outcome cannot be established | TRANSACTION.COMMIT; never assume callback replay is safe |
| Server explicitly rejects COMMIT | Original classified SqlFailure, or the elapsed transaction deadline with that failure as its cause |
PostgreSQL can replace a discarded physical connection from its pool. SQLite uses one worker connection; discarding it requires reopening the database, and an in-memory database cannot preserve its contents across that reopen.
SqlFailure exposes conservative retryTransaction, retryCommit and commitRejected
classification. Native PostgreSQL errors use PostgresFailure, retaining the
SQLSTATE in code and the original package:postgres exception in cause.
SQLite retains SqliteFailure.code, extendedCode and message.
| Database error | Possible transaction retry after confirmed rollback | Known COMMIT rejection |
|---|---|---|
PostgreSQL 40001 or 40P01 |
Yes | Yes |
PostgreSQL integrity constraints, SQLSTATE class 23 |
No | Yes |
PostgreSQL 40003 |
No | No |
SQLite BUSY, including extended busy codes |
Yes | Yes |
| SQLite constraint errors | No | Yes |
| Other errors | No automatic eligibility | Conservatively unknown |
These flags do not enable retries by themselves. Without retry:, the ORM makes
one attempt, rolls back a rejected transaction and returns its failure.
PostgreSQL serialization failures require repeating the whole transaction logic,
including reads and application decisions.
PostgreSQL retry guidance
and the SQLSTATE catalog
explain these distinctions.
SQLite reports actual autocommit state from the worker after every completed
request, including errors. If an interrupt or OR ROLLBACK has already rolled
back the whole transaction, the ORM skips a redundant ROLLBACK. A vanished
savepoint marks its parent failed and prevents subsequent writes from silently
running in autocommit mode. Healthy SQLite connections remain usable. PostgreSQL's
current Dart driver does not expose transaction status publicly, so its adapter
reports an unknown state and confirms cleanup with ROLLBACK.
SQLite autocommit status
and transaction error handling
describe why statement errors alone cannot establish SQLite transaction state.
Explicit bounded retries
final result = await db.transaction((tx) async {
final user = await tx.user.byId(userId).single();
await tx.user.where((u) => u.id.eq(userId))
.update((u) => [u.nickname.set(user.email)]).execute();
return user.email;
},
retry: const TransactionRetry(
maxAttempts: 3,
timeout: Duration(seconds: 5),
delay: Duration(milliseconds: 10),
maxDelay: Duration(milliseconds: 250),
),
);
The callback must be safe to repeat after database rollback. Each callback attempt receives a fresh transaction view and re-executes its reads and decisions. The previous view closes before another attempt can enter. Application errors, constraint violations, cancelled statements and caught transaction failures do not trigger replay. A failed attempt's write notifications are discarded; only a confirmed commit publishes them. Unknown COMMIT outcomes and unconfirmed rollback always prohibit replay, even when a retry policy is supplied.
Defaults are three attempts, a 30-second total budget, a 10-millisecond initial
backoff and a 250-millisecond cap. Backoff grows exponentially, with jitter between
approximately half and all of the current delay. Setting delay: Duration.zero
disables backoff. The initial attempt consumes one slot; each callback replay or
commit-only retry consumes one additional slot from the same maxAttempts.
For example, with three slots, one callback replay leaves room for only one
commit-only retry. Attempts are finite even when repeated failures happen quickly.
Exhausting slots returns the final database failure; expiry reports
TRANSACTION.TIMEOUT. Cancellation interrupts backoff and reports the transaction
cancellation error without starting another attempt.
SQLite COMMIT returning BUSY is retried in place only if the adapter confirms the same transaction is still active. The successful callback is not re-entered, so its in-memory result is retained. A SQLite busy snapshot error during a statement instead requires confirmed rollback and replay from BEGIN. PostgreSQL serialization failures and deadlocks also require whole-transaction replay. The current PostgreSQL adapter never retries COMMIT in place.
TransactionRetry.timeout starts at the transaction call and includes acquisition,
all attempts, callback time, SQL and backoff. transaction(timeout: ...) still
starts after acquisition and spans all attempts; the shorter remaining budget wins.
A shorter explicit acquisition timeout reports CONNECTION.TIMEOUT, as do native
pool/connection failures. An ORM acquisition timer limited by the retry budget
reports TRANSACTION.TIMEOUT. These error classes are tied to the source of expiry,
not inferred from timing after the error. Native cancellation support is required.
The cleanup and confirmed-COMMIT rules above also apply to retries.
One connection remains leased for the bounded attempt series, including backoff. This preserves session state and avoids reacquisition on each retry, but holds a pool slot while waiting. Keep budgets short under contention. The ORM does not reconnect and replay after a transport failure, and external callback effects are not rolled back. This API makes no exactly-once guarantee for such effects.
Libraries
- runtime Sessions and transactions
- Connection leases, transactions and streaming for parameterized SQL.
Classes
-
SqlDatabase<
B extends Backend> Sessions and transactions - Raw SQL execution with owned driver resources and explicit callback scopes.
-
TransactionOptions<
B extends Backend> Sessions and transactions - Engine-specific settings applied before a transaction callback starts.
- TransactionRetry Sessions and transactions
- Explicit opt-in: callback logic must be safe to repeat after rollback. The budget counts every scheduled retry, including commit-only retries.
Extensions
-
SqlDatabaseStreaming
on SqlDatabase<
Backend> Sessions and transactions - Demand-driven raw-row streaming with a bounded database cursor.