Cuaderno/Proyectos/7 · Vigía de vencimientos
Proyecto 7 de 10 · Bloque 2

Vigía de vencimientos

Un proceso que corre cada mañana sin que nadie lo mire, revisa qué lotes vencen pronto y avisa por Telegram. Hasta ahora todos tus programas corrían porque tú los lanzabas; este corre solo, y eso cambia todas las preguntas.

concepto · procesos en segundo plano e idempotencia5 sesionescron · Cloud Run Jobs · Telegram · logs · backoff
Construyesinv vigia: aviso diario idempotente + inv vigia-estado
AprendesIdempotencia, reintentos con backoff, latido, fallos parciales
CostoLocal: nada. Nube: free tier de Cloud Run Jobs/Scheduler.
Terminas conUn proceso que lleva una semana corriendo solo

Objetivos

Al terminar este proyecto vas a haber hecho:

  • Un job idempotente: correrlo tres veces seguidas produce exactamente un mensaje, verificado con un test.
  • Un protocolo Notificador con dos implementaciones (consola y Telegram) intercambiables sin tocar el vigía.
  • Reintentos con espera exponencial que se rinden con un límite y dejan rastro en los logs.
  • Un latido: el vigía registra cuándo corrió por última vez, y un comando falla si ese dato envejece.
  • El job corriendo programado: primero con cron en tu máquina, luego con Cloud Run Jobs + Cloud Scheduler.
El cambio de mentalidad

Un programa que tú lanzas falla delante de ti: ves el error y lo arreglas. Un proceso automático falla en silencio, a las 6 de la mañana, un feriado. Las tres preguntas nuevas de esta etapa: ¿qué pasa si corre dos veces? ¿cómo sé que sigue vivo? ¿qué pasa si muere a la mitad? Todo el proyecto es aprender a responderlas antes de que pasen.

Temas que vas a usar

Como siempre: léelos antes o cuando aparezcan. Los primeros son de El libro de Python; los últimos, de esta guía.

  • Programar tareasLa idea de ejecutar código en momentos programados.
  • El módulo timetime.sleep y medir duración: la base del backoff.
  • LoggingNiveles INFO/WARNING, formato, por qué no print en un proceso sin pantalla.
  • Duck typing · Clases abstractasPor qué cualquier objeto con enviar(texto) sirve como notificador.
  • try / except / finallyCapturar para reintentar, relanzar cuando te rindes. Y por qué except: pass es veneno.
  • DecoradoresAsí empaquetan las librerías los reintentos; aquí lo escribes como función para entenderlo.
  • Scraping con BeautifulSoupSolo para la extensión opcional del vigía de precios.
  • AntipatronesTres de este proyecto: except: pass, fechas sin zona, rutas relativas.
  • Los 4 hábitosEl vigía nace con tests, en rama, con PR. Ya lo sabes.

Antes de empezar

Sigues en el repo inventario-lab. Necesitas terminado el P3 (modelos, servicios, CLI) — la API de P4–P6 no participa: el vigía habla directo con la capa de servicios, y en Entiende el código discutimos por qué. Tu repo debería verse así:

inventario-lab/
├── src/inventario/
│   ├── models.py        Ubicacion, Reactivo, Lote, Movimiento
│   ├── db.py            engine + get_session (INVENTARIO_DB_URL)
│   ├── errors.py        StockInsuficiente, LoteNoExiste
│   ├── services.py      crear_*, agregar_lote, registrar_salida, lotes_por_vencer…
│   ├── cli.py           inv add-*, take, expiring, find
│   └── api/             (P4–P6; hoy no la tocamos)
├── migrations/          alembic
└── tests/

Para la parte de Telegram necesitas una cuenta de Telegram (gratis). Para el paso 8 en la nube, el proyecto GCP que creaste en el P6. Nada nuevo que instalar: httpx ya está en el proyecto desde P5.

Crea la rama de trabajo:

git switch main
git pull
git switch -c feat/vigia

Construcción paso a paso

Paso 1 Las tablas Aviso y Latido

La idempotencia no es magia: es memoria. El vigía recuerda a quién ya avisó en una tabla, y antes de avisar consulta esa memoria. Y el latido es otra tabla mínima: cuándo corrió por última vez cada proceso automático.

src/inventario/models.py (agregar al final)
class Aviso(SQLModel, table=True):
    """Registro de avisos enviados. Es lo que hace idempotente al vigía."""

    id: int | None = Field(default=None, primary_key=True)
    lote_id: int = Field(foreign_key="lote.id")
    tipo: str  # "vence_30d", "vence_7d", "vencido"
    enviado_en: datetime


class Latido(SQLModel, table=True):
    """Última corrida de cada proceso automático. Un registro por proceso."""

    id: int | None = Field(default=None, primary_key=True)
    proceso: str = Field(unique=True)
    ultima_corrida: datetime
    detalle: str | None = None

El esquema cambió, así que toca migración, como aprendiste en el P3:

