jfastframework
Documentación English PyPI GitHub

Elegir datastores

Cada datastore es un plugin. Habilita lo que el servicio necesita, y el cliente, el health check y el contenedor llegan todos juntos.

[plugins]
enabled = ["observability", "database", "cache", "qdrant", "mongo"]
Plugin Store Provee Extra Offset de puerto
database PostgreSQL (+pgvector) db.engine, db.sessionmaker, db.databases [db] +1 (y +2, +5, +6 para más instancias)
cache Redis cache, cache.client [cache] +3
mongo MongoDB mongo.client, mongo.db [mongo] +4
qdrant Qdrant qdrant.client [qdrant] +7 (HTTP), +8 (gRPC)

Correr más de uno es normal. Los datos relacionales con foreign keys van en PostgreSQL; los historiales de chat y los payloads scrapeados están más cómodos en Mongo. El error es adoptar un segundo store antes de que el primero deje de alcanzar — cada uno es una cosa más que respaldar, monitorear y restaurar a las 3am.


Instancias de base de datos con nombre

El plugin database guardaba un solo DSN. Ese único campo es la razón de que no hubiera réplica de lectura, ni base de datos por tenant, ni shard — no eran cuatro features faltantes, era una estructura faltante: una base de datos que el servicio pueda nombrar.

[plugin.database.connections.primary]
dsn_env = "JFAST_DB_DSN"

[plugin.database.connections.replica]
dsn_env = "JFAST_DB_REPLICA_DSN"
read_only = true
pool_size = 4

Omitir connections es exactamente lo mismo, escrito más corto. Sin ese bloque hay una sola instancia llamada default, configurada por los campos de arriba y leyendo JFAST_DB_DSN. Todos los proyectos que ya existen, todos los templates generados y ctx.require("db.engine") siguen funcionando sin cambiar nada.

Setting Por conexión Valor por defecto
dsn / dsn_env sí JFAST_DB_DSN, si no JFAST_DB_<NAME>_DSN
read_only sí false
pool_size, max_overflow, pool_timeout, pool_recycle, pool_pre_ping sí el valor a nivel plugin
include_infra, image, port_offset, database, user sí el valor a nivel plugin

dsn_env es el nombre de una variable, nunca un valor. Una conexión sin dsn ni dsn_env lee JFAST_DB_<NAME>_DSN, así que una conexión replica no necesita ninguna línea para encontrar JFAST_DB_REPLICA_DSN.

Cuál es "la" base de datos

db.engine y db.sessionmaker siguen significando la instancia que se puede escribir: la que se llama default, o primary, o la primera que no sea read_only. Usa default_connection para decirlo explícitamente. Una configuración donde todas las conexiones son read_only se rechaza al arrancar — nada en ese servicio podría escribir, y enterarte en el primer POST es enterarte tarde.

El mapa completo se publica como db.databases:

databases = ctx.require("db.databases")
databases.names            # ("primary", "replica")
databases.engine("replica")
databases.sessionmaker()   # la instancia por defecto

jfast describe --json las lista, con la variable que lee cada una y nunca su valor, más max_connections — la cantidad de conexiones al servidor que un proceso puede abrir entre todas las instancias. Ese número es el que tiene que entrar bajo el max_connections de PostgreSQL, y es el que la gente descubre durante un incidente.

Contenedores

Cada instancia con include_infra declara su propio contenedor, su propio volumen y su propia variable de password:

postgres          8011:5432   POSTGRES_PASSWORD           postgres_data
postgres-replica  8012:5432   POSTGRES_REPLICA_PASSWORD   postgres_replica_data

Un password compartido haría que una filtración en cualquier lado fuera una filtración en todos. Un servicio tiene diez puertos y los otros plugins ya reclaman algunos (cache +3, mongo +4, qdrant +7 y +8, gRPC +9), así que las bases de datos usan +1, +2, +5 y +6 — pide una quinta y el generador lo dice en vez de chocar. Usa include_infra = false para una instancia administrada en otro lado, que es el caso normal de una réplica en la nube.

Kubernetes no genera ninguna base de datos, a propósito (ver Kubernetes), pero cada instancia enlazada llega al pod como su propio secretKeyRef: JFAST_DB_DSN desde db-dsn, JFAST_DB_REPLICA_DSN desde db-replica-dsn.


Separación de lecturas y escrituras

[plugin.database]
read_write_split = true

Las lecturas van a una instancia read_only, las escrituras al primario:

from jfastframework.plugins.builtin.database import DbSession, ReadSession

