Connection & Registry¶
Functions for managing database connections and the global model registry. connect() registers a (optionally named) connection pool. reset_engine() tears everything down. The registry helpers control schema creation and the identity map. Sessionized routing is exposed via ferro.engines.session(name) / ferro.Session. See the Connections & Databases guide.
Session
dataclass
¶
A unit of work: one connection scope, one identity map, one settings set.
settings are session settings — Postgres settings (GUCs) that Ferro
applies to every operation this session runs, so queries are scoped without
a per-query where:
async with engines.session(settings={"myapp.tenant_id": "acme"}):
# Postgres sees myapp.tenant_id = 'acme' for every statement this
# session sends, which is what a row-level-security policy reads
# back — inside a transaction() block or, as here, outside one.
invoices = await Invoice.where(lambda invoice: invoice.paid).all()
They are validated when the Session is constructed and again when it is
entered, which is when the effective set — this session's settings merged
over anything it inherits from an enclosing session — is snapshotted. See
EngineManager.session for the full contract, including what an operation
outside a transaction sends and how inherited settings behave off Postgres.
A value not known until partway through the session's life — resolved
from an auth chain, say — is set with set_config rather than at open;
see there for the mid-transaction and identity-map-eviction contract.
Source code in src/ferro/session.py
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 | |
Attributes¶
connection_name = None
class-attribute
instance-attribute
¶
session_id = None
class-attribute
instance-attribute
¶
settings = None
class-attribute
instance-attribute
¶
effective_settings = field(default_factory=dict, init=False, repr=False, compare=False)
class-attribute
instance-attribute
¶
Functions¶
__post_init__()
¶
Source code in src/ferro/session.py
__aenter__()
async
¶
Source code in src/ferro/session.py
__aexit__(exc_type, exc, tb)
async
¶
close()
async
¶
Close this session and release its runtime state.
Under settings_delivery="connection" this is also where the
connection the session pinned goes back to the pool — after Ferro
resets exactly the settings keys this session set on it, and
nothing else. (Never RESET ALL: that would also wipe the
search_path the pool installs on every connection.) A session
under the default transaction delivery has nothing to release and
this costs no round trip.
Safe to call from a different asyncio context than __aenter__.
Repeated calls are no-ops.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the ambient session in this asyncio context does not match this handle (same-context lifecycle misuse), or if session-scoped transactions are still open, or if resetting a pinned connection failed — in which case the session is closed regardless and the connection was discarded rather than returned to the pool. |
Source code in src/ferro/session.py
set_config(key, value)
async
¶
Mutate this session's settings while it is open.
Everything you can settings= at open, you can also set later, for the
case where the value isn't known until partway through a request — an
auth chain that resolves the tenant only after checking a token, say:
async with engines.session() as session: # no settings yet
tenant = await resolve_tenant_from_auth_header(request)
await session.set_config("myapp.tenant_id", tenant)
# every query from here on is scoped to `tenant`
invoices = await Invoice.where(lambda invoice: invoice.paid).all()
Deep in a call stack, reach the session through ferro.current_session()
rather than threading it through every function signature:
async def resolve_tenant_and_scope(request) -> None:
tenant = await resolve_tenant_from_auth_header(request)
await ferro.current_session().set_config("myapp.tenant_id", tenant)
When the new value takes effect, precisely. Any operation or
transaction() STARTED after set_config returns sees the new value
— this holds for any task, not just the one that called set_config,
because the change is committed before set_config's await
returns. An operation or transaction already in flight when
set_config runs keeps running under the scope it started with —
that is the existing per-operation-atomicity guarantee, working as
intended, not something set_config reaches back into. The one place
that needs (and gets) special handling is a transaction() block
already open in the SAME task that calls set_config — there, the
very next statement in that same transaction sees the new value
immediately, rather than waiting for the transaction to end and a new
one to begin (there is no race to resolve here: the two are
sequential code in one task, by construction):
async with engines.session() as session:
async with transaction():
await session.set_config("myapp.tenant_id", "acme")
# this SELECT, in the SAME transaction, already sees it
rows = await Invoice.where(lambda invoice: invoice.paid).all()
set_config also evicts this session's identity map on a real change:
an instance get()-ed under the old scope is never handed back under
the new one — the next get() for that primary key refetches instead.
A call that doesn't actually change the value (the key already holds
it) is a true no-op: no database round trip, no identity-map eviction.
Validation matches engines.session(settings=...) exactly (see there
for the full rationale): key and value must be str, and key must
be a dotted custom setting name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
str
|
Dotted custom Postgres setting name (e.g. |
required |
value
|
str
|
The setting's new value. |
required |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
This session is not open ( |
TypeError
|
|
ValueError
|
|
Source code in src/ferro/session.py
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 | |
query(model_cls)
¶
__init__(connection_name=None, session_id=None, settings=None, _token=None, _enter_context=None, _enter_task=None, _close_lock=None)
¶
connect(url, auto_migrate=False, name=None, default=False, pool=None, *, identity_map=True, migrate_updates=False, migrate_destructive=False)
async
¶
Establish a connection to the database.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
The database connection string (e.g., "sqlite:example.db?mode=rwc"). |
required |
auto_migrate
|
bool
|
If True, automatically create tables for all registered models.
Existing tables are left completely untouched — whatever their shape —
unless |
False
|
name
|
str | None
|
Optional connection name. Omitted connections register as "default". |
None
|
default
|
bool
|
If True, make this named connection the default for unqualified operations. |
False
|
pool
|
PoolConfig | None
|
Optional per-connection pool configuration, including
|
None
|
identity_map
|
bool
|
If True (default), sessions opened on this connection keep an identity
map so the same primary key maps to a single Python instance within a session.
Identity maps are session-scoped: operations outside a session never cache or
dedup instances. If False, loads on this connection return fresh instances even
inside a session (lower memory use; no |
True
|
migrate_updates
|
bool
|
If True, additionally update existing tables to match the
registered models. Implies
After any schema change, the connection pool is refreshed so no cached statement can observe the pre-migration schema. |
False
|
migrate_destructive
|
bool
|
If True, additionally drop live columns that no
longer exist on the model (never whole tables). Implies
|
False
|
Raises:
| Type | Description |
|---|---|
ValueError
|
A connection with this name (or a default connection,
when |
For schema changes beyond these (renames, primary-key changes, complex
transforms), use the Alembic bridge — see docs/guide/migrations.md.
Source code in src/ferro/__init__.py
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 | |
PoolConfig
¶
Bases: BaseModel
Connection pool settings for a named Ferro connection.
settings_delivery chooses how sessions on this connection get their
session settings (the Postgres GUCs row-level-security policies read) onto
the database. The two modes send different SQL for the very same session:
async with engines.session(settings={"myapp.tenant_id": "acme"}):
invoices = await Invoice.where(lambda invoice: invoice.paid).all()
"transaction" (the default) wraps the query in a transaction of its own
and scopes the value to it::
BEGIN
SELECT set_config($1, $2, true) -- 'myapp.tenant_id', 'acme'
SELECT "invoice".* FROM "invoice" WHERE "paid"
COMMIT
"connection" sets the value once, on a connection it keeps for the
session, and then sends your statements bare::
SELECT set_config($1, $2, false), set_config($3, $4, false)
-- 'myapp.tenant_id', 'acme', 'ferro.pinned_keys', 'myapp.tenant_id'
SELECT "invoice".* FROM "invoice" WHERE "paid"
... -- every later query, no wrap
SELECT set_config($1, NULL, false), set_config($2, NULL, false)
-- at session close: exactly the keys above, reset
The difference that matters is true vs false — Postgres'
is_local flag. true makes the value die with the transaction, which
is why the default is safe even behind a transaction pooler like PgBouncer,
where consecutive statements can land on different backends. false
makes it live for the whole database session, which is only safe if that
session belongs to one Ferro session and nobody else — hence the pinned
connection.
So settings_delivery="connection" is a promise about your deployment:
this pool talks to Postgres directly. Ferro never guesses it. A pooler
is invisible to its clients, and guessing wrong means one tenant's value
answering another tenant's query.
What you get for the promise: no per-operation wrap at all, so a
settings-bearing session costs one extra round trip for its whole life
instead of about two per operation outside transaction().
What it costs, and there are four costs worth knowing before you opt in.
One connection per scoped session. A settings-bearing session holds a
pool connection from its first operation until it closes, so no more
settings-bearing sessions can run at once than the pool has
connections. Number max_connections + 1's first query waits for a
connection, exactly like any other pool checkout. Size the pool for peak
concurrent scoped sessions.
One session, one connection, therefore one thing at a time. Everything
a pinned session does is serialized: two sibling tasks sharing the session
run one after the other, and while a transaction() block is open a
sibling task's operation waits for it rather than running inside it.
That is the honest meaning of a session that owns a single connection —
the same fact as the cap above, seen from inside one session. Operations
in the same task inside a transaction() block are unaffected; they
already own the block.
A marker check on release while anyone is pinned. The pool verifies that a connection coming back is not still carrying a session's settings. While no session is pinned this costs nothing at all. While any session on the pool is pinned, every connection release on that pool performs one marker check — including releases by settings-less sessions and by sessionless operations.
Schema changes wait for scoped sessions. connect(migrate_updates=…)
and migrate() refresh the pool afterwards, and a refresh cannot finish
until every connection comes back — including the pinned ones. Migrating
while long-lived tenant sessions are open therefore blocks until they
close; Ferro warns, naming how many it is waiting on. Run schema changes
before opening tenant-scoped sessions.
Sessions without settings never pin: same statements, same connections, no wrap, no serialization.
Two smaller behaviours worth knowing:
- A session that opens with no
settingsand gains them later viaset_confighas nothing pinned yet, so it pins on its next operation and applies the values then. - Closing a session resets its keys to the value the connection started
with. For an ordinary custom setting that is the empty string, which is
what fail-closed policies rely on. If an operator has set a default with
ALTER ROLE ... SET myapp.tenant_id = ...orALTER DATABASE ... SET ..., resetting brings that value back rather than clearing it — so never configure a tenancy key as a role or database default.
Source code in src/ferro/__init__.py
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | |
Attributes¶
model_config = ConfigDict(frozen=True)
class-attribute
instance-attribute
¶
max_connections = PydanticField(default=5, ge=1)
class-attribute
instance-attribute
¶
min_connections = PydanticField(default=0, ge=0)
class-attribute
instance-attribute
¶
settings_delivery = 'transaction'
class-attribute
instance-attribute
¶
Functions¶
set_default_connection(name)
¶
Source code in src/ferro/_core.pyi
create_tables(using=None)
async
¶
Manually create the missing tables for registered models on a connected
engine. A table that already exists is left completely untouched; altering
existing tables belongs to migrate(updates=True).
Compiles and pushes the current registry SchemaIR to the Rust runtime
before delegating to the Rust create entrypoint, so a model defined after
connect() (and thus absent from the connect-time snapshot) is still
created. The runtime emits each CREATE TABLE from this SchemaIR via the
shared emitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
using
|
Named connection to create tables on, or None for the default. |
None
|
Source code in src/ferro/__init__.py
migrate(using=None, updates=True, destructive=False)
async
¶
Manually run the auto-migrate pass against a connected engine.
Compiles and pushes the current registry SchemaIR to the Rust runtime, then delegates to the Rust migrate entrypoint.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
using
|
Named connection to migrate, or None for the default. |
None
|
|
updates
|
If True (default), add missing columns and reconcile
type, nullability, and foreign-key definition drift (see |
True
|
|
destructive
|
If True, also drop live columns absent from the model. Implies |
False
|
Source code in src/ferro/__init__.py
clear_registry()
¶
Reset the compiled/registered schema state.
Delegates to the Rust core (which clears the Rust model registry and the
pushed SchemaIR modelset) and additionally clears the Python join-table
registry. That registry must be reset here because
connect/create_tables/migrate compile the full registry via
compile_registry_schema_ir(): a join table left behind by a prior run
would be re-created with foreign keys to tables that no longer exist —
tolerated by SQLite but rejected by Postgres (relation ... does not
exist). (#153)
The Python model registry is intentionally not cleared here: clearing
the Rust registry while keeping the declared Python models is what allows
cold re-hydration after reset_engine (see
tests/test_enum_cold_hydration.py). Callers that want a full
Python-side reset use REGISTRY.reset_for_test().
Each purged join table's compiled SchemaIR envelope is evicted with it —
Registry.clear_join_tables owns that agreement (#153): a lingering
join envelope would let a future assemble step resurrect the stale join
table.
Source code in src/ferro/__init__.py
evict_instance(model, pk, *, using=None, session=None)
¶
Remove one instance from the active scope's identity map.
model is a model class, its qualified identity, or an unambiguous
bare class name (ambiguity raises with the candidates listed).
Public wrapper around the FFI evict_instance (FF-D D3): resolves the
route once via resolve_operation_scope, then passes it through. Model
instance methods (save/delete/refresh) call the FFI symbol
directly with their already-resolved route instead of going through this
wrapper, so a route is never resolved twice for one operation.