How to manage engines and connection pools¶
Your first query builds one engine, registers it, and never mentions it again. That is fine for a script. A service has to decide how big the pool is, when it closes, and what happens when a second warehouse needs an engine of its own.
This page is about that lifecycle. For the credentials and settings a particular warehouse needs, see How to configure your warehouse backend.
An Engine owns one ADBC connection
pool and the dialect for a warehouse. You build it once with
create_engine(), then run queries through it.
An AsyncEngine is its sibling,
built with create_async_engine() from the same configs and
carrying the same dialect. Each section below covers both. Which kind you get is
fixed by which constructor you called, so an engine is never switched between
modes.
Two ways to use an engine¶
There are two patterns, and you can mix them in the same application.
The direct engine pattern keeps a reference to the engine and calls it
yourself. It mirrors SQLAlchemy: create_engine(...) hands you an engine, and
engine.execute(query) runs a query
through its pool.
from adbc_poolhouse import SnowflakeConfig
from semolina import (
SemanticView,
Metric,
Dimension,
create_engine,
)
class Sales(SemanticView, view="sales"):
revenue = Metric()
country = Dimension()
engine = create_engine(
SnowflakeConfig(
account="xy12345.us-east-1",
user="svc_analytics",
password="...",
database="analytics",
warehouse="compute_wh",
)
)
query = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
)
cursor = engine.execute(query)
for row in cursor.fetchall_rows():
print(row.country, row.revenue)
The named registry pattern registers an engine under a name and lets the
query resolve it. It mirrors Django’s database aliases: register once at startup,
then .execute() finds the engine for you. A query with no .using() clause
resolves the "default" engine. register=True builds and registers in one
call, under the connection name:
from semolina import create_engine
create_engine("default", register=True)
# No .using() -> resolves the "default" engine
cursor = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.execute()
)
for row in cursor.fetchall_rows():
print(row.country, row.revenue)
The registry pattern is the better fit for web applications, where the engine is created once at startup and endpoints query without passing it around. The direct pattern suits scripts and notebooks where you already hold the engine.
Build an engine from a config object or a connection name¶
create_engine() accepts either an adbc-poolhouse config
object or the name of a .semolina.toml connection section. The dialect is
derived from the config type, so you never select a backend by hand. What comes
back is the matching subclass, one of
SnowflakeEngine,
DatabricksEngine, or
DuckDBEngine. Annotate against
Engine rather than the subclass, since
that is what the factory’s return type declares.
Pass a config object when credentials come from a vault, a secrets manager, or your own code. The example below is Snowflake; the fields each backend takes are in How to configure your warehouse backend.
from adbc_poolhouse import SnowflakeConfig
from semolina import create_engine
engine = create_engine(
SnowflakeConfig(
account="xy12345.us-east-1",
user="svc_analytics",
password="...",
database="analytics",
warehouse="compute_wh",
)
)
Pass a connection name to read settings from a [connections.<name>] section of
.semolina.toml instead:
from semolina import create_engine
engine = create_engine(
"default"
) # reads [connections.default]
create_engine() with no argument is the same as create_engine("default").
Point at a different file with create_engine("default", config_path="config/warehouse.toml").
create_async_engine() has the same signature and accepts the same
two argument forms:
from adbc_poolhouse import SnowflakeConfig
from semolina import create_async_engine
# From a config object
engine = create_async_engine(
SnowflakeConfig(
account="xy12345.us-east-1",
user="svc_analytics",
password="...",
database="analytics",
warehouse="compute_wh",
)
)
# Or from a .semolina.toml connection name
engine = create_async_engine("default")
Note the missing await. create_async_engine() is an ordinary call you can make at
import time or outside a running event loop. It does block briefly: building the pool opens
one connection to the warehouse, the source the pooled connections are cloned from, so a bad
credential fails here rather than on the first query. Teardown is the half that is awaited;
see Manage the engine lifecycle below. Async support needs the semolina[async] extra;
see Installation.
Size the pool¶
A pool keeps pool_size connections open and hands them out one query at a time. When
all of them are busy, it opens up to max_overflow more for the burst and closes each
one when it comes back. A query that finds every connection busy, overflow included,
waits up to timeout seconds for one to be returned and then fails.
Pool sizing lives on the config object, for async pools as much as synchronous ones.
The config classes carry pool_size (steady-state connections), max_overflow
(burst capacity above pool_size), timeout, and recycle. For Snowflake and
Databricks the defaults are 5 and 3, so up to 8 concurrent connections:
from adbc_poolhouse import SnowflakeConfig
from semolina import create_engine
engine = create_engine(
SnowflakeConfig(
account="xy12345.us-east-1",
user="svc_analytics",
password="...",
database="analytics",
warehouse="compute_wh",
pool_size=10,
max_overflow=5,
timeout=30,
recycle=1800,
)
)
Set the same fields in a .semolina.toml section to size a pool you build by
name:
[connections.default]
type = "snowflake"
account = "xy12345.us-east-1"
user = "svc_analytics"
password = "..."
database = "analytics"
warehouse = "compute_wh"
pool_size = 10
max_overflow = 5
recycle = 1800
The pool parameters control connection behaviour:
Parameter |
Default |
Description |
|---|---|---|
|
|
Number of connections kept open and reused |
|
|
Extra connections allowed above |
|
|
Seconds to wait for a connection before raising an error |
|
|
Seconds before a connection is replaced with a fresh one |
Size the pool per process, for the number of queries in flight at once in that process.
Under FastAPI, sync def handlers run in a threadpool that holds 40 threads by default,
so that is how many queries one process can have in flight; the number of uvicorn workers
does not enter into it. Each worker process builds its own pool, so the warehouse sees at
most workers × (pool_size + max_overflow) connections from the service. Set
max_overflow to 50–100% of pool_size for traffic spikes. A recycle of 1800
seconds keeps connections from going stale through a quiet period.
Both pool kinds read these fields from the config you pass, so an async pool is sized the same way a synchronous one is. Nothing extra is needed to size it, and there is no async-only tuning knob.
Warning
The checkout timeout raises SQLAlchemy’s TimeoutError, not Python’s
When every connection is busy and none frees up within timeout seconds, the
checkout fails with sqlalchemy.exc.TimeoutError. That class derives from
SQLAlchemyError, not from the builtin TimeoutError, so a bare
except TimeoutError: does not catch it.
Import it explicitly to map pool exhaustion onto a 503:
from sqlalchemy.exc import TimeoutError as PoolTimeout
try:
cursor = query.execute()
except PoolTimeout:
raise HTTPException(
status_code=503,
detail="No warehouse connection available",
)
This applies to async engines too. The async pool offloads the same checkout to a
worker thread, so the same exception surfaces from aexecute().
DuckDB is the one backend that sizes itself from what you point it at. An in-memory
database pins pool_size to 1 and refuses anything larger; a file-backed path
defaults to 5, like the other backends, and can be raised. Use a file whenever you need
concurrent connections, including on the async path, where a single connection
serializes every query behind one slot. DuckDB has the reason and
the error you get for asking an in-memory database for more.
The pool is also your concurrency bound. adbc-poolhouse gives each async pool a
capacity limiter sized to pool_size + max_overflow, so that many queries can be in
flight and the rest wait for a slot. Semolina adds no second bound of its own – no
semaphore, no worker count to tune. Raise or lower concurrency by changing
pool_size and max_overflow. Wrapping your own semaphore around aexecute()
stacks a second limit on top of the pool’s and generally just lowers throughput below
what you configured.
Open a raw connection¶
When you need to run something the query builder does not cover, check a
connection out of the engine’s pool with
engine.connect(). It is a context
manager, so the connection returns to the pool on exit:
with engine.connect() as conn:
cur = conn.cursor()
cur.execute("SHOW VIEWS")
rows = cur.fetchall()
Handle a warehouse you can’t reach¶
Semolina raises SemolinaConnectionError wherever it opens a
connection and the driver can’t. That covers building the engine, since create_engine()
and create_async_engine() open the pool’s source connection, and every checkout after
that: engine.connect(), the checkout .execute() and aexecute() start with, and
introspect(). The driver’s own error is attached as __cause__, so its message is
still in the traceback:
from adbc_poolhouse import DuckDBConfig
from semolina import SemolinaConnectionError, create_engine
try:
engine = create_engine(
DuckDBConfig(
database="/data/missing.duckdb", read_only=True
)
)
except SemolinaConnectionError as e:
print(e)
# Could not connect to the warehouse: INTERNAL: IO Error: Cannot open
# database "/data/missing.duckdb" in read-only mode: database does not exist
print(type(e.__cause__).__name__)
# InternalError
Once a connection is open, a statement the warehouse rejects is not a connection failure.
It raises the driver’s own adbc_driver_manager.Error subclass, unwrapped. Pool
exhaustion is the third case: it never reaches the driver and raises
sqlalchemy.exc.TimeoutError, as the warning in Size the pool describes.
How to run Semolina inside FastAPI maps all three onto HTTP status codes.
Manage the engine lifecycle¶
Create the engine at application startup and close it at shutdown. An
Engine is a context manager, so one with
block covers both ends: register=True registers the engine on the way in, and leaving
the block unregisters that name and disposes the pool on the way out.
from adbc_poolhouse import SnowflakeConfig
from semolina import create_engine
with create_engine(
SnowflakeConfig(
account="xy12345.us-east-1",
user="svc_analytics",
password="...",
database="analytics",
warehouse="compute_wh",
pool_size=10,
),
register=True,
):
... # the application runs here
The registration name is the connection name, and the connection name defaults to
"default". A config object carries no section name, so the engine above is registered
as "default", while create_engine("analytics", register=True) is registered as
"analytics". Pass a string such as register="reports" to choose the name yourself. A
name that is already taken raises ValueError and leaves the engine holding it alone,
exactly as a hand-written register() call does.
An AsyncEngine is an async context
manager, and the async with form is otherwise the same:
from semolina import create_async_engine
async with create_async_engine("default", register=True):
... # the application runs here
Note the missing await on the construction. Construction is the same ordinary call
described in Build an engine from a config object or a connection name, so there is
nothing to await. Disposing the pool closes live driver connections, and the async engine
offloads that work rather than block the event loop during shutdown, so the awaited half
is the block’s exit. That block clears the
async registry, which is a separate store – see
Async engines live in a separate registry.
Write the steps out when you need to¶
The same lifecycle written out is a build, a registration, and two teardown calls. Nothing is deprecated here – the block is shorthand for exactly this:
from adbc_poolhouse import SnowflakeConfig
from semolina import register, unregister, create_engine
# Startup
engine = create_engine(
SnowflakeConfig(
account="xy12345.us-east-1",
user="svc_analytics",
password="...",
database="analytics",
warehouse="compute_wh",
pool_size=10,
)
)
register("default", engine)
# ... application runs ...
# Shutdown
unregister("default")
engine.dispose()
The shutdown order is the part to keep. unregister()
removes the engine from the registry so no new query resolves it, and
engine.dispose() then closes the pool
and the ADBC source connection behind it. Reverse the two and the disposed engine is still
reachable by name: the next query resolves it, hands it work, and fails inside the driver
with ProgrammingError: INVALID_ARGUMENT: [Driver Manager] Database is not initialized,
a message that never mentions disposal. The with block runs the two in that order, and
disposes the pool even when the block is left by an exception.
Dispose an engine once. One you opened with with is disposed by the block, so do not
call dispose() on it as well.
The async longhand uses the async trio and awaits the disposal:
from semolina import (
create_async_engine,
register_async_engine,
unregister_async_engine,
)
# Startup -- an ordinary call
engine = create_async_engine("default")
register_async_engine("default", engine)
# ... application runs ...
# Shutdown -- awaited
unregister_async_engine("default")
await engine.dispose()
The method is dispose(), awaited, rather than an adispose(). That follows one naming
rule across Semolina’s async surface: an async-only object names its methods like the
synchronous one, because the object already tells you the call is awaited.
AsyncEngine has connect() and dispose(), and
AsyncSemolinaCursor has fetchall() and into(), all
awaited. The a prefix appears only where a name would otherwise be ambiguous or where
Python names the method: aexecute() on a query, which also has a synchronous
execute(), and aclose() on the async cursor, which is what
contextlib.aclosing() calls.
Reach for the longhand when the engine outlives any one block (a module-level engine a
whole process shares, or one opened in one function and closed in another), and when a
single engine answers to several names. The block undoes only its own registration: it
drops the one name create_engine gave it, and a name you added with
register() is yours to remove, because the engine cannot know
which of several names you meant.
Warning
Dispose the engine rather than reaching into engine._pool yourself.
dispose() closes the underlying ADBC source connection as well as the pool, and
it picks the right teardown call for the pool kind. On an async engine the pool’s own
close() is a coroutine, so calling it without awaiting closes nothing and leaks
the pool silently.
Look up a registered engine¶
get_engine() returns the engine registered under a name, so you
can reach it without keeping your own reference:
from semolina import get_engine
engine = get_engine("default")
Call it with no argument (or None) to get the "default" engine – the same
lookup .execute() performs when a query has no .using() clause:
engine = get_engine() # the "default" engine
To close an engine at shutdown without tracking your own reference, look it up, unregister it, then dispose it:
from semolina import get_engine, unregister
engine = get_engine("reports")
unregister("reports")
engine.dispose()
If no engine is registered under the name, get_engine raises
SemolinaEngineNotRegisteredError listing the names that are
available. It is a ValueError too, so code written for an earlier release still catches
it.
Async engines live in a separate registry¶
register_async_engine(),
get_async_engine(), and
unregister_async_engine() are the async trio, and they
operate on their own store. create_async_engine(..., register=True) writes to that
store too, never to the synchronous one:
from semolina import (
create_async_engine,
get_async_engine,
register_async_engine,
unregister_async_engine,
)
register_async_engine(
"reports", create_async_engine("reports")
)
# get_async_engine() with no argument resolves "default"
engine = get_async_engine("reports")
unregister_async_engine("reports")
await engine.dispose()
The two registries are separate, which has two consequences.
One name can hold a sync engine and an async engine at the same time, which is
what you want when the same warehouse is queried from both a batch script and a request
handler. And get_async_engine never falls back to the sync store: a name registered
only with register() raises
SemolinaEngineNotRegisteredError when the async path looks it
up. The error names the async registration function, so a lookup that fails this way
tells you which call you skipped rather than failing later inside query execution.
Query several warehouses with .using()¶
Register engines under different names to query multiple warehouses, or to use different credentials for different workloads:
from adbc_poolhouse import SnowflakeConfig
from semolina import create_engine
# Production engine -- large warehouse, for dashboard queries
create_engine(
SnowflakeConfig(
account="xy12345.us-east-1",
user="svc_dashboard",
password="...",
database="analytics",
warehouse="large_wh",
pool_size=20,
max_overflow=10,
),
register="default",
)
# Reporting engine -- small warehouse, for scheduled reports
create_engine(
SnowflakeConfig(
account="xy12345.us-east-1",
user="svc_reports",
password="...",
database="analytics",
warehouse="small_wh",
pool_size=3,
),
register="reports",
)
A config object has no connection name to reuse, so each of these names is given as a string. Neither engine is held in a variable: Close every engine at shutdown looks them up by name when it is time to close them.
Use .using() on a query to pick which engine to run against:
from semolina import SemanticView, Metric, Dimension
class Sales(SemanticView, view="sales"):
revenue = Metric()
country = Dimension()
# Uses the "default" engine (implicit)
cursor = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.execute()
)
# Uses the "reports" engine (explicit)
cursor = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.using("reports")
.execute()
)
Engine resolution is lazy – it happens at .execute() time, not when
.using() is called. You can build queries before any engine is registered.
On the async path the same .using() clause resolves against the async registry:
async with await (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.using("reports")
.aexecute()
) as cursor:
rows = await cursor.fetchall_rows()
So a name you use with aexecute() has to be in the async registry, put there by
register_async_engine() or by create_async_engine(..., register=...). Registering
"reports" synchronously and then reaching for it from aexecute() raises rather than
silently running your query somewhere unexpected.
To drive multiple named engines from one TOML file, define a section per connection and build each by name:
[connections.default]
type = "snowflake"
account = "xy12345.us-east-1"
user = "svc_dashboard"
password = "..."
database = "analytics"
warehouse = "large_wh"
[connections.reports]
type = "snowflake"
account = "xy12345.us-east-1"
user = "svc_reports"
password = "..."
database = "analytics"
warehouse = "small_wh"
from semolina import create_engine
create_engine("default", register=True)
create_engine("reports", register=True)
register=True reuses each section name, so the registry keys and the TOML sections
cannot drift apart.
Close every engine at shutdown¶
Engines opened in a with block close themselves. The ones you registered without a
block are closed by name at shutdown, one at a time:
from semolina import get_engine, unregister
for name in ("default", "reports"):
engine = get_engine(name)
unregister(name)
engine.dispose()
Async engines are closed in the same loop, one await at a time:
from semolina import (
get_async_engine,
unregister_async_engine,
)
for name in ("default", "reports"):
engine = get_async_engine(name)
unregister_async_engine(name)
await engine.dispose()
If your application runs both kinds, close both. Neither loop sees the other’s engines, even where the names match.
See also¶
Your first query – the one-engine version this page takes apart
Serve a dashboard endpoint – opening and closing an engine in a FastAPI lifespan
How to configure your warehouse backend – the credentials and TOML fields each warehouse needs
How to run Semolina inside FastAPI – opening an async engine in a FastAPI lifespan, and choosing an engine per endpoint with
.using()How to fetch results in bulk – stream rows with
for row in cursor:orasync forInstallation – install the
semolina[async]extraConfiguration file reference – the
.semolina.tomlfile format