@router.get("/invoices")
async def list_invoices(session: ReadSession):
    ...

@router.post("/invoices")
async def create_invoice(session: DbSession):
    ...

DbSession y ReadSession son session_dependency y read_session_dependency con scope="function" ya aplicado, que es lo que hace que el commit ocurra antes de enviar la respuesta -- ver Transacciones. Sin réplica configurada, ReadSession va a la misma base que DbSession. Úsala en todos lados desde el principio y la separación llega después como un bloque de configuración.

La parte que no es opcional: el pin

Una réplica va atrasada. Guardas una fila, rediriges, lees de la réplica, y la fila todavía no está. Es un 404 intermitente que aparece bajo carga y nunca se reproduce en una laptop, porque una laptop no tiene réplica. Ningún flag lo arregla: una separación sin pin es un bug que ya publicaste, no una feature.

Por eso, después de una escritura, las lecturas de ese cliente van al primario durante pin_window segundos (5 por defecto).

Cómo viaja el pin. Como un token que lleva el cliente — una cookie jfast_rw y un header X-JFast-Read-Pin — no como una entrada en una tabla de este proceso. Un redirect puede caer en cualquier réplica del servicio, y un pin que el siguiente proceso no puede ver es un pin que silenciosamente no está. Los navegadores llevan la cookie gratis; un cliente de API que no guarda cookies devuelve el header.

Por qué confiar en un valor que controla el cliente es seguro acá. La única dirección en la que el cliente puede empujar es hacia el primario, que nunca está desactualizado. El costo de un valor falsificado es capacidad del primario, así que el valor se recorta a pin_window desde ahora: nadie puede quedarse pineado para siempre.

Qué dispara el pin. Un método no seguro (POST, PUT, PATCH, DELETE) que respondió por debajo de 400. Esto se tiene que decidir antes de que el handler retorne: una sesión hace commit durante el teardown de la dependencia, que corre cuando los headers de la respuesta ya salieron, así que el commit no puede ser lo que setea la cookie. Para la escritura rara detrás de un GET — un upsert perezoso, un contador — dilo:

from jfastframework.plugins.builtin.database import mark_write

@router.get("/reports/{id}")
async def report(request: Request, session: DbSession):
    await touch_last_seen(session, id)
    mark_write(request)

La aproximación cuesta que un POST que no escribió nada pinee una ventana de lecturas. Eso es carga, no incorrección, y pin_on_unsafe_methods = false lo apaga para un servicio que marca sus escrituras a mano.

Por qué cinco segundos, y qué sería mejor. Un standby sano en la misma red está a milisegundos; cinco segundos igual cubren un pico de checkpoint o un WAL sender trabado, y pinear a un cliente cinco segundos después de escribir es despreciable en una carga dominada por lecturas. La respuesta exacta es basada en LSN: registrar pg_current_wal_lsn() en la escritura, compararlo con el pg_last_wal_replay_lsn() de la réplica, y dejar de pinear en cuanto la réplica alcanzó. Eso cuesta un round trip por lectura y solo funciona contra un standby físico real, así que es el camino de mejora y no el default.

Las escrituras no pueden llegar a una réplica. Dos guardas, porque una de ellas no puede ver SQL crudo. Una sesión de lectura se niega a terminar con cambios ORM pendientes (ReadOnlySessionError), y toda conexión asyncpg read_only setea default_transaction_read_only = on, así que un INSERT colado por session.execute(text(...)) lo rechaza PostgreSQL mismo.

Sharding no es esto

Las instancias con nombre son la base sobre la que se construirá un mapa de shards — resolución de claves, migraciones por shard y queries entre shards son un trabajo aparte, y nada de eso era expresable mientras el plugin tenía un solo DSN. No está construido.

Una base de datos por tenant sí: ver Multi-tenancy.


Paginación

BaseRepository pagina de tres formas, y la elección es sobre lo que cuesta la respuesta, no sobre estilo.

Llamada Costo Qué te da
paginate() un COUNT + un LIMIT/OFFSET total exacto, acceso aleatorio
paginate(with_total=False) un LIMIT limit + 1 has_more, acceso aleatorio, sin COUNT
paginate_keyset(after=...) un range scan has_more + next_cursor, costo plano a cualquier profundidad

OFFSET n obliga a la base a recorrer y descartar n filas antes de devolver nada, así que la página 200 cuesta 200 páginas de trabajo. Una página keyset es un range scan desde un punto conocido y cuesta lo mismo donde sea que caiga. El canje es el acceso aleatorio: hay una página siguiente, no una página 40.