uv run alembic revision --autogenerate -m "tablas aviso y latido"
uv run alembic upgrade head

La salida es como la del P3: Generating migrations/versions/…py … done y luego Running upgrade … -> …. Revisa el archivo generado antes del upgrade: debe crear exactamente dos tablas.

Por qué tipo es un string y no un booleano. Hoy hay un solo tipo de aviso ("vence_30d"). Mañana querrás avisar a 7 días y cuando ya venció, y el mismo lote puede recibir los tres avisos, uno de cada tipo. Una fila por (lote, tipo) lo modela sin tocar el esquema de nuevo. Es la cantidad justa de futuro: ni "por si acaso", ni pintarte a un rincón.

Paso 2 El protocolo Notificador

El vigía no debe saber si avisa por Telegram, por consola o por correo. Solo necesita algo con un método enviar(texto):

src/inventario/notificadores.py
"""Canales por los que el vigía avisa. Todos cumplen el mismo protocolo: enviar(texto)."""

import os

import httpx


class NotificadorConsola:
    """Imprime el aviso. Sirve para desarrollo y para tests."""

    def enviar(self, texto: str) -> None:
        print(texto)


class TelegramNotificador:
    """Manda el aviso a un chat de Telegram usando la API HTTP del bot."""

    def __init__(self, token: str | None = None, chat_id: str | None = None, timeout: float = 10.0):
        self.token = token or os.environ["TELEGRAM_BOT_TOKEN"]
        self.chat_id = chat_id or os.environ["TELEGRAM_CHAT_ID"]
        self.timeout = timeout

    def enviar(self, texto: str) -> None:
        url = f"https://api.telegram.org/bot{self.token}/sendMessage"
        respuesta = httpx.post(
            url, json={"chat_id": self.chat_id, "text": texto}, timeout=self.timeout
        )
        respuesta.raise_for_status()
Fíjate en lo que NO hay. Ninguna de las dos clases hereda de nada, y no existe una NotificadorBase. En Python basta con que tengan el mismo método (duck typing); el Protocol que declararemos en el vigía solo le pone nombre al contrato para que el type checker lo verifique. Compara con la alternativa de una clase abstracta: válida también, pero aquí no aporta nada. Anota la decisión.

Paso 3 Los tests: la idempotencia se define antes de existir

Igual que en el P0: primero el contrato, después la implementación. Para testear no mandamos nada de verdad; usamos un notificador falso que solo recuerda qué le pidieron enviar, y otro que falla a propósito:

tests/test_vigia.py
from datetime import timedelta

import pytest
from sqlmodel import select

from inventario import services
from inventario.models import Aviso, Latido
from inventario.vigia import enviar_con_reintentos, revisar_vencimientos


class NotificadorFalso:
    """Guarda lo que le piden enviar, sin mandar nada."""

    def __init__(self):
        self.enviados: list[str] = []

    def enviar(self, texto: str) -> None:
        self.enviados.append(texto)


class NotificadorQueFalla:
    """Falla las primeras `veces` llamadas y luego funciona."""

    def __init__(self, veces: int):
        self.veces = veces
        self.llamadas = 0

    def enviar(self, texto: str) -> None:
        self.llamadas += 1
        if self.llamadas <= self.veces:
            raise ConnectionError("Telegram no responde")


def _lab_con_lotes(session):
    hoy = services.hoy()
    ubi = services.crear_ubicacion(session, "Congelador -20 A")
    taq = services.crear_reactivo(session, "Taq polimerasa", "µL")
    etoh = services.crear_reactivo(session, "Etanol 96%", "mL")
    aga = services.crear_reactivo(session, "Agarosa", "g")
    services.agregar_lote(session, taq.id, ubi.id, "TAQ-2401", 500, hoy + timedelta(days=10))
    services.agregar_lote(session, etoh.id, ubi.id, "ETOH-2312", 2500, hoy + timedelta(days=20))
    services.agregar_lote(session, aga.id, ubi.id, "AGA-2405", 250, hoy + timedelta(days=200))


def test_avisa_solo_lotes_por_vencer(session):
    _lab_con_lotes(session)
    notificador = NotificadorFalso()
    avisados = revisar_vencimientos(session, notificador, dias=30)
    assert avisados == 2
    assert len(notificador.enviados) == 1
    assert "TAQ-2401" in notificador.enviados[0]
    assert "AGA-2405" not in notificador.enviados[0]


def test_es_idempotente_tres_corridas_un_mensaje(session):
    _lab_con_lotes(session)
    notificador = NotificadorFalso()
    resultados = [revisar_vencimientos(session, notificador, dias=30) for _ in range(3)]
    assert resultados == [2, 0, 0]
    assert len(notificador.enviados) == 1


