←

Scheduling con slots timezone-aware, conflictos y concurrency control

Contexto

ulfblk-scheduling maneja citas con horarios disponibles. El sistema necesita soportar usuarios en diferentes zonas horarias, prevenir double-booking cuando dos personas intentan reservar el mismo slot al mismo tiempo, y detectar conflictos de horario. Almacenar timestamps sin timezone awareness es una receta para bugs silenciosos.

Lo que aprendi

La regla fundamental: almacenar en UTC, mostrar en local. Y para concurrency, delegar el locking a PostgreSQL con SELECT FOR UPDATE.

Modelo de slot

from datetime import datetime
from sqlalchemy import DateTime, String, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from ulfblk_db import Base


class Slot(Base):
    __tablename__ = "slots"

    id: Mapped[str] = mapped_column(String, primary_key=True)
    provider_id: Mapped[str] = mapped_column(String, ForeignKey("providers.id"))
    start_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True))
    end_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True))
    is_available: Mapped[bool] = mapped_column(Boolean, default=True)
    booked_by: Mapped[str | None] = mapped_column(String, nullable=True)
    timezone: Mapped[str] = mapped_column(String, default="America/Mexico_City")

Consulta de disponibilidad con locking

SELECT FOR UPDATE bloquea las filas seleccionadas hasta que la transaccion termine. Si dos requests intentan reservar el mismo slot, la segunda espera hasta que la primera haga commit o rollback.

from datetime import datetime
from zoneinfo import ZoneInfo
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession


async def get_available_slots(
    session: AsyncSession,
    provider_id: str,
    date_local: datetime,
    user_timezone: str,
) -> list[Slot]:
    """Obtiene slots disponibles para un dia en la timezone del usuario."""
    tz = ZoneInfo(user_timezone)

    # Calcular inicio y fin del dia en UTC
    day_start_local = date_local.replace(hour=0, minute=0, second=0, tzinfo=tz)
    day_end_local = date_local.replace(hour=23, minute=59, second=59, tzinfo=tz)
    day_start_utc = day_start_local.astimezone(ZoneInfo("UTC"))
    day_end_utc = day_end_local.astimezone(ZoneInfo("UTC"))

    result = await session.execute(
        select(Slot)
        .where(
            and_(
                Slot.provider_id == provider_id,
                Slot.is_available == True,
                Slot.start_utc >= day_start_utc,
                Slot.start_utc <= day_end_utc,
            )
        )
        .order_by(Slot.start_utc)
    )
    return list(result.scalars().all())


async def book_slot(
    session: AsyncSession,
    slot_id: str,
    user_id: str,
) -> Slot | None:
    """Reserva un slot con locking para evitar double-booking."""
    # SELECT FOR UPDATE bloquea la fila hasta el commit
    result = await session.execute(
        select(Slot)
        .where(and_(Slot.id == slot_id, Slot.is_available == True))
        .with_for_update()
    )
    slot = result.scalar_one_or_none()

    if slot is None:
        return None  # Ya fue reservado por alguien mas

    slot.is_available = False
    slot.booked_by = user_id
    await session.commit()
    return slot

Deteccion de conflictos

Dos rangos de tiempo se superponen si el inicio de uno es anterior al fin del otro y viceversa. Esta formula cubre todos los casos de overlap.

async def has_conflict(
    session: AsyncSession,
    provider_id: str,
    start_utc: datetime,
    end_utc: datetime,
    exclude_slot_id: str | None = None,
) -> bool:
    """Detecta si un rango de tiempo tiene conflicto con slots existentes."""
    query = select(Slot).where(
        and_(
            Slot.provider_id == provider_id,
            Slot.is_available == False,  # Solo slots reservados
            Slot.start_utc < end_utc,    # Overlap formula
            Slot.end_utc > start_utc,    # start1 < end2 AND start2 < end1
        )
    )

    if exclude_slot_id:
        query = query.where(Slot.id != exclude_slot_id)

    result = await session.execute(query)
    return result.scalar_one_or_none() is not None

Conversion de timezone para display

from zoneinfo import ZoneInfo


def format_slot_for_user(slot: Slot, user_timezone: str) -> dict:
    """Convierte un slot de UTC a la timezone del usuario para display."""
    tz = ZoneInfo(user_timezone)
    start_local = slot.start_utc.astimezone(tz)
    end_local = slot.end_utc.astimezone(tz)

    return {
        "id": slot.id,
        "date": start_local.strftime("%Y-%m-%d"),
        "start_time": start_local.strftime("%H:%M"),
        "end_time": end_local.strftime("%H:%M"),
        "timezone": user_timezone,
        "available": slot.is_available,
    }

Leccion clave

Almacenar UTC y mostrar local suena simple, pero el detalle esta en las queries: un "lunes" en CDMX es un rango de horas diferente en UTC que un "lunes" en Buenos Aires. Siempre convierte el rango del dia del usuario a UTC antes de consultar. Y para concurrency, no inventes tu propio lock -- PostgreSQL SELECT FOR UPDATE existe exactamente para esto.

Referencia