class MessageRepository(BaseRepository[Message]):
    model = Message
    order_by = ("-edited_at",)


page = await repository.paginate_keyset(limit=50)
while page.has_more:
    page = await repository.paginate_keyset(limit=50, after=page.next_cursor)

Columnas de orden que aceptan NULL

edited_at, last_message_at, archived_at — las columnas por las que ordena un feed suelen ser justo las que están en NULL hasta que algo pasa. Eso está soportado, y conviene saber qué hace el framework al respecto, porque la versión ingenua de la paginación keyset pierde filas sin decirlo.

Chocan dos hechos. NULL compara UNKNOWN contra todo, incluso contra sí mismo, así que un cursor que lleva un NULL no matchea ninguna fila: la página siguiente vuelve vacía, has_more es False, y el recorrido informa que la tabla se terminó. Y los backends no coinciden en dónde va el bloque de NULL — PostgreSQL los ordena al final ascendente y al principio descendente, SQLite los pone al principio en ambas direcciones — así que el mismo código pierde un conjunto distinto de filas en cada uno. En una tabla de 200 filas con 40 claves de orden en NULL, eso eran 180 filas inalcanzables en SQLite y 40 en PostgreSQL, sin error en ninguno de los dos casos.

Por eso todo orden que arma el repositorio fija el bloque de NULL al final:

ORDER BY messages.edited_at DESC NULLS LAST, messages.id DESC

y toda comparación de keyset está escrita contra esa fijación — un empate sobre un NULL es IS NULL, y un paso más allá de un valor real también admite el bloque de NULL que viene detrás. Un cursor cuyo primer valor es None es un cursor legítimo que apunta a ese bloque, así que la codificación por la que pases un cursor para meterlo en una URL tiene que sobrevivir a un None; JSON lo hace, un ",".join(...) ingenuo no.

Dos consecuencias que vale la pena decir:

La alternativa que se consideró era rechazar la consulta: lanzar cuando una columna de orden acepta NULL. Es un cambio más chico y convierte la pérdida de datos en un error ruidoso, pero rechaza "las ediciones más nuevas primero", que no es un error que nadie esté cometiendo. Devolver un subconjunto en silencio nunca fue una opción.

Qué cuesta la corrección acá

paginate_keyset tiene costo plano — el mismo trabajo en la página 2 y en la 2.000 — mientras las columnas de orden sean NOT NULL. Una columna nullable renuncia a eso. El predicado gana un disyunto OR c IS NULL, y PostgreSQL no puede convertir una disyunción en un rango de índice: degrada el range scan a un index scan con filtro y vuelve a leer desde el principio del índice.

Medido sobre 200k filas, con un índice en (edited_at, id), una página de 50 filas a profundidad 100k:

Columna de orden Plan Buffers
NOT NULL Index Cond: ROW(edited_at, id) > ROW(...) 4
nullable, cursor dentro del bloque de NULL Index Cond: edited_at IS NULL AND id > ... 5
nullable, cursor sobre un valor real Filter: ... OR edited_at IS NULL, 80k filas descartadas 840
la misma página por OFFSET index scan, 100k filas descartadas 1049

Así que sigue siendo la más barata de las tres y ya no es plana. Si eso importa más que la comodidad, haz la columna NOT NULL con un centinela (edited_at DEFAULT created_at) y el range scan vuelve. Partir el scan en el rango sin NULL más el bloque terminal de NULL lo recuperaría sin el centinela; eso no está construido.

Los empates no son el mismo problema

paginate_keyset agrega la clave primaria al orden, así que el orden es total y ninguna fila puede quedar a caballo entre dos páginas. paginate no lo hace: ordena solo por order_by, así que las filas que empatan en la clave de orden vuelven en la secuencia que haya elegido el planner, y el bloque de NULL es un empate grande. La pertenencia sigue siendo correcta — cada fila está en exactamente una página — pero si necesitas que la secuencia dentro de un grupo empatado sea estable entre requests, pon tú una columna única en order_by.


Cache

get_or_set es el camino de lectura. Todo lo demás en la fachada es una primitiva a la que recurres cuando read-through no es la forma correcta.

cache = ctx.require("cache")

report = await cache.get_or_set(
    f"report:{tenant_id}",
    lambda: build_report(tenant_id),   # cualquier corrutina sin argumentos
    ttl=300,
)

Hace tres cosas que get + set a mano no hacen.