def test_lote_nuevo_genera_aviso_nuevo_sin_repetir_los_viejos(session):
    _lab_con_lotes(session)
    notificador = NotificadorFalso()
    revisar_vencimientos(session, notificador, dias=30)
    services.agregar_lote(session, 1, 1, "TAQ-2402", 200, services.hoy() + timedelta(days=5))
    avisados = revisar_vencimientos(session, notificador, dias=30)
    assert avisados == 1
    assert len(notificador.enviados) == 2
    assert "TAQ-2402" in notificador.enviados[1]
    assert "ETOH-2312" not in notificador.enviados[1]


def test_sin_lotes_no_envia_pero_registra_latido(session):
    notificador = NotificadorFalso()
    assert revisar_vencimientos(session, notificador, dias=30) == 0
    assert notificador.enviados == []
    latido = session.exec(select(Latido).where(Latido.proceso == "vigia")).one()
    assert "lotes=0" in latido.detalle


def test_reintenta_con_espera_exponencial():
    notificador = NotificadorQueFalla(veces=2)
    esperas: list[float] = []
    intento = enviar_con_reintentos(
        notificador, "hola", intentos=5, espera_base=1.0, dormir=esperas.append
    )
    assert intento == 3
    assert esperas == [1.0, 2.0]


def test_reintentos_agotados_lanza_y_no_registra_aviso(session):
    _lab_con_lotes(session)
    notificador = NotificadorQueFalla(veces=99)
    with pytest.raises(ConnectionError):
        revisar_vencimientos(
            session, notificador, dias=30, intentos=3, espera_base=0, dormir=lambda s: None
        )
    assert notificador.llamadas == 3
    assert session.exec(select(Aviso)).all() == []

Dos detalles nuevos frente a tests anteriores. Primero, services.hoy(): si tu services.py del P3 usaba date.today() suelto, este es el momento de centralizarlo en una función con zona horaria explícita (el porqué está en Entiende el código). Segundo, el parámetro dormir: los tests de reintentos no esperan de verdad; le pasan una función que solo anota cuánto se habría dormido. Sin eso, el último test tardaría 15 segundos.

uv run pytest tests/test_vigia.py
Deberías ver (rojo, correcto)
E   ModuleNotFoundError: No module named 'inventario.vigia'

Paso 4 El vigía

src/inventario/vigia.py
"""El vigía: revisa los lotes por vencer y avisa. Corre solo; nadie está mirando."""

import logging
import time
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Protocol

from sqlmodel import Session, select

from inventario import services
from inventario.models import Aviso, Latido, Lote, Reactivo

log = logging.getLogger("inventario.vigia")


class Notificador(Protocol):
    def enviar(self, texto: str) -> None: ...


def enviar_con_reintentos(
    notificador: Notificador,
    texto: str,
    intentos: int = 5,
    espera_base: float = 1.0,
    dormir: Callable[[float], None] = time.sleep,
) -> int:
    """Intenta enviar hasta `intentos` veces con espera exponencial (1, 2, 4, 8… s).

    Devuelve el número del intento que funcionó. Si se agotan, relanza el último error.
    """
    for intento in range(1, intentos + 1):
        try:
            notificador.enviar(texto)
            return intento
        except Exception as exc:
            log.warning("envío falló (intento %d/%d): %s", intento, intentos, exc)
            if intento == intentos:
                raise
            dormir(espera_base * 2 ** (intento - 1))
    raise AssertionError("inalcanzable")


def formatear_aviso(session: Session, lotes: list[Lote], dias: int) -> str:
    lineas = [f"Reactivos que vencen en los próximos {dias} días:"]
    for lote in lotes:
        reactivo = session.get(Reactivo, lote.reactivo_id)
        lineas.append(
            f"- {lote.codigo} · {reactivo.nombre} · {lote.cantidad:g} {reactivo.unidad}"
            f" · vence {lote.vencimiento.isoformat()}"
        )
    return "\n".join(lineas)


def registrar_latido(session: Session, proceso: str, detalle: str) -> None:
    latido = session.exec(select(Latido).where(Latido.proceso == proceso)).first()
    ahora = datetime.now(UTC)
    if latido is None:
        latido = Latido(proceso=proceso, ultima_corrida=ahora, detalle=detalle)
    else:
        latido.ultima_corrida = ahora
        latido.detalle = detalle
    session.add(latido)
    session.commit()


def revisar_vencimientos(
    session: Session,
    notificador: Notificador,
    dias: int = 30,
    intentos: int = 5,
    espera_base: float = 1.0,
    dormir: Callable[[float], None] = time.sleep,
) -> int:
    """Avisa de los lotes que vencen en `dias` días y que aún no fueron avisados.

    Devuelve cuántos lotes se avisaron en esta corrida (0 si no había nada nuevo).
    Idempotente: correrla dos veces seguidas manda un solo aviso.
    """
    inicio = time.perf_counter()
    tipo = f"vence_{dias}d"

    lotes = services.lotes_por_vencer(session, dias)
    ya_avisados = set(session.exec(select(Aviso.lote_id).where(Aviso.tipo == tipo)))
    pendientes = [lote for lote in lotes if lote.id not in ya_avisados]

    if pendientes:
        texto = formatear_aviso(session, pendientes, dias)
        # Primero enviar, luego registrar. Si el proceso muere entre ambos, mañana
        # se repite el aviso: preferimos un duplicado a un silencio. Ver DECISIONES.md.
        enviar_con_reintentos(notificador, texto, intentos, espera_base, dormir)
        ahora = datetime.now(UTC)
        for lote in pendientes:
            session.add(Aviso(lote_id=lote.id, tipo=tipo, enviado_en=ahora))
        session.commit()

    duracion_ms = round((time.perf_counter() - inicio) * 1000)
    detalle = f"lotes={len(lotes)} nuevos={len(pendientes)} duracion_ms={duracion_ms}"
    registrar_latido(session, "vigia", detalle)
    log.info("vigia dias=%d %s", dias, detalle)
    return len(pendientes)
