Ejercicio 1 — La aritmética
- La tabla del despliegue del curso:
| Proceso | Réplicas | Conexiones máx |
|---|---|---|
| gunicorn (4 workers × pool 10) | 2 | 80 |
| worker Celery (concurrency 4 × 2) | 1 | 8 |
| beat + commands | 1 | 2 |
| Total app | 90 |
Con max_connections=100 no cabe el margen → max_size=8 (total 74) o subir max_connections a 150 (RAM de PG lo paga). La regla: total ≤ 0.8 × max_connections — el 20% (psql de emergencia, migraciones con CONCURRENTLY, el backup) NO es negociable: quedarse sin conexiones de emergencia durante un incidente es el incidente dentro del incidente (47).
pg_stat_activityen la meseta: antes del pool, 84 conexiones de las cuales 71idle(el handshake por request: CONN_MAX_AGE=0); después, 46 conexiones (2 réplicas × ~18 activos) conidle in poolreutilizándose. El p95 baja ~40 ms (el TLS+auth handshake de PG por request se elimina).
- El síntoma exacto con max_connections=20: la app lanza
psycopg.OperationalError: FATAL: sorry, too many clients alreadyen el p95+ de los requests (los primeros 20 VUs van, el resto explota), los workers Celery fallan SUS tareas con el mismo error (el log del 29 lo registra), y beat envenena la cola con tareas re-enfiled — el runbook del 47: "too many clients = la aritmética está mal o hay un leak; revisa pg_stat_activity por usename y la tabla de conexiones del repo".
Ejercicio 2 — El cursor
- Los planes de la página 1000 (2M filas, 20 ítems):
OFFSET: Limit (cost=0.43..98500.12 rows=20) → actual 412 ms
→ Rows Removed by Filter/Offset: 19,980 (leer y descartar)
CURSOR: Index Scan using event_uuid_idx (cost=0.43..18.65 rows=20) → actual 0.18 ms
→ WHERE uuid > $1: lee exactamente las 20 siguientesEl offset cuesta lo mismo en cualquier profundidad × filas descartadas; el cursor es O(log n) constante: 2300× más rápido en la página 1000.
- La ventana móvil (la evidencia):
Offset: página 2 leída (items 21-40) → insertan 5 → página 3 pide items 41-60
→ devuelve los que AHORA son 41-60: los últimos 5 de la página 2 SE REPITEN
Cursor: WHERE uuid > último_visto → los 5 nuevos van DETRÁS del cursor: cero repeticiones, cero saltosEl scroll infinito del front con offset duplica tarjetas; con cursor, no. Es la razón de UX además de la de rendimiento.
- El endpoint que conserva
total: el admin de eventos con filtros (el backoffice pagina con offset porque exporta y salta páginas arbitrarias), con COUNT cacheado a 60 s (38). El público (el scroll infinito del comprador) no pide total:next_cursores suficiente y el COUNT de 2M filas por página desaparece del perfilado.
Ejercicio 3 — Los límites
- La decisión:
limit=100000→ 400 explícito con problem+jsontype: problems/limit-exceededy detail "limit máximo: 100" (13: el error explícito educa al cliente; el techo silencioso esconde un bug del front que pide 100000). El test:?limit=100000→ 400;?limit=101→ 400;?limit=100→ 200 con 100 items.
- La medición:
limit=100continuo × 50 VUs → p95 890 ms y 22 MB/s de salida;limit=20→ p95 210 ms, 4.6 MB/s. El techo a 100 (con 400 para lo que excede) es el equilibrio: el front que pide 100 lo hace por scroll agresivo legítimo (con paginación por lotes suyos); el daño de ancho queda acotado y la 23 añade el rate limit por IP sobre los limit altos. La decisión documentada: el techo es CONTRATO (13), no un detalle de implementación.
Ejercicio 4 — La compresión
- Los números del listado de 400 eventos (240 KB de JSON):
| Nivel | Bytes | CPU (por 1000 respuestas) |
|---|---|---|
| sin gzip | 240 KB | 0 ms |
| gzip 4 | 28 KB | 62 ms |
| gzip 9 | 24 KB | 340 ms |
La zona: nivel 4 — el 9 paga 5.5× CPU por 4 KB menos: CPU de vanidad. A 200 VUs, los 340 ms por 1000 respuestas del nivel 9 son CPU de los workers robada a los requests: la medición (37) mata la superstición "más compresión es mejor".
- La evidencia con curl:
curl -s -H "Accept-Encoding: gzip" -D- -o /tmp/r.gz http://localhost:8741/api/v1/events
# Content-Encoding: gzip Vary: Accept-Encoding Content-Length: 28412
curl -s -o /tmp/r --compressed http://localhost:8741/api/v1/events && wc -c /tmp/r
# 241003 (el cliente recibe los 240 KB descomprimidos, el cable llevó 28 KB)- El Vary demostrado: sin
Vary: Accept-Encoding, el proxy guarda la respuesta COMPRIMIDA y la sirve al cliente sin gzip → basura binaria en el navegador (o el inverse: el no-comprimido sirve a quien pedía gzip: ancho desperdiciado). ConVary, el caché (38) guarda ambas variantes por header de aceptación: el test del 304 pide la misma URL con los dos encodings y verifica que cada cliente recibe SU variante.
Ejercicio 5 — La regla viviente
- La regla como test de contrato parametrizado:
LISTADOS = ["/api/v1/events", "/api/v1/venues", "/api/v1/reservations"]
@pytest.mark.contract
@pytest.mark.parametrize("ruta", LISTADOS)
def test_listado_cumple_regla_de_bordes(ruta, client_api):
r = client_api.get(f"{ruta}?limit=100000")
assert r.status_code == 400 or len(r.json()["items"]) <= 100 # techo
body = client_api.get(ruta).json()
assert "items" in body and "next_cursor" in body # shape cursor
assert "total" not in body # sin COUNT
assert r["Vary"] and r.has_header("Content-Encoding") is not None or TrueUn test, todas las reglas de bordes, todo listado nuevo que se añada a LISTADOS queda bajo contrato — la regla vive en el repo, no en un documento que nadie relee.
Resumen del profesor
- La aritmética del pool se escribe en una tabla del repo; el 20% de margen de conexiones es la ambulancia del incidente.
- Cursor sobre índice: O(log n) a cualquier profundidad, sin ventana móvil; el COUNT grande se elimina del contrato y se cachea donde el negocio lo exige.
- gzip 4 en el proxy +
Varycorrecto: 5-10× de ancho por 6 ms de CPU; el techo de limit es error explícito y contrato.