diff --git a/lab-01-fundamentos/README.md b/lab-01-fundamentos/README.md index 1ea1c54..9bf48cb 100644 --- a/lab-01-fundamentos/README.md +++ b/lab-01-fundamentos/README.md @@ -38,6 +38,8 @@ lab-01-fundamentos/ │ └── tui_dashboard.py # Dashboard interactivo con Textual ├── tests/ │ └── test_scripts.py # Tests con pytest y fixtures +├── docs/ +│ └── architecture.md # Arquitectura, flujo de datos y decisiones de diseño └── README.md ``` diff --git a/lab-01-fundamentos/docs/architecture.md b/lab-01-fundamentos/docs/architecture.md new file mode 100644 index 0000000..13d7af3 --- /dev/null +++ b/lab-01-fundamentos/docs/architecture.md @@ -0,0 +1,118 @@ +# Lab 01 — Arquitectura y Funcionamiento + +Este documento explica la estructura del lab, el flujo de datos entre scripts y las decisiones de diseño. + +--- + +## Visión General + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Docker Network │ +│ │ +│ ┌─────────────────┐ psycopg2 ┌─────────────────┐ │ +│ │ crud_postgres.py│ ──────────────▶ │ PostgreSQL │ │ +│ │ │ ◀────────────── │ (lab01_db) │ │ +│ └─────────────────┘ SQL results └─────────────────┘ │ +│ │ +│ ┌─────────────────┐ │ +│ │ io_formats.py │ CSV → Polars → Parquet │ +│ │ │ (sin DB, solo archivos locales) │ +│ └─────────────────┘ │ +│ │ +│ ┌─────────────────┐ │ +│ │ async_demo.py │ asyncio + aiohttp (I/O concurrente) │ +│ └─────────────────┘ │ +│ │ +│ ┌─────────────────┐ │ +│ │tui_dashboard.py │ Textual TUI → consulta PostgreSQL │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Scripts + +### `crud_postgres.py` + +Demuestra conexión directa a PostgreSQL con `psycopg2` sin ORM. Realiza: +- `CREATE TABLE` con schema de paradas de bus +- `INSERT` masivo con `execute_values` (bulk insert eficiente) +- `SELECT` con filtros y ordenamiento +- `UPDATE` y `DELETE` con confirmación de filas afectadas + +La conexión se gestiona con context managers (`with psycopg2.connect(...) as conn`) para garantizar el cierre y rollback automático ante excepciones. + +### `io_formats.py` + +Pipeline de transformación de datos sin base de datos: + +``` +data/stops_raw.csv + │ + ▼ leer con Polars (lazy frame) + │ + ▼ transformar: filtrar, renombrar columnas, cast de tipos + │ + ▼ escribir Parquet (columnar, comprimido) +data/stops_clean.parquet +``` + +**Por qué Polars sobre Pandas:** Polars usa Apache Arrow internamente, lo que permite lazy evaluation (el plan de query se optimiza antes de ejecutar) y operaciones vectorizadas en Rust. Para ETL de archivos es 5-20× más rápido que Pandas. + +**Por qué Parquet sobre CSV:** Parquet es columnar y comprimido. Una query que solo necesita 2 de 10 columnas solo lee esas 2 columnas del disco. CSV siempre lee todo el archivo. + +### `async_demo.py` + +Demuestra concurrencia I/O con `asyncio`. Lanza múltiples peticiones HTTP en paralelo con `aiohttp` usando `asyncio.gather()`. Compara tiempos vs. requests síncronos para evidenciar la diferencia en workloads I/O-bound. + +### `tui_dashboard.py` + +Dashboard interactivo en terminal construido con **Textual**. Consulta las paradas de PostgreSQL y las muestra en una tabla navegable con teclado. Demuestra que las herramientas de inspección de datos no requieren un navegador web. + +--- + +## Flujo de Datos + +``` +stops_raw.csv ──▶ io_formats.py ──▶ stops_clean.parquet + │ + ▼ (datos de ejemplo) + crud_postgres.py ──▶ PostgreSQL + │ + ▼ + tui_dashboard.py +``` + +--- + +## Testing + +Los tests en `tests/test_scripts.py` usan pytest con fixtures que: +1. Crean una conexión a PostgreSQL de test +2. Aplican el schema +3. Insertan datos de prueba +4. Verifican los resultados de cada operación CRUD +5. Hacen rollback al finalizar (aislamiento entre tests) + +Se usa `pytest.fixture(scope="function")` para que cada test tenga un estado limpio. + +--- + +## Gestión de Dependencias — uv + +`uv` reemplaza a `pip` + `venv`. Es un resolvedor de dependencias escrito en Rust, ~10-100× más rápido que pip. El archivo `pyproject.toml` declara las dependencias; `uv pip install --system` las instala en el entorno del contenedor. + +--- + +## Decisiones de Diseño + +| Decisión | Alternativa | Razón | +|---|---|---| +| psycopg2 directo (sin ORM) | SQLAlchemy / Django ORM | Demostrar el nivel más bajo: SQL puro, cursores, transacciones manuales | +| Polars | Pandas | Velocidad, lazy evaluation, API más ergonómica para ETL | +| Parquet | CSV / JSON | Formato columnar, compresión, eficiencia en lectura parcial | +| Textual | Rich / curses | API declarativa para TUI, componentes reutilizables, soporte de eventos | +| asyncio + aiohttp | threading / requests | Modelo de concurrencia cooperativo sin overhead de threads para I/O-bound | +| uv | pip + venv | Resolución de dependencias ~100× más rápida, reproducible | diff --git a/lab-02-django-api/README.md b/lab-02-django-api/README.md index bb30293..eb4aa1d 100644 --- a/lab-02-django-api/README.md +++ b/lab-02-django-api/README.md @@ -62,6 +62,8 @@ lab-02-django-api/ │ ├── factories.py # Factories con factory-boy para datos de test │ ├── test_api.py # Tests de endpoints con APIClient │ └── test_models.py # Tests de modelos y consultas espaciales +├── docs/ +│ └── architecture.md # Arquitectura, flujo de datos y decisiones de diseño └── README.md ``` diff --git a/lab-02-django-api/docs/architecture.md b/lab-02-django-api/docs/architecture.md new file mode 100644 index 0000000..b4e59e2 --- /dev/null +++ b/lab-02-django-api/docs/architecture.md @@ -0,0 +1,138 @@ +# Lab 02 — Arquitectura y Funcionamiento + +Este documento explica la arquitectura de la API REST, el modelo de datos geoespacial y las decisiones de diseño. + +--- + +## Visión General + +``` +┌────────────────────────────────────────────────────────────────┐ +│ Docker Network │ +│ │ +│ Cliente HTTP │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Django (DRF) │ │ +│ │ │ │ +│ │ JWT Auth ──▶ Permissions ──▶ ViewSets ──▶ Serializers │ │ +│ │ │ │ │ +│ │ Querysets │ │ +│ └──────────────────────────────────┬───────────────────────┘ │ +│ │ psycopg2 │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ PostgreSQL │ │ +│ │ + PostGIS │ │ +│ │ (geometrías) │ │ +│ └─────────────────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Modelo de Datos + +``` +Stop (parada) +├── code: CharField (unique) +├── name: CharField +├── location: PointField (PostGIS) ← coordenada GPS +├── zone: CharField (choices) +├── total_routes: IntegerField +└── is_active: BooleanField + +Route (ruta) +├── code: CharField (unique) +├── name: CharField +├── origin: CharField +├── destination: CharField +├── path: LineStringField (PostGIS) ← trazado geográfico (opcional) +├── stops: ManyToManyField → Stop +└── is_active: BooleanField + +Schedule (horario) +├── route: ForeignKey → Route +├── stop: ForeignKey → Stop +├── departure_time: TimeField +└── stop_order: IntegerField +``` + +--- + +## Capas de la Aplicación + +### Autenticación — JWT + +`djangorestframework-simplejwt` emite tokens firmados con HMAC-SHA256. El flujo es: + +``` +POST /api/auth/token/ → { access, refresh } +POST /api/auth/token/refresh/ → { access } (renueva con refresh) +``` + +Cada request a endpoints protegidos incluye `Authorization: Bearer `. DRF valida la firma y extrae el usuario del payload sin consultar la base de datos. + +### Permisos — `permissions.py` + +Se implementa `IsOwnerOrReadOnly`: un permiso custom de DRF que permite lectura a cualquier usuario autenticado, pero escritura (`PUT`, `PATCH`, `DELETE`) solo al creador del objeto. Hereda de `BasePermission` y sobreescribe `has_object_permission()`. + +### ViewSets + +Se usa `ModelViewSet` de DRF que genera automáticamente los 5 endpoints REST: + +| Método | URL | Acción | +|---|---|---| +| GET | `/api/routes/` | list | +| POST | `/api/routes/` | create | +| GET | `/api/routes/{id}/` | retrieve | +| PUT/PATCH | `/api/routes/{id}/` | update | +| DELETE | `/api/routes/{id}/` | destroy | + +Además hay acciones custom (`@action`) para consultas geoespaciales. + +### Consultas Geoespaciales — PostGIS + +`geospatial.py` centraliza las queries geoespaciales: + +```python +# Paradas dentro de un radio dado +Stop.objects.filter( + location__distance_lte=(point, D(m=radius_m)) +).order_by('location') +``` + +PostGIS convierte coordenadas GPS en objetos geométricos indexados con GiST. Las queries de distancia usan ese índice — sin PostGIS habría que calcular distancias en Python para cada fila de la tabla. + +--- + +## Testing — pytest-django + factory_boy + +### Factories + +`factory_boy` genera objetos de modelo con datos realistas sin definirlos manualmente en cada test: + +```python +class StopFactory(DjangoModelFactory): + name = factory.Faker("street_name") + location = factory.LazyFunction(lambda: Point(-84.08, 9.93)) + ... +``` + +### Fixtures de pytest-django + +`@pytest.fixture` con `django_db` habilita acceso a la base de datos. Cada test corre en una transacción que se revierte al finalizar — aislamiento total sin necesidad de limpiar datos manualmente. + +--- + +## Decisiones de Diseño + +| Decisión | Alternativa | Razón | +|---|---|---| +| DRF ModelViewSet | APIView manual | Reduce boilerplate; el CRUD estándar se genera automáticamente | +| JWT (simplejwt) | Session auth / OAuth2 | Stateless, ideal para APIs consumidas por frontend desacoplado | +| PostGIS | Calcular distancias en Python | Índice GiST en DB hace queries geoespaciales O(log n) en lugar de O(n) | +| factory_boy | Fixtures estáticas / setUp | Factories son composables y generan datos únicos; evita colisiones entre tests | +| pytest-django | unittest.TestCase | Fixtures de pytest son más ergonómicas y componibles que setUp/tearDown | +| Separar `geospatial.py` | Queries inline en views | Centraliza la lógica de dominio geoespacial, testeable de forma independiente | diff --git a/lab-03-async-realtime/README.md b/lab-03-async-realtime/README.md index bef899e..ae94ce6 100644 --- a/lab-03-async-realtime/README.md +++ b/lab-03-async-realtime/README.md @@ -97,6 +97,8 @@ lab-03-async-realtime/ ├── conftest.py # InMemoryChannelLayer + patch de OriginValidator para tests ├── test_consumers.py # 7 tests — VehicleTrackingConsumer y FleetOverviewConsumer └── test_tasks.py # 8 tests — ingest_vehicle_position, cleanup, fleet summary +docs/ +└── architecture.md # Arquitectura, flujo de datos y decisiones de diseño ``` --- diff --git a/lab-03-async-realtime/docs/architecture.md b/lab-03-async-realtime/docs/architecture.md new file mode 100644 index 0000000..1a0be37 --- /dev/null +++ b/lab-03-async-realtime/docs/architecture.md @@ -0,0 +1,108 @@ +# Lab 03 — Arquitectura y Funcionamiento + +Este documento explica el flujo de mensajes desde la ingesta MQTT hasta la entrega por WebSocket al cliente. + +--- + +## Visión General + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Docker Network │ +│ │ +│ Sensor GPS │ +│ (publisher) │ +│ │ MQTT publish │ +│ ▼ │ +│ ┌──────────┐ consume ┌──────────────┐ .delay() ┌───────┐ │ +│ │ NanoMQ │ ────────────▶ │mqtt_ingester │ ───────────▶ │Celery │ │ +│ │ (MQTT) │ │ (thread) │ │Worker │ │ +│ └──────────┘ └──────────────┘ └───┬───┘ │ +│ │ │ +│ persist + │ │ +│ group_send│ │ +│ ▼ │ +│ ┌──────────┐ channel ┌──────────────┐ WS message ┌──────┐ │ +│ │ Redis │ ◀──────────▶ │ Channels │ ────────────▶│ │ │ +│ │ (layer) │ │ (Daphne) │ │Client│ │ +│ └──────────┘ └──────────────┘ └──────┘ │ +│ │ +│ ┌──────────┐ broker │ +│ │ RabbitMQ │ ◀──────────── Celery tasks │ +│ └──────────┘ │ +│ │ +│ ┌──────────┐ persist │ +│ │PostgreSQL│ ◀──────────── Celery worker │ +│ └──────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Flujo de Datos Completo + +1. **Sensor GPS** publica en `transit/vehicle/BUS-001/position` (NanoMQ) +2. **`mqtt_ingester`** (hilo del worker) consume el mensaje y llama `ingest_vehicle_position.delay(...)` — despacha la tarea a Celery sin bloquear +3. **Celery Worker** ejecuta la tarea: + - Persiste `VehiclePosition` en PostgreSQL (con geometría PostGIS) + - Llama `channel_layer.group_send("vehicle_BUS-001", event)` dos veces: una al grupo del vehículo, otra al grupo `"fleet"` +4. **Redis** (Channel Layer) enruta el evento a los consumers suscritos +5. **`VehicleTrackingConsumer`** (o `FleetOverviewConsumer`) recibe el evento y hace `self.send(text_data=json.dumps(event))` al WebSocket conectado + +--- + +## Componentes + +### NanoMQ — Broker MQTT + +Broker MQTT liviano para telemetría IoT. Cada vehículo publica en su propio topic: +``` +transit/vehicle/BUS-001/position +transit/vehicle/BUS-002/position +``` + +### mqtt_ingester — Consumidor MQTT + +Hilo dentro del worker de Celery que se mantiene suscrito al wildcard `transit/vehicle/+/position`. Al recibir un mensaje, no lo procesa directamente — lo despacha como tarea Celery para no bloquear el loop de MQTT. + +### Celery + RabbitMQ — Procesamiento Asíncrono + +**RabbitMQ** es el broker de mensajes: recibe tareas de productores y las entrega a workers. **Celery** es el framework de workers que ejecuta las tareas. La separación permite escalar workers horizontalmente sin cambiar el código. + +La tarea `ingest_vehicle_position` tiene: +- `bind=True`: acceso a `self` para reintentos +- `max_retries=3`: reintenta ante fallos transitorios (ej. DB no disponible) +- `default_retry_delay=5`: espera 5s entre reintentos + +### Django Channels + Daphne — WebSockets + +Django es WSGI (síncrono). **Channels** añade soporte ASGI (asíncrono) para WebSockets. **Daphne** es el servidor ASGI que maneja conexiones WS persistentes. + +Hay dos consumers: +- `VehicleTrackingConsumer`: suscrito al grupo `vehicle_{vehicle_id}` — solo recibe eventos de un vehículo +- `FleetOverviewConsumer`: suscrito al grupo `fleet` — recibe eventos de toda la flota + +### Redis — Channel Layer + +Redis actúa como bus de mensajes entre el worker de Celery (que llama `group_send`) y los consumers de Channels (que reciben `vehicle_position`). Sin Redis, los workers y los consumers no podrían comunicarse porque corren en procesos separados. + +--- + +## WebSocket URLs + +``` +ws://localhost:8001/ws/tracking/BUS-001/ ← vehículo específico +ws://localhost:8001/ws/fleet/ ← toda la flota +``` + +--- + +## Decisiones de Diseño + +| Decisión | Alternativa | Razón | +|---|---|---| +| MQTT → Celery (dispatch) | Procesamiento directo en hilo MQTT | Desacopla ingesta de procesamiento; Celery maneja reintentos y backpressure | +| RabbitMQ como broker Celery | Redis como broker | RabbitMQ tiene routing más avanzado (exchanges, routing keys); Redis es solo una lista | +| Redis como Channel Layer | InMemoryChannelLayer | InMemoryChannelLayer no persiste entre procesos; Redis permite múltiples workers | +| Daphne como servidor ASGI | uvicorn | Daphne es el servidor oficial de Django Channels; soporte nativo para el protocolo ASGI de Channels | +| Dos grupos (vehicle + fleet) | Solo un grupo fleet | Permite subscripción selectiva: un cliente puede seguir un vehículo sin recibir ruido de toda la flota | diff --git a/lab-04-data-pipeline/README.md b/lab-04-data-pipeline/README.md index 16e6df1..c67469c 100644 --- a/lab-04-data-pipeline/README.md +++ b/lab-04-data-pipeline/README.md @@ -101,6 +101,8 @@ lab-04-data-pipeline/ └── tests/ ├── conftest.py # Fixtures: schema, aislamiento de tablas └── test_flows.py # 17 tests de integración (TimescaleDB real) +docs/ +└── architecture.md # Arquitectura, flujo de datos y decisiones de diseño ``` --- diff --git a/lab-04-data-pipeline/docs/architecture.md b/lab-04-data-pipeline/docs/architecture.md new file mode 100644 index 0000000..0c4ddd7 --- /dev/null +++ b/lab-04-data-pipeline/docs/architecture.md @@ -0,0 +1,139 @@ +# Lab 04 — Arquitectura y Funcionamiento + +Este documento explica el pipeline ETL, el modelo de series temporales en TimescaleDB y los notebooks de análisis. + +--- + +## Visión General + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Docker Network │ +│ │ +│ data/sample_gtfs/*.txt │ +│ │ │ +│ ▼ Prefect Flow │ +│ ┌──────────────────┐ Polars ┌──────────────────────────────┐ │ +│ │ ingest_gtfs.py │ ──────────▶ │ TimescaleDB │ │ +│ │ (ETL: stops, │ │ stops, routes, trips, │ │ +│ │ routes, trips) │ │ stop_times, calendar │ │ +│ └──────────────────┘ │ │ │ +│ │ vehicle_events (hypertable) │ │ +│ ┌──────────────────┐ Polars │ stop_ridership (hypertable)│ │ +│ │ transform.py │ ──────────▶ │ │ │ +│ │ (estadísticas, │ └──────────────────────────────┘ │ +│ │ Parquet export)│ │ │ +│ └──────────────────┘ │ SQL time-series │ +│ ▼ │ +│ ┌──────────────────┐ ┌──────────────┐ │ +│ │ analyze.py │ ────────────▶ │ Notebooks │ │ +│ │ (seed events, │ Polars DF │ (Jupyter) │ │ +│ │ fleet summary) │ └──────────────┘ │ +│ └──────────────────┘ │ +└────────────────────────────────────────────────────────────────────┘ + +Prefect Server (puerto 4200) — orquesta y monitorea los flows +``` + +--- + +## Componentes + +### Prefect — Orquestador de Flows + +Prefect 3 usa `@flow` y `@task` para definir pipelines con dependencias, reintentos y observabilidad. Cada `@task` es una unidad de trabajo que Prefect puede: +- Reintentar ante fallos (`retries=3`) +- Cachear resultados +- Registrar duración y estado en la UI (puerto 4200) + +En los tests se configura `PREFECT_API_URL=""` para correr flows localmente sin servidor. + +### Polars — Transformaciones ETL + +Los archivos GTFS son CSV con tipos heterogéneos. Polars los lee con `infer_schema_length=0` (todo como strings) para evitar inferencias incorrectas, luego castea explícitamente cada columna. + +Las transformaciones incluyen: +- Filtrado de filas inválidas +- Extracción de hora desde strings `"HH:MM:SS"` (para clasificar períodos del día) +- Joins sin índices explícitos (Polars usa hash joins automáticamente) + +### TimescaleDB — Series Temporales + +TimescaleDB extiende PostgreSQL con **hypertables**: tablas que se particionan automáticamente por tiempo. Las dos hypertables del lab son: + +| Hypertable | Chunk interval | Uso | +|---|---|---| +| `vehicle_events` | 1 día | Posiciones GPS de vehículos en tiempo real | +| `stop_ridership` | 1 día | Abordajes/descensos por parada cada 15 min | + +La función `time_bucket('5 minutes', recorded_at)` agrega eventos en ventanas de 5 minutos — equivalente a `GROUP BY` sobre intervalos de tiempo, pero optimizada con índices de partición. + +### GTFS — Formato de Datos + +GTFS (General Transit Feed Specification) es el estándar de facto para datos de transporte público. Son archivos CSV con nombres fijos: + +``` +stops.txt → paradas (id, nombre, lat, lon) +routes.txt → rutas (id, nombre, agencia) +trips.txt → viajes (id, ruta, servicio, horario) +stop_times.txt → horarios por parada +calendar.txt → calendarios de servicio (días hábiles, fines de semana) +``` + +--- + +## Flujo de Datos + +``` +GTFS (.txt) + │ + ▼ read_gtfs_file (Polars) + │ + ▼ validate_file (conteo, columnas requeridas) + │ + ▼ load_stops / load_routes / ... (psycopg2 execute_values) + │ ON CONFLICT DO UPDATE (idempotente) + ▼ +TimescaleDB (tablas relacionales) + │ + ▼ fetch_table (Polars DataFrame) + │ + ▼ compute_route_stats / compute_busiest_stops / ... + │ + ▼ export_parquet → data/processed/ +``` + +--- + +## Notebooks + +### `explore_data.ipynb` + +Análisis exploratorio interactivo. Se ejecuta dentro del contenedor `app` con JupyterLab: + +```bash +docker compose exec app jupyter lab --ip=0.0.0.0 --port=8888 --no-browser +``` + +Conecta a TimescaleDB con psycopg2, ejecuta queries con `time_bucket()`, y visualiza con Matplotlib. + +### `ml_demand.ipynb` + +Modelo predictivo de demanda con scikit-learn: +1. Feature engineering con Polars: lag features (`shift(1).over('stop_id')`) +2. Rolling mean como baseline +3. Regresión lineal para predicción del próximo bucket de 15 min +4. Evaluación con MAE por parada + +--- + +## Decisiones de Diseño + +| Decisión | Alternativa | Razón | +|---|---|---| +| Prefect sobre Celery | Celery Beat | Prefect está diseñado para pipelines con DAGs, reintentos y observabilidad; Celery es para tareas reactivas | +| TimescaleDB sobre InfluxDB | InfluxDB, ClickHouse | TimescaleDB es PostgreSQL — mismo driver (psycopg2), SQL estándar, joins con tablas relacionales | +| Polars sobre Pandas | Pandas, DuckDB | Lazy evaluation, zero-copy Parquet, API más ergonómica para ETL | +| execute_values con ON CONFLICT | ORM de Django | Bulk insert nativo de psycopg2 es significativamente más rápido; idempotencia garantizada | +| Puerto 5433 (no 5432) | Puerto estándar | Evita colisión con PostgreSQL de Labs 01/02 si corren simultáneamente | +| Chunk interval 1 día | 1 semana / 1 hora | Balance entre número de chunks (overhead) y tamaño de cada partición para datos de transporte | diff --git a/lab-05-graphql-cache/README.md b/lab-05-graphql-cache/README.md index 012c8e7..d58c7d7 100644 --- a/lab-05-graphql-cache/README.md +++ b/lab-05-graphql-cache/README.md @@ -51,6 +51,8 @@ lab-05-graphql-cache/ └── tests/ ├── conftest.py └── test_queries.py +docs/ +└── architecture.md # Arquitectura, flujo de datos y decisiones de diseño ``` --- diff --git a/lab-05-graphql-cache/docs/architecture.md b/lab-05-graphql-cache/docs/architecture.md new file mode 100644 index 0000000..74bc5f7 --- /dev/null +++ b/lab-05-graphql-cache/docs/architecture.md @@ -0,0 +1,148 @@ +# Lab 05 — Arquitectura y Funcionamiento + +Este documento explica el esquema GraphQL, la estrategia de caching con Redis y la solución al problema N+1 con DataLoaders. + +--- + +## Visión General + +``` +┌────────────────────────────────────────────────────────────────┐ +│ Docker Network │ +│ │ +│ Cliente GraphQL │ +│ │ POST /graphql/ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Django + Strawberry │ │ +│ │ │ │ +│ │ Query/Mutation ──▶ Resolver ──▶ Cache (Redis)? │ │ +│ │ │ │ │ │ +│ │ │ HIT ◀─┘ │ │ +│ │ │ MISS │ │ +│ │ ▼ │ │ +│ │ DataLoader (batch) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ └──────────────────────────┬───────────────────────────────┘ │ +│ │ ORM queries │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ PostgreSQL │ │ +│ │ Route/Stop/ │ │ +│ │ Schedule │ │ +│ └─────────────────┘ │ +│ │ +│ ┌─────────────────┐ │ +│ │ Redis │ ← cache de queries GraphQL │ +│ └─────────────────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Esquema GraphQL + +```graphql +type Route { + id: ID! + name: String! + origin: String! + destination: String! + active: Boolean! + stops: [Stop!]! # resuelto por DataLoader +} + +type Stop { + id: ID! + name: String! + lat: Float! + lon: Float! + schedules: [Schedule!]! # resuelto por DataLoader +} + +type Query { + routes(activeOnly: Boolean): [Route!]! + route(id: ID!): Route + stopsNearby(lat: Float!, lon: Float!, radiusKm: Float!): [Stop!]! +} + +type Mutation { + createRoute(...): Route! + updateRoute(...): Route! + deleteRoute(id: ID!): Boolean! +} +``` + +--- + +## El Problema N+1 y DataLoaders + +### Sin DataLoaders + +Una query que pide 10 rutas con sus paradas genera: +``` +1 query → SELECT * FROM routes (10 rutas) +10 queries → SELECT * FROM stops WHERE route_id = ? (una por ruta) += 11 queries totales +``` + +Con 100 rutas serían 101 queries. Con relaciones anidadas (stops → schedules), se multiplica exponencialmente. + +### Con DataLoaders + +`StopsByRouteLoader` acumula todos los `route_id` solicitados durante la resolución de un request y los resuelve en **una sola query**: + +```python +class StopsByRouteLoader(DataLoader): + async def batch_load_fn(self, route_ids): + stops = await Stop.objects.filter(route_id__in=route_ids) + # agrupa por route_id y devuelve en el mismo orden + ... +``` + +``` +1 query → SELECT * FROM routes WHERE id IN (...) +1 query → SELECT * FROM stops WHERE route_id IN (...) +1 query → SELECT * FROM schedules WHERE stop_id IN (...) += 3 queries totales, independientemente del número de rutas +``` + +--- + +## Caching con Redis + +El decorator de cache envuelve los resolvers de lectura: + +```python +def cached(key_fn, ttl=300): + def resolver(...): + key = f"gql:{key_fn(...)}" + if result := cache.get(key): + return result + result = db_query(...) + cache.set(key, result, ttl) + return result +``` + +Las mutations invalidan las claves afectadas con `cache.delete_pattern("gql:routes:*")`. + +**TTL de 5 minutos (300s):** Balance entre frescura de datos (rutas no cambian frecuentemente) y reducción de carga a la DB. + +--- + +## Playground GraphQL + +Strawberry incluye una UI interactiva en `http://localhost:8005/graphql/`. El renderer HTML requiere que `REST_FRAMEWORK` tenga `DEFAULT_RENDERER_CLASSES` configurado con `JSONRenderer` únicamente — de lo contrario Django busca templates de DRF que no existen. + +--- + +## Decisiones de Diseño + +| Decisión | Alternativa | Razón | +|---|---|---| +| Strawberry | graphene-django | Strawberry usa type annotations nativas de Python; graphene requiere clases heredadas con sintaxis propia | +| DataLoaders async | prefetch_related de Django | DataLoaders son agnósticos al ORM y funcionan en contexto async (ASGI) | +| Redis para cache | Memcached / locmem | Redis persiste ante reinicios, soporta `delete_pattern`, y ya está en el stack | +| Haversine en Python | PostGIS / extensión pg_sphere | Sin PostGIS en este lab, Haversine en Python es suficiente para radio de búsqueda de paradas | +| TTL 5 min | TTL más largo / sin TTL | Las rutas cambian poco pero el sistema debe reflejar cambios sin reinicio | diff --git a/lab-06-frontend-nuxt/README.md b/lab-06-frontend-nuxt/README.md index 34304d1..a8fc6df 100644 --- a/lab-06-frontend-nuxt/README.md +++ b/lab-06-frontend-nuxt/README.md @@ -57,6 +57,8 @@ lab-06-frontend-nuxt/ └── api/ ├── routes.ts # Proxy → Lab 02 DRF /api/routes/ └── routes/[id].ts # Proxy → Lab 02 DRF /api/routes/:id/ +docs/ +└── architecture.md # Arquitectura, flujo de datos y decisiones de diseño ``` --- diff --git a/lab-06-frontend-nuxt/docs/architecture.md b/lab-06-frontend-nuxt/docs/architecture.md new file mode 100644 index 0000000..c75aa3d --- /dev/null +++ b/lab-06-frontend-nuxt/docs/architecture.md @@ -0,0 +1,126 @@ +# Lab 06 — Arquitectura y Funcionamiento + +Este documento explica el patrón BFF, el flujo de datos desde el backend hasta la UI, y la integración WebSocket. + +--- + +## Visión General + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Browser │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Nuxt 3 (SSR + CSR) │ │ +│ │ │ │ +│ │ pages/index.vue ──▶ useFetch('/api/routes') │ │ +│ │ pages/routes/ ──▶ useFetch('/api/routes') │ │ +│ │ pages/live.vue ──▶ useFleetWebSocket() │ │ +│ │ │ │ │ +│ │ ┌───────────────┴──────────────┐ │ │ +│ │ ▼ ▼ │ │ +│ │ server/api/routes.ts ws://localhost:8001 │ │ +│ │ (BFF — Nuxt server route) /ws/fleet/ │ │ +│ └──────────────────┬───────────────────────────────────────┘ │ +└─────────────────────┼────────────────────────────────────────────┘ + │ HTTP (host.docker.internal) + ▼ + ┌─────────────────────────────┐ + │ Lab 02 — Django DRF │ + │ GET /api/routes/ │ + │ port 8000 │ + └─────────────────────────────┘ + + ┌─────────────────────────────┐ + │ Lab 03 — Django Channels │ + │ ws://.../ws/fleet/ │ + │ port 8001 │ + └─────────────────────────────┘ +``` + +--- + +## Patrón BFF (Backend for Frontend) + +Los **server routes** de Nuxt (`server/api/*.ts`) actúan como BFF: el browser nunca llama directamente a los backends de Django. En cambio: + +1. El browser llama `/api/routes` (mismo origen → sin CORS) +2. El server route de Nuxt llama `http://host.docker.internal:8000/api/routes/` +3. Transforma la respuesta paginada DRF `{ count, results: [...] }` en un array plano +4. Devuelve al browser + +Ventajas: +- CORS no es problema (mismo origen para el browser) +- Permite transformar, filtrar o combinar respuestas de múltiples backends +- El backend URL nunca se expone al cliente + +--- + +## Renderizado — SSR + Hydration + +Nuxt 3 usa SSR (Server-Side Rendering) por defecto: + +1. En el servidor: `useFetch('/api/routes')` ejecuta el server route, obtiene datos, renderiza HTML +2. El HTML inicial llega al browser con datos embebidos (no hay flash de contenido vacío) +3. Vue hydrata el HTML estático y toma control del DOM + +**Problema de cache SSR resuelto:** Si el servidor renderizó la página cuando no había datos y los cacheó, `onMounted(() => refresh())` fuerza una re-fetch client-side al montar el componente, garantizando datos frescos. + +--- + +## WebSocket — Flota en Tiempo Real + +`useFleetWebSocket()` (composable en `composables/useWebSocket.ts`) gestiona la conexión WebSocket: + +```typescript +const ws = new WebSocket('ws://localhost:8001/ws/fleet/') + +ws.onmessage = (event) => { + const data = JSON.parse(event.data) + // actualiza el mapa reactivo de vehículos + vehicles.value.set(data.vehicle_id, data) +} +``` + +El composable maneja reconexión automática y limpia la conexión en `onUnmounted`. La página `/live` se suscribe al grupo `fleet` de Lab 03, que recibe posiciones de todos los buses simultáneamente. + +--- + +## Componentes Vue + +| Componente | Responsabilidad | +|---|---| +| `pages/index.vue` | Dashboard: KPIs (rutas activas, total) + lista reciente | +| `pages/routes/index.vue` | Tabla de rutas con búsqueda y filtrado | +| `pages/routes/[id].vue` | Detalle de una ruta específica | +| `pages/live.vue` | Tabla en tiempo real de posiciones vehiculares | + +--- + +## Composables TypeScript + +| Composable | Qué expone | +|---|---| +| `useWebSocket.ts` | `vehicles` (ref reactivo), `connected` (estado WS) | + +Los composables encapsulan lógica stateful y son reutilizables entre páginas. + +--- + +## Dependencias de Labs Externos + +Lab 06 depende de Labs 02 y 03 corriendo simultáneamente. Ambos usan el puerto 8000 en su configuración original — en esta implementación Lab 03 corre en el puerto **8001** para evitar la colisión. + +El acceso desde el contenedor de Lab 06 a los otros labs usa `host.docker.internal` (nombre DNS especial que resuelve a la IP del host en Docker Desktop para Windows/Mac). + +--- + +## Decisiones de Diseño + +| Decisión | Alternativa | Razón | +|---|---|---| +| BFF con server routes | Llamadas directas desde el browser | Sin CORS, transformación de respuesta, URL de backend no expuesta | +| `onMounted(() => refresh())` | Solo SSR / solo CSR | Garantiza datos frescos en F5 y en navegación de vuelta con el router | +| Nuxt UI | Vuetify / PrimeVue | Diseñado para Nuxt 3, integra Tailwind CSS, componentes con dark mode por defecto | +| `host.docker.internal` | Nombres de servicio Docker | Los labs corren en redes Docker separadas; `host.docker.internal` cruza la frontera de redes | +| WebSocket directo (no BFF) | Proxy WS en server route | Nuxt no soporta proxy de WebSockets en server routes; el browser conecta directamente a Lab 03 | diff --git a/lab-07-observability/README.md b/lab-07-observability/README.md index b1e563d..3bd89ad 100644 --- a/lab-07-observability/README.md +++ b/lab-07-observability/README.md @@ -63,6 +63,8 @@ lab-07-observability/ │ └── transit-overview.json # 4 paneles: req/s · latencia p95 · errores 5xx · latencia por endpoint └── otel/ └── otel-collector-config.yml # Receivers: OTLP gRPC/HTTP · Exporters: Prometheus + logging +docs/ +└── architecture.md # Arquitectura, flujo de datos y decisiones de diseño ``` --- diff --git a/lab-07-observability/docs/architecture.md b/lab-07-observability/docs/architecture.md new file mode 100644 index 0000000..81cc175 --- /dev/null +++ b/lab-07-observability/docs/architecture.md @@ -0,0 +1,156 @@ +# Lab 07 — Arquitectura y Funcionamiento + +Este documento explica el pipeline de observabilidad: cómo fluyen las métricas desde Django hasta los dashboards de Grafana. + +--- + +## Visión General + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Docker Network │ +│ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Django (puerto 8002) │ │ +│ │ │ │ +│ │ Request ──▶ TransitMetricsMiddleware ──▶ View │ │ +│ │ │ │ │ +│ │ transit_request_duration_seconds │ │ +│ │ (histograma custom por endpoint) │ │ +│ │ │ │ +│ │ GET /metrics ──▶ django-prometheus ──▶ formato Prometheus│ │ +│ └──────────────────────────────┬──────────────────────────── ┘ │ +│ │ scrape /metrics │ +│ ▼ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Prometheus │──▶│ Grafana │ │ +│ │ (puerto 9090) │ │ (puerto 3001) │ │ +│ │ scrape + store │ │ dashboards │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ OTel Collector (puertos 4317/4318/8889) │ │ +│ │ Recibe traces OTLP ──▶ exporta métricas a Prometheus │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Componentes + +### django-prometheus — Métricas Automáticas + +Añadir `django_prometheus` a `INSTALLED_APPS` y sus middlewares activa automáticamente: +- Contadores de peticiones HTTP por método, path y status code +- Histogramas de latencia HTTP +- Métricas de modelos (inserts, updates, deletes) +- Métricas de migraciones pendientes/aplicadas +- Métricas de proceso Python (GC, memoria, CPU) + +Todo en `/metrics` en formato Prometheus (texto plano con `# HELP` y `# TYPE`). + +### `middleware.py` — Métrica Custom + +`TransitMetricsMiddleware` registra `transit_request_duration_seconds`, un histograma adicional específico del dominio: + +```python +TRANSIT_ENDPOINTS = {"/api/routes/", "/api/routes//", "/api/stops/"} + +histogram.labels(endpoint=endpoint, method=method).observe(duration) +``` + +La diferencia con las métricas automáticas de django-prometheus: esta métrica solo registra endpoints de transporte y agrupa `/api/routes/1/`, `/api/routes/2/` bajo la etiqueta `"/api/routes//"`, lo que hace las queries PromQL más limpias. + +### Prometheus — Scraping y Storage + +Prometheus hace polling a `/metrics` cada 15 segundos (configurado en `prometheus.yml`). Almacena las series temporales en TSDB (Time Series Database) local. Las queries se hacen en PromQL. + +```yaml +scrape_configs: + - job_name: django + static_configs: + - targets: ["django:8002"] + - job_name: otel-collector + static_configs: + - targets: ["otel-collector:8889"] +``` + +### Grafana — Dashboards como Código + +El dashboard `transit-overview.json` se monta como volumen y Grafana lo carga al iniciar mediante provisioning: + +```yaml +# grafana/provisioning/dashboards/default.yml +providers: + - name: default + folder: SIMOVI + options: + path: /var/lib/grafana/dashboards +``` + +Los 4 paneles del dashboard: + +| Panel | Query PromQL | +|---|---| +| Peticiones HTTP/s | `rate(django_http_requests_total_by_method_total[1m])` | +| Latencia p95 (ms) | `histogram_quantile(0.95, rate(django_http_request_duration_seconds_bucket[1m])) * 1000` | +| Tasa de errores 5xx | `rate(django_http_responses_total_by_status_class_total{status_class="5xx"}[1m])` | +| Latencia endpoints (p95) | `histogram_quantile(0.95, rate(transit_request_duration_seconds_bucket[1m]))` | + +### OTel Collector — Pipeline de Traces + +Recibe traces del SDK de OpenTelemetry instrumentado en Django (`opentelemetry-instrumentation-django`), los procesa y los exporta. En este lab exporta como métricas Prometheus (puerto 8889) además de logging. + +--- + +## Flujo de una Petición Instrumentada + +``` +Cliente + │ GET /api/routes/ + ▼ +Django middleware stack + │ PrometheusBeforeMiddleware (inicia timer) + │ TransitMetricsMiddleware (inicia timer custom) + │ OpenTelemetry middleware (inicia span) + │ + ▼ View ejecuta queryset + │ + │ OpenTelemetry middleware (cierra span → envía a OTel Collector) + │ TransitMetricsMiddleware (cierra timer → observe histogram) + │ PrometheusAfterMiddleware (incrementa contadores) + ▼ +Response → Cliente + +/metrics (scrapeado por Prometheus c/15s) + ▼ +Prometheus TSDB + ▼ +Grafana dashboard (refresh c/10s) +``` + +--- + +## `makemigrations` con Label Explícito + +La app `lab07.apps.routes` tiene `AppConfig.label = "lab07_routes"` para evitar colisión de nombres con otras apps `routes` del repositorio. Django requiere el label explícito en `makemigrations`: + +```bash +python manage.py makemigrations lab07_routes +``` + +Sin el label, Django reporta "No changes detected" aunque la app tenga modelos nuevos. + +--- + +## Decisiones de Diseño + +| Decisión | Alternativa | Razón | +|---|---|---| +| django-prometheus | prometheus_client manual | Instrumentación automática sin modificar vistas; métricas de Django listas en una línea | +| Middleware custom | django-prometheus solo | La métrica `transit_request_duration_seconds` agrupa paths dinámicos (`/routes//`) — django-prometheus registra la URL completa | +| Grafana provisioning (JSON) | Configurar dashboards en UI | El dashboard como código es reproducible; el volumen se monta al iniciar, sin configuración manual | +| OTel Collector | Exportar traces directo a Jaeger | El Collector desacopla la app del backend de trazas; se puede cambiar el destino sin tocar el código | +| Puerto 8002 | Puerto 8000 | Evita colisión con Labs 02 y 03 que ya usan 8000/8001 | +| Puerto 3001 para Grafana | Puerto 3000 estándar | Evita colisión con Lab 06 (Nuxt) que usa 3000 |