uv run pytest tests/test_vigia.py -v
Deberías ver
tests/test_vigia.py::test_avisa_solo_lotes_por_vencer PASSED             [ 16%]
tests/test_vigia.py::test_es_idempotente_tres_corridas_un_mensaje PASSED [ 33%]
tests/test_vigia.py::test_lote_nuevo_genera_aviso_nuevo_sin_repetir_los_viejos PASSED [ 50%]
tests/test_vigia.py::test_sin_lotes_no_envia_pero_registra_latido PASSED [ 66%]
tests/test_vigia.py::test_reintenta_con_espera_exponencial PASSED        [ 83%]
tests/test_vigia.py::test_reintentos_agotados_lanza_y_no_registra_aviso PASSED [100%]

============================== 6 passed in 0.05s ===============================

El comentario más importante del proyecto está en el medio de la función. Léelo de nuevo:

¿Qué pasa si el proceso muere justo ahí?

Entre enviar_con_reintentos(...) y session.commit() hay una ventana. Si el proceso muere ahí (corte de luz, el contenedor lo matan), el mensaje ya salió pero el Aviso no se registró: mañana se repite el aviso. ¿Y el orden inverso? Registrar primero y enviar después: si muere en la ventana, el Aviso quedó registrado pero el mensaje nunca salió — silencio para siempre. No existe el orden perfecto sin infraestructura extra (colas con confirmación, transacciones distribuidas). Se elige el fallo tolerable: un duplicado molesta, un silencio pudre reactivos. Escribe esta decisión en DECISIONES.md; es la respuesta a una de las casillas de "Listo cuando".

uv run ruff check . && uv run ruff format .
git add . && git commit -m "Vigía de vencimientos: idempotente, con reintentos y latido"

Paso 5 CLI: inv vigia y inv vigia-estado

Dos comandos nuevos en src/inventario/cli.py: el vigía en sí, y el que responde "¿sigue vivo?" leyendo el latido:

src/inventario/cli.py (agregar)
from datetime import datetime, timedelta, timezone

from sqlmodel import select

from inventario.models import Latido
from inventario.notificadores import NotificadorConsola, TelegramNotificador
from inventario.vigia import revisar_vencimientos


@app.command("vigia")
def vigia(
    dias: int = typer.Option(30, "--dias"),
    canal: str = typer.Option("consola", "--canal", help="consola | telegram"),
) -> None:
    """Revisa vencimientos y avisa una sola vez por lote. Pensado para correr solo, cada día."""
    notificador = TelegramNotificador() if canal == "telegram" else NotificadorConsola()
    with get_session() as s:
        avisados = revisar_vencimientos(s, notificador, dias=dias)
    typer.echo(f"avisados={avisados}")


@app.command("vigia-estado")
def vigia_estado(max_horas: int = typer.Option(26, "--max-horas")) -> None:
    """¿Sigue vivo el vigía? Muestra el latido y falla si es más viejo que --max-horas."""
    with get_session() as s:
        latido = s.exec(select(Latido).where(Latido.proceso == "vigia")).first()
    if latido is None:
        typer.echo("el vigía nunca corrió")
        raise typer.Exit(code=1)
    ultima = latido.ultima_corrida.replace(tzinfo=timezone.utc)
    edad = datetime.now(timezone.utc) - ultima
    typer.echo(
        f"última corrida {ultima.isoformat()} ({edad.total_seconds() / 3600:.1f} h) · {latido.detalle}"
    )
    if edad > timedelta(hours=max_horas):
        typer.echo("ALERTA: el vigía lleva demasiado sin correr")
        raise typer.Exit(code=2)

Pruébalo de punta a punta con los datos del laboratorio (ajusta las fechas: una a ~10 días, otra a ~20, otra lejos):

uv run inv add-lote 1 1 TAQ-2401 500 2026-08-26
uv run inv add-lote 2 1 ETOH-2312 2500 2026-09-05
uv run inv add-lote 3 1 AGA-2405 250 2027-03-04
uv run inv vigia
uv run inv vigia
uv run inv vigia
Deberías ver (1ª corrida avisa; 2ª y 3ª no)
2026-08-16 07:34:45 INFO inventario.vigia vigia dias=30 lotes=2 nuevos=2 duracion_ms=4
Reactivos que vencen en los próximos 30 días:
- TAQ-2401 · Taq polimerasa · 500 µL · vence 2026-08-26
- ETOH-2312 · Etanol 96% · 2500 mL · vence 2026-09-05
avisados=2

