Todos los artículos

Disponibilidad en tiempo real: el problema difícil detrás de cualquier sistema de reservas

Publicado 13 sept 20236 min de lectura
  • Ingeniería
  • Lueira
Disponibilidad en tiempo real: el problema difícil detrás de cualquier sistema de reservas

Ideas clave

  • La disponibilidad es un agregado calculado, no un valor guardado: depende de acciones del equipo, reservas de clientes, el estado del material, ediciones de horario y holds que caducan, a menudo dentro del mismo segundo.
  • Consultar el estado en vivo al leer suele ser más seguro que mantener una tabla de disponibilidad precalculada, porque una caché solo es tan correcta como su camino de invalidación más débil.
  • Si cacheas, ata la invalidación al recurso y la ventana temporal exactos que cambiaron: la inconsistencia entre tienda, backoffice y vistas de reporting destruye la confianza del equipo muy rápido.

En junio escribí sobre cómo Lueira gestiona el momento en que alguien confirma de verdad una reserva: instructor, material y franja horaria bloqueados juntos en una transacción atómica, o nada en absoluto. Aquel artículo trataba sobre el commit. Este trata sobre todo lo que tiene que ser cierto antes de que ese commit tenga sentido: el número que ve un cliente, o una persona del equipo, que dice que un recurso está "disponible" u "ocupado" en un momento dado. Ese número resulta ser una de las cosas más difíciles de acertar de forma consistente en toda la plataforma. En cierto modo es más difícil que la propia transacción de reserva, porque, a diferencia de una transacción, nunca se queda quieto.

La disponibilidad no es un hecho, es un objetivo móvil

El instinto, la primera vez que modelas esto, es tratar la disponibilidad como una propiedad que puedes leer directamente de un registro: este monitor de esquí está libre a las 10, este par de esquís está en stock, esta plaza en una clase colectiva todavía tiene hueco. En la práctica, "disponible" es un agregado calculado a partir de varias fuentes de verdad independientes y concurrentes, y cualquiera de ellas puede cambiar la respuesta:

  • Alguien del equipo bloquea manualmente una franja en el calendario del backoffice, porque un instructor ha llamado diciendo que está enfermo o necesita salir antes.
  • Un cliente completa una reserva online en la tienda, consumiendo la última plaza o el último par de botas de una talla.
  • Un artículo se marca como roto o se envía a reparar a media mañana, sacándolo del stock alquilable.
  • Alguien edita la plantilla de horario semanal recurrente —mueve una clase, cambia el aforo, añade una franja nueva— y eso se propaga hacia adelante a todas las semanas futuras que heredan de esa plantilla.
  • Un hold de un checkout abandonado caduca, liberando una plaza o un artículo que parecía comprometido durante los últimos diez minutos pero que nunca llegó a convertirse en una reserva de verdad.

Un martes tranquilo, estas cosas casi nunca chocan entre sí. Un sábado por la mañana con mucho ajetreo en el mostrador de alquiler, con la tienda online también aceptando reservas y un instructor reorganizando su día, varias de ellas pueden ocurrir dentro del mismo segundo. El sistema tiene que dar una única respuesta consistente, sin importar cuál de estos cinco caminos haya hecho la pregunta en último lugar.

Consulta en vivo, no confíes en que una caché vaya al día

El atajo tentador es una tabla de disponibilidad precalculada: una fila por recurso y franja horaria, actualizada cada vez que algo cambia, leída de forma barata desde la tienda. Es tentador porque es rápido, y porque parece la forma natural de estos datos. El problema es que hay que invalidarla correctamente desde cinco caminos de escritura independientes, y basta con que uno de ellos se olvide de actualizar, o se cruce con otro, para que el número en pantalla sea incorrecto. Y equivocarse en este terreno no es algo cosmético: significa hacer overbooking de un instructor, o decirle a un cliente que un alquiler está disponible cuando acaba de marcarse como roto.

Mi razonamiento por defecto aquí ha sido tratar la disponibilidad como algo que se calcula al leer, contra el estado actual, en lugar de algo que se guarda y se espera que se mantenga sincronizado. Una consulta en vivo —cruzar el calendario del recurso, sus reservas actuales, el estado de su material, los holds activos— es más cara por petición que una lectura de caché, pero es correcta por construcción: no hay lógica de invalidación que se pueda estropear, porque no hay nada quieto que invalidar. Para una consulta que casi siempre toca los recursos y reservas de un solo día, ese coste suele ser manejable mucho antes de convertirse en el cuello de botella.

