Módulo 6: Fronteras reales: DB, archivos, HTTP
3. La frontera de base de datos: una transacción real
Descripción
Cruzamos la primera frontera a fondo, y la abrimos por su regla más propia: la transacción. En el módulo 5 y en el 1 usaste SqliteBookingRepository y viste que la reserva sobrevive a cerrar y reabrir el archivo, pero pasaste de largo sobre por qué: la línea self._conn.commit() al final de save. Esa línea no es un detalle de higiene —es la esencia de la frontera de la base de datos—. Un motor de base de datos no escribe cada instrucción a piedra en el momento en que la ejecutas; la acumula en una transacción, un borrador de cambios que existe en suspenso hasta que decides una de dos cosas: confirmarlo (commit), y entonces se vuelve permanente y visible para el mundo; o descartarlo (rollback), y entonces desaparece como si nunca hubiera pasado. Entre el INSERT y el commit hay un limbo, y entender ese limbo es entender la frontera.
Esto importa por dos razones que esta lección demuestra con salida real. La primera: el commit es lo que hace que SqliteBookingRepository persista de verdad. Sin él, la escritura vive solo dentro de tu conexión y muere al cerrarla; con él, cruza a piedra y otra conexión la ve. La segunda: la transacción es la palanca que el módulo 7 usará para aislar tests —escribir sin confirmar y hacer rollback al final, para que ninguna prueba deje rastro—. Aquí no la usamos para aislar todavía; aquí la estudiamos como herramienta: qué hace commit, qué hace rollback, y qué ve —o no ve— otra conexión mientras la transacción está en suspenso. Con eso, la frontera de la base de datos deja de ser magia y se vuelve un mecanismo que puedes probar.
Conexión con el módulo: esta es la primera de dos lecciones sobre la frontera de base de datos. Aquí ves la transacción —commit, rollback, la visibilidad entre conexiones—; la lección 4 ve la otra decisión de esa frontera —una base :memory: contra una en archivo—. Juntas te dan el dominio completo del recurso SQLite. La frontera con el módulo 7 se respeta con cuidado: el rollback que aquí ves es la herramienta de la transacción (deshacer un borrador de cambios); usarlo como técnica para aislar cada test —envolver la prueba en una transacción y revertirla al terminar— es el módulo 7. La diferencia es la misma que entre aprender qué es un freno y aprender a manejar en ciudad: aquí conoces el freno; allá lo usas con una estrategia.
Analogía: el pedido antes de confirmar
Piensa en armar un pedido en una tienda en línea. Vas metiendo cosas al carrito: un libro, unos audífonos, un cargador. Mientras el carrito está abierto, nada es definitivo —puedes quitar el libro, cambiar los audífonos, vaciarlo entero—, y, dato clave, nadie más ve tu carrito: el inventario de la tienda no se toca, tu tarjeta no se cobra, el almacén no prepara nada. Todo eso vive en un borrador que es solo tuyo. Hay exactamente dos formas de sacar el pedido de ese limbo. Una: presionas "Confirmar compra" —el commit—: en ese instante el cobro se hace, el inventario baja, el almacén recibe la orden, y el pedido se vuelve un hecho que todos los sistemas de la tienda ven. La otra: cierras la pestaña sin confirmar —el rollback—: el carrito se descarta, no se cobró nada, el inventario nunca se movió, y es como si no hubieras entrado. El carrito abierto es la transacción; confirmar o abandonar son commit y rollback.
Una transacción de SQLite es ese carrito. Cuando haces un INSERT, metes la reserva al carrito: existe, pero en un borrador que solo tu conexión ve. Si haces commit, confirmas la compra: la fila se vuelve permanente y cualquier otra conexión la encuentra. Si haces rollback, abandonas el carrito: la fila desaparece y el archivo queda como estaba. Y mientras el carrito está abierto —entre el INSERT y el commit—, otra conexión que mire la base no ve tu reserva, igual que otro cliente no ve tu carrito. Toda esta lección es abrir el carrito, mirarlo desde adentro y desde afuera, y confirmarlo o abandonarlo, para sentir en las manos cómo la frontera de la base de datos decide qué es real y qué no.
rollback: descartar el carrito
Empecemos por lo que un doble jamás podría hacer: escribir una fila y luego deshacerla. Vamos a insertar una reserva y, antes de confirmar, hacer rollback. Después consultamos la tabla: no debe haber ninguna fila. Para tener el terreno limpio, primero creamos el esquema y lo confirmamos aparte —así la transacción que vamos a deshacer contiene solo el INSERT—.
# tests/test_db_boundary.py — el rollback descarta la escritura
import sqlite3
from reservo.sqlite_repo import SCHEMA
ROW = ("bk-1", "focus", "m-ana", "2026-03-10T09:00:00",
"2026-03-10T12:00:00", "confirmed", 6000)
INSERT = ("INSERT INTO bookings "
"(id, room_id, member_id, start, end, status, price_cents) "
"VALUES (?, ?, ?, ?, ?, ?, ?)")
def test_rollback_discards_the_write(tmp_path):
conn = sqlite3.connect(tmp_path / "reservo.db")
conn.execute(SCHEMA)
conn.commit() # el esquema queda; arrancamos limpios
conn.execute(INSERT, ROW) # escribimos, dentro de una transaccion abierta
conn.rollback() # ...y la deshacemos antes de confirmar
rows = conn.execute("SELECT id FROM bookings").fetchall()
assert rows == [] # el rollback borro la escritura
conn.close()
Léelo despacio. Creamos la tabla y hacemos commit de esa creación: el esquema es permanente. Luego insertamos la reserva —eso abre una transacción, el carrito—. Pero en vez de commit, hacemos conn.rollback(): descartamos el carrito. Cuando consultamos SELECT id FROM bookings, la tabla está vacía: la escritura se deshizo. Fíjate en lo que esto significa: la fila existió dentro de la transacción —entre el INSERT y el rollback estaba ahí para esta conexión—, pero como no la confirmamos, nunca fue real. El carrito se abandonó.
commit: confirmar la compra, y que otra conexión la vea
Ahora lo contrario: insertar, confirmar, y probar que la escritura es permanente de la forma más contundente —abriéndola desde una conexión nueva—. Si otra conexión, que no participó en la transacción, ve la fila, es porque el commit la hizo real para todo el mundo, no solo para la conexión que la escribió.
def test_commit_persists_across_connections(tmp_path):
path = tmp_path / "reservo.db"
conn1 = sqlite3.connect(path)
conn1.execute(SCHEMA)
conn1.execute(INSERT, ROW)
conn1.commit() # ahora si: permanente y visible para otros
conn1.close()
conn2 = sqlite3.connect(path) # conexion NUEVA al mismo archivo
rows = conn2.execute("SELECT id, price_cents FROM bookings").fetchall()
conn2.close()
assert rows == [("bk-1", 6000)]
conn1 inserta y hace commit; luego se cierra. conn2 es una conexión completamente nueva al mismo archivo, que nunca vio la transacción de conn1. Y sin embargo encuentra la fila ("bk-1", 6000): eso solo es posible porque el commit la escribió a piedra, al archivo compartido. El price_cents vuelve como 6000, un entero —el INTEGER cruzó la frontera intacto—. Esta es la persistencia real que en el módulo 5 viste con la reserva que sobrevivía a reabrir el archivo; ahora ves la palanca exacta que lo permite: el commit.
El limbo: la escritura sin confirmar es invisible desde afuera
La prueba más reveladora de la frontera es mirar el carrito mientras está abierto, desde otra conexión. Una escritura sin confirmar vive solo dentro de la transacción que la hizo; otra conexión ve la base como estaba antes. Probémoslo con dos conexiones abiertas a la vez: una escribe sin confirmar, la otra mira y no ve nada; luego la primera confirma, y entonces la segunda sí ve.
def test_uncommitted_write_is_invisible_to_another_connection(tmp_path):
path = tmp_path / "reservo.db"
setup = sqlite3.connect(path)
setup.execute(SCHEMA)
setup.commit()
setup.close()
writer = sqlite3.connect(path)
reader = sqlite3.connect(path)
writer.execute(INSERT, ROW) # escrito, pero SIN commit
seen = reader.execute("SELECT id FROM bookings").fetchall()
assert seen == [] # el lector NO ve la escritura sin confirmar
writer.commit() # confirmamos...
seen_after = reader.execute("SELECT id FROM bookings").fetchall()
assert seen_after == [("bk-1",)] # ...ahora si la ve
writer.close()
reader.close()
El writer inserta pero no confirma —el carrito está abierto—. El reader, en ese momento, consulta la tabla y la ve vacía: la reserva del writer es invisible para él, porque vive en una transacción sin confirmar. En cuanto el writer hace commit, la fila se vuelve real para todos, y el reader —la misma conexión, sin reabrir nada— ahora sí la encuentra. Esta propiedad tiene nombre —aislamiento de transacciones— y es una de las garantías más importantes de una base de datos: los cambios a medio hacer de una conexión no contaminan lo que ven las demás. Aquí lo observas; ejercerlo para aislar tus tests es el módulo 7.
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1), los tres tests juntos, más el del repositorio que viene abajo:
python3 -m pytest tests/test_db_boundary.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 4 items
tests/test_db_boundary.py::test_rollback_discards_the_write PASSED [ 25%]
tests/test_db_boundary.py::test_commit_persists_across_connections PASSED [ 50%]
tests/test_db_boundary.py::test_uncommitted_write_is_invisible_to_another_connection PASSED [ 75%]
tests/test_db_boundary.py::test_repository_save_persists_the_booking PASSED [100%]
============================== 4 passed in 0.03s ===============================
Cuatro verdes que retratan la transacción entera: rollback descarta, commit persiste y hace visible, y una escritura sin confirmar es invisible desde afuera. Esas tres verdades son la frontera de la base de datos —lo que la distingue de un dict que guarda el objeto y ya—.
Por qué el commit de save es lo que hace persistir a Reservo
Cerremos el círculo con Reservo. El SqliteBookingRepository.save termina con self._conn.commit(); esa línea es la que, en el flujo real, saca la reserva del limbo y la hace permanente. Probémoslo cruzando la frontera con el servicio completo: book guarda a través de save (que confirma), cerramos la conexión, y leemos con una conexión nueva.
def test_repository_save_persists_the_booking(tmp_path):
path = tmp_path / "reservo.db"
conn = sqlite3.connect(path)
repo = SqliteBookingRepository(conn)
service = BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
booking = service.book(FOCUS, ANA, START, END)
conn.close()
fresh = sqlite3.connect(path) # save hizo commit: sobrevive a reabrir
reread = SqliteBookingRepository(fresh).get(booking.id)
fresh.close()
assert reread.status == "confirmed"
assert reread.price_cents == 6000
book reserva, y su save interno hace commit; por eso, cuando cerramos conn y abrimos fresh —una conexión nueva—, la reserva sigue ahí con su estado confirmed y su precio de 6000 centavos. Si save no hiciera commit, la transacción quedaría abierta dentro de conn, y al cerrarla SQLite la descartaría con un rollback implícito —fresh no encontraría nada y get lanzaría KeyError—. El commit de save es, literalmente, lo que convierte "reservé" en "la reserva existe". Esa es la frontera de la base de datos haciendo su trabajo, y ahora sabes exactamente por qué.
Errores comunes
Olvidar el commit y creer que se guardó. Qué pasa: alguien hace conn.execute("INSERT ...") y da por hecho que la fila quedó; más tarde, otra conexión —o el mismo programa tras reiniciar— no la encuentra. Por qué pasa: dentro de la misma conexión y transacción, la escritura sí se ve, así que parece guardada. Cómo detectarlo: si la fila aparece al leerla con la conexión que la escribió pero desaparece al reabrir o desde otra conexión, falta un commit. Cómo corregirlo: una escritura no es permanente hasta confirmarla; en Reservo, save hace commit por eso. Si escribes SQL crudo en un test, confirma antes de cerrar o de leer desde otra conexión.
Creer que rollback "borra" en el sentido de un DELETE. Qué pasa: alguien piensa que rollback ejecuta un borrado de las filas insertadas. Por qué pasa: el efecto observable —la fila ya no está— se parece a un DELETE. Cómo detectarlo: pregúntate qué pasa con una fila que ya estaba confirmada antes de abrir la transacción; un rollback no la toca. Cómo corregirlo: rollback no borra nada; descarta los cambios no confirmados de la transacción actual, devolviendo la base al estado del último commit. No es un DELETE; es un "deshacer" del borrador. Esta distinción es justo lo que hace al rollback útil para aislar tests (módulo 7): revierte tus cambios sin tocar lo que ya estaba.
Probar la escritura solo dentro de la misma conexión y transacción. Qué pasa: un test inserta y verifica con la misma conexión, sin confirmar, y pasa —dando falsa confianza en que persiste—. Por qué pasa: la conexión que escribió ve sus propias escrituras no confirmadas. Cómo detectarlo: si tu test nunca cierra ni reabre la conexión, ni usa una segunda, no está probando la persistencia, solo la memoria de la transacción. Cómo corregirlo: para probar que algo persiste de verdad, confírmalo y léelo desde una conexión nueva —como en test_commit_persists_across_connections—. Es la única forma de distinguir "escrito en el carrito" de "compra confirmada".
Ejercicios
Ejercicio 1 — Predice el estado tras cada operación. Una conexión ejecuta, en orden: execute(SCHEMA), commit(), INSERT de la reserva A, commit(), INSERT de la reserva B, rollback(). Sin correr nada, di qué reservas hay en la tabla al final y por qué.
Ver solución
Al final hay solo la reserva A. Recorre las operaciones:
execute(SCHEMA)+commit(): la tablabookingsqueda creada y confirmada, vacía.INSERTde A: abre una transacción y mete A al carrito.commit(): confirma la compra; A se vuelve permanente.INSERTde B: abre una transacción nueva y mete B al carrito.rollback(): descarta el carrito actual; B desaparece, como si nunca se hubiera insertado.
El rollback del paso 5 solo afecta a la transacción abierta en ese momento —la que contiene a B—. No toca a A, que ya estaba confirmada por su propio commit en el paso 3. Por eso la regla del error común importa: rollback devuelve la base al estado del último commit, no borra lo ya confirmado. La tabla termina con [A].
Ejercicio 2 — El bug del commit faltante. Un compañero "optimiza" SqliteBookingRepository.save quitando la línea self._conn.commit() "porque hace lento el guardado". Los tests que usan una sola conexión y no la cierran siguen en verde. Explica qué test lo cazaría y qué error daría exactamente.
Ver solución
Lo cazaría cualquier test que lea la reserva desde una conexión distinta de la que la escribió, o que cierre y reabra la conexión —como test_repository_save_persists_the_booking—. Sin el commit, la escritura de save queda en una transacción abierta dentro de la conexión original. Cuando esa conexión se cierra (conn.close()), SQLite descarta la transacción sin confirmar con un rollback implícito, así que la fila nunca llega al archivo.
El test entonces abre fresh = sqlite3.connect(path), una conexión nueva, y hace get(booking.id). El SELECT ... WHERE id = ? no encuentra ninguna fila, fetchone() devuelve None, y el repositorio, al ver row is None, lanza KeyError con el id de la reserva. El test fallaría con KeyError, no con un AssertionError —la reserva no es que esté mal, es que no existe—. Los tests que usan una sola conexión sin cerrarla no lo cazan porque esa conexión ve sus propias escrituras no confirmadas: el INSERT está en su carrito abierto, visible solo para ella. La moraleja: para proteger la persistencia hay que probarla cruzando de verdad la frontera —otra conexión, o cerrar y reabrir—, no leyendo desde el mismo carrito que escribió.
Ejercicio 3 — Aislamiento entre dos conexiones. Dos conexiones, a y b, están abiertas a la misma base con una reserva ya confirmada. a ejecuta INSERT de una segunda reserva pero no confirma. En ese instante, ¿cuántas reservas ve a al hacer SELECT? ¿Cuántas ve b? Explica.
Ver solución
ave dos reservas. La conexión que hizo elINSERTve sus propias escrituras no confirmadas: la reserva ya confirmada más la que acaba de insertar en su carrito abierto. Paraa, ambas existen.bve una sola reserva. La conexión que no participó en la transacción deave la base como estaba en el últimocommit: solo la reserva ya confirmada. La segunda, queainsertó sin confirmar, es invisible parab—vive en el limbo de la transacción dea—.
Esto es exactamente el aislamiento que demostró test_uncommitted_write_is_invisible_to_another_connection: los cambios a medio hacer de una conexión no contaminan lo que ven las demás hasta que se confirman. Si a hiciera commit, entonces b (sin reabrir) pasaría a ver dos; si a hiciera rollback, b seguiría viendo una y a volvería a ver una también. Esta garantía es la que el módulo 7 aprovecha para aislar tests: cada prueba trabaja dentro de su propia transacción, y al revertirla con rollback no deja rastro para las demás. Aquí la observas como propiedad; allá la usas como estrategia.
Resumen y siguiente paso
En esta lección cruzaste la frontera de la base de datos por su regla más propia: la transacción. Con el carrito antes de confirmar entendiste que una escritura vive en un limbo hasta que decides commit (confirmar: permanente y visible para todos) o rollback (descartar: como si nunca hubiera pasado). Lo probaste con salida real: el rollback dejó la tabla vacía, el commit hizo que una conexión nueva viera la fila, y una escritura sin confirmar fue invisible para otra conexión hasta confirmarla. Y cerraste el círculo con Reservo: el commit de save es, literalmente, lo que convierte "reservé" en "la reserva persiste", como lo mostró la prueba de cerrar y reabrir la conexión.
Antes de avanzar deberías poder: explicar qué hacen commit y rollback y en qué se diferencia un rollback de un DELETE; demostrar la persistencia leyendo desde una conexión nueva; y explicar por qué una escritura sin confirmar es invisible desde afuera (el aislamiento de transacciones).
Lo que sigue es la otra gran decisión de esta frontera. En la lección 4 ponemos lado a lado las dos formas de tener SQLite real: una base :memory: —instantánea, sin disco, que muere con la conexión— y una en archivo —que persiste entre conexiones pero cuesta más de diez veces en velocidad—. Vas a ver, con una medición real, cuánto pesa el disco, y a decidir cuál usar según lo que cada test necesita probar. Con la transacción de esta lección y la elección de recurso de la siguiente, tendrás el dominio completo de la frontera de la base de datos.
Recursos
sqlite3— Control de transacciones (documentación de Python) — la referencia exacta de cómosqlite3manejacommit,rollbacky cuándo se abre una transacción; el fundamento de todo lo que esta lección demuestra.sqlite3.Connection.commit(documentación de Python) — el detalle de qué hacecommity por qué sin él la escritura no persiste entre conexiones, el corazón del ejercicio 2.sqlite3.Connection.rollback(documentación de Python) — la referencia derollback, la palanca que aquí usas como herramienta y que el módulo 7 usa como técnica de aislamiento.- Documentación de pytest — Cómo usar directorios y archivos temporales (
tmp_path) — el fixture con el que creamos la base de datos en un archivo temporal para probar elcommitcruzando conexiones; a fondo en la lección 5.