2026-08-16 07:34:45 INFO inventario.vigia vigia dias=30 lotes=2 nuevos=0 duracion_ms=2
avisados=0

2026-08-16 07:34:45 INFO inventario.vigia vigia dias=30 lotes=2 nuevos=0 duracion_ms=2
avisados=0
uv run inv vigia-estado
Deberías ver
última corrida 2026-08-16T12:34:45.442236+00:00 (0.0 h) · lotes=2 nuevos=0 duracion_ms=2
Por qué el log dice lotes=2 nuevos=0 y no solo "ok". Un log útil responde las preguntas del futuro: ¿cuántos vio? ¿cuántos avisó? ¿cuánto tardó? Cuando en un mes te preguntes "¿por qué no avisó del lote X?", la diferencia entre lotes=2 nuevos=0 (lo vio y ya estaba avisado) y lotes=0 (no lo vio: problema de datos o de fecha) te ahorra una hora. Y fíjate: --max-horas por defecto es 26, no 24 — un job diario con un deploy o un reinicio de por medio se atrasa minutos; alertar por eso es ruido.

Paso 6 Telegram de verdad

Crear un bot toma dos minutos y no requiere programar nada:

  1. En Telegram, busca @BotFather (el verificado) y envíale /newbot. Te pide un nombre ("Vigía del lab") y un username que termine en bot (alexandra_vigia_bot). Te responde con el token: algo como 8123456789:AAF…. Es un secreto: va en .env, jamás en el repo (P6).
  2. Ábrele chat a tu bot (búscalo por su username) y mándale cualquier mensaje. Sin esto el bot no puede escribirte: los bots no inician conversaciones.
  3. Obtén tu chat_id preguntándole a la API qué mensajes recibió:
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates" | python3 -m json.tool
Deberías ver (busca "chat" → "id")
"chat": {
    "id": 123456789,
    "first_name": "Alexandra",
    …

Guarda ambos en tu .env (y las claves sin valor en .env.example, como siempre):

.env (local, ignorado por git)
TELEGRAM_BOT_TOKEN=8123456789:AAF…
TELEGRAM_CHAT_ID=123456789

Y pruébalo. Borra antes los avisos ya registrados para forzar un envío (o agrega un lote nuevo por vencer):

uv run inv vigia --canal telegram

El mensaje debe aparecer en tu chat. Ahora la parte más valiosa del paso: ensaya el fallo. Pon un token falso y mira al vigía pelear y rendirse:

TELEGRAM_BOT_TOKEN=123:token-falso uv run inv vigia --canal telegram
Deberías ver (reintentos con espera creciente, luego se rinde; ~23 s en total)
WARNING inventario.vigia envío falló (intento 1/5): Client error '401 Unauthorized' for url
'https://api.telegram.org/bot123:token-falso/sendMessage'
WARNING inventario.vigia envío falló (intento 2/5): _ssl.c:993: The handshake operation timed out
WARNING inventario.vigia envío falló (intento 3/5): Client error '401 Unauthorized' for url …
WARNING inventario.vigia envío falló (intento 4/5): [Errno 54] Connection reset by peer
…
HTTPStatusError: Client error '401 Unauthorized' for url
'https://api.telegram.org/bot123:token-falso/sendMessage'

Tres cosas para observar en esa salida (es real, de esta guía): los fallos son variados (401, timeout de TLS, conexión reseteada — así se ve la red de verdad), cada intento quedó en el log con su número, y al final el error explotó en vez de esconderse. Comprueba lo más importante: que ningún Aviso se registró — el envío no ocurrió, así que mañana lo reintentará entero. El test test_reintentos_agotados… verifica exactamente esto.

git add . && git commit -m "Canal Telegram y comandos vigia/vigia-estado"

Paso 7 Docker y compose

La imagen es la misma del proyecto (no una nueva): solo cambia el comando. Dos ajustes al Dockerfile que ya tienes:

Dockerfile (ajustes)
RUN uv sync --frozen
# El entorno que creó uv queda primero en el PATH: "inv" es el script instalado por pyproject
ENV PATH="/app/.venv/bin:$PATH"

CMD ["inv", "vigia"]
compose.yaml (agregar servicio)
services:
  vigia:
    build: .
    command: ["inv", "vigia", "--canal", "consola"]
    environment:
      INVENTARIO_DB_URL: sqlite:////data/inventario.db
      INVENTARIO_TZ: America/Lima
    volumes:
      - datos:/data
volumes:
  datos:
docker build -t inventario .
docker run --rm -v "$PWD/inventario.db:/app/inventario.db" inventario
docker run --rm -v "$PWD/inventario.db:/app/inventario.db" inventario inv vigia-estado --max-horas 0
Deberías ver (ya no avisa: la memoria está en la DB montada; y --max-horas 0 fuerza la alerta, exit 2)
avisados=0
2026-08-16 12:38:49 INFO inventario.vigia vigia dias=30 lotes=2 nuevos=0 duracion_ms=2

última corrida 2026-08-16T12:38:49.173411+00:00 (0.0 h) · lotes=2 nuevos=0 duracion_ms=2
ALERTA: el vigía lleva demasiado sin correr

Ojo con el primer docker run que hagas sin el volumen: verás lotes=0. No es un bug del vigía: el contenedor trae su propio sistema de archivos, y sqlite:///inventario.db (ruta relativa) crea una base nueva y vacía adentro. Es la trampa de rutas del P0/P3 en su versión de procesos: en producción la URL de la DB siempre viene del entorno y apunta a un lugar persistente.

Paso 8 Programarlo: cron local, luego la nube

Primero en tu máquina, para entender el mecanismo. crontab -e abre tu tabla de tareas; agrega una línea:

crontab (una línea)
# min hora día mes día-semana   comando
0 7 * * *  cd /Users/alexandra/inventario-lab && /usr/local/bin/docker run --rm -v "$PWD/inventario.db:/app/inventario.db" --env-file .env inventario inv vigia --canal telegram >> vigia.log 2>&1

Léela en voz alta: "a las 7:00 de cada día". Los cinco campos son minuto, hora, día del mes, mes, día de la semana; * significa "todos". La ruta absoluta a docker y el cd no son manía: cron corre con un entorno mínimo, sin tu PATH ni tu carpeta actual (trampa clásica, ver abajo). El >> vigia.log 2>&1 guarda lo que el proceso diga, porque no habrá nadie mirando la pantalla.

Ahora la nube, donde no depende de que tu laptop esté prendida. En Google Cloud el par estándar es Cloud Run Jobs (un contenedor que corre y termina, a diferencia del servicio del P6 que atiende requests) + Cloud Scheduler (el cron gestionado). Con el proyecto GCP del P6:

# La imagen ya está en Artifact Registry desde P6; si no: gcloud builds submit --tag $IMAGEN
gcloud run jobs create vigia-job \
    --region southamerica-west1 \
    --image $IMAGEN \
    --command inv --args "vigia,--canal,telegram" \
    --set-env-vars INVENTARIO_DB_URL=$DB_URL,INVENTARIO_TZ=America/Lima \
    --set-secrets TELEGRAM_BOT_TOKEN=telegram-token:latest,TELEGRAM_CHAT_ID=telegram-chat:latest

# Pruébalo una vez a mano:
gcloud run jobs execute vigia-job --region southamerica-west1 --wait

# Y prográmalo: Scheduler dispara el job cada día a las 7:00 de Lima,
# autenticándose con OIDC (una cuenta de servicio con permiso de invocar el job):
gcloud scheduler jobs create http vigia-diario \
    --location southamerica-west1 \
    --schedule "0 7 * * *" \
    --time-zone "America/Lima" \
    --uri "https://run.googleapis.com/v2/projects/$PROYECTO/locations/southamerica-west1/jobs/vigia-job:run" \
    --http-method POST \
    --oidc-service-account-email vigia-scheduler@$PROYECTO.iam.gserviceaccount.com

Los secretos van en Secret Manager (gcloud secrets create telegram-token --data-file=-), nunca en --set-env-vars: regla del P6. Y fíjate en --time-zone: sin él, el "0 7" sería en UTC y tu aviso llegaría a las 2 de la mañana.

Queda la última pieza del oficio: vigilar al vigilante. El latido ya existe; algo tiene que mirarlo. La opción mínima honesta: el propio inv vigia-estado corriendo en otro job programado unas horas después, que al fallar (exit 2) deja el error en los logs de Cloud Run, donde ya tienes alertas desde P6. La opción profesional que conocerás después se llama dead man's switch (servicios como Healthchecks.io: si el vigía no "toca" una URL cada día, ellos te avisan). Anota en DECISIONES.md cuál elegiste y qué agujero le queda: si Telegram entero está caído, ¿quién te avisa de que no te avisó?

Extensión opcional: el vigía de precios

El mismo esqueleto sirve para vigilar el precio de un reactivo en la web de un proveedor: un job diario que descarga la página (BeautifulSoup), extrae el precio, lo guarda y avisa si bajó. Las reglas de cortesía no son opcionales: revisa robots.txt y los términos del sitio, identifícate con un User-Agent honesto, una visita al día (jamás en bucle), solo páginas públicas. Y si el sitio ofrece API, se usa la API.

git add . && git commit -m "Programación del vigía: cron local y Cloud Run Jobs + Scheduler"
git push -u origin feat/vigia
gh pr create --title "Vigía de vencimientos" --reviewer rotorrest

Paso 9 Limpieza (nube)

Como en los tutoriales de GCP: lo que no borres se queda, y a veces cobra. Cuando termines de experimentar, o si quieres pausar el vigía en la nube:

# Pausar solo el disparo diario (el job queda, ya no corre):
gcloud scheduler jobs pause vigia-diario --location southamerica-west1

# O borrar ambos:
gcloud scheduler jobs delete vigia-diario --location southamerica-west1
gcloud run jobs delete vigia-job --region southamerica-west1

El cron local se quita con crontab -e borrando la línea. Verifica con crontab -l.

Entiende el código

Estructura al terminar

src/inventario/
├── models.py         + Aviso, Latido
├── vigia.py          revisar_vencimientos, enviar_con_reintentos, latido
├── notificadores.py  NotificadorConsola, TelegramNotificador
└── cli.py            + inv vigia, inv vigia-estado
tests/test_vigia.py   6 tests: idempotencia, reintentos, latido
compose.yaml          + servicio vigia con volumen
migrations/versions/  + la migración de Aviso y Latido

La idempotencia, dibujada

Cada corrida hace: (1) ¿qué lotes vencen pronto? — consulta a services, la de siempre; (2) ¿a cuáles ya avisé? — consulta a Aviso; (3) la resta entre ambos conjuntos es lo pendiente. Si está vacía, no envía nada. "Correr el vigía" se vuelve seguro de repetir porque guarda memoria de sí mismo, no porque alguien prometa correrlo una sola vez. Esa es la definición operativa de idempotencia, y el patrón (una tabla de "ya lo hice" + la resta) te va a servir de nuevo en la ingesta del P9 y en cualquier robot que toque el mundo.

Por qué inyectar dormir y notificador

revisar_vencimientos no crea sus dependencias: las recibe. En producción, notificador es Telegram y dormir es time.sleep; en tests, uno falso que anota y una función que no espera. Se llama inyección de dependencias y no necesita frameworks: son parámetros con valores por defecto. La señal de que está bien hecho: el test de "reintenta con esperas 1 y 2" corre en milisegundos.

La matemática del backoff

Esperas espera_base × 2^(intento−1): 1, 2, 4, 8 segundos — ~15 s de espera antes de rendirse con 5 intentos. ¿Por qué creciente y no 5 seguidos? Porque si el servicio está caído, martillarlo no lo levanta (y si todos sus clientes martillan a la vez, lo rematan). ¿Por qué un límite? Porque un job que reintenta para siempre es un job colgado que nadie nota: mejor fallar ruidosamente y que el error llegue a los logs. Las librerías (tenacity) hacen esto con un decorador y le suman jitter (aleatoriedad para desincronizar clientes); lo escribiste a mano una vez para saber qué hay dentro.

¿Y por qué no llama a la API del P4?

El vigía podría consumir GET /lotes/por-vencer por HTTP, como el tablero del P5. Elegimos que use services directo: vive en el mismo repo, comparte las reglas de negocio, y le quitas una dependencia de red a un proceso cuyo tema es precisamente sobrevivir a la red (la dependencia frágil que sí le queda —Telegram— ya te dio bastante trabajo). El costo: el vigía necesita ver la base de datos. Si mañana viviera en otra máquina sin acceso a la DB, la API sería el camino. Las dos opciones son defendibles; lo indefendible es no saber por qué elegiste la tuya. A DECISIONES.md.

services.hoy() y la zona horaria

"Vence en 30 días" depende de qué día es hoy — ¿hoy dónde? Un servidor en la nube vive en UTC: a las 19:01 de Lima ya es "mañana" en UTC, y un lote puede entrar o salir de la ventana de aviso según quién pregunte. Por eso hoy() se calcula con zona explícita (INVENTARIO_TZ, default America/Lima) y los timestamps se guardan en UTC:

src/inventario/services.py (si no lo hiciste en P3)
def hoy() -> date:
    """La fecha de hoy en la zona del laboratorio, no la del servidor (que suele ser UTC)."""
    zona = ZoneInfo(os.getenv("INVENTARIO_TZ", "America/Lima"))
    return datetime.now(zona).date()

Es la versión en miniatura de un incidente real que Rodrigo te puede contar con cicatrices (está en Antipatrones: fechas sin zona).

Con Claude

El esqueleto del vigía (protocolo, backoff, tabla de avisos) lo escribiste a mano: son los conceptos del proyecto. De aquí en adelante Claude puede escribir variantes (el aviso a 7 días, otro canal) mientras tú revisas contra los tests. Pedidos que valen la pena:

Para entender

Explícame la diferencia entre at-most-once, at-least-once y exactly-once en entrega de mensajes. ¿Cuál es mi vigía y por qué exactly-once es tan difícil? ¿Por qué Protocol en vez de una clase abstracta para Notificador? ¿Cuándo preferirías ABC? Un ejemplo de cada lado. Aquí está mi revisar_vencimientos. Hazme de abogado del diablo: ¿en qué escenarios concretos manda un aviso duplicado, y en cuáles se queda callado indebidamente?

Para construir (tú revisas el diff contra los tests)

Agrega el tipo de aviso "vence_7d" reutilizando revisar_vencimientos. No toques los tests existentes; agrega los que falten. Propón un NotificadorCorreo con el mismo protocolo, usando smtplib de la librería estándar. Antes de escribirlo, dime qué configuración necesitaría y de dónde debería salir.

Si algo falla

El vigía corre "bien" pero no avisa nada hace semanas

El fallo más caro de esta etapa, y casi siempre es un except: pass (o un except Exception: log.debug(...), que es lo mismo con corbata) tragándose el error real. En este proyecto no lo hay: los reintentos relanzan al agotarse. Las dos defensas estructurales: el latido (si el proceso muere, vigia-estado lo delata) y el detalle lotes=N nuevos=M en el log (si dice lotes=0 semanas seguidas, el problema está en los datos o en la fecha, no en el envío). Revisa vigia.log o los logs de Cloud Run antes de tocar código.

Te llegaron 40 mensajes iguales

Mira tu crontab: * 7 * * * significa "cada minuto entre las 7:00 y las 7:59", no "a las 7". La línea correcta empieza con 0 7. La idempotencia te protegió a medias: los 60 procesos corrieron, pero solo avisaron los que encontraron lotes sin registro. Si aun así llegaron duplicados, dos corridas se cruzaron en la ventana enviar→registrar; con un job diario eso es teórico, con uno por minuto es rutina. La lección: la frecuencia del cron es parte del diseño, no un detalle.

En cron o en Docker: lotes=0, pero en tu terminal ves los lotes

Estás mirando dos bases distintas. sqlite:///inventario.db es una ruta relativa: depende de la carpeta desde donde corre el proceso. Cron arranca en tu home, Docker en /app: cada uno crea su propia base vacía sin quejarse. Solución: URL absoluta vía INVENTARIO_DB_URL (nota las cuatro barras: sqlite:////data/inventario.db) y, en Docker, el volumen montado. Compruébalo con docker run --rm inventario python -c "from inventario.db import DB_URL; print(DB_URL)".

Telegram responde 401 Unauthorized o 400 Bad Request: chat not found

401: el token está mal (copiado a medias, con espacios). Verifícalo directo: curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe" debe devolver los datos de tu bot. 400 con "chat not found": el chat_id está mal o nunca le escribiste al bot (paso 6.2 — los bots no inician conversaciones). Si getUpdates devuelve vacío, mándale otro mensaje al bot y repite.

El test de reintentos tarda 15 segundos

Olvidaste pasar dormir= en el test y está usando time.sleep de verdad. Es un olor de diseño convertido en molestia inmediata: si el test necesita esperar tiempo real, la función no permite inyectar el reloj. La versión del paso 4 sí lo permite; úsala. Regla general: los tests que duermen son tests que alguien terminará borrando.

El aviso salió ayer, hoy agregaste un lote y el mensaje repite los viejos

Tu resta está al revés: estás filtrando contra "avisados hoy" o consultando Aviso sin filtrar por tipo. La consulta correcta trae todos los lote_id con aviso de ese tipo, sin importar cuándo, y el mensaje solo incluye pendientes. El test test_lote_nuevo_genera_aviso_nuevo_sin_repetir_los_viejos existe exactamente para esto; si lo tienes en verde y aun así pasa, el bug está en otro lado (¿dos bases distintas otra vez?).

En Cloud Scheduler el job dispara pero Cloud Run responde 403

La cuenta de servicio del --oidc-service-account-email no tiene permiso de invocar el job. Es el modelo del P6: quién eres (OIDC lo resuelve) y qué puedes (falta el rol). gcloud run jobs add-iam-policy-binding vigia-job --member serviceAccount:… --role roles/run.invoker --region … y reintenta con "Force run" desde la consola de Scheduler.

Listo cuando

Las mismas casillas del índice. Una aclaración honesta sobre la segunda: en esta implementación el vigía habla con la base de datos directamente (ver Entiende el código), así que "apagar la API" se traduce en cortarle su dependencia externa frágil, que aquí es Telegram. El ensayo del paso 6 con el token falso es exactamente eso.

La pregunta de Rodrigo en la sesión

"Telegram estuvo caído 6 horas esta madrugada. Cuéntame, minuto a minuto, qué hizo tu vigía, qué quedó en la base, qué quedó en los logs, y qué va a pasar mañana a las 7:00." Si puedes narrarlo sin abrir el código, este proyecto está terminado.

Siguiente

Hasta aquí, todo tu sistema es determinista: misma entrada, misma salida, y los tests lo demuestran. En el Proyecto 8 · Asistente de laboratorio con LLM entra la primera pieza que no lo es: un modelo de lenguaje respondiendo preguntas sobre tus protocolos. Vas a descubrir que "¿funciona?" deja de ser una pregunta de sí o no y se convierte en un porcentaje que se mide con evals — y que cuesta dinero por token.