Varias bases de datos¶
from snakeorm import SnakeColumn, SnakeModel, snake_auto, snake_model, snake_session, snake_str
@snake_model(table="events", database="analytics") # tied to another connection
class Event(SnakeModel):
id: SnakeColumn[int] = snake_auto()
kind: SnakeColumn[str] = snake_str()
analytics = snake_session("analytics") # opens the connection by name
database es declarativo y estático: se lee del modelo, no se decide en runtime. No cambia una
coma del SQL — y tampoco encamina nada. Nada de la sesión ni de la query lo lee: una sesión manda a
la conexión con la que SE ABRIÓ, así que abrir una contra default y consultar Event por ella
emite SQL perfectamente válido contra la base equivocada, sin error y sin aviso. Quien SÍ lee
database es snake_link(), para rechazar una relación que cruza conexiones, y el --database de
la CLI, para elegir las tablas y el historial de migraciones de una conexión. Darle el modelo a la
sesión correcta es cosa tuya: el ORM no tiene encaminador, porque encaminar exigiría adivinar en
ejecución lo que esta etiqueta ya afirma al importar.
Declarar las conexiones¶
La default sale de DATABASE_URL / SNAKEORM_DSN / piezas DB_*. Las demás van por nombre, en
SNAKEORM_DSN_<NAME>:
DATABASE_URL=postgresql://user:pass@localhost/app
SNAKEORM_DSN_ANALYTICS=postgresql://user:pass@other-host/events
Si falta un DSN, se dice EXACTAMENTE cuál
There is no DSN for connection 'analytics': set the environment variable
SNAKEORM_DSN_ANALYTICS (or put it in the .env). Connection 'default' is the only
one resolved from the DB_* pieces.
Un DSN que se resuelve a ciegas acaba conectando a la base equivocada — peor que no conectar.
Cada conexión lleva su propio motor¶
Una conexión con nombre no está atada a PostgreSQL. El motor se LEE, en este orden: un
SNAKEORM_BACKEND_<NOMBRE> explícito (o DB_BACKEND para la conexión por defecto) y luego el
esquema del propio DSN —postgresql://, mysql://, sqlite://—, porque un esquema es una
declaración y leerlo no es adivinar.
SNAKEORM_DSN_ANALYTICS=postgresql://user:pass@other-host/events # engine read off the scheme
SNAKEORM_DSN_ARCHIVO=sqlite:////var/data/archivo.db # SQLite: FOUR slashes = absolute
SNAKEORM_DSN_LEGACY=mysql://user:pass@old-host:3307/ventas # MySQL, likewise
SNAKEORM_BACKEND_LEGACY=mysql # only when you want to say it out loud
La cuarta barra del DSN de SQLite no es una errata. La tercera es el separador de la URL, así que
sqlite:///var/data/archivo.db nombraría la ruta RELATIVA var/data/archivo.db, resuelta contra el
directorio desde el que se arrancara el proceso. Una ruta absoluta lleva cuatro.
Un DSN sin esquema es PostgreSQL, y eso es una derivación y no un defecto a ciegas: la forma
host=x dbname=y es la sintaxis de palabras clave de libpq, y no la escribe ningún otro motor.
Un motor mal escrito se rechaza, no se redondea al más parecido
Abrir sesión¶
from snakeorm import snake_session
session = snake_session() # "default"
analytics = snake_session("analytics")
La fábrica monta driver y dialecto desde la configuración.
Migraciones por conexión¶
Cada conexión CON NOMBRE tiene su directorio (migrations/<database>/) y su numeración propia; la
default se queda en migrations/ a secas, así que un proyecto de una sola base nunca ve cambiar la
estructura bajo los pies. Sin el filtro, makemigrations intentaría crear TODAS las tablas en CADA
base.
Relaciones entre bases: no¶
@snake_model(table="orders") # default
class Order(SnakeModel):
id: SnakeColumn[int] = snake_auto()
event_id: SnakeColumn[int] = snake_int()
event: SnakeToOne[Event] = snake_to_one(event_id) # Event lives in "analytics"
snake_link() falla al arrancar, a propósito:
Relationship Order.event crosses databases: Order lives in 'default' and Event in
'analytics'. There is neither a foreign key nor a JOIN possible across connections.
Move one of the two, or store the identifier as a plain column and resolve it
yourself with two queries.
Sin la guarda emitiría un FOREIGN KEY contra una tabla que no existe en esa base. Si necesitas
cruzar los datos, hazlo en la aplicación: dos consultas y un dict.
Un pool que sobrevive a un despliegue¶
Prestar conexiones es la parte fácil de un pool. La difícil es qué pasa cuando la conexión está podrida y tú no lo sabes:
from snakeorm import PostgresDialect, SnakeSession, psycopg_pool
pool = psycopg_pool(
dsn,
maximum=20,
pre_ping=True, # check the pulse before lending
recycle_seconds=1800, # drop connections older than 30 min
timeout_seconds=5, # wait up to 5 s for one to free up
)
with pool.connection() as driver:
session = SnakeSession(driver, PostgresDialect())
pool.connection() es la entrada: te presta el driver y lo devuelve siempre, también si el
bloque revienta.
| Perilla | El problema que quita |
|---|---|
pre_ping |
La base se reinicia (despliegue, failover) y el pool sigue repartiendo conexiones muertas. Sin esto, el error no sale en el pool: sale en la primera consulta del usuario. Cuesta un viaje de ida y vuelta por préstamo. |
recycle_seconds |
El wait_timeout de MySQL cierra las ociosas por su cuenta y el pool no se entera. Reciclar por edad no pregunta: previene. |
timeout_seconds |
Con el pool agotado, psycopg2 lanza PoolError al instante. Un pico de tráfico revienta aunque una conexión fuera a liberarse 50 ms después. Con plazo, se espera; agotado, sale un SnakePoolTimeout, que tiene nombre propio porque pide otra acción (más pool, menos carga) que un fallo puntual. |
Las tres van apagadas por defecto: cuestan viajes o tiran conexiones sanas, y ese precio lo decide quien conoce su despliegue, no la librería.
Si tres conexiones seguidas vuelven muertas, acquire() se rinde con SnakePoolTimeout en vez de
girar tirando y pidiendo: a esas alturas la que está caída es la base, no la conexión.
El mismo pool, en async: AsyncSnakePool¶
AsyncSnakePool es el espejo, con las mismas tres salvaguardas y el mismo tope de descartes. En
async el pool importa MÁS, no menos: un servidor con cien tareas concurrentes abre cien conexiones si
nadie las reparte, y una conexión de Postgres cuesta memoria en el servidor aunque esté sin hacer
nada. Con los drivers sobre hilo (MySQL, SQLite) cuesta además un hilo por cabeza.
No hay un gemelo de psycopg_pool() para él, porque el pool asíncrono es agnóstico del motor: le das
tres corrutinas —cómo pedir prestada, cómo devolver, cómo cerrarlo todo— y él pone las reglas encima.
import asyncio
from snakeorm import AsyncDriver, AsyncPsycopgDriver, AsyncSession, AsyncSnakePool, PostgresDialect
free: asyncio.Queue[AsyncDriver] = asyncio.Queue()
async def borrow() -> AsyncDriver:
return free.get_nowait() if not free.empty() else await AsyncPsycopgDriver.connect(dsn)
async def give_back(driver: AsyncDriver) -> None:
free.put_nowait(driver)
async def close_all() -> None:
while not free.empty():
await free.get_nowait().close()
pool = AsyncSnakePool(
borrow,
give_back,
close_all,
pre_ping=True,
recycle_seconds=1800,
timeout_seconds=5,
)
async with pool.connection() as driver:
session = AsyncSession(driver, PostgresDialect())
Dos diferencias con el hermano síncrono, y solo dos:
- Mientras espera a que se libere una conexión, cede el bucle de eventos (
asyncio.sleep) en vez de bloquear el hilo. Es lo que hace que una tarea esperando conexión no pare a las otras noventa y nueve. - El
close()del driver prestado devuelve la conexión en vez de cerrarla, y es idempotente. Lo segundo no es un detalle: unclose()repetido —el de la sesión más unfinallyde fuera— metería la MISMA conexión dos veces en la cola, y a partir de ahí dos tareas creerían tener cada una la suya.
Siguiente: DB-first y scaffolding.