feat: analisis NATS pub/sub con 3 notebooks
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
# JUPYTER HABILITADO EN ESTE ANALISIS
|
||||||
|
|
||||||
|
## Reglas OBLIGATORIAS para Claude
|
||||||
|
|
||||||
|
### 1. CODIGO INMUTABLE — NUNCA MODIFICAR CELDAS EXISTENTES
|
||||||
|
- **PROHIBIDO** usar NotebookEdit para reemplazar celdas existentes
|
||||||
|
- **SIEMPRE** anadir celdas NUEVAS al final del notebook
|
||||||
|
- Si hay un error en una celda, crear celda nueva con la correccion
|
||||||
|
- El historial de trabajo debe quedar intacto para trazabilidad
|
||||||
|
|
||||||
|
### 2. PROGRAMACION FUNCIONAL OBLIGATORIA
|
||||||
|
- **Funciones puras**: sin efectos secundarios, mismo input -> mismo output
|
||||||
|
- **Inmutabilidad**: nunca mutar datos, crear copias transformadas
|
||||||
|
- **Composicion**: funciones pequenas que se combinan
|
||||||
|
- Preferir: `map`, `filter`, `reduce`, list comprehensions
|
||||||
|
- Evitar: loops con mutacion, `global`, modificar argumentos in-place
|
||||||
|
|
||||||
|
### 3. SIEMPRE usar MCP jupyter para ejecutar codigo Python
|
||||||
|
- Las ejecuciones se ven en tiempo real en Jupyter Lab del usuario
|
||||||
|
- Compartimos variables y estado del kernel
|
||||||
|
- **NUNCA usar bash para ejecutar Python en este analisis**
|
||||||
|
|
||||||
|
### 4. Verificar Jupyter activo ANTES de ejecutar
|
||||||
|
- Si no esta activo: pedir al usuario que ejecute `./run-jupyter-lab.sh`
|
||||||
|
|
||||||
|
### 5. Gestion de notebooks
|
||||||
|
- Notebooks en la carpeta `notebooks/` o subcarpetas
|
||||||
|
- Si un notebook tiene >50 celdas, crear uno nuevo
|
||||||
|
- Nombrar descriptivamente: `01_exploracion.ipynb`, `02_limpieza.ipynb`
|
||||||
|
|
||||||
|
### 6. Gestion de Python
|
||||||
|
- **SIEMPRE usar `uv`** para gestionar dependencias
|
||||||
|
- Anadir paquetes con `uv add nombre_paquete`
|
||||||
|
|
||||||
|
### 7. Acceso al fn_registry
|
||||||
|
- `FN_REGISTRY_ROOT` apunta a la raiz del registry
|
||||||
|
- Para importar funciones Python: `sys.path.insert(0, os.path.join(os.environ["FN_REGISTRY_ROOT"], "python", "functions"))`
|
||||||
|
- Para consultar registry.db: `sqlite3` o `import sqlite3` con la ruta `$FN_REGISTRY_ROOT/registry.db`
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
fn_registry kernel startup
|
||||||
|
Autoconfigura acceso al registry en cada notebook.
|
||||||
|
Generado por write_jupyter_registry_kernel (fn_registry).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── FN_REGISTRY_ROOT ────────────────────────────────────────
|
||||||
|
# Prioridad: env var > path hardcoded > descubrimiento automatico
|
||||||
|
def _discover_registry_root():
|
||||||
|
if os.environ.get("FN_REGISTRY_ROOT"):
|
||||||
|
return Path(os.environ["FN_REGISTRY_ROOT"]).resolve()
|
||||||
|
hardcoded = Path("/home/enmanuel/fn_registry")
|
||||||
|
if (hardcoded / "registry.db").exists():
|
||||||
|
return hardcoded
|
||||||
|
# Subir desde CWD hasta encontrar registry.db
|
||||||
|
p = Path.cwd()
|
||||||
|
for _ in range(10):
|
||||||
|
if (p / "registry.db").exists():
|
||||||
|
return p
|
||||||
|
if p.parent == p:
|
||||||
|
break
|
||||||
|
p = p.parent
|
||||||
|
return hardcoded
|
||||||
|
|
||||||
|
FN_REGISTRY_ROOT = _discover_registry_root()
|
||||||
|
os.environ["FN_REGISTRY_ROOT"] = str(FN_REGISTRY_ROOT)
|
||||||
|
|
||||||
|
# ── sys.path: importar funciones Python del registry ────────
|
||||||
|
_python_functions = FN_REGISTRY_ROOT / "python" / "functions"
|
||||||
|
for _domain in sorted(_python_functions.iterdir()) if _python_functions.exists() else []:
|
||||||
|
if _domain.is_dir() and not _domain.name.startswith("_"):
|
||||||
|
_path = str(_domain)
|
||||||
|
if _path not in sys.path:
|
||||||
|
sys.path.insert(0, _path)
|
||||||
|
|
||||||
|
# Tambien el directorio padre para imports por dominio: from core import filter_list
|
||||||
|
_pf = str(_python_functions)
|
||||||
|
if _pf not in sys.path:
|
||||||
|
sys.path.insert(0, _pf)
|
||||||
|
|
||||||
|
# ── fn_query: consultar registry.db desde el notebook ───────
|
||||||
|
_REGISTRY_DB = FN_REGISTRY_ROOT / "registry.db"
|
||||||
|
|
||||||
|
def fn_query(sql, params=()):
|
||||||
|
"""Ejecuta una consulta SQL sobre registry.db y retorna las filas.
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
fn_query("SELECT id, description FROM functions WHERE domain = ?", ("finance",))
|
||||||
|
fn_query("SELECT id FROM functions_fts WHERE functions_fts MATCH ?", ("slice*",))
|
||||||
|
"""
|
||||||
|
if not _REGISTRY_DB.exists():
|
||||||
|
raise FileNotFoundError(f"registry.db no encontrado en {_REGISTRY_DB}")
|
||||||
|
con = sqlite3.connect(str(_REGISTRY_DB))
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
rows = con.execute(sql, params).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
def fn_search(term):
|
||||||
|
"""Busca funciones y tipos en el registry por nombre o descripcion.
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
fn_search("slice")
|
||||||
|
fn_search("finance")
|
||||||
|
"""
|
||||||
|
fts_term = f"name:{term}* OR description:{term}*"
|
||||||
|
functions = fn_query(
|
||||||
|
"SELECT id, kind, purity, lang, description FROM functions "
|
||||||
|
"WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH ?) "
|
||||||
|
"ORDER BY name", (fts_term,)
|
||||||
|
)
|
||||||
|
types = fn_query(
|
||||||
|
"SELECT id, algebraic, lang, description FROM types "
|
||||||
|
"WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH ?) "
|
||||||
|
"ORDER BY name", (fts_term,)
|
||||||
|
)
|
||||||
|
return {"functions": functions, "types": types}
|
||||||
|
|
||||||
|
def fn_code(function_id):
|
||||||
|
"""Retorna el codigo fuente de una funcion del registry.
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
print(fn_code("filter_list_py_core"))
|
||||||
|
"""
|
||||||
|
rows = fn_query("SELECT code FROM functions WHERE id = ?", (function_id,))
|
||||||
|
if not rows:
|
||||||
|
raise KeyError(f"Funcion no encontrada: {function_id}")
|
||||||
|
return rows[0]["code"]
|
||||||
|
|
||||||
|
# ── Mensaje de bienvenida ───────────────────────────────────
|
||||||
|
print(f"fn_registry conectado: {FN_REGISTRY_ROOT}")
|
||||||
|
print(f" registry.db: {'OK' if _REGISTRY_DB.exists() else 'NO ENCONTRADO'}")
|
||||||
|
print(f" Python functions: {_pf}")
|
||||||
|
print(f" Helpers: fn_query(), fn_search(), fn_code()")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
8890
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"a298cd70-8577-4ca9-82d7-517ca946d137": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"created_at": "2026-06-03T17:36:33.757848+00:00",
|
||||||
|
"document_version": "2.0.0"
|
||||||
|
},
|
||||||
|
"17291f8f-336e-4a5f-8407-a4d22149d581": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"created_at": "2026-06-03T17:46:15.674895+00:00",
|
||||||
|
"document_version": "2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"jupyter": {
|
||||||
|
"command": "/home/enmanuel/fn_registry/analysis/nats/.venv/bin/jupyter-mcp-server",
|
||||||
|
"args": [
|
||||||
|
"--transport", "stdio",
|
||||||
|
"--jupyter-url", "http://localhost:8890",
|
||||||
|
"--jupyter-token", ""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
name: nats
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
description: "Demostracion de envio de datos por pub/sub entre procesos con NATS (core pub/sub, wildcards, queue groups, request/reply, JetStream y procesos OS reales)"
|
||||||
|
tags: [nats, pubsub, messaging, jetstream, asyncio, docker]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
framework: "jupyterlab"
|
||||||
|
entry_point: "notebooks/01_core_pubsub.ipynb"
|
||||||
|
dir_path: "analysis/nats"
|
||||||
|
repo_url: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Analisis didactico de **NATS** como sistema de mensajeria pub/sub entre procesos. El broker corre en Docker (`nats:latest -js`, puerto 4222) y el cliente es `nats-py` (asyncio). Tres notebooks progresivos:
|
||||||
|
|
||||||
|
| Notebook | Contenido |
|
||||||
|
|---|---|
|
||||||
|
| `01_core_pubsub.ipynb` | Modelo base: conexion, publish/subscribe, fan-out a N subscribers, wildcards `*` y `>`. |
|
||||||
|
| `02_queue_request_jetstream.ipynb` | Queue groups (reparto de carga), request/reply (RPC con inbox temporal), JetStream (stream persistente + consumer durable + replay). |
|
||||||
|
| `03_procesos_reales.ipynb` | Publisher y subscribers como **procesos del SO independientes** (`subprocess`), cada uno con su PID. Demuestra el desacople real: el publisher no conoce a sus subscribers. |
|
||||||
|
|
||||||
|
Los scripts `notebooks/procs/publisher.py` y `notebooks/procs/subscriber.py` son los programas que el notebook 03 lanza como procesos reales.
|
||||||
|
|
||||||
|
### Como usar
|
||||||
|
|
||||||
|
1. Requiere Docker disponible (con permisos para el usuario actual). La primera celda de cada notebook arranca el broker de forma idempotente con la funcion `ensure_nats`, asi que cada notebook funciona de forma aislada.
|
||||||
|
2. Lanzar Jupyter del analisis: `cd analysis/nats && ./run-jupyter-lab.sh` (puerto 8890).
|
||||||
|
3. Abrir cualquier notebook y ejecutar las celdas en orden.
|
||||||
|
|
||||||
|
### Requisitos
|
||||||
|
|
||||||
|
- `nats-py` (instalado en el `.venv` del analisis).
|
||||||
|
- Imagen Docker `nats:latest` (se descarga con `docker pull nats:latest` la primera vez).
|
||||||
|
- El broker comparte el contenedor `nats_demo` entre los tres notebooks.
|
||||||
|
|
||||||
|
### Parar el broker
|
||||||
|
|
||||||
|
Cuando termines, para y elimina el contenedor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop nats_demo && docker rm nats_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reproducir / regenerar los notebooks
|
||||||
|
|
||||||
|
El script `build_notebooks.py` regenera los tres `.ipynb` desde cero con `nbformat` (sin ejecutar). La ejecucion se hace luego desde JupyterLab o via el MCP de Jupyter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python build_notebooks.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nota tecnica sobre el MCP de Jupyter
|
||||||
|
|
||||||
|
Si abres Claude desde la raiz de `fn_registry`, su MCP de Jupyter apunta al servidor global (puerto 8899, root `fn_registry`), no al servidor de este analisis (8890). Para usar el MCP contra este analisis con su propio `.venv`, abre Claude desde el directorio del analisis: `cd analysis/nats && claude` — el `.mcp.json` local enlaza el MCP al puerto 8890.
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generador de los notebooks del analisis NATS.
|
||||||
|
|
||||||
|
Construye los tres .ipynb con nbformat (sin ejecutar). La ejecucion se hace
|
||||||
|
despues a traves del MCP de Jupyter, contra el kernel del servidor, para que
|
||||||
|
los outputs queden persistidos y visibles en JupyterLab.
|
||||||
|
|
||||||
|
Reproducible: re-ejecutar este script regenera los notebooks desde cero.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import nbformat as nbf
|
||||||
|
|
||||||
|
NBDIR = Path("/home/enmanuel/fn_registry/analysis/nats/notebooks")
|
||||||
|
PROCS_ABS = str(NBDIR / "procs")
|
||||||
|
|
||||||
|
|
||||||
|
def build(filename: str, cells: list[tuple[str, str]]) -> None:
|
||||||
|
nb = nbf.v4.new_notebook()
|
||||||
|
nb.metadata["kernelspec"] = {
|
||||||
|
"name": "python3",
|
||||||
|
"display_name": "Python 3 (ipykernel)",
|
||||||
|
"language": "python",
|
||||||
|
}
|
||||||
|
nb.cells = [
|
||||||
|
nbf.v4.new_markdown_cell(src) if typ == "md" else nbf.v4.new_code_cell(src)
|
||||||
|
for typ, src in cells
|
||||||
|
]
|
||||||
|
nbf.write(nb, str(NBDIR / filename))
|
||||||
|
print(f"escrito {filename}: {len(cells)} celdas")
|
||||||
|
|
||||||
|
|
||||||
|
# Bloque de codigo reutilizado al inicio de cada notebook para arrancar el broker.
|
||||||
|
ENSURE_NATS = '''import subprocess, time, json
|
||||||
|
|
||||||
|
NATS_CONTAINER = "nats_demo"
|
||||||
|
NATS_PORT = 4222
|
||||||
|
NATS_URL = f"nats://127.0.0.1:{NATS_PORT}"
|
||||||
|
|
||||||
|
def _docker(*args, check=True):
|
||||||
|
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check)
|
||||||
|
|
||||||
|
def ensure_nats(name=NATS_CONTAINER, port=NATS_PORT):
|
||||||
|
"""Arranca un broker NATS en Docker de forma idempotente. Devuelve el estado."""
|
||||||
|
out = _docker("ps", "-a", "--filter", f"name=^{name}$", "--format", "{{.State}}", check=False).stdout.strip()
|
||||||
|
if out == "running":
|
||||||
|
state = "already-running"
|
||||||
|
elif out in ("exited", "created", "paused"):
|
||||||
|
_docker("start", name)
|
||||||
|
state = "started"
|
||||||
|
else:
|
||||||
|
_docker("run", "-d", "--name", name, "-p", f"{port}:4222", "-p", "8222:8222",
|
||||||
|
"nats:latest", "-js", "-m", "8222")
|
||||||
|
state = "created"
|
||||||
|
time.sleep(1.0)
|
||||||
|
return state'''
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Notebook 01 — Core pub/sub y wildcards
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
nb1 = [
|
||||||
|
("md", """# NATS pub/sub — 01 · Core publish/subscribe y wildcards
|
||||||
|
|
||||||
|
**NATS** es un sistema de mensajería ligero y de alto rendimiento orientado a la comunicación entre procesos (microservicios, IoT, pipelines de datos). Su modelo central es **publish/subscribe** sobre *subjects*.
|
||||||
|
|
||||||
|
### Conceptos clave
|
||||||
|
|
||||||
|
- **Subject**: una cadena jerárquica separada por puntos, por ejemplo `pedidos.creados` o `sensor.cocina.temp`. Es la *dirección* de un mensaje. No hay que declararlo: existe en cuanto alguien publica o se suscribe.
|
||||||
|
- **Publisher**: un proceso que envía un mensaje a un subject. No sabe quién lo va a recibir (ni si alguien lo recibirá).
|
||||||
|
- **Subscriber**: un proceso que expresa interés en uno o varios subjects. Recibe todos los mensajes publicados en ellos mientras esté conectado.
|
||||||
|
- **Broker (`nats-server`)**: el proceso central que enruta cada mensaje publicado a todos los subscribers interesados. Desacopla a publishers de subscribers: ninguno conoce la dirección de red del otro, solo el broker y el subject.
|
||||||
|
|
||||||
|
### Garantías del *core* NATS
|
||||||
|
|
||||||
|
El core es **fire-and-forget** (*at-most-once*): si en el momento de publicar no hay ningún subscriber conectado a ese subject, el mensaje se descarta. No hay persistencia ni reintentos. Esto lo hace extremadamente rápido. Cuando se necesita persistencia y *replay* se usa **JetStream** (notebook 02).
|
||||||
|
|
||||||
|
En este notebook levantamos un broker en Docker y demostramos: conexión, pub/sub básico, *fan-out* a varios subscribers y *wildcards*."""),
|
||||||
|
|
||||||
|
("md", """## 0 · Entorno: el broker NATS en Docker
|
||||||
|
|
||||||
|
Levantamos `nats:latest` con el flag `-js` (habilita JetStream, que usaremos en el notebook 02) y publicamos el puerto estándar `4222` en el host. La función `ensure_nats` es **idempotente**: si el contenedor ya existe lo reutiliza, si está parado lo arranca, y solo lo crea si no existe. Así los tres notebooks de este análisis comparten el mismo broker."""),
|
||||||
|
|
||||||
|
("code", ENSURE_NATS + '''
|
||||||
|
|
||||||
|
state = ensure_nats()
|
||||||
|
print(f"Broker NATS: {state} en {NATS_URL}")
|
||||||
|
# El endpoint de monitorización (puerto 8222) expone métricas del servidor en JSON
|
||||||
|
import urllib.request
|
||||||
|
varz = json.loads(urllib.request.urlopen("http://127.0.0.1:8222/varz", timeout=3).read())
|
||||||
|
print(f"nats-server version: {varz['version']} | jetstream activo: {bool(varz.get('jetstream'))}")'''),
|
||||||
|
|
||||||
|
("md", """## 1 · Conectar un cliente
|
||||||
|
|
||||||
|
Usamos `nats-py`, el cliente oficial para Python basado en `asyncio`. IPython permite usar `await` directamente en las celdas (*top-level await*), así que mantenemos la conexión `nc` viva en una variable global y la reutilizamos a lo largo del notebook, igual que haría un proceso real."""),
|
||||||
|
|
||||||
|
("code", '''import asyncio
|
||||||
|
import nats
|
||||||
|
|
||||||
|
# Conexión persistente del notebook (simula el cliente de un proceso de larga vida)
|
||||||
|
nc = await nats.connect(NATS_URL, name="notebook-01")
|
||||||
|
info = nc._server_info # metadata que el broker envía en el handshake
|
||||||
|
print("Conectado.")
|
||||||
|
print(f" server_id : {info['server_id']}")
|
||||||
|
print(f" max_payload: {info['max_payload']/1024/1024:.0f} MiB por mensaje")
|
||||||
|
print(f" client_id : {info['client_id']}")'''),
|
||||||
|
|
||||||
|
("md", """## 2 · Publish/Subscribe básico
|
||||||
|
|
||||||
|
El patrón mínimo: un subscriber declara interés en un subject, un publisher envía un mensaje, el broker lo entrega.
|
||||||
|
|
||||||
|
Aquí usamos una **suscripción síncrona** (`sub.next_msg()`), cómoda para ir paso a paso en un notebook: pedimos explícitamente el siguiente mensaje. El payload siempre viaja como `bytes` — NATS no impone formato; aquí codificamos texto UTF-8."""),
|
||||||
|
|
||||||
|
("code", '''# El subscriber declara interés ANTES de que se publique (core = at-most-once)
|
||||||
|
sub = await nc.subscribe("saludos")
|
||||||
|
|
||||||
|
# El publisher envía. No sabe quién escucha; solo conoce el subject.
|
||||||
|
await nc.publish("saludos", b"hola desde el publisher")
|
||||||
|
await nc.flush() # fuerza el envío al broker antes de seguir
|
||||||
|
|
||||||
|
# El subscriber recoge el mensaje
|
||||||
|
msg = await sub.next_msg(timeout=2)
|
||||||
|
print(f"subject recibido : {msg.subject}")
|
||||||
|
print(f"payload : {msg.data.decode()}")
|
||||||
|
|
||||||
|
await sub.unsubscribe()'''),
|
||||||
|
|
||||||
|
("md", """## 3 · Fan-out: un publisher, N subscribers
|
||||||
|
|
||||||
|
La potencia del pub/sub es el **fan-out**: cuando varios subscribers están interesados en el mismo subject, el broker entrega una **copia a cada uno**. El publisher hace *una* llamada `publish` y no cambia nada en su código aunque haya 1 o 1000 subscribers.
|
||||||
|
|
||||||
|
Aquí registramos 3 subscribers al subject `noticias` mediante *callbacks* asíncronos (cada uno simula un proceso distinto) y publicamos un único mensaje."""),
|
||||||
|
|
||||||
|
("code", '''recibidos = [] # registro de quién recibió qué
|
||||||
|
|
||||||
|
def make_handler(nombre):
|
||||||
|
async def handler(msg):
|
||||||
|
recibidos.append({"subscriber": nombre, "subject": msg.subject, "data": msg.data.decode()})
|
||||||
|
return handler
|
||||||
|
|
||||||
|
# Tres subscribers independientes al MISMO subject
|
||||||
|
subs = []
|
||||||
|
for nombre in ["sub-A", "sub-B", "sub-C"]:
|
||||||
|
s = await nc.subscribe("noticias", cb=make_handler(nombre))
|
||||||
|
subs.append(s)
|
||||||
|
|
||||||
|
# Un único publish...
|
||||||
|
await nc.publish("noticias", b"titular: NATS entrega a todos")
|
||||||
|
await nc.flush()
|
||||||
|
await asyncio.sleep(0.3) # dar tiempo a los callbacks
|
||||||
|
|
||||||
|
for r in recibidos:
|
||||||
|
print(f"{r['subscriber']} <- [{r['subject']}] {r['data']}")
|
||||||
|
print()
|
||||||
|
print(f"Un publish -> {len(recibidos)} entregas (fan-out)")
|
||||||
|
|
||||||
|
for s in subs:
|
||||||
|
await s.unsubscribe()'''),
|
||||||
|
|
||||||
|
("md", """## 4 · Wildcards: `*` y `>`
|
||||||
|
|
||||||
|
Los subjects son jerárquicos y los subscribers pueden usar comodines para suscribirse a familias enteras de subjects:
|
||||||
|
|
||||||
|
- **`*`** (asterisco) — comodín de **un único token**. `sensor.*.temp` casa con `sensor.cocina.temp` y `sensor.salon.temp`, pero **no** con `sensor.cocina.planta1.temp`.
|
||||||
|
- **`>`** (mayor que) — comodín de **uno o más tokens** hasta el final. `sensor.>` casa con `sensor.cocina.temp`, `sensor.salon.humedad`, `sensor.garaje.puerta.estado`...
|
||||||
|
|
||||||
|
Esto permite que un proceso se suscriba a "todo lo de los sensores" o "la temperatura de cualquier habitación" sin conocer de antemano qué subjects concretos existirán."""),
|
||||||
|
|
||||||
|
("code", '''wild = []
|
||||||
|
|
||||||
|
async def on_star(msg):
|
||||||
|
wild.append({"patron": "sensor.*.temp", "subject": msg.subject, "data": msg.data.decode()})
|
||||||
|
|
||||||
|
async def on_gt(msg):
|
||||||
|
wild.append({"patron": "sensor.>", "subject": msg.subject, "data": msg.data.decode()})
|
||||||
|
|
||||||
|
s_star = await nc.subscribe("sensor.*.temp", cb=on_star)
|
||||||
|
s_gt = await nc.subscribe("sensor.>", cb=on_gt)
|
||||||
|
|
||||||
|
# Publicamos en varios subjects concretos
|
||||||
|
publicados = {
|
||||||
|
"sensor.cocina.temp": "21.5",
|
||||||
|
"sensor.salon.temp": "22.1",
|
||||||
|
"sensor.salon.humedad": "48",
|
||||||
|
"sensor.garaje.puerta.estado": "abierta",
|
||||||
|
}
|
||||||
|
for subj, val in publicados.items():
|
||||||
|
await nc.publish(subj, val.encode())
|
||||||
|
await nc.flush()
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
print("Mensajes publicados:", list(publicados))
|
||||||
|
print()
|
||||||
|
for w in wild:
|
||||||
|
print(f"[{w['patron']:14}] casó {w['subject']}")
|
||||||
|
|
||||||
|
await s_star.unsubscribe(); await s_gt.unsubscribe()'''),
|
||||||
|
|
||||||
|
("md", """## 5 · Visualización: qué patrón casó qué subject
|
||||||
|
|
||||||
|
Una matriz `subject × patrón` deja claro el alcance de cada comodín: `sensor.>` captura los cuatro subjects; `sensor.*.temp` solo las dos temperaturas de un nivel (no la humedad, que no es `temp`, ni la del garaje, que tiene un token de más)."""),
|
||||||
|
|
||||||
|
("code", '''import pandas as pd
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
patrones = ["sensor.*.temp", "sensor.>"]
|
||||||
|
subjects = list(publicados)
|
||||||
|
|
||||||
|
# Construir matriz de coincidencias a partir de lo que recibió cada suscripción
|
||||||
|
M = np.zeros((len(patrones), len(subjects)), dtype=int)
|
||||||
|
for w in wild:
|
||||||
|
i = patrones.index(w["patron"])
|
||||||
|
j = subjects.index(w["subject"])
|
||||||
|
M[i, j] = 1
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(9, 2.6))
|
||||||
|
ax.imshow(M, cmap="Greens", vmin=0, vmax=1, aspect="auto")
|
||||||
|
ax.set_xticks(range(len(subjects))); ax.set_xticklabels(subjects, rotation=25, ha="right", fontsize=9)
|
||||||
|
ax.set_yticks(range(len(patrones))); ax.set_yticklabels(patrones, fontsize=10)
|
||||||
|
for i in range(len(patrones)):
|
||||||
|
for j in range(len(subjects)):
|
||||||
|
ax.text(j, i, "OK" if M[i, j] else "-", ha="center", va="center",
|
||||||
|
color="white" if M[i, j] else "#999", fontsize=12)
|
||||||
|
ax.set_title("Coincidencia de wildcards (OK = el subscriber recibió el mensaje)")
|
||||||
|
plt.tight_layout(); plt.show()
|
||||||
|
|
||||||
|
pd.DataFrame(M, index=patrones, columns=subjects)'''),
|
||||||
|
|
||||||
|
("md", """## Resumen
|
||||||
|
|
||||||
|
- El **broker** desacopla publishers y subscribers: solo comparten el *subject*.
|
||||||
|
- El core es **fire-and-forget**: sin subscriber conectado, el mensaje se pierde.
|
||||||
|
- **Fan-out** automático: una publicación llega a todos los subscribers interesados.
|
||||||
|
- **Wildcards** `*` (un token) y `>` (resto de la jerarquía) permiten suscribirse a familias de subjects.
|
||||||
|
|
||||||
|
**Siguiente** → `02_queue_request_jetstream.ipynb`: repartir carga entre workers (*queue groups*), RPC (*request/reply*) y mensajería **persistente** con JetStream.
|
||||||
|
|
||||||
|
> La conexión `nc` y el contenedor `nats_demo` siguen vivos para los siguientes notebooks."""),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Notebook 02 — Queue groups, Request/Reply, JetStream
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
nb2 = [
|
||||||
|
("md", """# NATS pub/sub — 02 · Queue groups, Request/Reply y JetStream
|
||||||
|
|
||||||
|
En el notebook 01 vimos el *fan-out* del core: una publicación llega a **todos** los subscribers. Aquí cubrimos tres patrones que construyen sobre esa base:
|
||||||
|
|
||||||
|
1. **Queue groups** — repartir la carga: varios *workers* comparten el trabajo y cada mensaje lo procesa **uno solo**.
|
||||||
|
2. **Request/Reply** — RPC sobre mensajería: un cliente pregunta y espera la respuesta de un servicio.
|
||||||
|
3. **JetStream** — la capa de **persistencia**: streams que almacenan los mensajes y permiten *replay*, a diferencia del core *fire-and-forget*.
|
||||||
|
|
||||||
|
> Requiere el broker `nats_demo` del notebook 01. La primera celda lo arranca de forma idempotente, así que este notebook también funciona de forma aislada."""),
|
||||||
|
|
||||||
|
("md", """## 0 · Setup: broker + conexión
|
||||||
|
|
||||||
|
Reutilizamos el mismo broker en Docker. `ensure_nats` es idempotente; si el contenedor sigue vivo del notebook 01, simplemente se reaprovecha."""),
|
||||||
|
|
||||||
|
("code", ENSURE_NATS + '''
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import nats
|
||||||
|
|
||||||
|
print("Broker:", ensure_nats())
|
||||||
|
nc = await nats.connect(NATS_URL, name="notebook-02")
|
||||||
|
print("Conectado, client_id:", nc._server_info["client_id"])'''),
|
||||||
|
|
||||||
|
("md", """## 1 · Queue groups — reparto de carga entre workers
|
||||||
|
|
||||||
|
Un **queue group** convierte el fan-out en una **cola de trabajo**. Varios subscribers se suscriben al mismo subject pero declarando el mismo nombre de *queue*. El broker entonces entrega cada mensaje a **exactamente uno** de los miembros del grupo (balanceo de carga), en lugar de a todos.
|
||||||
|
|
||||||
|
Es el patrón de los *worker pools*: escalas el procesamiento añadiendo más workers al grupo, sin tocar al publisher. Si un worker cae, los demás siguen recibiendo. Aquí lanzamos 3 workers en el queue `procesadores` y publicamos 12 tareas."""),
|
||||||
|
|
||||||
|
("code", '''from collections import Counter
|
||||||
|
|
||||||
|
trabajo = Counter() # cuántas tareas procesó cada worker
|
||||||
|
orden = [] # traza temporal (worker, tarea)
|
||||||
|
|
||||||
|
def make_worker(nombre):
|
||||||
|
async def worker(msg):
|
||||||
|
tarea = msg.data.decode()
|
||||||
|
trabajo[nombre] += 1
|
||||||
|
orden.append((nombre, tarea))
|
||||||
|
return worker
|
||||||
|
|
||||||
|
# 3 workers, MISMO subject, MISMO queue group -> NATS reparte
|
||||||
|
workers = []
|
||||||
|
for nombre in ["worker-1", "worker-2", "worker-3"]:
|
||||||
|
s = await nc.subscribe("tareas", queue="procesadores", cb=make_worker(nombre))
|
||||||
|
workers.append(s)
|
||||||
|
|
||||||
|
# Publicar 12 tareas
|
||||||
|
for i in range(12):
|
||||||
|
await nc.publish("tareas", f"tarea-{i:02d}".encode())
|
||||||
|
await nc.flush()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
print("Reparto de carga (cada tarea fue a UN solo worker):")
|
||||||
|
for w, n in sorted(trabajo.items()):
|
||||||
|
print(f" {w}: {n} tareas")
|
||||||
|
print(f" TOTAL procesado: {sum(trabajo.values())} de 12 tareas")
|
||||||
|
|
||||||
|
for s in workers:
|
||||||
|
await s.unsubscribe()'''),
|
||||||
|
|
||||||
|
("md", """### Visualización del reparto
|
||||||
|
|
||||||
|
El total siempre suma 12 (ninguna tarea se duplica ni se pierde), repartido de forma aproximadamente equilibrada entre los workers."""),
|
||||||
|
|
||||||
|
("code", '''import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
nombres = [f"worker-{i}" for i in (1, 2, 3)]
|
||||||
|
valores = [trabajo.get(n, 0) for n in nombres]
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(7, 3))
|
||||||
|
barras = ax.bar(nombres, valores, color=["#2563eb", "#16a34a", "#db2777"])
|
||||||
|
ax.bar_label(barras, padding=3)
|
||||||
|
ax.set_ylabel("tareas procesadas")
|
||||||
|
ax.set_title(f"Queue group 'procesadores' — 12 tareas repartidas entre {len(nombres)} workers")
|
||||||
|
ax.set_ylim(0, max(valores) + 2)
|
||||||
|
plt.tight_layout(); plt.show()'''),
|
||||||
|
|
||||||
|
("md", """## 2 · Request/Reply — RPC sobre NATS
|
||||||
|
|
||||||
|
NATS implementa **petición/respuesta** sobre el mismo modelo pub/sub. El cliente usa `nc.request(subject, datos)`: por debajo, NATS crea un subject de respuesta temporal único (un *inbox*), lo adjunta al mensaje y espera la primera respuesta que llegue a ese inbox.
|
||||||
|
|
||||||
|
El servicio se suscribe al subject, procesa, y responde con `msg.respond(datos)`. Esto da RPC con descubrimiento automático (el cliente no conoce la dirección del servicio, solo el subject) y *timeouts* integrados. Si varios servicios escuchan en un queue group, además se balancea la carga de las peticiones."""),
|
||||||
|
|
||||||
|
("code", '''import nats.errors
|
||||||
|
|
||||||
|
# Servicio: convierte el texto recibido a mayúsculas y responde
|
||||||
|
async def servicio_mayusculas(msg):
|
||||||
|
respuesta = msg.data.decode().upper()
|
||||||
|
await msg.respond(respuesta.encode())
|
||||||
|
|
||||||
|
sub_svc = await nc.subscribe("servicio.mayusculas", cb=servicio_mayusculas)
|
||||||
|
|
||||||
|
# Cliente: pide y espera respuesta (con timeout)
|
||||||
|
peticiones = ["hola mundo", "nats request reply", "desacople total"]
|
||||||
|
for texto in peticiones:
|
||||||
|
resp = await nc.request("servicio.mayusculas", texto.encode(), timeout=1.0)
|
||||||
|
print(f" peticion : {texto!r}")
|
||||||
|
print(f" respuesta: {resp.data.decode()!r} (vino por el inbox {resp.subject})")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ¿Qué pasa si nadie escucha el subject? El broker avisa al instante con
|
||||||
|
# NoRespondersError (no hace falta esperar al timeout completo).
|
||||||
|
try:
|
||||||
|
await nc.request("servicio.inexistente", b"hay alguien?", timeout=0.5)
|
||||||
|
except nats.errors.NoRespondersError:
|
||||||
|
print("servicio.inexistente -> NoRespondersError: el broker confirma que no hay ningún servicio escuchando")
|
||||||
|
except (nats.errors.TimeoutError, asyncio.TimeoutError):
|
||||||
|
print("servicio.inexistente -> TimeoutError: nadie respondió a tiempo")
|
||||||
|
|
||||||
|
await sub_svc.unsubscribe()'''),
|
||||||
|
|
||||||
|
("md", """## 3 · JetStream — persistencia y replay
|
||||||
|
|
||||||
|
Todo lo anterior es **efímero**: si no hay nadie escuchando en el instante exacto de la publicación, el mensaje se pierde. **JetStream** añade una capa de almacenamiento:
|
||||||
|
|
||||||
|
- Un **stream** captura y persiste todos los mensajes de unos subjects dados.
|
||||||
|
- Los **consumers** leen del stream a su ritmo, pueden ser **durables** (recuerdan por dónde iban) y permiten **replay** de mensajes históricos.
|
||||||
|
|
||||||
|
Demostramos la diferencia clave con el core: publicamos a un stream **sin ningún consumidor activo** y, *después*, creamos un consumidor que recupera todos esos mensajes."""),
|
||||||
|
|
||||||
|
("code", '''js = nc.jetstream()
|
||||||
|
|
||||||
|
# Crear (o recrear limpio) un stream que persiste todo lo de 'eventos.>'
|
||||||
|
try:
|
||||||
|
await js.delete_stream("EVENTOS")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
stream = await js.add_stream(name="EVENTOS", subjects=["eventos.>"])
|
||||||
|
print(f"Stream creado: {stream.config.name} subjects={stream.config.subjects} storage={stream.config.storage}")
|
||||||
|
|
||||||
|
# Publicar 5 eventos SIN que haya ningún consumidor escuchando todavía
|
||||||
|
for i in range(5):
|
||||||
|
ack = await js.publish("eventos.click", f"evento #{i}".encode())
|
||||||
|
print(f" publicado eventos.click -> stream='{ack.stream}' seq={ack.seq}")
|
||||||
|
|
||||||
|
estado = (await js.stream_info("EVENTOS")).state
|
||||||
|
print()
|
||||||
|
print(f"Mensajes retenidos en el stream: {estado.messages} (siguen ahí aunque nadie los haya leído)")'''),
|
||||||
|
|
||||||
|
("md", """### Replay: leer los mensajes históricos
|
||||||
|
|
||||||
|
Ahora creamos un **consumer durable** (`lector-eventos`) y hacemos *fetch*. Recuperamos los 5 mensajes que se publicaron **antes** de que este consumidor existiera — algo imposible con el core NATS."""),
|
||||||
|
|
||||||
|
("code", '''# Pull consumer durable: pedimos los mensajes bajo demanda
|
||||||
|
psub = await js.pull_subscribe("eventos.>", durable="lector-eventos")
|
||||||
|
|
||||||
|
mensajes = await psub.fetch(5, timeout=2)
|
||||||
|
print(f"Recuperados {len(mensajes)} mensajes del stream (replay):")
|
||||||
|
for m in mensajes:
|
||||||
|
print(f" seq={m.metadata.sequence.stream} subject={m.subject} data={m.data.decode()!r}")
|
||||||
|
await m.ack() # confirmamos el procesado; el durable avanza su cursor
|
||||||
|
|
||||||
|
# Tras el ack, un segundo fetch no devuelve nada nuevo (cursor avanzado)
|
||||||
|
try:
|
||||||
|
extra = await psub.fetch(1, timeout=1)
|
||||||
|
except Exception:
|
||||||
|
extra = []
|
||||||
|
print()
|
||||||
|
print(f"Segundo fetch tras ack: {len(extra)} mensajes (el durable recuerda que ya los leyó)")'''),
|
||||||
|
|
||||||
|
("md", """## Resumen
|
||||||
|
|
||||||
|
| Patrón | Entrega | Persistencia | Caso de uso |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Core pub/sub (nb 01) | a **todos** los subscribers | no (at-most-once) | eventos en vivo, telemetría |
|
||||||
|
| **Queue group** | a **uno** del grupo | no | *worker pool*, reparto de carga |
|
||||||
|
| **Request/Reply** | a uno, con respuesta | no | RPC, servicios |
|
||||||
|
| **JetStream** | configurable + **replay** | **sí** | event sourcing, colas durables, auditoría |
|
||||||
|
|
||||||
|
**Siguiente** → `03_procesos_reales.ipynb`: hasta ahora todo ha ocurrido dentro de un mismo kernel. Allí lanzamos publisher y subscribers como **procesos del sistema operativo independientes** para ver el desacople real."""),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Notebook 03 — Procesos del sistema operativo reales
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
nb3 = [
|
||||||
|
("md", """# NATS pub/sub — 03 · Procesos del sistema operativo reales
|
||||||
|
|
||||||
|
En los notebooks 01 y 02 todo ocurrió dentro de un mismo kernel: varias conexiones `asyncio` simulaban procesos distintos. Eso es cómodo para explicar, pero NATS brilla precisamente cuando los participantes son **procesos del sistema operativo separados** —incluso en máquinas distintas— que solo comparten la dirección del broker y los nombres de subject.
|
||||||
|
|
||||||
|
Aquí lanzamos **procesos reales** con `subprocess`:
|
||||||
|
|
||||||
|
- un **publisher** (`procs/publisher.py`) que emite telemetría a `telemetria.cpu` y `telemetria.mem`;
|
||||||
|
- dos **subscribers** independientes (`procs/subscriber.py`), cada uno con su propio PID:
|
||||||
|
- `sub-todo` escucha `telemetria.>` (toda la telemetría),
|
||||||
|
- `sub-cpu` escucha solo `telemetria.cpu`.
|
||||||
|
|
||||||
|
Cada proceso abre su propia conexión al broker. El publisher **no sabe** cuántos subscribers hay ni qué escuchan: solo publica a un subject. Ese es el desacople real."""),
|
||||||
|
|
||||||
|
("md", """## 0 · Broker + scripts de los procesos
|
||||||
|
|
||||||
|
Arrancamos el broker (idempotente) y mostramos el código de los dos scripts que vamos a lanzar como procesos. Cada uno es un programa autónomo que se conecta a `nats://127.0.0.1:4222` y emite eventos como líneas JSON en su stdout, que el notebook recogerá."""),
|
||||||
|
|
||||||
|
("code", ENSURE_NATS + f'''
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROCS = Path(r"{PROCS_ABS}")
|
||||||
|
print("Broker:", ensure_nats())
|
||||||
|
print("Scripts de proceso en:", PROCS)
|
||||||
|
print()
|
||||||
|
print("=== procs/publisher.py ===")
|
||||||
|
print(Path(PROCS / "publisher.py").read_text())'''),
|
||||||
|
|
||||||
|
("code", '''print("=== procs/subscriber.py ===")
|
||||||
|
print((PROCS / "subscriber.py").read_text())'''),
|
||||||
|
|
||||||
|
("md", """## 1 · Lanzar los procesos y orquestarlos
|
||||||
|
|
||||||
|
El notebook actúa de **orquestador**:
|
||||||
|
|
||||||
|
1. Lanza los dos subscribers como procesos (`subprocess.Popen`), cada uno con su PID. Les damos 1.5 s para que conecten y se suscriban.
|
||||||
|
2. Lanza el publisher, que emite 8 mensajes y termina.
|
||||||
|
3. Espera a que los subscribers terminen solos (su `--seconds`) y recoge su stdout.
|
||||||
|
|
||||||
|
Usamos `sys.executable` para que los procesos hijos usen el mismo intérprete (con `nats-py` instalado) que el kernel."""),
|
||||||
|
|
||||||
|
("code", '''import subprocess, sys, json, time
|
||||||
|
|
||||||
|
def lanzar_subscriber(nombre, subjects, seconds=4.5):
|
||||||
|
return subprocess.Popen(
|
||||||
|
[sys.executable, str(PROCS / "subscriber.py"),
|
||||||
|
"--name", nombre, "--subjects", subjects, "--seconds", str(seconds)],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Subscribers: procesos OS independientes
|
||||||
|
procs = {
|
||||||
|
"sub-todo": lanzar_subscriber("sub-todo", "telemetria.>"),
|
||||||
|
"sub-cpu": lanzar_subscriber("sub-cpu", "telemetria.cpu"),
|
||||||
|
}
|
||||||
|
print("Subscribers lanzados (PIDs del SO):", {n: p.pid for n, p in procs.items()})
|
||||||
|
time.sleep(1.5) # que conecten y se suscriban antes de publicar
|
||||||
|
|
||||||
|
# 2. Publisher: otro proceso OS, publica 8 mensajes y termina
|
||||||
|
pub = subprocess.run(
|
||||||
|
[sys.executable, str(PROCS / "publisher.py"), "--count", "8", "--interval", "0.15"],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||||
|
)
|
||||||
|
pub_eventos = [json.loads(l) for l in pub.stdout.splitlines() if l.strip()]
|
||||||
|
print(f"Publisher (PID {pub_eventos[0]['pid']}) publicó {sum(1 for e in pub_eventos if e['event']=='published')} mensajes")
|
||||||
|
|
||||||
|
# 3. Recoger stdout de los subscribers (terminan solos por --seconds)
|
||||||
|
eventos = []
|
||||||
|
for nombre, p in procs.items():
|
||||||
|
out, err = p.communicate(timeout=10)
|
||||||
|
for l in out.splitlines():
|
||||||
|
if l.strip():
|
||||||
|
eventos.append(json.loads(l))
|
||||||
|
if err.strip():
|
||||||
|
print(f"[{nombre} stderr] {err.strip()[:200]}")
|
||||||
|
|
||||||
|
msgs = [e for e in eventos if e["event"] == "msg"]
|
||||||
|
print(f"\\nTotal de entregas recibidas entre todos los procesos: {len(msgs)}")'''),
|
||||||
|
|
||||||
|
("md", """## 2 · Qué recibió cada proceso
|
||||||
|
|
||||||
|
Cada subscriber es un PID distinto. `sub-todo` (suscrito a `telemetria.>`) recibe los 8 mensajes; `sub-cpu` (suscrito solo a `telemetria.cpu`) recibe únicamente los 4 de CPU. El broker filtró por subject sin que el publisher supiera nada de ello."""),
|
||||||
|
|
||||||
|
("code", '''import pandas as pd
|
||||||
|
|
||||||
|
df = pd.DataFrame(msgs)
|
||||||
|
# PID por nombre de proceso (demuestra que son procesos distintos)
|
||||||
|
pids = {e["name"]: e["pid"] for e in eventos if e["event"] == "ready"}
|
||||||
|
print("PID de cada proceso subscriber:", pids)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Conteo de mensajes por (proceso, subject)
|
||||||
|
tabla = df.groupby(["name", "subject"]).size().unstack(fill_value=0)
|
||||||
|
print("Mensajes recibidos por proceso y subject:")
|
||||||
|
print(tabla)
|
||||||
|
tabla'''),
|
||||||
|
|
||||||
|
("code", '''import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
resumen = df.groupby("name").size().reindex(["sub-todo", "sub-cpu"]).fillna(0).astype(int)
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(7, 3.2))
|
||||||
|
barras = ax.bar(resumen.index, resumen.values, color=["#7c3aed", "#0891b2"])
|
||||||
|
ax.bar_label(barras, padding=3)
|
||||||
|
ax.set_ylabel("mensajes recibidos")
|
||||||
|
ax.set_title("Telemetría recibida por cada PROCESO (8 publicados: 4 cpu + 4 mem)")
|
||||||
|
ax.set_ylim(0, 10)
|
||||||
|
for i, name in enumerate(resumen.index):
|
||||||
|
ax.text(i, -1.4, f"PID {pids.get(name, '?')}\\n{('telemetria.>' if name=='sub-todo' else 'telemetria.cpu')}",
|
||||||
|
ha="center", va="top", fontsize=8, color="#555")
|
||||||
|
plt.tight_layout(); plt.show()'''),
|
||||||
|
|
||||||
|
("md", """## 3 · Línea de tiempo de las entregas
|
||||||
|
|
||||||
|
Ordenando los mensajes por su marca temporal (`t`, segundos desde que cada proceso arrancó) se ve cómo ambos subscribers reciben los mensajes de CPU casi a la vez (fan-out), mientras que los de memoria solo llegan a `sub-todo`."""),
|
||||||
|
|
||||||
|
("code", '''fig, ax = plt.subplots(figsize=(9, 3))
|
||||||
|
colores = {"telemetria.cpu": "#ef4444", "telemetria.mem": "#3b82f6"}
|
||||||
|
y_de = {"sub-todo": 1, "sub-cpu": 0}
|
||||||
|
for e in msgs:
|
||||||
|
ax.scatter(e["t"], y_de[e["name"]], color=colores[e["subject"]], s=80, zorder=3)
|
||||||
|
ax.set_yticks([0, 1]); ax.set_yticklabels(["sub-cpu", "sub-todo"])
|
||||||
|
ax.set_xlabel("t (segundos desde el arranque de cada proceso)")
|
||||||
|
ax.set_title("Timeline de entregas — rojo: telemetria.cpu, azul: telemetria.mem")
|
||||||
|
ax.grid(axis="x", alpha=0.3)
|
||||||
|
from matplotlib.patches import Patch
|
||||||
|
ax.legend(handles=[Patch(color=c, label=s) for s, c in colores.items()], loc="upper right")
|
||||||
|
plt.tight_layout(); plt.show()'''),
|
||||||
|
|
||||||
|
("md", """## Resumen del análisis
|
||||||
|
|
||||||
|
A lo largo de los tres notebooks hemos visto cómo distintos procesos envían datos por pub/sub con NATS:
|
||||||
|
|
||||||
|
- **01** — el modelo base: publishers y subscribers desacoplados por un broker, *fan-out* y *wildcards*.
|
||||||
|
- **02** — patrones de orden superior: *queue groups* (reparto de carga), *request/reply* (RPC) y *JetStream* (persistencia y replay).
|
||||||
|
- **03** — **procesos del SO reales**: el desacople de verdad. El publisher no conoce a sus subscribers; el broker enruta por subject. Añadir o quitar procesos consumidores no cambia ni una línea del publisher.
|
||||||
|
|
||||||
|
Esa es la idea central de NATS: **los procesos se comunican por nombres de subject, no por direcciones**, y el broker se encarga del resto.
|
||||||
|
|
||||||
|
### Limpieza (opcional)
|
||||||
|
|
||||||
|
Para parar el broker cuando termines:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import subprocess
|
||||||
|
subprocess.run(["docker", "stop", "nats_demo"]) # detener
|
||||||
|
subprocess.run(["docker", "rm", "nats_demo"]) # eliminar
|
||||||
|
```"""),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build("01_core_pubsub.ipynb", nb1)
|
||||||
|
build("02_queue_request_jetstream.ipynb", nb2)
|
||||||
|
build("03_procesos_reales.ipynb", nb3)
|
||||||
|
print("OK: 3 notebooks generados en", NBDIR)
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
════════════════════════════════════════════════
|
||||||
|
Jupyter Lab + Colaboracion en puerto 8890
|
||||||
|
════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Abre: http://localhost:8890
|
||||||
|
Ctrl+C para detener
|
||||||
|
|
||||||
|
[W 2026-06-03 19:41:26.217 ServerApp] ServerApp.token config is deprecated in 2.0. Use IdentityProvider.token.
|
||||||
|
[I 2026-06-03 19:41:26.656 ServerApp] jupyter_lsp | extension was successfully linked.
|
||||||
|
[I 2026-06-03 19:41:26.658 ServerApp] jupyter_mcp_server | extension was successfully linked.
|
||||||
|
[I 2026-06-03 19:41:26.658 ServerApp] jupyter_mcp_tools | extension was successfully linked.
|
||||||
|
[I 2026-06-03 19:41:26.660 ServerApp] jupyter_server_fileid | extension was successfully linked.
|
||||||
|
[I 2026-06-03 19:41:26.662 ServerApp] jupyter_server_nbmodel | extension was successfully linked.
|
||||||
|
[I 2026-06-03 19:41:26.663 ServerApp] jupyter_server_terminals | extension was successfully linked.
|
||||||
|
[I 2026-06-03 19:41:26.665 ServerApp] jupyter_server_ydoc | extension was successfully linked.
|
||||||
|
[I 2026-06-03 19:41:26.667 ServerApp] jupyterlab | extension was successfully linked.
|
||||||
|
[I 2026-06-03 19:41:26.669 ServerApp] notebook | extension was successfully linked.
|
||||||
|
[I 2026-06-03 19:41:26.670 ServerApp] notebook_shim | extension was successfully linked.
|
||||||
|
[W 2026-06-03 19:41:26.680 ServerApp] All authentication is disabled. Anyone who can connect to this server will be able to run code.
|
||||||
|
[I 2026-06-03 19:41:26.681 ServerApp] notebook_shim | extension was successfully loaded.
|
||||||
|
[I 2026-06-03 19:41:26.682 ServerApp] jupyter_lsp | extension was successfully loaded.
|
||||||
|
[06/03/26 19:41:26] INFO Auto-enrolled document extension.py:195
|
||||||
|
'notebook.ipynb' as 'default'
|
||||||
|
INFO Jupyter MCP Server Extension extension.py:197
|
||||||
|
settings initialized
|
||||||
|
INFO Registered MCP handlers at /mcp/ extension.py:233
|
||||||
|
INFO - MCP protocol: /mcp (SSE-based) extension.py:234
|
||||||
|
INFO - Health check: /mcp/healthz extension.py:235
|
||||||
|
INFO - List tools: /mcp/tools/list extension.py:236
|
||||||
|
INFO - Call tool: /mcp/tools/call extension.py:237
|
||||||
|
[I 2026-06-03 19:41:26.784 ServerApp] jupyter_mcp_server | extension was successfully loaded.
|
||||||
|
[I 2026-06-03 19:41:26.784 ServerApp] Registered jupyter_mcp_tools server extension
|
||||||
|
[I 2026-06-03 19:41:26.784 ServerApp] jupyter_mcp_tools | extension was successfully loaded.
|
||||||
|
[I 2026-06-03 19:41:26.784 FileIdExtension] Configured File ID manager: ArbitraryFileIdManager
|
||||||
|
[I 2026-06-03 19:41:26.784 FileIdExtension] ArbitraryFileIdManager : Configured root dir: /home/enmanuel/fn_registry/analysis/nats
|
||||||
|
[I 2026-06-03 19:41:26.784 FileIdExtension] ArbitraryFileIdManager : Configured database path: /home/enmanuel/.local/share/jupyter/file_id_manager.db
|
||||||
|
[I 2026-06-03 19:41:26.785 FileIdExtension] ArbitraryFileIdManager : Successfully connected to database file.
|
||||||
|
[I 2026-06-03 19:41:26.785 FileIdExtension] ArbitraryFileIdManager : Creating File ID tables and indices with journal_mode = DELETE
|
||||||
|
[I 2026-06-03 19:41:26.785 FileIdExtension] Attached event listeners.
|
||||||
|
[I 2026-06-03 19:41:26.785 ServerApp] jupyter_server_fileid | extension was successfully loaded.
|
||||||
|
[I 2026-06-03 19:41:26.785 ServerApp] jupyter_server_nbmodel | extension was successfully loaded.
|
||||||
|
[I 2026-06-03 19:41:26.786 ServerApp] jupyter_server_terminals | extension was successfully loaded.
|
||||||
|
[I 2026-06-03 19:41:26.787 ServerApp] jupyter_server_ydoc | extension was successfully loaded.
|
||||||
|
[I 2026-06-03 19:41:26.789 LabApp] JupyterLab extension loaded from /home/enmanuel/fn_registry/analysis/nats/.venv/lib/python3.12/site-packages/jupyterlab
|
||||||
|
[I 2026-06-03 19:41:26.789 LabApp] JupyterLab application directory is /home/enmanuel/fn_registry/analysis/nats/.venv/share/jupyter/lab
|
||||||
|
[I 2026-06-03 19:41:26.789 LabApp] Extension Manager is 'pypi'.
|
||||||
|
[I 2026-06-03 19:41:26.810 ServerApp] jupyterlab | extension was successfully loaded.
|
||||||
|
[I 2026-06-03 19:41:26.811 ServerApp] notebook | extension was successfully loaded.
|
||||||
|
[I 2026-06-03 19:41:26.812 ServerApp] Serving notebooks from local directory: /home/enmanuel/fn_registry/analysis/nats
|
||||||
|
[I 2026-06-03 19:41:26.812 ServerApp] Jupyter Server 2.19.0 is running at:
|
||||||
|
[I 2026-06-03 19:41:26.812 ServerApp] http://localhost:8890/lab
|
||||||
|
[I 2026-06-03 19:41:26.812 ServerApp] http://127.0.0.1:8890/lab
|
||||||
|
[I 2026-06-03 19:41:26.812 ServerApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).
|
||||||
|
[I 2026-06-03 19:41:26.876 ServerApp] Skipped non-installed server(s): basedpyright, bash-language-server, dockerfile-language-server-nodejs, javascript-typescript-langserver, jedi-language-server, julia-language-server, pyrefly, pyright, python-language-server, python-lsp-server, r-languageserver, sql-language-server, texlab, typescript-language-server, unified-language-server, vscode-css-languageserver-bin, vscode-html-languageserver-bin, vscode-json-languageserver-bin, yaml-language-server
|
||||||
|
[I 2026-06-03 19:46:15.569 ServerApp] Request for Y document (previously indexed) 'notebooks/01_core_pubsub.ipynb' with room ID: f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[I 2026-06-03 19:46:15.654 LabApp] Build is up to date
|
||||||
|
[I 2026-06-03 19:46:15.656 ServerApp] MCP Tools WebSocket connection opened
|
||||||
|
[W 2026-06-03 19:46:15.669 ServerApp] The websocket_ping_timeout (90000) cannot be longer than the websocket_ping_interval (30000).
|
||||||
|
Setting websocket_ping_timeout=30000
|
||||||
|
[I 2026-06-03 19:46:15.672 YDocExtension] Creating FileLoader for: notebooks/01_core_pubsub.ipynb
|
||||||
|
[I 2026-06-03 19:46:15.673 YDocExtension] Watching file: notebooks/01_core_pubsub.ipynb
|
||||||
|
[I 2026-06-03 19:46:15.675 ServerApp] Initializing room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[I 2026-06-03 19:46:15.676 ServerApp] Registered 414 tools
|
||||||
|
[I 2026-06-03 19:46:15.716 ServerApp] Content in room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6 loaded from the ystore SQLiteYStore
|
||||||
|
[I 2026-06-03 19:46:15.719 ServerApp] Content in file notebooks/01_core_pubsub.ipynb is out-of-sync with the ystore SQLiteYStore
|
||||||
|
[I 2026-06-03 19:46:15.720 ServerApp] Content in room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6 loaded from file notebooks/01_core_pubsub.ipynb
|
||||||
|
[I 2026-06-03 19:46:16.735 ServerApp] Saving the content from room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[I 2026-06-03 19:46:16.738 YDocExtension] Saving file: notebooks/01_core_pubsub.ipynb
|
||||||
|
[I 2026-06-03 19:47:15.407 YDocExtension] Processed 31 Y patches in one minute
|
||||||
|
[I 2026-06-03 19:47:15.407 YDocExtension] Connected Y users: 2
|
||||||
|
[I 2026-06-03 19:48:15.407 YDocExtension] Processed 16 Y patches in one minute
|
||||||
|
[I 2026-06-03 19:48:15.408 YDocExtension] Connected Y users: 2
|
||||||
|
[I 2026-06-03 19:49:15.408 YDocExtension] Processed 16 Y patches in one minute
|
||||||
|
[I 2026-06-03 19:49:15.409 YDocExtension] Connected Y users: 2
|
||||||
|
[I 2026-06-03 19:49:52.746 ServerApp] Out-of-band changes. Overwriting the content in room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[W 2026-06-03 19:49:52.770 ServerApp] Notebook notebooks/01_core_pubsub.ipynb is not trusted
|
||||||
|
[I 2026-06-03 19:50:15.409 YDocExtension] Processed 16 Y patches in one minute
|
||||||
|
[I 2026-06-03 19:50:15.409 YDocExtension] Connected Y users: 2
|
||||||
|
[I 2026-06-03 19:50:16.900 ServerApp] Out-of-band changes. Overwriting the content in room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[I 2026-06-03 19:50:17.929 ServerApp] Out-of-band changes. Overwriting the content in room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[I 2026-06-03 19:50:22.970 ServerApp] Out-of-band changes. Overwriting the content in room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[I 2026-06-03 19:50:26.008 ServerApp] Out-of-band changes. Overwriting the content in room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[I 2026-06-03 19:50:30.051 ServerApp] Out-of-band changes. Overwriting the content in room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[I 2026-06-03 19:50:34.088 ServerApp] Out-of-band changes. Overwriting the content in room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[I 2026-06-03 19:50:37.127 ServerApp] Out-of-band changes. Overwriting the content in room json:notebook:f0c81420-7285-42fa-afcf-aa618e651df6
|
||||||
|
[06/03/26 19:50:37] ERROR Notebook JSON is invalid: Additional __init__.py:98
|
||||||
|
properties are not allowed
|
||||||
|
('transient' was unexpected)
|
||||||
|
|
||||||
|
Failed validating
|
||||||
|
'additionalProperties' in
|
||||||
|
display_data:
|
||||||
|
|
||||||
|
On
|
||||||
|
instance['cells'][12]['outputs'][0]:
|
||||||
|
{'data': {'image/png':
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAA3oAAAD5CAY
|
||||||
|
AAABxn0eTAAAAOnRFWHRTb2Z0d2Fy...',
|
||||||
|
'text/plain': '<Figure
|
||||||
|
size 900x260 with 1 Axes>'},
|
||||||
|
'metadata': {},
|
||||||
|
'output_type': 'display_data',
|
||||||
|
'transient': {}}
|
||||||
|
[W 2026-06-03 19:50:37.149 ServerApp] Notebook notebooks/01_core_pubsub.ipynb is not trusted
|
||||||
|
[I 2026-06-03 19:51:15.410 YDocExtension] Processed 16 Y patches in one minute
|
||||||
|
[I 2026-06-03 19:51:15.411 YDocExtension] Connected Y users: 2
|
||||||
|
[I 2026-06-03 19:52:15.414 YDocExtension] Processed 16 Y patches in one minute
|
||||||
|
[I 2026-06-03 19:52:15.414 YDocExtension] Connected Y users: 2
|
||||||
|
[I 2026-06-03 19:53:15.417 YDocExtension] Processed 16 Y patches in one minute
|
||||||
|
[I 2026-06-03 19:53:15.417 YDocExtension] Connected Y users: 2
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from nats!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"cells": [],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "Python 3 (ipykernel)",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"name": "python"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"cells": [],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "Python 3 (ipykernel)",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"name": "python"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "05860b9f",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# NATS pub/sub — 03 · Procesos del sistema operativo reales\n",
|
||||||
|
"\n",
|
||||||
|
"En los notebooks 01 y 02 todo ocurrió dentro de un mismo kernel: varias conexiones `asyncio` simulaban procesos distintos. Eso es cómodo para explicar, pero NATS brilla precisamente cuando los participantes son **procesos del sistema operativo separados** —incluso en máquinas distintas— que solo comparten la dirección del broker y los nombres de subject.\n",
|
||||||
|
"\n",
|
||||||
|
"Aquí lanzamos **procesos reales** con `subprocess`:\n",
|
||||||
|
"\n",
|
||||||
|
"- un **publisher** (`procs/publisher.py`) que emite telemetría a `telemetria.cpu` y `telemetria.mem`;\n",
|
||||||
|
"- dos **subscribers** independientes (`procs/subscriber.py`), cada uno con su propio PID:\n",
|
||||||
|
" - `sub-todo` escucha `telemetria.>` (toda la telemetría),\n",
|
||||||
|
" - `sub-cpu` escucha solo `telemetria.cpu`.\n",
|
||||||
|
"\n",
|
||||||
|
"Cada proceso abre su propia conexión al broker. El publisher **no sabe** cuántos subscribers hay ni qué escuchan: solo publica a un subject. Ese es el desacople real."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "c5127085",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 0 · Broker + scripts de los procesos\n",
|
||||||
|
"\n",
|
||||||
|
"Arrancamos el broker (idempotente) y mostramos el código de los dos scripts que vamos a lanzar como procesos. Cada uno es un programa autónomo que se conecta a `nats://127.0.0.1:4222` y emite eventos como líneas JSON en su stdout, que el notebook recogerá."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 1,
|
||||||
|
"id": "bb720c29",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Broker: already-running\n",
|
||||||
|
"Scripts de proceso en: /home/enmanuel/fn_registry/analysis/nats/notebooks/procs\n",
|
||||||
|
"\n",
|
||||||
|
"=== procs/publisher.py ===\n",
|
||||||
|
"#!/usr/bin/env python3\n",
|
||||||
|
"\"\"\"Publisher NATS como proceso del sistema operativo independiente.\n",
|
||||||
|
"\n",
|
||||||
|
"Se conecta al broker y publica una rafaga de mensajes de telemetria,\n",
|
||||||
|
"alternando entre los subjects `telemetria.cpu` y `telemetria.mem`.\n",
|
||||||
|
"No sabe ni le importa cuantos subscribers hay escuchando: solo conoce el\n",
|
||||||
|
"subject. Emite cada publicacion como linea JSON en stdout.\n",
|
||||||
|
"\"\"\"\n",
|
||||||
|
"import argparse\n",
|
||||||
|
"import asyncio\n",
|
||||||
|
"import json\n",
|
||||||
|
"import os\n",
|
||||||
|
"import random\n",
|
||||||
|
"import time\n",
|
||||||
|
"\n",
|
||||||
|
"import nats\n",
|
||||||
|
"\n",
|
||||||
|
"NATS_URL = \"nats://127.0.0.1:4222\"\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"def emit(event: dict) -> None:\n",
|
||||||
|
" print(json.dumps(event), flush=True)\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"async def main(count: int, interval: float) -> None:\n",
|
||||||
|
" pid = os.getpid()\n",
|
||||||
|
" nc = await nats.connect(NATS_URL, name=\"publisher\")\n",
|
||||||
|
" emit({\"event\": \"ready\", \"pid\": pid, \"name\": \"publisher\"})\n",
|
||||||
|
"\n",
|
||||||
|
" for i in range(count):\n",
|
||||||
|
" subject = \"telemetria.cpu\" if i % 2 == 0 else \"telemetria.mem\"\n",
|
||||||
|
" payload = json.dumps({\"i\": i, \"valor\": round(random.uniform(0, 100), 1)})\n",
|
||||||
|
" await nc.publish(subject, payload.encode())\n",
|
||||||
|
" emit({\"event\": \"published\", \"pid\": pid, \"subject\": subject, \"i\": i})\n",
|
||||||
|
" await asyncio.sleep(interval)\n",
|
||||||
|
"\n",
|
||||||
|
" await nc.flush()\n",
|
||||||
|
" emit({\"event\": \"done\", \"pid\": pid, \"name\": \"publisher\", \"published\": count})\n",
|
||||||
|
" await nc.drain()\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"if __name__ == \"__main__\":\n",
|
||||||
|
" parser = argparse.ArgumentParser(description=\"Publisher NATS de demostracion\")\n",
|
||||||
|
" parser.add_argument(\"--count\", type=int, default=8, help=\"Numero de mensajes a publicar\")\n",
|
||||||
|
" parser.add_argument(\"--interval\", type=float, default=0.15,\n",
|
||||||
|
" help=\"Segundos entre publicaciones\")\n",
|
||||||
|
" args = parser.parse_args()\n",
|
||||||
|
" asyncio.run(main(args.count, args.interval))\n",
|
||||||
|
"\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"import subprocess, time, json\n",
|
||||||
|
"\n",
|
||||||
|
"NATS_CONTAINER = \"nats_demo\"\n",
|
||||||
|
"NATS_PORT = 4222\n",
|
||||||
|
"NATS_URL = f\"nats://127.0.0.1:{NATS_PORT}\"\n",
|
||||||
|
"\n",
|
||||||
|
"def _docker(*args, check=True):\n",
|
||||||
|
" return subprocess.run([\"docker\", *args], capture_output=True, text=True, check=check)\n",
|
||||||
|
"\n",
|
||||||
|
"def ensure_nats(name=NATS_CONTAINER, port=NATS_PORT):\n",
|
||||||
|
" \"\"\"Arranca un broker NATS en Docker de forma idempotente. Devuelve el estado.\"\"\"\n",
|
||||||
|
" out = _docker(\"ps\", \"-a\", \"--filter\", f\"name=^{name}$\", \"--format\", \"{{.State}}\", check=False).stdout.strip()\n",
|
||||||
|
" if out == \"running\":\n",
|
||||||
|
" state = \"already-running\"\n",
|
||||||
|
" elif out in (\"exited\", \"created\", \"paused\"):\n",
|
||||||
|
" _docker(\"start\", name)\n",
|
||||||
|
" state = \"started\"\n",
|
||||||
|
" else:\n",
|
||||||
|
" _docker(\"run\", \"-d\", \"--name\", name, \"-p\", f\"{port}:4222\", \"-p\", \"8222:8222\",\n",
|
||||||
|
" \"nats:latest\", \"-js\", \"-m\", \"8222\")\n",
|
||||||
|
" state = \"created\"\n",
|
||||||
|
" time.sleep(1.0)\n",
|
||||||
|
" return state\n",
|
||||||
|
"\n",
|
||||||
|
"from pathlib import Path\n",
|
||||||
|
"\n",
|
||||||
|
"PROCS = Path(r\"/home/enmanuel/fn_registry/analysis/nats/notebooks/procs\")\n",
|
||||||
|
"print(\"Broker:\", ensure_nats())\n",
|
||||||
|
"print(\"Scripts de proceso en:\", PROCS)\n",
|
||||||
|
"print()\n",
|
||||||
|
"print(\"=== procs/publisher.py ===\")\n",
|
||||||
|
"print(Path(PROCS / \"publisher.py\").read_text())"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "3412b705",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"print(\"=== procs/subscriber.py ===\")\n",
|
||||||
|
"print((PROCS / \"subscriber.py\").read_text())"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "e17dd705",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 1 · Lanzar los procesos y orquestarlos\n",
|
||||||
|
"\n",
|
||||||
|
"El notebook actúa de **orquestador**:\n",
|
||||||
|
"\n",
|
||||||
|
"1. Lanza los dos subscribers como procesos (`subprocess.Popen`), cada uno con su PID. Les damos 1.5 s para que conecten y se suscriban.\n",
|
||||||
|
"2. Lanza el publisher, que emite 8 mensajes y termina.\n",
|
||||||
|
"3. Espera a que los subscribers terminen solos (su `--seconds`) y recoge su stdout.\n",
|
||||||
|
"\n",
|
||||||
|
"Usamos `sys.executable` para que los procesos hijos usen el mismo intérprete (con `nats-py` instalado) que el kernel."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "f647029e",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import subprocess, sys, json, time\n",
|
||||||
|
"\n",
|
||||||
|
"def lanzar_subscriber(nombre, subjects, seconds=4.5):\n",
|
||||||
|
" return subprocess.Popen(\n",
|
||||||
|
" [sys.executable, str(PROCS / \"subscriber.py\"),\n",
|
||||||
|
" \"--name\", nombre, \"--subjects\", subjects, \"--seconds\", str(seconds)],\n",
|
||||||
|
" stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,\n",
|
||||||
|
" )\n",
|
||||||
|
"\n",
|
||||||
|
"# 1. Subscribers: procesos OS independientes\n",
|
||||||
|
"procs = {\n",
|
||||||
|
" \"sub-todo\": lanzar_subscriber(\"sub-todo\", \"telemetria.>\"),\n",
|
||||||
|
" \"sub-cpu\": lanzar_subscriber(\"sub-cpu\", \"telemetria.cpu\"),\n",
|
||||||
|
"}\n",
|
||||||
|
"print(\"Subscribers lanzados (PIDs del SO):\", {n: p.pid for n, p in procs.items()})\n",
|
||||||
|
"time.sleep(1.5) # que conecten y se suscriban antes de publicar\n",
|
||||||
|
"\n",
|
||||||
|
"# 2. Publisher: otro proceso OS, publica 8 mensajes y termina\n",
|
||||||
|
"pub = subprocess.run(\n",
|
||||||
|
" [sys.executable, str(PROCS / \"publisher.py\"), \"--count\", \"8\", \"--interval\", \"0.15\"],\n",
|
||||||
|
" stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,\n",
|
||||||
|
")\n",
|
||||||
|
"pub_eventos = [json.loads(l) for l in pub.stdout.splitlines() if l.strip()]\n",
|
||||||
|
"print(f\"Publisher (PID {pub_eventos[0]['pid']}) publicó {sum(1 for e in pub_eventos if e['event']=='published')} mensajes\")\n",
|
||||||
|
"\n",
|
||||||
|
"# 3. Recoger stdout de los subscribers (terminan solos por --seconds)\n",
|
||||||
|
"eventos = []\n",
|
||||||
|
"for nombre, p in procs.items():\n",
|
||||||
|
" out, err = p.communicate(timeout=10)\n",
|
||||||
|
" for l in out.splitlines():\n",
|
||||||
|
" if l.strip():\n",
|
||||||
|
" eventos.append(json.loads(l))\n",
|
||||||
|
" if err.strip():\n",
|
||||||
|
" print(f\"[{nombre} stderr] {err.strip()[:200]}\")\n",
|
||||||
|
"\n",
|
||||||
|
"msgs = [e for e in eventos if e[\"event\"] == \"msg\"]\n",
|
||||||
|
"print(f\"\\nTotal de entregas recibidas entre todos los procesos: {len(msgs)}\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "33dcf1f4",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 2 · Qué recibió cada proceso\n",
|
||||||
|
"\n",
|
||||||
|
"Cada subscriber es un PID distinto. `sub-todo` (suscrito a `telemetria.>`) recibe los 8 mensajes; `sub-cpu` (suscrito solo a `telemetria.cpu`) recibe únicamente los 4 de CPU. El broker filtró por subject sin que el publisher supiera nada de ello."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "01ae57ed",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import pandas as pd\n",
|
||||||
|
"\n",
|
||||||
|
"df = pd.DataFrame(msgs)\n",
|
||||||
|
"# PID por nombre de proceso (demuestra que son procesos distintos)\n",
|
||||||
|
"pids = {e[\"name\"]: e[\"pid\"] for e in eventos if e[\"event\"] == \"ready\"}\n",
|
||||||
|
"print(\"PID de cada proceso subscriber:\", pids)\n",
|
||||||
|
"print()\n",
|
||||||
|
"\n",
|
||||||
|
"# Conteo de mensajes por (proceso, subject)\n",
|
||||||
|
"tabla = df.groupby([\"name\", \"subject\"]).size().unstack(fill_value=0)\n",
|
||||||
|
"print(\"Mensajes recibidos por proceso y subject:\")\n",
|
||||||
|
"print(tabla)\n",
|
||||||
|
"tabla"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "9a5ee65b",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import matplotlib.pyplot as plt\n",
|
||||||
|
"\n",
|
||||||
|
"resumen = df.groupby(\"name\").size().reindex([\"sub-todo\", \"sub-cpu\"]).fillna(0).astype(int)\n",
|
||||||
|
"\n",
|
||||||
|
"fig, ax = plt.subplots(figsize=(7, 3.2))\n",
|
||||||
|
"barras = ax.bar(resumen.index, resumen.values, color=[\"#7c3aed\", \"#0891b2\"])\n",
|
||||||
|
"ax.bar_label(barras, padding=3)\n",
|
||||||
|
"ax.set_ylabel(\"mensajes recibidos\")\n",
|
||||||
|
"ax.set_title(\"Telemetría recibida por cada PROCESO (8 publicados: 4 cpu + 4 mem)\")\n",
|
||||||
|
"ax.set_ylim(0, 10)\n",
|
||||||
|
"for i, name in enumerate(resumen.index):\n",
|
||||||
|
" ax.text(i, -1.4, f\"PID {pids.get(name, '?')}\\n{('telemetria.>' if name=='sub-todo' else 'telemetria.cpu')}\",\n",
|
||||||
|
" ha=\"center\", va=\"top\", fontsize=8, color=\"#555\")\n",
|
||||||
|
"plt.tight_layout(); plt.show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "b8d60d73",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 3 · Línea de tiempo de las entregas\n",
|
||||||
|
"\n",
|
||||||
|
"Ordenando los mensajes por su marca temporal (`t`, segundos desde que cada proceso arrancó) se ve cómo ambos subscribers reciben los mensajes de CPU casi a la vez (fan-out), mientras que los de memoria solo llegan a `sub-todo`."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "1656576f",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"fig, ax = plt.subplots(figsize=(9, 3))\n",
|
||||||
|
"colores = {\"telemetria.cpu\": \"#ef4444\", \"telemetria.mem\": \"#3b82f6\"}\n",
|
||||||
|
"y_de = {\"sub-todo\": 1, \"sub-cpu\": 0}\n",
|
||||||
|
"for e in msgs:\n",
|
||||||
|
" ax.scatter(e[\"t\"], y_de[e[\"name\"]], color=colores[e[\"subject\"]], s=80, zorder=3)\n",
|
||||||
|
"ax.set_yticks([0, 1]); ax.set_yticklabels([\"sub-cpu\", \"sub-todo\"])\n",
|
||||||
|
"ax.set_xlabel(\"t (segundos desde el arranque de cada proceso)\")\n",
|
||||||
|
"ax.set_title(\"Timeline de entregas — rojo: telemetria.cpu, azul: telemetria.mem\")\n",
|
||||||
|
"ax.grid(axis=\"x\", alpha=0.3)\n",
|
||||||
|
"from matplotlib.patches import Patch\n",
|
||||||
|
"ax.legend(handles=[Patch(color=c, label=s) for s, c in colores.items()], loc=\"upper right\")\n",
|
||||||
|
"plt.tight_layout(); plt.show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "5914c849",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Resumen del análisis\n",
|
||||||
|
"\n",
|
||||||
|
"A lo largo de los tres notebooks hemos visto cómo distintos procesos envían datos por pub/sub con NATS:\n",
|
||||||
|
"\n",
|
||||||
|
"- **01** — el modelo base: publishers y subscribers desacoplados por un broker, *fan-out* y *wildcards*.\n",
|
||||||
|
"- **02** — patrones de orden superior: *queue groups* (reparto de carga), *request/reply* (RPC) y *JetStream* (persistencia y replay).\n",
|
||||||
|
"- **03** — **procesos del SO reales**: el desacople de verdad. El publisher no conoce a sus subscribers; el broker enruta por subject. Añadir o quitar procesos consumidores no cambia ni una línea del publisher.\n",
|
||||||
|
"\n",
|
||||||
|
"Esa es la idea central de NATS: **los procesos se comunican por nombres de subject, no por direcciones**, y el broker se encarga del resto.\n",
|
||||||
|
"\n",
|
||||||
|
"### Limpieza (opcional)\n",
|
||||||
|
"\n",
|
||||||
|
"Para parar el broker cuando termines:\n",
|
||||||
|
"\n",
|
||||||
|
"```python\n",
|
||||||
|
"import subprocess\n",
|
||||||
|
"subprocess.run([\"docker\", \"stop\", \"nats_demo\"]) # detener\n",
|
||||||
|
"subprocess.run([\"docker\", \"rm\", \"nats_demo\"]) # eliminar\n",
|
||||||
|
"```"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "Python 3 (ipykernel)",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"name": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publisher NATS como proceso del sistema operativo independiente.
|
||||||
|
|
||||||
|
Se conecta al broker y publica una rafaga de mensajes de telemetria,
|
||||||
|
alternando entre los subjects `telemetria.cpu` y `telemetria.mem`.
|
||||||
|
No sabe ni le importa cuantos subscribers hay escuchando: solo conoce el
|
||||||
|
subject. Emite cada publicacion como linea JSON en stdout.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
|
import nats
|
||||||
|
|
||||||
|
NATS_URL = "nats://127.0.0.1:4222"
|
||||||
|
|
||||||
|
|
||||||
|
def emit(event: dict) -> None:
|
||||||
|
print(json.dumps(event), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def main(count: int, interval: float) -> None:
|
||||||
|
pid = os.getpid()
|
||||||
|
nc = await nats.connect(NATS_URL, name="publisher")
|
||||||
|
emit({"event": "ready", "pid": pid, "name": "publisher"})
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
subject = "telemetria.cpu" if i % 2 == 0 else "telemetria.mem"
|
||||||
|
payload = json.dumps({"i": i, "valor": round(random.uniform(0, 100), 1)})
|
||||||
|
await nc.publish(subject, payload.encode())
|
||||||
|
emit({"event": "published", "pid": pid, "subject": subject, "i": i})
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
await nc.flush()
|
||||||
|
emit({"event": "done", "pid": pid, "name": "publisher", "published": count})
|
||||||
|
await nc.drain()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Publisher NATS de demostracion")
|
||||||
|
parser.add_argument("--count", type=int, default=8, help="Numero de mensajes a publicar")
|
||||||
|
parser.add_argument("--interval", type=float, default=0.15,
|
||||||
|
help="Segundos entre publicaciones")
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(main(args.count, args.interval))
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Subscriber NATS como proceso del sistema operativo independiente.
|
||||||
|
|
||||||
|
Se conecta al broker, se suscribe a uno o varios subjects y emite cada evento
|
||||||
|
como una linea JSON en stdout para que el proceso padre (el notebook) la lea.
|
||||||
|
Termina solo tras `--seconds` segundos.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import nats
|
||||||
|
|
||||||
|
NATS_URL = "nats://127.0.0.1:4222"
|
||||||
|
|
||||||
|
|
||||||
|
def emit(event: dict) -> None:
|
||||||
|
"""Escribe un evento como linea JSON en stdout, con flush inmediato."""
|
||||||
|
print(json.dumps(event), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def main(name: str, subjects: list[str], seconds: float) -> None:
|
||||||
|
pid = os.getpid()
|
||||||
|
nc = await nats.connect(NATS_URL, name=name)
|
||||||
|
received = 0
|
||||||
|
t0 = time.monotonic()
|
||||||
|
|
||||||
|
async def handler(msg):
|
||||||
|
nonlocal received
|
||||||
|
received += 1
|
||||||
|
emit({
|
||||||
|
"event": "msg",
|
||||||
|
"pid": pid,
|
||||||
|
"name": name,
|
||||||
|
"subject": msg.subject,
|
||||||
|
"data": msg.data.decode(),
|
||||||
|
"t": round(time.monotonic() - t0, 4),
|
||||||
|
})
|
||||||
|
|
||||||
|
for subject in subjects:
|
||||||
|
await nc.subscribe(subject, cb=handler)
|
||||||
|
|
||||||
|
# Senal de que este proceso ya esta escuchando (el padre la espera).
|
||||||
|
emit({"event": "ready", "pid": pid, "name": name, "subjects": subjects})
|
||||||
|
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
emit({"event": "done", "pid": pid, "name": name, "received": received})
|
||||||
|
await nc.drain()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Subscriber NATS de demostracion")
|
||||||
|
parser.add_argument("--name", required=True, help="Nombre logico del subscriber")
|
||||||
|
parser.add_argument("--subjects", required=True,
|
||||||
|
help="Subjects separados por coma (admite wildcards)")
|
||||||
|
parser.add_argument("--seconds", type=float, default=4.0,
|
||||||
|
help="Tiempo de escucha antes de terminar")
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(main(args.name, args.subjects.split(","), args.seconds))
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[project]
|
||||||
|
name = "nats"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"jupyter>=1.1.1",
|
||||||
|
"jupyter-collaboration>=4.4.0",
|
||||||
|
"jupyter-mcp-server>=1.0.2",
|
||||||
|
"jupyterlab>=4.5.7",
|
||||||
|
"matplotlib>=3.10.9",
|
||||||
|
"nats-py>=2.14.0",
|
||||||
|
"numpy>=2.4.6",
|
||||||
|
"pandas>=3.0.3",
|
||||||
|
]
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Jupyter Lab — modo colaborativo con autodeteccion de puerto
|
||||||
|
# Generado por write_jupyter_launcher (fn_registry)
|
||||||
|
|
||||||
|
find_free_port() {
|
||||||
|
for port in 8888 8889 8890 8891 8892 8893 8894 8895 8896 8897 8898 8899; do
|
||||||
|
if ! ss -tln 2>/dev/null | grep -q ":${port} " && \
|
||||||
|
! lsof -i:"$port" >/dev/null 2>&1; then
|
||||||
|
echo $port
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo 8888
|
||||||
|
}
|
||||||
|
|
||||||
|
PORT=${1:-$(find_free_port)}
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo $PORT > .jupyter-port
|
||||||
|
|
||||||
|
source .venv/bin/activate 2>/dev/null || true
|
||||||
|
|
||||||
|
# IPython startup: cargar .ipython/ local (FN_REGISTRY_ROOT, helpers, sys.path)
|
||||||
|
if [ -d "$(pwd)/.ipython" ]; then
|
||||||
|
export IPYTHONDIR="$(pwd)/.ipython"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python -c "import jupyter_collaboration" 2>/dev/null; then
|
||||||
|
echo "ERROR: jupyter-collaboration no esta instalado"
|
||||||
|
echo "Instala con: uv add jupyter-collaboration"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "════════════════════════════════════════════════"
|
||||||
|
echo " Jupyter Lab + Colaboracion en puerto $PORT"
|
||||||
|
echo "════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo " Abre: http://localhost:$PORT"
|
||||||
|
echo " Ctrl+C para detener"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
jupyter lab \
|
||||||
|
--port=$PORT \
|
||||||
|
--no-browser \
|
||||||
|
--ServerApp.token='' \
|
||||||
|
--ServerApp.password='' \
|
||||||
|
--ServerApp.disable_check_xsrf=True \
|
||||||
|
--ServerApp.allow_origin='*' \
|
||||||
|
--ServerApp.root_dir="$(pwd)" \
|
||||||
|
--collaborative
|
||||||
Reference in New Issue
Block a user