Sobrevive a que el cache esté caído. Cualquier falla del backend dentro de get_or_set degrada a llamar al loader. Un reinicio de Redis le cuesta a esas requests una recomputación, no un 500. Esto es lo que vuelve cierto el health_critical=False del plugin en vez de aspiracional — y es cierto solo en este camino:

Llamada Redis inalcanzable
get_or_set(...) devuelve el valor del loader
get / set / delete / exists / publish lanza excepción

Las primitivas lanzan a propósito. Un servicio que no distingue "no hay nada cacheado" de "Redis desapareció" sirve respuestas viejas para siempre y nadie se entera. Si las llamas directo en un camino de request, el try/except es tuyo.

Los errores que lanza el loader siempre se propagan. Degradar ante una caída del cache es el objetivo; degradar ante una query rota es cómo un servicio devuelve respuestas incorrectas en silencio.

Colapsa los misses concurrentes. Cuando una key caliente expira bajo carga, el read-through ingenuo manda todas las requests en vuelo a la base de datos al mismo tiempo. Acá el primer caller que falla toma un lock corto en Redis y recomputa; los demás consultan su resultado hasta stampede_wait y después se rinden y cargan por su cuenta.

El trade, dicho sin vueltas: el lock cuesta un round trip extra en cada miss, y un caller que pierde la carrera espera hasta stampede_wait antes de recurrir al loader. Ese límite es lo que lo hace seguro — un loader trabado cuesta trabajo duplicado, nunca una fila de requests trabadas.

La alternativa, recomputar temprano antes de que expire el TTL, se ahorra el round trip pero necesita envolver cada valor en un sobre que cargue su expiry lógico. Eso cambia lo que se guarda, y este Redis normalmente se comparte con algo que no es un servicio JFast. Mantener los valores cacheados como documentos JSON planos valía más que el round trip.

[plugin.cache]
stampede_wait = 2.0      # 0 apaga el lock por completo
stampede_lock_ttl = 10   # techo de cuánto puede retenerlo un caller

Está contado. Con el plugin metrics habilitado, el cache registra cuatro counters en el registry compartido y /metrics los sirve junto con todo lo demás:

Counter Significado
cache_hits_total lecturas servidas desde el cache
cache_misses_total lecturas que no encontraron nada guardado
cache_errors_total operaciones que el backend rechazó
cache_stampede_suppressed_total ejecuciones del loader evitadas esperando a otro caller

metrics es una dependencia blanda (after, no requires): un servicio que quiere un cache no debería tener que cargar prometheus-client para tenerlo. Deshabilita metrics y los counters pasan a ser no-ops en silencio.

TTL

ttl=None significa "usa default_ttl", así que no puede significar además "nunca expira". ttl=0 es esa vía de escape:

await cache.set("feature-flags", flags, ttl=0)   # hasta que algo lo borre

Un ttl negativo lanza ValueError en el punto de llamada y no en el round trip, para que el mensaje pueda nombrar la key.

Dos cosas que parecen bugs y no lo son

publish no aplica el prefijo de keys. Todos los demás métodos namespacean su key; los nombres de canal son un contrato con quien más esté en este Redis — muchas veces una app Laravel que jamás oyó hablar de nuestro prefijo. Renombrar el canal en silencio rompería justamente la interoperación para la que existe el Redis compartido. Namespacear canales es tarea del plugin channels, bajo su propio setting prefix.

get devuelve el string crudo cuando el valor no es JSON. Misma razón: este servicio no es el único que escribe. Una key que escribió otro sistema vale la pena devolverla como string, no vale la pena lanzar una excepción.


Búsqueda vectorial: pgvector o Qdrant

El plugin rag habla contra un protocolo VectorStore, así que el backend es una línea de config. Cómo ingerir, buscar y responder con él -- tenants, búsqueda híbrida, ingesta incremental -- está en RAG y búsqueda vectorial; aquí solo se elige el store.

[plugins]
enabled = ["observability", "database", "rag"]

[plugin.rag]
store = "pgvector"       # el default
collection = "rag_chunks"
dimensions = 1536

Cambiar a Qdrant:

[plugins]
enabled = ["observability", "qdrant", "rag"]

[plugin.rag]
store = "qdrant"

El servicio rag, la forma de SearchHit y los scores no cambian -- cada store normaliza a similitud coseno en [0, 1]. Lo que sí cambia: Qdrant aquí no tiene búsqueda híbrida (texto completo + vectores), y el servicio cae a búsqueda vectorial.

Cuál

