Software · 25 oct 2026 · 7 min de lectura
Dos personas abren la misma página de reservas, eligen el mismo martes a las 10:00 y pulsan Reservar con unos milisegundos de diferencia. El código comprueba si el hueco está libre, lo encuentra libre las dos veces y guarda las dos. Es el fallo más habitual de los sistemas de reservas y nunca sale en una demo, porque una demo tiene un solo usuario. En el sistema de reservas que usamos nosotros, el que hay detrás de nuestra propia página para agendar llamadas, no puede pasar: no porque el código compruebe con cuidado, sino porque la base de datos rechaza la segunda reserva.
La versión obvia lee las reservas, decide que el hueco está libre e inserta la nueva. Cada paso es correcto. El problema es el hueco entre ellos: dos peticiones pueden leer antes de que ninguna escriba, y las dos ven el hueco libre.
| Momento | Petición A | Petición B |
|---|---|---|
| 1 | Lee las reservas del martes: las 10:00 están libres | |
| 2 | Lee las reservas del martes: las 10:00 están libres | |
| 3 | Inserta su reserva a las 10:00 | |
| 4 | Inserta su reserva a las 10:00 | |
| Resultado | Confirmada | Confirmada también: dos personas, un hueco |
Hacer la comprobación más rápida, añadir una caché o pasarla al navegador solo estrecha la ventana. Con carga, o con un cliente que reintenta, una ventana pequeña se sigue abriendo. La solución es dejar de pedirle a la aplicación que garantice algo que no puede ver: lo que se está escribiendo en ese mismo instante.
Dos visitantes piden a la API en Rust el mismo hueco de las 10:00; los dos INSERT llegan a PostgreSQL, donde la restricción de exclusión acepta el primero (201, reservada) y rechaza el segundo con el error 23P01, que la API devuelve como 409 SLOT_TAKEN.
PostgreSQL puede imponer por sí solo que «no haya dos reservas confirmadas que se solapen» con una restricción de exclusión. Una restricción única dice que no puede haber dos filas iguales; una de exclusión lo generaliza a cualquier operador, aquí && (se solapan) entre dos rangos de tiempo. Esta es la tabla, sacada de nuestra migración:
create extension if not exists btree_gist;
create table bookings (
id uuid primary key default gen_random_uuid(),
starts_at timestamptz not null,
ends_at timestamptz not null,
status text not null default 'confirmed'
check (status in ('confirmed', 'cancelled')),
-- ...
constraint bookings_ends_after_start check (ends_at > starts_at),
constraint bookings_no_overlap
exclude using gist (tstzrange(starts_at, ends_at) with &&)
where (status = 'confirmed')
);Repite ahora las dos peticiones. Las dos leen el hueco libre, las dos insertan, y PostgreSQL acepta la primera y rechaza la segunda con el error 23P01, exclusion_violation. La carrera sigue existiendo; simplemente ahora tiene árbitro.
Una violación de la restricción solo sirve si la aplicación la convierte en algo que una persona entienda. Nuestra API (Rust, con sqlx) traduce ese código concreto a un conflicto y deja pasar cualquier otro error de la base de datos como lo que es:
const EXCLUSION_VIOLATION: &str = "23P01";
fn slot_taken_on_overlap(error: sqlx::Error) -> ApiError {
let overlapped = error
.as_database_error()
.and_then(|database| database.code())
.is_some_and(|code| code == EXCLUSION_VIOLATION);
if overlapped {
return ApiError::slot_taken(); // 409 SLOT_TAKEN
}
error.into()
}La web decide por el estado, nunca por el mensaje: ante un 409, la página de reservas avisa de que ese hueco se acaba de ocupar y recarga los libres, así que quien reserva elige otro sin empezar de cero. Mover una reserva a otra hora pasa por las mismas comprobaciones y es una actualización bajo la misma restricción, así que tampoco puede caer encima de otra.
La restricción tiene la última palabra, no la única. Antes de insertar, la API calcula si la hora pedida se puede reservar, y esas reglas viven en la aplicación porque cambian con los ajustes y con el mundo de fuera:
Ser preciso con los límites también es diseño. Nuestra restricción protege las reservas, no el margen: el margen lo comprueba la aplicación. Dos peticiones que compiten por huecos vecinos podrían, en teoría, confirmarse más cerca de lo que pide el margen. Nunca se solaparían, que es el fallo que importa, y aceptamos ese compromiso a propósito, porque el margen es un ajuste y una restricción lo congelaría en el esquema.
Si el margen tuviera que ser absoluto, el rango de la restricción puede llevarlo: tstzrange(starts_at - interval '15 minutes', ends_at + interval '15 minutes'). El precio es una migración cada vez que cambie. Lo mismo vale para el máximo diario: es una política, que se aplica en el código, no una invariante de los datos.
Una llamada queda confirmada en cuanto se guarda. Un alquiler no: entre elegir las fechas y que el pago se complete puede pasar un minuto o dos, y en ese tiempo nadie más debería poder quedarse el hueco. En Boathous, el marketplace de alquiler de barcos que construimos, el hueco se retiene cinco minutos mientras el cliente paga, la comprobación y la retención ocurren en un solo paso atómico, y la confirmación del pago comprueba que la retención es de quien paga.
Se guarde donde se guarde la retención, importan tres cosas: comprobar y retener en una sola operación atómica, una caducidad para que un pago abandonado libere el hueco y una última comprobación al confirmar. Una variante apoyada en la base de datos es guardar la retención como una reserva con su propio estado y su caducidad, bajo la misma restricción de exclusión, para que el árbitro sea el mismo en los dos casos.
Casi todos los sistemas reales reservan más de una cosa. La restricción dice entonces que dos reservas chocan solo si son del mismo recurso y sus horas se solapan, y esa igualdad sobre una columna normal es justo lo que btree_gist hace indexable junto al rango:
exclude using gist (
boat_id with =,
tstzrange(starts_at, ends_at) with &&
) where (status = 'confirmed')Si un sistema de reservas es el corazón de lo que estás construyendo, esta es la clase de decisión que tomamos con el equipo en las primeras semanas de un proyecto de software a medida: escribir qué reglas no se pueden romper nunca y poner cada una donde nadie se la pueda saltar.
Solo si todas las reservas duran lo mismo y empiezan en la misma rejilla. En cuanto las duraciones cambian o una reserva se puede mover, dos reservas pueden solaparse sin empezar en el mismo instante y un índice único no lo ve. Una restricción de exclusión compara rangos, así que detecta cualquier solape.
Un hueco libre no tiene fila que bloquear, así que bloquear las reservas que lees no impide que alguien inserte una nueva. Habría que bloquear una fila padre, como el calendario o el barco, lo que funciona pero pone en fila todas las reservas de ese recurso y depende de que cada camino del código se acuerde de bloquear. La restricción aguanta incluso el camino que se olvida.
Sí, PostgreSQL abortaría una de las dos transacciones con un fallo de serialización (40001). Pero entonces cada escritura tiene que estar preparada para reintentar, y la regla no aparece en ningún sitio del esquema. La restricción de exclusión la escribe una vez, junto a los datos.
MySQL no tiene restricciones de exclusión. Las opciones habituales allí son bloquear una fila padre por recurso o modelar el calendario como una tabla de huecos fijos con una clave única por hueco y recurso.
IA · 26 oct 2026 · 10 min de lectura
En Fundaia, nuestra plataforma de despliegue, un despliegue fallido tiene un botón Arreglar con Claude. Cómo el agente diagnostica, reproduce y arregla dentro de un contenedor que solo puede editar ficheros, qué revisa la plataforma antes de abrir el pull request, por qué nunca lo fusiona y qué pasa cuando el agente no consigue arreglar nada.
Casos de estudio · 26 oct 2026 · 11 min de lectura
El equipo comercial de Leaderbrock trabaja por Europa y América. Cómo sustituimos una hoja de cálculo compartida, un sobre de tickets y la falta de un mapa por un backend reactivo en Java, una app con Expo y un panel web: gastos con la foto que la IA rellena y una persona presenta, una agenda en la hora del móvil y una ruta GPS que distingue un vuelo del ruido.
Casos de estudio · 26 oct 2026 · 12 min de lectura
ReservPark detecta por el Bluetooth del coche que has aparcado, te pregunta si te vas cuando vuelves a él y avisa a los conductores cercanos, y deja alquilar un garaje por horas o con abono. Cómo construimos la detección nativa, las reglas de la ubicación en segundo plano, las plazas libres de 30 minutos y unas reservas de garaje que no se pueden vender dos veces.