392 lines
37 KiB
Plaintext
392 lines
37 KiB
Plaintext
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "a5e95055",
|
|
"metadata": {},
|
|
"source": [
|
|
"# NATS pub/sub — 02 · Queue groups, Request/Reply y JetStream\n",
|
|
"\n",
|
|
"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:\n",
|
|
"\n",
|
|
"1. **Queue groups** — repartir la carga: varios *workers* comparten el trabajo y cada mensaje lo procesa **uno solo**.\n",
|
|
"2. **Request/Reply** — RPC sobre mensajería: un cliente pregunta y espera la respuesta de un servicio.\n",
|
|
"3. **JetStream** — la capa de **persistencia**: streams que almacenan los mensajes y permiten *replay*, a diferencia del core *fire-and-forget*.\n",
|
|
"\n",
|
|
"> 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."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "1ab4e0d1",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 0 · Setup: broker + conexión\n",
|
|
"\n",
|
|
"Reutilizamos el mismo broker en Docker. `ensure_nats` es idempotente; si el contenedor sigue vivo del notebook 01, simplemente se reaprovecha."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": 1,
|
|
"id": "f17ec9ef",
|
|
"metadata": {},
|
|
"outputs": [
|
|
{
|
|
"name": "stdout",
|
|
"output_type": "stream",
|
|
"text": [
|
|
"Broker: already-running\n",
|
|
"Conectado, client_id: 15\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",
|
|
"import asyncio\n",
|
|
"import nats\n",
|
|
"\n",
|
|
"print(\"Broker:\", ensure_nats())\n",
|
|
"nc = await nats.connect(NATS_URL, name=\"notebook-02\")\n",
|
|
"print(\"Conectado, client_id:\", nc._server_info[\"client_id\"])"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "6736be4f",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 1 · Queue groups — reparto de carga entre workers\n",
|
|
"\n",
|
|
"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.\n",
|
|
"\n",
|
|
"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."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": 2,
|
|
"id": "630b847e",
|
|
"metadata": {},
|
|
"outputs": [
|
|
{
|
|
"name": "stdout",
|
|
"output_type": "stream",
|
|
"text": [
|
|
"Reparto de carga (cada tarea fue a UN solo worker):\n",
|
|
" worker-1: 7 tareas\n",
|
|
" worker-3: 5 tareas\n",
|
|
" TOTAL procesado: 12 de 12 tareas\n"
|
|
]
|
|
}
|
|
],
|
|
"source": [
|
|
"from collections import Counter\n",
|
|
"\n",
|
|
"trabajo = Counter() # cuántas tareas procesó cada worker\n",
|
|
"orden = [] # traza temporal (worker, tarea)\n",
|
|
"\n",
|
|
"def make_worker(nombre):\n",
|
|
" async def worker(msg):\n",
|
|
" tarea = msg.data.decode()\n",
|
|
" trabajo[nombre] += 1\n",
|
|
" orden.append((nombre, tarea))\n",
|
|
" return worker\n",
|
|
"\n",
|
|
"# 3 workers, MISMO subject, MISMO queue group -> NATS reparte\n",
|
|
"workers = []\n",
|
|
"for nombre in [\"worker-1\", \"worker-2\", \"worker-3\"]:\n",
|
|
" s = await nc.subscribe(\"tareas\", queue=\"procesadores\", cb=make_worker(nombre))\n",
|
|
" workers.append(s)\n",
|
|
"\n",
|
|
"# Publicar 12 tareas\n",
|
|
"for i in range(12):\n",
|
|
" await nc.publish(\"tareas\", f\"tarea-{i:02d}\".encode())\n",
|
|
"await nc.flush()\n",
|
|
"await asyncio.sleep(0.5)\n",
|
|
"\n",
|
|
"print(\"Reparto de carga (cada tarea fue a UN solo worker):\")\n",
|
|
"for w, n in sorted(trabajo.items()):\n",
|
|
" print(f\" {w}: {n} tareas\")\n",
|
|
"print(f\" TOTAL procesado: {sum(trabajo.values())} de 12 tareas\")\n",
|
|
"\n",
|
|
"for s in workers:\n",
|
|
" await s.unsubscribe()"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "5b390568",
|
|
"metadata": {},
|
|
"source": [
|
|
"### Visualización del reparto\n",
|
|
"\n",
|
|
"El total siempre suma 12 (ninguna tarea se duplica ni se pierde), repartido de forma aproximadamente equilibrada entre los workers."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": 3,
|
|
"id": "3d9bbd36",
|
|
"metadata": {},
|
|
"outputs": [
|
|
{
|
|
"data": {
|
|
"image/png": "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IiLSNSayleCjjz7CRx99pO8wdOrQoUMYN24c+vbtCxsbGxw9ehTffPMNmjRpgr59++o7PCIiInoFMJGlcnFzc4OLiwsWL16Me/fuoVatWnjvvfcwa9asYpPcExEREVUEjpElIiIiIoPEeWSJiIiIyCAxkSUiIiIig2TQY2SLiopw8+ZNWFpaSv4JPCIiIiKqeoQQyM3NhZOTE4yMSu9zNehE9ubNm3BxcdF3GERERESkY9euXUOdOnVKrWPQiazypzavXbsGKysrPUdDRERERC8qJycHLi4uqjyvNAadyCqHE1hZWTGRJSIiInqJaDNslDd7EREREZFBYiJLRERERAaJiSwRERERGSQmskRERERkkJjIEhEREZFBYiJLRERERAaJiSwRERERGSQmskRERERkkJjIEhEREZFBYiJLRERERAaJiSwRERERGSQmskRERERkkJjIEhEREZFBYiJLRERERAaJiSwRERERGSQmskRERERkkJjIEhEREZFBYiJLRERERAZJr4lsYWEhpkyZgrp168Lc3BweHh749NNPIYTQZ1hEREREZACq6XPjs2fPRlxcHNasWYPGjRvj8OHDCA8Ph0KhwOjRo/UZGhERERFVcXpNZP/66y+888476Nq1KwDAzc0N69evx99//63PsIiIiIjIAOh1aEHr1q2RkJCACxcuAACOHz+O/fv3o3Pnzhrr5+XlIScnR+2PiIiIiF5Neu2RnThxInJycuDp6QljY2MUFhZi+vTpGDRokMb6M2fORGxsbCVHSURERERVkV57ZDdu3IjvvvsO33//PY4ePYo1a9Zg7ty5WLNmjcb6kyZNQnZ2turv2rVrlRwxEREREVUVMqHHKQJcXFwwceJEREVFqco+++wzrFu3DufOnStz+ZycHCgUCmRnZ8PKyqoiQyUiIiKiSiAlv9Nrj+zDhw9hZKQegrGxMYqKivQUEREREREZCr2Oke3WrRumT5+O1157DY0bN8axY8cwf/58RERE6DMsIiIiIjIAeh1akJubiylTpmDr1q1IS0uDk5MTQkJCMHXqVJiampa5PIcWEBEREb1cpOR3ek1kXxQTWSIiIqKXi8GMkSUiIiIiKi8mslSp3NzcIJPJiv09O3MFERERkTb0erMXvXqSk5NRWFioenzq1Cl06NABffv21WNUREREZIiYyFKlsrW1VXs8a9YseHh4IDAwUE8RERERkaHi0ALSm/z8fKxbtw4RERGQyWT6DoeIiIgMDBNZ0puffvoJWVlZGDx4sL5DISIiIgPERJb05ptvvkHnzp3h5OSk71CIiIjIAHGMLOnF1atXsXv3bmzZskXfoRAREZGBYo8s6cWqVatgZ2eHrl276jsUIiIiMlBMZKnSFRUVYdWqVQgLC0O1arwoQEREROXDRJYq3e7du5GamoqIiAh9h0JEREQGjN1hVOk6duwIIYS+wyAiIiIDxx5ZIiIiIjJITGSJiIiIyCAxkSUiIiIig8REloiIiIgMEhNZIiIiIjJITGSJiIiIyCBJnn5rzZo1qF27tuoXmT788EOsWLECXl5eWL9+PVxdXXUeZFXT4P0MfYdApHLh69r6DoGIiEgvJPfIzpgxA+bm5gCAgwcP4ssvv8Tnn3+O2rVrY9y4cToPkIiIiIhIE8k9steuXUO9evUAAD/99BN69+6NoUOHws/PD23bttV1fEREREREGknuka1Rowbu3r0LANi5cyc6dOgAADAzM8OjR490Gx0RERERUQkk98h26NAB77//Pt544w1cuHABXbp0AQCcPn0abm5uuo6PiIiIiEgjyT2yX375JVq1aoX09HRs3rwZNjY2AIAjR44gJCRE5wESEREREWkiE0IIfQdRXjk5OVAoFMjOzoaVlVWlbZezFlBVwlkLiIjoZSIlv5M8tEDp4cOHSE1NRX5+vlr566+/Xt5VEhERERFpTXIim56ejsGDB2PHjh0any8sLHzhoIiIiIiIyiJ5jOzYsWORnZ2NpKQkmJubY8eOHVizZg3q16+Pn3/+uSJiJCIiIiIqRnKP7B9//IFt27bB19cXRkZGcHV1RYcOHWBlZYWZM2eqfvGLiIiIiKgiSe6RffDgAezs7AAANWvWRHp6OgCgadOmOHr0qG6jIyIiIiIqgeREtmHDhjh//jwAwNvbG8uXL8eNGzewbNkyODo66jxAIiIiIiJNJA8tGDNmDG7dugUAiImJQadOnfDdd9/B1NQUq1ev1nV8REREREQaSU5kQ0NDVf/7+Pjg6tWrOHfuHF577TXUrs35LImIiIiocpR7Hlml6tWro3nz5rqIhYiIiIhIa1olstHR0VqvcP78+eUOhoiIiIhIW1olsseOHVN7fPToURQUFKBhw4YAgAsXLsDY2Bg+Pj66j5CIiIiISAOtEtk9e/ao/p8/fz4sLS2xZs0a1KxZEwCQmZmJ8PBw+Pv7V0yURERERETPkQkhhJQFnJ2dsXPnTjRu3Fit/NSpU+jYsSNu3ryp0wBLk5OTA4VCgezsbFhZWVXadhu8n1Fp2yIqy4WveZMlERG9PKTkd5Lnkc3JyVH9CMKz0tPTkZubK3V1uHHjBkJDQ2FjYwNzc3M0bdoUhw8flrweIiIiInq1SJ61oGfPnggPD8e8efPQokULAEBSUhImTJiAXr16SVpXZmYm/Pz88NZbb+G3336Dra0tLl68qBqyQERERERUEsmJ7LJlyzB+/HgMHDgQT548ebqSatUwZMgQzJkzR9K6Zs+eDRcXF6xatUpVVrduXakhEREREdErSPIYWaUHDx7g8uXLAAAPDw9YWFhIXoeXlxeCg4Nx/fp17N27F87Ozhg5ciQiIyO1Wp5jZIk4RpaIiF4uUvK7cv8ggoWFBV5//fXyLg4A+PfffxEXF4fo6Gj873//Q3JyMkaPHg1TU1OEhYUVq5+Xl4e8vDzV45ycnBfaPhEREREZrnIlsocPH8bGjRuRmpqK/Px8tee2bNmi9XqKiorg6+uLGTNmAADeeOMNnDp1CsuWLdOYyM6cOROxsbHlCZmIiIiIXjKSZy344Ycf0Lp1a5w9exZbt27FkydPcPr0afzxxx9QKBSS1uXo6AgvLy+1skaNGiE1NVVj/UmTJiE7O1v1d+3aNanhExEREdFLQnKP7IwZM7BgwQJERUXB0tISixYtQt26dTFs2DA4OjpKWpefnx/Onz+vVnbhwgW4urpqrC+XyyGXy6WGTEREREQvIck9spcvX0bXrl0BAKampnjw4AFkMhnGjRuHFStWSFrXuHHjcOjQIcyYMQOXLl3C999/jxUrViAqKkpqWERERET0ipGcyNasWVP1wwfOzs44deoUACArKwsPHz6UtK4333wTW7duxfr169GkSRN8+umnWLhwIQYNGiQ1LCIiIiJ6xUgeWhAQEIBdu3ahadOm6Nu3L8aMGYM//vgDu3btQlBQkOQA3n77bbz99tuSlyMiIiKiV5vkRHbJkiV4/PgxAGDy5MkwMTHBX3/9hd69e+Pjjz/WeYBERERERJpITmRr1aql+t/IyAgTJ07UaUBERERERNqQPEb26NGjOHnypOrxtm3b0KNHD/zvf/8rNqcsERERUWWaNm0aZDKZ2p+np6e+w6IKIjmRHTZsGC5cuADg6S9z9e/fH9WrV8ePP/6IDz/8UOcBEhEREUnRuHFj3Lp1S/W3f/9+fYdEFURyInvhwgU0a9YMAPDjjz8iMDAQ33//PVavXo3NmzfrOj4iIiIiSapVqwYHBwfVX+3atfUdElUQyYmsEAJFRUUAgN27d6NLly4AABcXF2RkZOg2OiIiIiKJLl68CCcnJ7i7u2PQoEEl/mIoGT7Jiayvry8+++wzrF27Fnv37lX9OMKVK1dgb2+v8wCJiIiItNWyZUusXr0aO3bsQFxcHK5cuQJ/f3/VHPj0cpE8a4HyBwt++uknTJ48GfXq1QMAbNq0Ca1bt9Z5gERERETa6ty5s+r/119/HS1btoSrqys2btyIIUOG6DEyqgiSE9nXX39dbdYCpTlz5sDY2FgnQRERERHpgrW1NRo0aIBLly7pOxSqAJKHFgBPf47266+/xqRJk3Dv3j0AwJkzZ5CWlqbT4IiIiIhexP3793H58mU4OjrqOxSqAJJ7ZE+cOIGgoCBYW1sjJSUFkZGRqFWrFrZs2YLU1FR8++23FREnERERUZnGjx+Pbt26wdXVFTdv3kRMTAyMjY0REhKi79CoAkjukY2OjkZ4eDguXrwIMzMzVXmXLl2wb98+nQZHREREJMX169cREhKChg0bol+/frCxscGhQ4dga2ur79CoAkjukU1OTsby5cuLlTs7O+P27ds6CYqIiIioPH744Qd9h0CVSHKPrFwuR05OTrHyCxcu8NsOEREREVUayYls9+7d8cknn+DJkycAAJlMhtTUVHz00Ufo3bu3zgMkIiIiItJEciI7b9483L9/H3Z2dnj06BECAwNRr149WFpaYvr06RURIxERERFRMZLHyCoUCuzatQsHDhzA8ePHcf/+fTRv3hzt27eviPiIiIiIiDSSnMgq+fn5wc/PT5exEBERERFpTfLQgtGjR2Px4sXFypcsWYKxY8fqIiYiIiIiojJJ7pHdvHkzfv7552LlrVu3xqxZs7Bw4UJdxEVERPTKuuQ5Ud8hEKnUOzdL3yGUSHKP7N27d6FQKIqVW1lZISMjQydBERERERGVRXIiW69ePezYsaNY+W+//QZ3d3edBEVEREREVBbJQwuio6MxatQopKeno127dgCAhIQEzJs3j8MKiIiIiKjSSE5kIyIikJeXh+nTp+PTTz8FALi5uSEuLg7vvfeezgMkIiIiItKkXNNvjRgxAiNGjEB6ejrMzc1Ro0YNXcdFRERERFSqcs8jm56ejvPnzwMAPD09Ubt2bZ0FRURERERUFsk3ez148AARERFwdHREQEAAAgIC4OjoiCFDhuDhw4cVESMRERERUTGSE9no6Gjs3bsX27dvR1ZWFrKysrBt2zbs3bsX//3vfysiRiIiIiKiYsr1gwibNm1C27ZtVWVdunSBubk5+vXrh7i4OF3GR0RERESkkeQe2YcPH8Le3r5YuZ2dHYcWEBEREVGlkZzItmrVCjExMXj8+LGq7NGjR4iNjUWrVq10GhwRERERUUkkDy1YuHAhOnXqhDp16sDb2xsAcPz4cZiZmeH333/XeYBERERERJpITmSbNm2Kixcv4rvvvsO5c+cAACEhIRg0aBDMzc11HiARERERkSaSEtknT57A09MT8fHxiIyMrKiYiIiIiIjKJGmMrImJidrYWCIiIiIifZF8s1dUVBRmz56NgoKCioiHiIiIiEgrksfIJicnIyEhATt37kTTpk1hYWGh9vyWLVt0FhwRERERUUkkJ7LW1tbo3bt3RcRCRERERKQ1yYnsqlWrKiIOIiIiIiJJJI+RVUpLS0NiYiISExORlpb2woHMmjULMpkMY8eOfeF1EREREdHLT3Iim5OTg3fffRfOzs4IDAxEYGAgnJ2dERoaiuzs7HIFkZycjOXLl+P1118v1/JERERE9OqRnMhGRkYiKSkJ8fHxyMrKQlZWFuLj43H48GEMGzZMcgD379/HoEGD8NVXX6FmzZqSlyciIiKiV5PkRDY+Ph4rV65EcHAwrKysYGVlheDgYHz11VfYvn275ACioqLQtWtXtG/fXvKyRERERPTqknyzl42NDRQKRbFyhUIhuUf1hx9+wNGjR5GcnKxV/by8POTl5ake5+TkSNoeEREREb08JPfIfvzxx4iOjsbt27dVZbdv38aECRMwZcoUrddz7do1jBkzBt999x3MzMy0WmbmzJlQKBSqPxcXF6nhExEREdFLQiaEEFIWeOONN3Dp0iXk5eXhtddeAwCkpqZCLpejfv36anWPHj1a4np++ukn9OzZE8bGxqqywsJCyGQyGBkZIS8vT+05QHOPrIuLC7Kzs2FlZSVlN15Ig/czKm1bRGW58HVtfYdARDp2yXOivkMgUql3blalbi8nJwcKhUKr/E7y0IIePXqUNy41QUFBOHnypFpZeHg4PD098dFHHxVLYgFALpdDLpfrZPtEREREZNgkJ7IxMTE62bClpSWaNGmiVmZhYQEbG5ti5UREREREzyv3DyIQEREREemT5B7ZivTnn3/qOwQiIiIiMhDskSUiIiIig8REloiIiIgM0gsnsoWFhfjnn3+QmZmpi3iIiIiIiLQiOZEdO3YsvvnmGwBPk9jAwEA0b94cLi4uHONKRERERJVGciK7adMmeHt7AwC2b9+OK1eu4Ny5cxg3bhwmT56s8wCJiIiIiDSRnMhmZGTAwcEBAPDrr7+ib9++aNCgASIiIor9wAERERERUUWRnMja29vjzJkzKCwsxI4dO9ChQwcAwMOHDzX+GhcRERERUUWQPI9seHg4+vXrB0dHR8hkMrRv3x4AkJSUBE9PT50HSERERESkieREdtq0aWjSpAmuXbuGvn37Qi6XAwCMjY0xceJEnQdIRERERKRJuX7Zq0+fPsXKwsLCXjgYIiIiIiJtlSuRffDgAfbu3YvU1FTk5+erPTd69GidBEZEREREVBrJieyxY8fQpUsXPHz4EA8ePECtWrWQkZGB6tWrw87OjoksEREREVUKybMWjBs3Dt26dUNmZibMzc1x6NAhXL16FT4+Ppg7d25FxEhEREREVIzkRPaff/7Bf//7XxgZGcHY2Bh5eXlwcXHB559/jv/9738VESMRERERUTGSE1kTExMYGT1dzM7ODqmpqQAAhUKBa9eu6TY6IiIiIqISSB4j+8YbbyA5ORn169dHYGAgpk6dioyMDKxduxZNmjSpiBiJiIiIiIqR3CM7Y8YMODo6AgCmT5+OmjVrYsSIEUhPT8eKFSt0HiARERERkSaSe2R9fX1V/9vZ2WHHjh06DYiIiIiISBuSe2QBoKCgALt378by5cuRm5sLALh58ybu37+v0+CIiIiIiEoiuUf26tWr6NSpE1JTU5GXl4cOHTrA0tISs2fPRl5eHpYtW1YRcRIRERERqZHcIztmzBj4+vqq5pFV6tmzJxISEnQaHBERERFRSST3yCYmJuKvv/6CqampWrmbmxtu3Lihs8CIiIiIiEojuUe2qKgIhYWFxcqvX78OS0tLnQRFRERERFQWyYlsx44dsXDhQtVjmUyG+/fvIyYmBl26dNFlbEREREREJZI8tGDu3Lno1KkTvLy88PjxYwwcOBAXL15E7dq1sX79+oqIkYiIiIioGMmJrIuLC44fP44NGzbg+PHjuH//PoYMGYJBgwap3fxFRERERFSRJCWyT548gaenJ+Lj4zFo0CAMGjSoouIiIiIiIiqVpDGyJiYmePz4cUXFQkRERESkNck3e0VFRWH27NkoKCioiHiIiIiIiLQieYxscnIyEhISsHPnTjRt2hQWFhZqz2/ZskVnwRERERERlURyImttbY3evXtXRCxERERERFqTnMiuWrWqIuIgIiIiIpJE8hhZIiIiIqKqQHKPLABs2rQJGzduRGpqKvLz89WeO3r0qE4CIyIiIiIqjeQe2cWLFyM8PBz29vY4duwYWrRoARsbG/z777/o3LlzRcRIRERERFSM5ER26dKlWLFiBb744guYmpriww8/xK5duzB69GhkZ2dXRIxERERERMVITmRTU1PRunVrAIC5uTlyc3MBAO+++y7Wr1+v2+iIiIiIiEogOZF1cHDAvXv3AACvvfYaDh06BAC4cuUKhBC6jY6IiIiIqASSE9l27drh559/BgCEh4dj3Lhx6NChA/r374+ePXvqPEAiIiIiIk0kz1qwYsUKFBUVAXj6c7U2Njb466+/0L17dwwbNkzSumbOnIktW7bg3LlzMDc3R+vWrTF79mw0bNhQalhERERE9IqR3CN7/fp1GBsbqx4PGDAAixcvxqhRo3D79m1J69q7dy+ioqJw6NAh7Nq1C0+ePEHHjh3x4MEDqWERERER0StGco9s3bp1cevWLdjZ2amV37t3D3Xr1kVhYaHW69qxY4fa49WrV8POzg5HjhxBQECA1NCIiIiI6BUiuUdWCAGZTFas/P79+zAzM3uhYJTTd9WqVeuF1kNERERELz+te2Sjo6MBADKZDFOmTEH16tVVzxUWFiIpKQnNmjUrdyBFRUUYO3Ys/Pz80KRJE4118vLykJeXp3qck5NT7u0RERERkWHTOpE9duwYgKc9sidPnoSpqanqOVNTU3h7e2P8+PHlDiQqKgqnTp3C/v37S6wzc+ZMxMbGlnsbRERERPTy0DqR3bNnD4CnU24tWrQIVlZWOgti1KhRiI+Px759+1CnTp0S602aNEnVMww87ZF1cXHRWRxEREREZDgk3+y1atUqnW1cCIEPPvgAW7duxZ9//om6deuWWl8ul0Mul+ts+0RERERkuCQnsroUFRWF77//Htu2bYOlpaVq+i6FQgFzc3N9hkZEREREVZzkWQt0KS4uDtnZ2Wjbti0cHR1Vfxs2bNBnWERERERkAPTaIyuE0OfmiYiIiMiA6bVHloiIiIiovJjIEhEREZFBYiJLRERERAaJiSwRERERGSQmskRERERkkJjIEhEREZFBYiJLRERERAaJiSwRERERGSQmskRERERkkJjIEhEREZFBYiJLRERERAaJiSwRERERGSQmskRERERkkJjIEhEREZFBYiJLRERERAaJiSwRERERGSQmskRERERkkJjIEhEREZFBYiJLRERERAaJiSwRERERGSQmskRERERkkJjIEhEREZFBYiJLRERERAaJiSwRERERGSQmskRERERkkJjIEhEREZFBYiJLRGQAvvzyS7i5ucHMzAwtW7bE33//re+QiIj0joksEVEVt2HDBkRHRyMmJgZHjx6Ft7c3goODkZaWpu/QiIj0ioksEVEVN3/+fERGRiI8PBxeXl5YtmwZqlevjpUrV+o7NCIivWIiS0RUheXn5+PIkSNo3769qszIyAjt27fHwYMH9RgZEZH+MZElIqrCMjIyUFhYCHt7e7Vye3t73L59W09RERFVDUxkiYiIiMggMZElIqrCateuDWNjY9y5c0et/M6dO3BwcNBTVEREVQMTWSKiKszU1BQ+Pj5ISEhQlRUVFSEhIQGtWrXSY2RERPpXTd8BEBFR6aKjoxEWFgZfX1+0aNECCxcuxIMHDxAeHq7v0IiI9IqJLBFRFde/f3+kp6dj6tSpuH37Npo1a4YdO3YUuwGMiOhVw0SWiMgAjBo1CqNGjdJ3GEREVQrHyBIRERGRQWIiS0REREQGqUoksl9++SXc3NxgZmaGli1b4u+//9Z3SERERERUxek9kd2wYQOio6MRExODo0ePwtvbG8HBwUhLS9N3aERERERUhek9kZ0/fz4iIyMRHh4OLy8vLFu2DNWrV8fKlSv1HRoRERERVWF6nbUgPz8fR44cwaRJk1RlRkZGaN++PQ4ePFisfl5eHvLy8lSPs7OzAQA5OTkVH+wzCvNzK3V7RKXJyTHVdwhEpGO5hXllVyKqJJWdZym3J4Qos65eE9mMjAwUFhYWmwvR3t4e586dK1Z/5syZiI2NLVbu4uJSYTESVXWKtfqOgIiI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",
|
|
"text/plain": [
|
|
"<Figure size 700x300 with 1 Axes>"
|
|
]
|
|
},
|
|
"metadata": {},
|
|
"output_type": "display_data",
|
|
"transient": {}
|
|
}
|
|
],
|
|
"source": [
|
|
"import matplotlib.pyplot as plt\n",
|
|
"\n",
|
|
"nombres = [f\"worker-{i}\" for i in (1, 2, 3)]\n",
|
|
"valores = [trabajo.get(n, 0) for n in nombres]\n",
|
|
"\n",
|
|
"fig, ax = plt.subplots(figsize=(7, 3))\n",
|
|
"barras = ax.bar(nombres, valores, color=[\"#2563eb\", \"#16a34a\", \"#db2777\"])\n",
|
|
"ax.bar_label(barras, padding=3)\n",
|
|
"ax.set_ylabel(\"tareas procesadas\")\n",
|
|
"ax.set_title(f\"Queue group 'procesadores' — 12 tareas repartidas entre {len(nombres)} workers\")\n",
|
|
"ax.set_ylim(0, max(valores) + 2)\n",
|
|
"plt.tight_layout(); plt.show()"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "fd720547",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 2 · Request/Reply — RPC sobre NATS\n",
|
|
"\n",
|
|
"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.\n",
|
|
"\n",
|
|
"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."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": 4,
|
|
"id": "6a0f9d88",
|
|
"metadata": {},
|
|
"outputs": [
|
|
{
|
|
"name": "stdout",
|
|
"output_type": "stream",
|
|
"text": [
|
|
" peticion : 'hola mundo'\n",
|
|
" respuesta: 'HOLA MUNDO' (vino por el inbox _INBOX.kmbvFiNMylb4SeFIS6GIZi.kmbvFiNMylb4SeFIS6GIdz68d9)\n",
|
|
"\n",
|
|
" peticion : 'nats request reply'\n",
|
|
" respuesta: 'NATS REQUEST REPLY' (vino por el inbox _INBOX.kmbvFiNMylb4SeFIS6GIZi.kmbvFiNMylb4SeFIS6GIiG3daa)\n",
|
|
"\n",
|
|
" peticion : 'desacople total'\n",
|
|
" respuesta: 'DESACOPLE TOTAL' (vino por el inbox _INBOX.kmbvFiNMylb4SeFIS6GIZi.kmbvFiNMylb4SeFIS6GImX083f)\n",
|
|
"\n",
|
|
"servicio.inexistente -> NoRespondersError: el broker confirma que no hay ningún servicio escuchando\n"
|
|
]
|
|
}
|
|
],
|
|
"source": [
|
|
"import nats.errors\n",
|
|
"\n",
|
|
"# Servicio: convierte el texto recibido a mayúsculas y responde\n",
|
|
"async def servicio_mayusculas(msg):\n",
|
|
" respuesta = msg.data.decode().upper()\n",
|
|
" await msg.respond(respuesta.encode())\n",
|
|
"\n",
|
|
"sub_svc = await nc.subscribe(\"servicio.mayusculas\", cb=servicio_mayusculas)\n",
|
|
"\n",
|
|
"# Cliente: pide y espera respuesta (con timeout)\n",
|
|
"peticiones = [\"hola mundo\", \"nats request reply\", \"desacople total\"]\n",
|
|
"for texto in peticiones:\n",
|
|
" resp = await nc.request(\"servicio.mayusculas\", texto.encode(), timeout=1.0)\n",
|
|
" print(f\" peticion : {texto!r}\")\n",
|
|
" print(f\" respuesta: {resp.data.decode()!r} (vino por el inbox {resp.subject})\")\n",
|
|
" print()\n",
|
|
"\n",
|
|
"# ¿Qué pasa si nadie escucha el subject? El broker avisa al instante con\n",
|
|
"# NoRespondersError (no hace falta esperar al timeout completo).\n",
|
|
"try:\n",
|
|
" await nc.request(\"servicio.inexistente\", b\"hay alguien?\", timeout=0.5)\n",
|
|
"except nats.errors.NoRespondersError:\n",
|
|
" print(\"servicio.inexistente -> NoRespondersError: el broker confirma que no hay ningún servicio escuchando\")\n",
|
|
"except (nats.errors.TimeoutError, asyncio.TimeoutError):\n",
|
|
" print(\"servicio.inexistente -> TimeoutError: nadie respondió a tiempo\")\n",
|
|
"\n",
|
|
"await sub_svc.unsubscribe()"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "c8d7cea4",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 3 · JetStream — persistencia y replay\n",
|
|
"\n",
|
|
"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:\n",
|
|
"\n",
|
|
"- Un **stream** captura y persiste todos los mensajes de unos subjects dados.\n",
|
|
"- Los **consumers** leen del stream a su ritmo, pueden ser **durables** (recuerdan por dónde iban) y permiten **replay** de mensajes históricos.\n",
|
|
"\n",
|
|
"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."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": 5,
|
|
"id": "aa891890",
|
|
"metadata": {},
|
|
"outputs": [
|
|
{
|
|
"name": "stdout",
|
|
"output_type": "stream",
|
|
"text": [
|
|
"Stream creado: EVENTOS subjects=['eventos.>'] storage=file\n",
|
|
" publicado eventos.click -> stream='EVENTOS' seq=1\n",
|
|
" publicado eventos.click -> stream='EVENTOS' seq=2\n",
|
|
" publicado eventos.click -> stream='EVENTOS' seq=3\n",
|
|
" publicado eventos.click -> stream='EVENTOS' seq=4\n",
|
|
" publicado eventos.click -> stream='EVENTOS' seq=5\n",
|
|
"\n",
|
|
"Mensajes retenidos en el stream: 5 (siguen ahí aunque nadie los haya leído)\n"
|
|
]
|
|
}
|
|
],
|
|
"source": [
|
|
"js = nc.jetstream()\n",
|
|
"\n",
|
|
"# Crear (o recrear limpio) un stream que persiste todo lo de 'eventos.>'\n",
|
|
"try:\n",
|
|
" await js.delete_stream(\"EVENTOS\")\n",
|
|
"except Exception:\n",
|
|
" pass\n",
|
|
"stream = await js.add_stream(name=\"EVENTOS\", subjects=[\"eventos.>\"])\n",
|
|
"print(f\"Stream creado: {stream.config.name} subjects={stream.config.subjects} storage={stream.config.storage}\")\n",
|
|
"\n",
|
|
"# Publicar 5 eventos SIN que haya ningún consumidor escuchando todavía\n",
|
|
"for i in range(5):\n",
|
|
" ack = await js.publish(\"eventos.click\", f\"evento #{i}\".encode())\n",
|
|
" print(f\" publicado eventos.click -> stream='{ack.stream}' seq={ack.seq}\")\n",
|
|
"\n",
|
|
"estado = (await js.stream_info(\"EVENTOS\")).state\n",
|
|
"print()\n",
|
|
"print(f\"Mensajes retenidos en el stream: {estado.messages} (siguen ahí aunque nadie los haya leído)\")"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "ff7c086f",
|
|
"metadata": {},
|
|
"source": [
|
|
"### Replay: leer los mensajes históricos\n",
|
|
"\n",
|
|
"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."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": 6,
|
|
"id": "04be5410",
|
|
"metadata": {},
|
|
"outputs": [
|
|
{
|
|
"name": "stdout",
|
|
"output_type": "stream",
|
|
"text": [
|
|
"Recuperados 5 mensajes del stream (replay):\n",
|
|
" seq=1 subject=eventos.click data='evento #0'\n",
|
|
" seq=2 subject=eventos.click data='evento #1'\n",
|
|
" seq=3 subject=eventos.click data='evento #2'\n",
|
|
" seq=4 subject=eventos.click data='evento #3'\n",
|
|
" seq=5 subject=eventos.click data='evento #4'\n"
|
|
]
|
|
},
|
|
{
|
|
"name": "stdout",
|
|
"output_type": "stream",
|
|
"text": [
|
|
"\n",
|
|
"Segundo fetch tras ack: 0 mensajes (el durable recuerda que ya los leyó)\n"
|
|
]
|
|
}
|
|
],
|
|
"source": [
|
|
"# Pull consumer durable: pedimos los mensajes bajo demanda\n",
|
|
"psub = await js.pull_subscribe(\"eventos.>\", durable=\"lector-eventos\")\n",
|
|
"\n",
|
|
"mensajes = await psub.fetch(5, timeout=2)\n",
|
|
"print(f\"Recuperados {len(mensajes)} mensajes del stream (replay):\")\n",
|
|
"for m in mensajes:\n",
|
|
" print(f\" seq={m.metadata.sequence.stream} subject={m.subject} data={m.data.decode()!r}\")\n",
|
|
" await m.ack() # confirmamos el procesado; el durable avanza su cursor\n",
|
|
"\n",
|
|
"# Tras el ack, un segundo fetch no devuelve nada nuevo (cursor avanzado)\n",
|
|
"try:\n",
|
|
" extra = await psub.fetch(1, timeout=1)\n",
|
|
"except Exception:\n",
|
|
" extra = []\n",
|
|
"print()\n",
|
|
"print(f\"Segundo fetch tras ack: {len(extra)} mensajes (el durable recuerda que ya los leyó)\")"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "9742fa68",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Resumen\n",
|
|
"\n",
|
|
"| Patrón | Entrega | Persistencia | Caso de uso |\n",
|
|
"|---|---|---|---|\n",
|
|
"| Core pub/sub (nb 01) | a **todos** los subscribers | no (at-most-once) | eventos en vivo, telemetría |\n",
|
|
"| **Queue group** | a **uno** del grupo | no | *worker pool*, reparto de carga |\n",
|
|
"| **Request/Reply** | a uno, con respuesta | no | RPC, servicios |\n",
|
|
"| **JetStream** | configurable + **replay** | **sí** | event sourcing, colas durables, auditoría |\n",
|
|
"\n",
|
|
"**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."
|
|
]
|
|
}
|
|
],
|
|
"metadata": {
|
|
"kernelspec": {
|
|
"display_name": "Python 3 (ipykernel)",
|
|
"language": "python",
|
|
"name": "python3"
|
|
},
|
|
"language_info": {
|
|
"name": ""
|
|
}
|
|
},
|
|
"nbformat": 4,
|
|
"nbformat_minor": 5
|
|
}
|