Database operations guide¶
Status: Phase 2. The SQLite provider is preview API and lives in the optional
databaseextra.
Install it with pip install "xyberos[database]", which provides SQLAlchemy 2.x Core,
aiosqlite, and Alembic. Importing xyberos never imports any of them, and importing a database
module without the extra fails with that command, not a bare ModuleNotFoundError.
Xyberos owns configuration, connection PRAGMAs, transaction boundaries, and shutdown. SQLAlchemy owns the driver integration. Applications use Core statements; ORM sessions are not a required framework abstraction.
SQLite is the default, not the contract. A service depends on DatabaseProvider — three members
over a SQLAlchemy AsyncConnection — so a different backend is a provider package rather than a change
to your business code. That path, and the conformance suite that proves a provider satisfies the
contract, is documented in writing a database provider. Whether SQLite is
enough for a given workload is answered with numbers in performance and
where Xyberos fits.
Configuration¶
from xyberos.database import SqliteSettings
settings = SqliteSettings(
path="var/app.db", # a file path, or ":memory:" for tests
foreign_keys=True,
journal_mode="WAL",
synchronous="NORMAL", # explicit policy, not a hidden guarantee
busy_timeout_ms=5000, # bounded wait before SQLite reports a busy database
retry_attempts=5, # bounded retry budget for lock contention
retry_base_delay=0.05,
)
| Setting | Notes |
|---|---|
path |
Relative paths resolve against the process working directory |
foreign_keys |
PRAGMA foreign_keys=ON on every connection |
journal_mode |
Default WAL; rejected for :memory: databases |
synchronous |
OFF, NORMAL, FULL, or EXTRA. NORMAL with WAL is fast but is a durability trade-off: a committed transaction can be lost if the operating system crashes. Choose FULL when that matters |
busy_timeout_ms |
How long SQLite waits for a lock before reporting the database as busy |
retry_attempts, retry_base_delay |
Defaults for the bounded retry helper |
Invalid values raise DatabaseConfigurationError rather than failing later at query time.
settings.url is the async URL the provider uses; settings.sync_url is the synchronous URL
Alembic needs. Starting the provider creates the database file's parent directory when it is
missing, so a zero-configuration default path works out of the box.
Ownership¶
Register the provider under the DatabaseProvider protocol so services depend on the contract,
not on SQLite:
from typing import Annotated
from xyberos import App, Inject
from xyberos.database import DatabaseProvider, SqliteSettings, sqlite_provider
app = App()
app.provide(DatabaseProvider, factory=sqlite_provider(SqliteSettings()), eager=True)
sqlite_provider(...) returns a zero-argument async factory. Marking it eager=True opens the
database, installs PRAGMAs, and runs a health check during app.start(), so readiness reflects a
reachable database. The provider closes itself on app.stop() because it exposes aclose().
You can also build one explicitly and hand it over; the application then owns and closes it:
database = await open_database(SqliteSettings(path="var/app.db"))
app.provide(DatabaseProvider, instance=database)
For tests, xyberos.database.temporary_database() yields a started database backed by a real
temporary file — real, not in-memory, so WAL mode and file locking behave as in production.
PRAGMAs¶
PRAGMAs are installed on connection initialization through a SQLAlchemy connect event, never
ad hoc inside a request. foreign_keys, busy_timeout, and synchronous are per-connection;
journal_mode=WAL is a database-level setting and is skipped for in-memory databases.
Transactions¶
from sqlalchemy import select
from myapp.tables import items
class ItemService:
def __init__(self, database: Annotated[DatabaseProvider, Inject()]) -> None:
self._database = database
async def list_items(self) -> list[dict[str, object]]:
async with self._database.transaction() as connection:
rows = (await connection.execute(select(items))).mappings().all()
return [dict(row) for row in rows]
transaction()opens a connection, begins a transaction, commits on success, and rolls back when the body raises. The body's exception propagates unchanged.- Keep transactions short and application-defined. Do not hold a write transaction across a network call or other slow work, because SQLite permits only one writer at a time.
- A commit-time lock failure becomes
TransientDatabaseError; another commit failure becomesTransactionError. Both preserve the original SQLAlchemy error as the cause.
Concurrency and the retry policy¶
SQLite allows one writer at a time. Readers continue under WAL. When a writer cannot take the
lock within busy_timeout_ms, SQLAlchemy raises an operational error whose message reports a
locked database.
retrying(operation) retries only that documented class of transient lock failure, with a
bounded budget and linear backoff:
async def insert_item(connection: AsyncConnection) -> int:
result = await connection.execute(insert(items).values(name="new"))
inserted = result.inserted_primary_key
if inserted is None: # typed as optional: an insert with a generated key reports one
raise AssertionError("the insert reported no primary key")
return int(inserted[0])
item_id = await database.retrying(insert_item, attempts=5, base_delay=0.05)
- Only operations that are safe to run more than once should be passed to
retrying. A retry can duplicate an external side effect; use an idempotency key when the operation is not naturally idempotent. - When the budget is exhausted the provider raises
TransientDatabaseError. It never hides an indefinitely blocked write, and there is no unbounded wait. retryingis opt-in.transaction()does not retry, so callers stay in control of side effects.
Keep the application database and the future durable journal in separate files with separate configuration, so a retrying workflow cannot lock up the application's tables.
Migrations¶
Xyberos does not build a migration engine. Alembic remains the tool, driven explicitly at deployment time rather than inside application startup:
from pathlib import Path
from xyberos.database import SqliteSettings, run_migrations
settings = SqliteSettings(path="var/app.db")
run_migrations(Path("migrations"), url=settings.sync_url) # synchronous, explicit
# or, from async code that must not block the event loop:
# await apply_migrations(Path("migrations"), url=settings.sync_url)
Alembic needs the synchronous URL because its engine is synchronous. SQLite cannot alter most
columns in place, so use op.batch_alter_table(...), which rebuilds the table; the example in
examples/sqlite_crud/migrations/ exercises a batch migration in the test suite so the rebuild
behavior is proven before it reaches production data. Run migrations with
python examples/sqlite_crud/migrate.py [path] to see the whole flow.
Testing¶
from xyberos.database import temporary_database
async def test_writes_are_atomic() -> None:
async with temporary_database() as database:
async with database.transaction() as connection:
await connection.execute(insert(items).values(name="kept"))
with pytest.raises(RuntimeError):
async with database.transaction() as connection:
await connection.execute(insert(items).values(name="lost"))
raise RuntimeError("abort")
async with database.transaction() as connection:
names = (await connection.execute(select(items.c.name))).scalars().all()
assert names == ["kept"]