Dynamic Registration¶
Register databases whose DSNs are discovered at runtime — per-tenant shards resolved from a service registry, credentials rotated by a secret manager — without maintaining an application-side engine registry.
Bootstrap dynamic-first¶
An explicitly empty databases: {} mapping is valid; every database arrives later:
from dbkit import AsyncDatabase, DatabaseTarget, Query, sql
db = AsyncDatabase.from_config({
"databases": {}, # dynamic-first
"max_databases": 500, # LRU cap on dynamic registrations (optional)
"max_engines": 64, # LRU cap on live engines/pools (optional)
"evict_lru_engines": True,
"defaults": {
"pool": {"size": 10, "max_overflow": 0, "timeout_seconds": 2.0},
"retry": {"attempts": 2},
"circuit_breaker": {"enabled": True},
},
})
await db.start()
ensure_database — the per-request pattern¶
Idempotent: a lock-free no-op when the config is unchanged (a few µs), so call it in front of every query routed by runtime topology:
shard = my_topology.resolve(tenant_id) # -> name + DSN, however you store it
await db.ensure_database(shard.name, {
"primary": {"url": shard.dsn, "required": False},
"concurrency": {"reads": 20},
})
rows = await db.fetch_all(
LIST_ORDERS, {"tenant": tenant_id},
target=DatabaseTarget(database=shard.name, role="read"),
)
A changed config re-registers in place: old engines are disposed (idle-only — in-flight work finishes), and the next query connects with the new settings. This covers host moves and password-only rotations with no restart.
Explicit control¶
replaced = await db.register_database("tenant-42", config) # register or replace
await db.unregister_database("tenant-42") # engines + resilience state freed
async with db.database_scope("migration-src", config) as target: # scoped lifetime
rows = await db.fetch_all(EXPORT, target=target)
# auto-unregistered — pools released even on error
Scope database_scope to a unit of work, never to a request
Engines/pools are created inside the block and disposed on exit — a per-request scope
recreates connections every request and defeats pooling. Long-lived services use
ensure_database/register_database and keep shards registered.
Guarantees¶
- Copy-on-write config swap — readers never observe a partially updated database map; the query hot path takes no registration lock (~350 ns regardless of shard count).
- Admission control — registering past
connection_budget.maximum_per_process(withenforce_at_startup: true) raises before anything is swapped in. max_databasesLRU — least-recently-ensured dynamic databases are fully purged (engines, concurrency limiter, circuit breakers, replica-selector entry) beyond the cap. Statically configured databases are never evicted.- Replicas participate immediately: registered replicas update the built-in
round-robin/weighted selectors (custom selectors may implement
set_replicas).
API reference¶
ensure_database
async
¶
ensure_database(
name: str,
config: DatabaseConfig | Mapping[str, Any],
*,
connect: bool | None = None,
) -> bool
Idempotent :meth:register_database: act only when name is missing or its
config changed. Returns True when a (re)registration actually happened.
The unchanged path is lock-free and cheap (build + compare one frozen config, a few µs), so callers routing by runtime topology can call this in front of every query::
await db.ensure_database(shard_id, {"primary": {"url": dsn}})
rows = await db.fetch_all(query, params,
target=DatabaseTarget(database=shard_id, role="read"))
A changed config (rotated host, credentials, pool settings) re-registers in place: old engines are disposed and the next query connects with the new settings — no restart needed, including password-only rotations.
register_database
async
¶
register_database(
name: str,
config: DatabaseConfig | Mapping[str, Any],
*,
connect: bool | None = None,
) -> bool
Register (or replace) a named database at runtime.
For topologies discovered after :meth:start — e.g. shard DSNs resolved per
tenant from a service registry — so callers do not need to maintain their own
engine registries. config is a :class:DatabaseConfig or the same dict
shape as one entry under the top-level databases mapping.
The database map is swapped copy-on-write, so concurrent readers always see a
complete mapping. Replacing an existing name first disposes its engines and
resets its limiter/breakers, so the new settings fully apply. Returns True
when an existing entry was replaced.
connect forces (or suppresses) eager engine creation; the default follows
primary.required when the facade is already started. Engine creation is
lazy in SQLAlchemy, so this never blocks on the network unless the pool is
warmed explicitly.
unregister_database
async
¶
Remove a dynamically (or statically) registered database and dispose its
engines, limiter, and breakers. Returns False if name is unknown.
In-flight operations against the database finish on their checked-out
connections (idle-only disposal); new calls raise DatabaseRoutingError.
database_scope
async
¶
database_scope(
name: str,
config: DatabaseConfig | Mapping[str, Any],
*,
connect: bool | None = None,
) -> AsyncIterator[DatabaseTarget]
Register name for the duration of the block, then unregister it.
Yields a primary-routed :class:DatabaseTarget for convenience::
async with db.database_scope("tenant-42", {"primary": {"url": dsn}}) as target:
rows = await db.fetch_all(query, params, target=target)
Scope this to a unit of work, never to a request. Engines/pools are
created on first use inside the block and disposed on exit — wrapping every
HTTP request in a scope recreates connections each time and defeats pooling
entirely. Intended uses: tests, migrations, one-off tenant batch jobs, or an
ad-hoc query against a shard the process does not normally serve. Long-lived
services should call :meth:register_database once per discovered shard and
keep it registered.
from_dict
classmethod
¶
Build one database config from a plain dict — the same shape as an entry under
the top-level databases mapping. Used by dynamic registration (§22.4).