pgvector Qdrant
Costo operativo ninguno — es la base de datos que ya corres un segundo servicio que correr y respaldar
Escala cómodo hasta unos pocos millones de chunks mucho más allá de eso
Búsqueda híbrida sí, texto completo + vectores no (necesita vectores dispersos)
Row-level security sí, como cualquier tabla no — el filtro del store es el único
Filtrado tenant, documentos, metadata tenant, documentos, metadata, indexados
Cuantización no sí

Empieza con pgvector. Pasa a Qdrant cuando choques con un muro específico que puedas nombrar — complejidad de los filtros, tiempo de build del índice, memoria. "Quizá escale mejor" no es ese muro.

Elegir mal falla ruidosamente

Elegir pgvector sin el plugin database, o qdrant sin el plugin qdrant, revienta al arrancar y con el arreglo en el mensaje:

rag store 'qdrant' needs the 'qdrant' plugin. Add "qdrant" to [plugins].enabled.

No en la primera búsqueda, en producción, un viernes.

Un store propio -- Weaviate, pgvecto.rs, lo que sea -- implementa el protocolo descrito en RAG: un store propio.


Dimensiones del embedding

El error que cuesta una tarde: dimensions tiene que coincidir con el modelo de embedding. nomic-embed-text es 768; mxbai-embed-large es 1024; text-embedding-3-small es 1536. El plugin compara al arrancar las dimensions del embedder con las de la tabla, pero cambiarlo después sigue significando volver a embeber cada documento que ya ingestaste. Decídelo antes del primer ingest.


Enums: qué mitad de la garantía estás comprando

Enum parece una decisión y son dos. Las dos respuestas importan, y los defaults no te dan el par que la mayoría asume.

class Status(enum.Enum):
    pending = "pending"
    done = "done"


status: Mapped[Status] = mapped_column(Enum(Status, native_enum=False))

Esa columna es un VARCHAR común. No se crea ningún CHECK. create_constraint viene en False desde SQLAlchemy 1.4, y native_enum=False solo apaga el tipo ENUM de PostgreSQL — no pone nada en su lugar. Nada fuera de la aplicación frena un valor inválido:

-- with native_enum=False and nothing else
CREATE TABLE t (s VARCHAR(7))

Las tres opciones, y lo que cuesta cada una:

Almacenamiento Se puede escribir un valor inválido Agregar un miembro
Enum(Status) (default) tipo nativo status_enum no — lo rechaza el servidor ALTER TYPE ... ADD VALUE, una migración
Enum(Status, native_enum=False) VARCHAR(n) sí — desde cualquier cliente que no sea el ORM nada; deployas el código
Enum(Status, native_enum=False, create_constraint=True) VARCHAR(n) + CHECK no una migración que reescribe el CHECK
-- with create_constraint=True
CREATE TABLE t (s VARCHAR(7), CONSTRAINT status_enum CHECK (s IN ('pending', 'done')))

Usa native_enum=False sin constraint para un conjunto que todavía se mueve — estados, tipos, cualquier cosa que cambie por una decisión de producto. Agregar un miembro es un deploy de código y nada más, que es toda la razón para resignar el tipo nativo. A cambio, acepta que una sesión de psql, un COPY masivo u otro servicio sobre la misma base pueden escribir "pendign" y la columna lo va a tomar. Valida en el borde, donde llega el valor, y trata los valores desconocidos como un caso real al leer — un ValueError saliendo de Status(row.status) es un 500 que parece un bug en el lugar equivocado.

Agrega create_constraint=True cuando algo que no es este servicio escribe la tabla, o cuando un valor incorrecto es un problema de corrección y no de presentación. Pagas una migración por miembro, igual que con el tipo nativo — pero un CHECK es más barato de cambiar que un ENUM de PostgreSQL, que ni siquiera puede eliminar un valor.

Ten en cuenta que autogenerate no detecta de forma confiable los cambios de miembros de un enum, en ninguna de las dos direcciones. Elijas la que elijas, esa migración se escribe a mano.


Deploy

Los plugins de datastore habilitados aportan sus contenedores al archivo de compose generado, automáticamente:

jfast deploy compose --stdout

Deshabilita cache y el contenedor de Redis desaparece de la siguiente generación. Ese es el punto de derivar la infraestructura del grafo de plugins en vez de mantenerla en paralelo.

Los secretos se quedan en el entorno. El compose generado los referencia con la forma fail-fast de compose, así una contraseña faltante detiene el stack en vez de arrancar PostgreSQL abierto de par en par:

POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}