Si cacheas algo, cachea de forma estrecha

Eso no significa que cachear esté descartado en todas partes: una tienda pública con tráfico real acabará necesitando alguna capa de caché delante de la consulta en vivo. Pero el modo de fallo que más me preocuparía es una caché cuyo disparador de invalidación sea demasiado amplio: algo cambia, así que se borra y se reconstruye todo. Eso es sencillo de escribir y reintroduce en silencio todos y cada uno de los problemas de corrección que tiene una tabla precalculada, solo que con pasos de más.

La alternativa es indexar la caché de forma lo bastante estrecha para que un disparador de invalidación solo tenga que razonar sobre el recurso y la ventana temporal exactos que cambiaron, ilustrado aquí como pseudocódigo simplificado en lugar de código de producción real:

// ingenuo: cualquier escritura en cualquier sitio borra todo
onAnyBookingWrite(event):
    cache.clear()  // correcto, pero también tira todo lo demás

// estrecho: invalida solo el recurso/día afectado
onInstructorAvailabilityChanged(event):
    key = availabilityKey(event.instructorId, event.date)
    cache.delete(key)

onBookingConfirmed(event):
    key = availabilityKey(event.resourceId, event.date)
    cache.delete(key)

onEquipmentStatusChanged(event):
    key = availabilityKey(event.itemId, event.date)
    cache.delete(key)

onHoldExpired(event):
    key = availabilityKey(event.resourceId, event.date)
    cache.delete(key)

La diferencia no es solo de rendimiento. Un disparador estrecho es algo sobre lo que puedes razonar y probar camino por camino: "¿marcar un artículo como roto invalida correctamente la clave adecuada?" es una pregunta con una respuesta clara de sí o no. "¿Se borra siempre la caché entera a tiempo?" es una pregunta sobre temporización y condiciones de carrera entre cinco caminos de código no relacionados entre sí, lo cual es mucho más difícil de responder con confianza.

Una sola verdad, tres ventanas

La otra cosa que hace que este problema sea especialmente delicado en la práctica es que la disponibilidad no se pregunta una sola vez: se pregunta desde al menos tres lugares distintos que tienen que coincidir: la tienda online donde un cliente reserva una actividad o un alquiler, el calendario del backoffice donde el equipo gestiona el día, y las vistas de reporting internas que se usan para planificar capacidad. Si esos tres alguna vez discrepan, aunque sea brevemente, alguien lo nota. Un caso concreto que aparece constantemente: un instructor decide a media mañana, por un motivo personal, marcarse como no disponible el resto del día. Eso tiene que llegar a la tienda online en cuestión de segundos —no en la siguiente sincronización programada— o la tienda venderá alegremente una clase privada con un instructor que ya se ha ido. El backoffice lo sabe al instante, porque ahí es donde se hizo el cambio; lo difícil es asegurarse de que la respuesta de la tienda a "¿está libre este instructor a las 14h?" se derive exactamente del mismo estado, y no de una fotografía de hace veinte minutos.

Este es, al final, el verdadero argumento para calcular la disponibilidad en vivo en lugar de mantener vistas precalculadas separadas por superficie: si la tienda, el backoffice y el reporting resuelven la disponibilidad mediante la misma consulta contra el mismo estado actual, estructuralmente no hay dónde puedan discrepar. Cualquier capa de caché que se añada después tiene que preservar esa propiedad, no esquivarla.

Nada de esto es ingeniería exótica: no hay ningún truco ingenioso que haga desaparecer la concurrencia y los múltiples escritores. Lo que de verdad ayuda es ser honesto sobre cuántas cosas independientes pueden cambiar "disponible" por debajo de ti, y diseñar el sistema para que pregunte de nuevo en lugar de fiarse de una respuesta que calculó hace un momento. Acertar la transacción de reserva, de lo que escribí en junio, importa mucho. Acertar el número que lleva a alguien a intentar esa transacción en primer lugar importa igual de mucho, y es la pieza más fácil de estropear sutilmente sin darse cuenta durante semanas.