This page records the design decisions behind dqlite-dbapi. The user-facing
behaviour they produce is documented in Transactions and
Differences from sqlite3.
dqlitedbapi sync PEP 249 surface: Connection, Cursor
│ one daemon thread + asyncio loop per Connection
dqlitedbapi.aio async surface: AsyncConnection, AsyncCursor ← all driver logic
│
dqliteclient DqliteConnection (wire session), ClusterClient (leader discovery)
All driver logic lives in dqlitedbapi.aio. The sync package is a thin
adapter: a sync Connection owns one AsyncConnection and one background
thread running an asyncio event loop; every sync method submits the matching
coroutine to that loop and blocks on the result. Fetch methods never hop to
the loop: results are fully buffered at execute time, so the sync cursor reads
the buffer directly.
Modules:
| Module | Responsibility |
|---|---|
aio/connection.py |
AsyncConnection: lifecycle, transactions, session mode, operation lock |
aio/cursor.py |
AsyncCursor: execute / executemany, result buffering, description |
connection.py, cursor.py |
sync adapters over the async classes |
_loop.py |
the background loop thread used by the sync adapter |
_sql.py |
statement classification (row-returning, DML verbs, BEGIN rewrite, PRAGMA intercept) |
_busy.py |
SQLITE_BUSY retry curve |
exceptions.py |
PEP 249 hierarchy and the client-error to dbapi-error mapping |
types.py, row.py |
type objects, constructors, adapters, datetime codecs, Row |
Lexical SQL helpers (comment stripping, top-level statement splitting,
literal blanking, leading keyword) come from dqliteclient.sql, which the
client also uses for its own transaction tracking. _sql.py holds only the
semantic rules the driver adds on top.
- Constructing a connection validates its arguments and does no I/O.
- The first operation (or an explicit
connect()/aconnect()) resolves the cluster leader from the seed address throughClusterClient, opens the wire session against the leader, and applies the session mode. - An invalidated connection is a closed connection. When the underlying
wire session is lost (transport error, leader change, cancellation or
interrupt during an operation),
closedbecomesTrue,invalidatedbecomesTrue, and every further operation raisesInterfaceError. The driver never reconnects silently: a reconnect would drop transaction state the caller believes is still open. Pools (SQLAlchemy's included) discard the connection and open a fresh one. close()is idempotent and does not roll back (stdlibsqlite3parity).force_close_transport()is synchronous, thread-safe, never raises, and tears the socket down without waiting for an in-flight operation. It exists for poolterminatepaths and for shutting down from a thread that is not the connection's owner.
check_same_thread=True(default): every method exceptforce_close_transport()must be called from the thread that created the connection; other threads getProgrammingError.check_same_thread=False: any thread may call the connection. Calls are serialised by a lock. Cursors are still single-thread objects, as insqlite3: share the connection, give each thread its own cursor.- Interrupts close the connection. A
KeyboardInterruptorSystemExitdelivered while a call is blocked cancels the in-flight operation, force-closes the connection, and propagates. The driver must not be re-entered from a signal handler; that case is out of contract. - The sync side imposes no deadline of its own. Every await in the async
layer is already bounded by the client's per-RPC timeouts, so a blocked sync
call ends when those fire or when
force_close_transport()is called from another thread.
- An
AsyncConnectionbinds to the event loop it first runs on. Using it from another loop raisesInterfaceError; it cannot be shared acrossasyncio.run()calls or threads. - One operation at a time: an
asyncio.Lockserialises execute, commit and rollback across tasks on the same loop. Waiting tasks queue rather than fail. A cursor is a single-task object. - Cancellation during a wire round-trip invalidates the connection (see above), because the server-side effect of the interrupted statement is unknown.
- One statement per
execute(); a second statement after;raisesProgrammingErrorbefore any network round-trip.CREATE TRIGGERbodies are understood, so their inner;do not count. - Empty, comment-only, and NUL-containing SQL raise
ProgrammingError. - A
?count that does not match the parameter count raisesProgrammingErrorclient-side. - Row-returning statements (
SELECT,VALUES,PRAGMA,EXPLAIN, aWITHwhose final statement is one of those, and any DML withRETURNING) go through the query path and populatedescriptionand the row buffer. Everything else goes through the exec path and populatesrowcount(INSERT/UPDATE/DELETE/REPLACE only, else -1) andlastrowid(INSERT/REPLACE only, sticky across other statements). PRAGMA busy_timeoutis answered locally: the server rejects it, and the driver owns the busy budget.- A bare
BEGIN/BEGIN TRANSACTIONis rewritten according to the session mode:immediate→BEGIN IMMEDIATE,exclusive→BEGIN EXCLUSIVE,deferredandread_only→ unchanged. An explicit qualifier is never rewritten. SQLITE_BUSYis retried on SQLite's default busy-callback curve until the connection'sbusy_timeoutbudget is spent. Eachexecutemanyiteration retries independently.descriptioncarries(name, type_code, None, None, None, None, None); the type code is the wireValueTypeof the first non-NULL cell in the column, or theUNKNOWNsentinel when there is none.ISO8601andUNIXTIMEwire cells decode todatetimeobjects; every other cell is passed through as the wire primitive.
- Autocommit by default: no implicit
BEGIN. commit()/rollback()are no-ops before the first connection and when no transaction is open. A server reply of "no transaction is active" is swallowed. Every other failure propagates.- A
COMMITthat fails with a leadership-lost code raisesAmbiguousCommitError(anOperationalError): the write may or may not have been applied. transaction()is a context manager issuingBEGIN/COMMIT/ROLLBACK. Callingcommit()orrollback()inside the block raisesInterfaceError, because the block owns the boundaries.with conn:/async with conn:commit on success and roll back on exception; neither closes the connection.session_modeis read-only;set_session_mode()changes it, emittingPRAGMA query_onlywhen crossing theread_onlyboundary. It may not be called inside a transaction.
Client exceptions are translated at the boundary by exceptions.translate:
| Client exception | dbapi exception |
|---|---|
OperationalError with code |
class chosen from the SQLite primary code (constraint → IntegrityError, internal/nomem/notfound → InternalError, toobig → DataError, range/misuse → InterfaceError, corrupt/format/notadb → bare DatabaseError, dqlite parse/notfound → ProgrammingError, else OperationalError) |
DqliteConnectionError, ClusterError, ProtocolError |
OperationalError with code=None |
ClusterPolicyError |
InterfaceError prefixed Cluster policy rejection (never retried) |
DataError, wire EncodeError |
DataError |
InterfaceError |
InterfaceError |
any other DqliteError |
DatabaseError |
Every raised error keeps code and raw_message (the untruncated server
text). Connect-time failures are prefixed Failed to connect: . These
prefixes and the Connection is closed / Cursor is closed /
invalidated (id= wordings are load-bearing: sqlalchemy-dqlite classifies
disconnects on them.
The dialect relies on exactly this dbapi surface, beyond PEP 249 itself:
- module:
connect, exception classes,paramstyle,sqlite_version_info,FAILED_TO_CONNECT_PREFIX,CLUSTER_POLICY_REJECTION_PREFIX; - connection:
cursor(),commit(),rollback(),close(),force_close_transport(),connect()(async),session_mode,default_session_mode,set_session_mode(),in_transaction; - cursor:
execute(),executemany(),description,rowcount,lastrowid,drain_rows()(async),close(); types.format_utc_offset()for the dialect's datetime processors.