<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Franchesco Romero</title>
    <description>The latest articles on DEV Community by Franchesco Romero (@elchesco_).</description>
    <link>https://dev.to/elchesco_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1636392%2Fc4a5090b-c32c-48a0-884c-1d8299fa6fb7.JPG</url>
      <title>DEV Community: Franchesco Romero</title>
      <link>https://dev.to/elchesco_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/elchesco_"/>
    <language>en</language>
    <item>
      <title>Pooling contra una t3.micro, el día que se reventó...RDS Proxy es la salida?</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Mon, 15 Jun 2026 03:53:30 +0000</pubDate>
      <link>https://dev.to/aws-builders/pooling-contra-una-t3micro-el-dia-que-se-reventords-proxy-es-la-salida-46nn</link>
      <guid>https://dev.to/aws-builders/pooling-contra-una-t3micro-el-dia-que-se-reventords-proxy-es-la-salida-46nn</guid>
      <description>&lt;p&gt;Cómo un backend de FastAPI + asyncpg comparte un solo Postgres chiquito con sus propios trabajadores en segundo plano y un segundo servicio, por qué el techo de conexiones, no el CPU, es lo que de verdad le pone tope a nuestro autoescalado, la caída en el cambio de hora que nos enseñó la cuenta, y una mirada honesta a RDS Proxy como la válvula de escape (incluyendo la trampa de asyncpg que lo puede dejar sin hacer nada).&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ajuste&lt;/th&gt;
&lt;th&gt;Valor&lt;/th&gt;
&lt;th&gt;Por qué&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Driver&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql+asyncpg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;asíncrono hasta el fondo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pool_size&lt;/code&gt; / &lt;code&gt;max_overflow&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;8 / 12&lt;/strong&gt; (20 por proceso)&lt;/td&gt;
&lt;td&gt;subido desde 3/5 después de una caída&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pool_pre_ping&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;True&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;mata los sockets muertos tras un reinicio de RDS / inactividad&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pool_recycle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;1800&lt;/code&gt; s&lt;/td&gt;
&lt;td&gt;techo duro que el pre-ping no puede cubrir&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tope de conexiones de RDS&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~87&lt;/strong&gt; (t3.micro, menos las reservadas)&lt;/td&gt;
&lt;td&gt;la verdadera restricción de todo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pruebas&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NullPool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sin pooling entre event loops en pytest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;La lección que replanteó todo el problema: &lt;strong&gt;en un RDS chico, tu cuenta máxima&lt;br&gt;
de tareas la fija &lt;code&gt;max_connections&lt;/code&gt;, no el CPU ni la memoria.&lt;/strong&gt; Un autoescalado&lt;br&gt;
que ignora el presupuesto de conexiones va a escalar directito hacia&lt;br&gt;
&lt;code&gt;QueuePool limit reached&lt;/code&gt;, o peor, &lt;code&gt;FATAL: too many connections&lt;/code&gt; del mismo&lt;br&gt;
Postgres.&lt;/p&gt;
&lt;h2&gt;
  
  
  El pool, y la cuenta escondida adentro de él
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;connect_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prefer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# negocia TLS en RDS, texto plano en local
&lt;/span&gt;    &lt;span class="n"&gt;pool_pre_ping&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pool_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_overflow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                  &lt;span class="c1"&gt;# 8 + 12 = 20 conexiones por proceso
&lt;/span&gt;    &lt;span class="n"&gt;pool_recycle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Veinte conexiones por proceso se ven modestas hasta que las multiplicas por&lt;br&gt;
cada capa entre ellas y la base de datos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  pool_size + max_overflow         = 20   por proceso de Python
  × 2 trabajadores de uvicorn      = 40   por tarea de Fargate
  × 2 durante un despliegue rolling = 80  (tarea vieja drenando + tarea nueva arrancando)
  + servicio de inteligencia (3 + 7) = 10 sobre la misma base de datos
  + alembic / ad-hoc / psql        ≈  unas pocas
  ----------------------------------------
  ≈ 87  ← el techo de la t3.micro, con ~0 de margen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ese es el presupuesto entero, agotado, con &lt;strong&gt;una sola&lt;/strong&gt; tarea de backend. El&lt;br&gt;
tope de conexiones es por qué el backend no puede simplemente &lt;code&gt;desiredCount: 5&lt;/code&gt;&lt;br&gt;
— cinco tareas querrían 200 conexiones contra una base de datos que reparte&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;El tamaño del pool no es un ajuste de afinación de rendimiento aquí; es un
ajuste de &lt;strong&gt;racionamiento&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Una sutileza que muerde: los trabajadores en segundo plano corren &lt;strong&gt;dentro del&lt;br&gt;
proceso&lt;/strong&gt;. &lt;code&gt;main.py&lt;/code&gt; los lanza como tareas de asyncio dentro del mismo proceso&lt;br&gt;
de uvicorn, así que jalan del &lt;em&gt;mismo&lt;/em&gt; pool de 20 conexiones que los manejadores&lt;br&gt;
de peticiones HTTP. No hay un pool de trabajadores aparte que dimensionar de&lt;br&gt;
manera independiente, el ciclo del cron y la petición a la API compiten por&lt;br&gt;
ranuras idénticas.&lt;/p&gt;
&lt;h2&gt;
  
  
  El día que se reventó
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;QueuePool limit of size 3 overflow 5 reached, connection timed out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;El 2026-05-27 el pool estaba en &lt;code&gt;3/5&lt;/code&gt;, 8 conexiones por proceso. En el cambio&lt;br&gt;
de hora, cuatro trabajadores de cron (&lt;code&gt;ama&lt;/code&gt;, &lt;code&gt;challenge&lt;/code&gt;, &lt;code&gt;marketplace&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;daily_token_digest&lt;/code&gt;) despertaron todos en el &lt;code&gt;:00&lt;/code&gt;, cada uno abriendo una&lt;br&gt;
sesión, mientras el tráfico normal de peticiones también jalaba del pool. Ocho&lt;br&gt;
ranuras, muchos más de ocho prestatarios concurrentes, y las escrituras de&lt;br&gt;
latido empezaron a vencerse por tiempo.&lt;/p&gt;

&lt;p&gt;Dos cosas estaban mal, y solo una era "el pool está muy chico":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;El pool de verdad estaba muy apretado&lt;/strong&gt; para la carga combinada de
peticiones + trabajadores. &lt;code&gt;3/5&lt;/code&gt; no dejaba holgura para una estampida de
planificadores que, por diseño, se disparan todos en la misma frontera del
cron.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los trabajadores no tenían jitter.&lt;/strong&gt; Que todo se dispare exactamente en el
&lt;code&gt;:00&lt;/code&gt; convierte trabajos independientes en una estampida sincronizada.
(Escalonar los desfases del tick es la mitad más barata de la solución; este
post trata de la mitad del pool.)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;La solución fue subir el pool a &lt;code&gt;8/12&lt;/code&gt; (20/proceso), elegido &lt;em&gt;porque&lt;/em&gt; la cuenta&lt;br&gt;
de conexiones de arriba decía que 20/proceso es el valor más grande que todavía&lt;br&gt;
cabe en dos tareas debajo de 87 durante un despliegue. No es un número redondo;&lt;br&gt;
es el número más grande que el techo permitía.&lt;/p&gt;
&lt;h2&gt;
  
  
  Mitigaciones que no son dimensionar
&lt;/h2&gt;

&lt;p&gt;Dimensionar te mantiene debajo del tope. Otros tres ajustes evitan que las&lt;br&gt;
conexiones que &lt;em&gt;sí&lt;/em&gt; tienes se queden viejas o se fuguen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pool_pre_ping&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# un SELECT 1 barato antes de entregar una conexión; reconecta si está muerta
&lt;/span&gt;&lt;span class="n"&gt;pool_recycle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# cierra a la fuerza + reabre tras 30 min pase lo que pase
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pool_pre_ping&lt;/code&gt; importa porque un proceso de larga vida (un ciclo de&lt;br&gt;
trabajador, un manejador de WebSocket) puede quedarse sentado sobre una&lt;br&gt;
conexión a través de un reinicio de RDS o de un timeout de inactividad de&lt;br&gt;
NAT/firewall. Sin el pre-ping, la primera consulta después de que el socket&lt;br&gt;
muere lanza excepción; con él, SQLAlchemy reconecta calladito. &lt;code&gt;pool_recycle&lt;/code&gt;&lt;br&gt;
es el respaldo que el pre-ping no puede dar, algunas conexiones muertas se ven&lt;br&gt;
vivas para un &lt;code&gt;SELECT 1&lt;/code&gt; hasta que de verdad las usas, así que le ponemos tope&lt;br&gt;
directo a la edad de la conexión.&lt;/p&gt;

&lt;p&gt;Y en las pruebas, &lt;strong&gt;nada de pool&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# el conftest amarra la fábrica de sesiones a un engine con NullPool
&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Las conexiones agrupadas no sobreviven a que las pasen entre los event loops&lt;br&gt;
por prueba que crea pytest-asyncio; &lt;code&gt;NullPool&lt;/code&gt; abre y cierra una conexión por&lt;br&gt;
cada checkout, lo cual es correcto (aunque más lento) para las pruebas y&lt;br&gt;
esquiva una clase de errores de "atado a un loop distinto".&lt;/p&gt;
&lt;h2&gt;
  
  
  El hilo trampa
&lt;/h2&gt;

&lt;p&gt;Como la siguiente caída de esta clase es invisible hasta que ya no lo es, la&lt;br&gt;
cadena de la falla está conectada directo a una alarma:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// infra: convierte la cadena exacta del error en una métrica + alarma de CloudWatch&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MetricFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DbPoolExhaustedMetricFilter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"QueuePool limit of size"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DbPoolExhausted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metricValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// Alarma: ≥3 en 15 min → "el pool sigue muy chico; considera subir el RDS&lt;/span&gt;
&lt;span class="c1"&gt;// (t3.micro→small) ANTES de volver a subir pool_size, tope de RDS ≈87 conexiones"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El texto de la alarma codifica el árbol de decisión a propósito: cuando se&lt;br&gt;
dispara, &lt;strong&gt;no&lt;/strong&gt; vuelves a echar mano de &lt;code&gt;pool_size&lt;/code&gt;, te quedaste sin&lt;br&gt;
presupuesto de conexiones, así que el movimiento es una base de datos más&lt;br&gt;
grande (o un proxy). Esa es la restricción alrededor de la que gira todo este&lt;br&gt;
post.&lt;/p&gt;
&lt;h2&gt;
  
  
  El techo de verdad: el autoescalado está atado a las conexiones
&lt;/h2&gt;

&lt;p&gt;Aquí está el problema estratégico. El backend autoescala por CPU&lt;br&gt;
(&lt;code&gt;scaleOnCpuUtilization&lt;/code&gt;). Pero el escalado por CPU y el presupuesto de&lt;br&gt;
conexiones están en conflicto directo: en el momento en que la presión de CPU&lt;br&gt;
nos escala de 1 tarea a 2, las conexiones brincan de ~40 a ~80; una tercera&lt;br&gt;
tarea rebasaría 87 y Postgres empezaría a rechazar conexiones con&lt;br&gt;
&lt;code&gt;FATAL: too many connections&lt;/code&gt;, una caída &lt;em&gt;causada por&lt;/em&gt; la cosa que debía&lt;br&gt;
prevenirla.&lt;/p&gt;

&lt;p&gt;Así que &lt;code&gt;maxTasks&lt;/code&gt; está fijado en silencio por &lt;code&gt;max_connections&lt;/code&gt;, no por la&lt;br&gt;
carga. No podemos de verdad usar escalado horizontal para los picos de&lt;br&gt;
tráfico, porque la base de datos no puede emitir las conexiones que las tareas&lt;br&gt;
nuevas demandan. Subir la clase de la instancia de RDS compra margen lineal&lt;br&gt;
(&lt;code&gt;t3.small&lt;/code&gt; ≈ 2× las conexiones) pero cuesta dinero y solo mueve el muro. &lt;strong&gt;Lo&lt;br&gt;
que quita el muro en lugar de moverlo es un proxy de conexiones.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  RDS Proxy: la válvula de escape, y la trampa de asyncpg
&lt;/h2&gt;

&lt;p&gt;RDS Proxy se sienta entre la app y Postgres y &lt;strong&gt;multiplexa&lt;/strong&gt;: cientos de&lt;br&gt;
conexiones de cliente comparten un pool chiquito de conexiones reales al&lt;br&gt;
backend. La app abre conexiones a sus anchas; el proxy mantiene la cuenta real&lt;br&gt;
de conexiones a la base de datos baja y estable. Eso desacopla la cuenta de&lt;br&gt;
tareas de &lt;code&gt;max_connections&lt;/code&gt;, el autoescalado por fin puede escalar por CPU sin&lt;br&gt;
el muro de conexiones, y una estampida de trabajadores le presta del proxy en&lt;br&gt;
lugar de a la base de datos.&lt;/p&gt;

&lt;p&gt;Eso es el discurso de venta. Aquí está la parte que el discurso se calla, y la&lt;br&gt;
razón por la que esto es una solución "posible" y no una ya publicada:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;asyncpg + prepared statements = fijado de conexión.&lt;/strong&gt; RDS Proxy solo puede&lt;br&gt;
multiplexar cuando una sesión está "limpia". En el momento en que una sesión&lt;br&gt;
hace algo con estado de conexión, un prepared statement, un &lt;code&gt;SET&lt;/code&gt;, un advisory&lt;br&gt;
lock, una tabla temporal, un &lt;code&gt;LISTEN&lt;/code&gt; a nivel de sesión, el proxy &lt;strong&gt;fija&lt;/strong&gt; ese&lt;br&gt;
cliente a una sola conexión de backend por el resto de su vida y deja de&lt;br&gt;
multiplexarlo. Y asyncpg, por default, &lt;strong&gt;cachea prepared statements para cada&lt;br&gt;
consulta que corre.&lt;/strong&gt; Tal como viene, asyncpg sobre RDS Proxy fija casi todo, y&lt;br&gt;
pagaste por un proxy que se comporta como un paso directo, más un salto de&lt;br&gt;
latencia.&lt;/p&gt;

&lt;p&gt;Las mitigaciones, en orden de cuánto duelen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Deshabilita el caché de prepared statements de asyncpg para que RDS Proxy pueda multiplexar.
&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;connect_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;require&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;# RDS Proxy exige TLS
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statement_cache_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# sin caché de prepared statements
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prepared_statement_cache_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# deja que el PROXY haga el pool; el pooling del lado de la app ya es redundante
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;statement_cache_size=0&lt;/code&gt;&lt;/strong&gt; impide que asyncpg cachee prepared statements,
que es lo que mantiene las sesiones multiplexables. Costo: un pequeño
sobrecosto por consulta de volver a preparar, normalmente un buen trato a
cambio de conseguir multiplexar siquiera.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audita los otros disparadores de fijado.&lt;/strong&gt; Nuestro conftest de CI usa
&lt;code&gt;pg_advisory_lock&lt;/code&gt; para montar la base de datos template, y algunos flujos
usan estado a nivel de sesión, cada uno es un fijado. Detrás de un proxy
quieres esos acotados con fuerza o movidos a nivel de transacción
(&lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;) para que el fijado se libere al hacer commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deja que el proxy sea dueño del pool.&lt;/strong&gt; Con RDS Proxy haciendo la
multiplexación, el &lt;code&gt;pool_size&lt;/code&gt; del lado de la app se vuelve doble-pooling;
&lt;code&gt;NullPool&lt;/code&gt; (o un pool diminuto) en la app y deja que el proxy racione.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  La economía del proxy
&lt;/h2&gt;

&lt;p&gt;RDS Proxy &lt;strong&gt;no es de capa gratuita.&lt;/strong&gt; Se cobra por vCPU de la instancia de la&lt;br&gt;
base de datos, más o menos &lt;code&gt;$0.015 / vCPU-hora&lt;/code&gt;. Para una t3.micro de 2 vCPU&lt;br&gt;
eso son ≈ &lt;code&gt;$0.03/hr&lt;/code&gt; ≈ &lt;strong&gt;$21–22/mes&lt;/strong&gt;, sobre un stack cuya premisa entera es&lt;br&gt;
la Capa Gratuita de AWS. Así que la comparación de verdad es de tres vías:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Opción&lt;/th&gt;
&lt;th&gt;$/mes (delta)&lt;/th&gt;
&lt;th&gt;Qué te compra&lt;/th&gt;
&lt;th&gt;Qué te cuesta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quedarte en 8/12 sobre t3.micro&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;nada nuevo&lt;/td&gt;
&lt;td&gt;el muro de conexiones se queda; sin escalado horizontal real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subir t3.micro → t3.small&lt;/td&gt;
&lt;td&gt;~$13&lt;/td&gt;
&lt;td&gt;~2× conexiones (~170)&lt;/td&gt;
&lt;td&gt;mueve el muro, no lo quita; sigue siendo finito&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agregar RDS Proxy&lt;/td&gt;
&lt;td&gt;~$21&lt;/td&gt;
&lt;td&gt;quita el muro; autoescalado fiel al CPU; suavizado de failover&lt;/td&gt;
&lt;td&gt;un salto de latencia; reconfigurar asyncpg; rompe la Capa Gratuita&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;La lectura honesta: &lt;strong&gt;a nuestra escala actual el muro todavía no aprieta.&lt;/strong&gt; Una&lt;br&gt;
tarea cabe, el subidón a 8/12 absorbió la estampida de trabajadores, y la&lt;br&gt;
alarma no se ha disparado desde entonces. RDS Proxy se gana sus $21 el día que&lt;br&gt;
de verdad necesitemos una segunda y tercera tarea para el tráfico, no antes.&lt;br&gt;
Comprarlo ahora sería pagar una mensualidad para resolver un problema que&lt;br&gt;
todavía no tenemos, y heredar la auditoría de fijado de asyncpg sin beneficio&lt;br&gt;
presente. El disparador para adoptarlo es concreto: &lt;strong&gt;la alarma de escalado por&lt;br&gt;
CPU y la alarma de presupuesto de conexiones disparándose en la misma&lt;br&gt;
ventana&lt;/strong&gt;, ese es el momento en que el CPU quiere más tareas y las conexiones&lt;br&gt;
no las pueden suministrar, y el proxy es lo único que resuelve la&lt;br&gt;
contradicción.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que te diría
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encuentra tu techo real primero.&lt;/strong&gt; &lt;code&gt;SELECT * FROM pg_settings WHERE name =
'max_connections';&lt;/code&gt; luego réstale las ranuras reservadas. Cada decisión de
pooling es río abajo de ese único número.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El tamaño del pool es un ajuste de racionamiento en un RDS chico, no un
ajuste de rendimiento.&lt;/strong&gt; Dimensiónalo desde el presupuesto de conexiones
(&lt;code&gt;por-proceso × trabajadores × tareas × traslape-de-despliegue&lt;/code&gt;), no desde un
benchmark.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los trabajadores dentro del proceso comparten el pool web.&lt;/strong&gt; Si tus
planificadores se disparan todos en la misma frontera del cron, son una
estampida sincronizada contra el pool de peticiones. Agrega jitter; dimensiona
para el pico.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pre_ping&lt;/code&gt; + &lt;code&gt;recycle&lt;/code&gt; no son opcionales&lt;/strong&gt; para procesos asíncronos de larga
vida detrás de un NAT/firewall o enfrente de una base de datos que se puede
reiniciar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RDS Proxy quita el muro de conexiones, pero con asyncpg, pon
&lt;code&gt;statement_cache_size=0&lt;/code&gt; y audita tus advisory locks, o fija y no hace nada.&lt;/strong&gt;
Mide la tasa de fijado (&lt;code&gt;DatabaseConnectionsCurrentlySessionPinned&lt;/code&gt;) antes de
cantar victoria.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compra el proxy cuando el muro apriete, no antes.&lt;/strong&gt; La señal es el
autoescalado por CPU y el agotamiento de conexiones peleándose en la misma
ventana de 15 minutos.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Si te llevas una sola cosa: &lt;strong&gt;en un Postgres administrado chico, las conexiones&lt;br&gt;
— no el CPU, son la unidad de escalado.&lt;/strong&gt; Dimensiona tu pool desde ese&lt;br&gt;
presupuesto, alarma sobre la cadena exacta de agotamiento, y trata a RDS Proxy&lt;br&gt;
como la cosa que compras el día que el muro de conexiones empieza a costarte&lt;br&gt;
tareas que de verdad necesitas.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>database</category>
      <category>postgres</category>
      <category>python</category>
    </item>
    <item>
      <title>500 portadas distintas de un solo modelo de $0.04: arte de portada con IA, consistente con la marca, en un generador económico</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Mon, 15 Jun 2026 00:59:08 +0000</pubDate>
      <link>https://dev.to/aws-builders/500-portadas-distintas-de-un-solo-modelo-de-004-arte-de-portada-con-ia-consistente-con-la-3am0</link>
      <guid>https://dev.to/aws-builders/500-portadas-distintas-de-un-solo-modelo-de-004-arte-de-portada-con-ia-consistente-con-la-3am0</guid>
      <description>&lt;p&gt;La lección de cabecera: en un modelo económico, la calidad no la consigues escribiendo un mejor prompt único, la consigues decidiendo en qué es bueno el modelo, haciendo el resto tú mismo, y diseñando la variedad como una característica del producto en lugar de esperar que la semilla la entregue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arquitectura
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Admin (SPA) ──POST /blog/generate-image──▶ FastAPI (Fargate, us-east-1)
                                              │
                              boto3 bedrock-runtime (us-west-2)
                                              │
                                   Stable Image Core ──▶ PNG en base64 (2016×1152)
                                              │
                       Pillow: composición opcional del lockup de marca
                                              │
                          redimensionar → WebP (1200×630) → S3 → CloudFront
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una sola petición sincrónica. Sin cola, sin GPU, sin hospedar el modelo.&lt;br&gt;
El endpoint es un orquestador delgado: construye un prompt, llama a&lt;br&gt;
Bedrock, opcionalmente superpone el logo, y le entrega los bytes a la&lt;br&gt;
pipeline de imágenes que ya existía (que quita los metadatos, redimensiona, y convierte a WebP).  &lt;/p&gt;

&lt;p&gt;La decisión arquitectónica más importante de todas es lo que &lt;em&gt;no&lt;/em&gt; está aquí: sin modelo con ajuste fino, sin LoRA, sin grupo de autoescalado de GPU. La consistencia de marca se compra por completo con un envoltorio de prompt y un post-proceso. Eso mantiene el costo marginal de una portada en unos centavos y el costo operativo en cero, que es justo el punto para arte editorial de bajo volumen.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fase 0, el bug del paso directo
&lt;/h2&gt;

&lt;p&gt;La primera versión hizo lo obvio: tomar el texto y mandarlo al&lt;br&gt;
modelo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# NO LO HAGAS, esto es el punto de partida para ejemplificar
&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;admin_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text-to-image&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El agente interno de autoría, mientras tanto, envolvía cada prompt en unestilo de la casa fijo. Así que las portadas hechas por el agente eran fieles a la marca y las tecleadas a mano eran un terreno sin reglas misma plataforma, dos identidades visuales. La solución es un envoltorio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fase 1, un estilo de la casa es un envoltorio de prompt más un prompt negativo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_styled_cover_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Wide editorial illustration for a community blog post. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Theme: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Modern flat-vector art style with subtle gradients, a warm &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;purple and teal palette, optimistic mood. Abstract metaphor, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no people in close-up, no readable text anywhere. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Clean 16:9 hero composition.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;NEGATIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text, words, watermark, logo, signature, low quality, blurry, frame, border&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El prompt negativo importa tanto como el positivo. &lt;code&gt;text, words&lt;/code&gt; está&lt;br&gt;
haciendo trabajo de verdad: es la forma más barata de impedir que el&lt;br&gt;
modelo garabatee letras falsas por todo el arte. Nos vamos a apoyar fuerte en eso en un momento.&lt;/p&gt;

&lt;p&gt;Este es el estilo de la casa "plano". Luego quisimos un segundo pictórico, dorado y oscuro, para contenido gamificado / de torneo. Ahí fue donde el modelo económico empezó a mostrar sus limites.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fase 2, nunca dejes que el modelo escriba texto; superpón el logo tú mismo
&lt;/h2&gt;

&lt;p&gt;Todo modelo de difusión de esta gama renderiza el texto como&lt;br&gt;
pseudo glifos garabateados. El arte de referencia que perseguíamos tenía un wordmark; el nuestro salió como MYBRAND → MYBARND. Dos opciones: pelear con el modelo, o dejar de pedirle que haga tipografía.&lt;/p&gt;

&lt;p&gt;Dejamos de pedírselo. El prompt positivo dice &lt;code&gt;no readable text anywhere&lt;/code&gt;, el prompt negativo prohíbe &lt;code&gt;text, logo&lt;/code&gt;, y el lockup de marca real y transparente, wordmark incluido, se superpone encima con Pillow después de generar.&lt;/p&gt;

&lt;p&gt;El pegado ingenuo falla: un logo dorado sobre una pintura cargada de&lt;br&gt;
dorado se desvanece. La solución es un respaldo oscuro difuminado detrás&lt;br&gt;
del lockup, luego el lockup, abajo a la derecha:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;overlay_brand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_bytes&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGBA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;logo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOGO_PATH&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGBA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;target_w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.22&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;logo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;target_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;target_w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;logo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;logo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.035&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;logo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.035&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alpha_composite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_soft_dark_ellipse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# respaldo de contraste
&lt;/span&gt;    &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alpha_composite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_to_png&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wordmark nítido, legible sobre cualquier arte, cero tipografía generada. Este es el principio general para modelos económicos: cualquier cosa que tenga que ser exacta al píxel, texto, logos, layout, lo haces en código determinista, no en el prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fase 3, Core fusiona sujetos; diseña alrededor de eso
&lt;/h2&gt;

&lt;p&gt;Pedimos "un grifo de frente a un dragón". Core regresó una sola criatura con partes de grifo y de dragón fusionadas. Este es un modo de falla conocido de los modelos chicos: dos sujetos independientes en un mismo cuadro se mezclan.&lt;/p&gt;

&lt;p&gt;Dos mitigaciones, las dos baratas:&lt;/p&gt;

&lt;p&gt;Prompt negativo: &lt;code&gt;fused creature, merged animals, two-headed, hybrid,&lt;br&gt;
extra limbs, extra heads&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Componer alrededor de arquetipos conocidos. Un consejo alrededor de una mesa redonda, un grupo de cuatro aventureros, figuras frente a un portal esas son composiciones que el modelo ha visto miles de veces y monta correctamente, multitud y todo. Un duelo libre de dos bestias distintas no lo es. Convertimos las cosas que queríamos retratar (comunidad, colaboración) en arquetipos que el modelo de verdad puede renderizar.&lt;/p&gt;

&lt;p&gt;La regla general: no pelees contra los ajustes composicionales del modelo elige sujetos o temas que se adecuen a ellos.&lt;/p&gt;
&lt;h3&gt;
  
  
  El callejón sin salida que vale la pena documentar: fusión cálido + frío
&lt;/h3&gt;

&lt;p&gt;Intentamos reproducir una referencia que fusionaba una taberna medieval acogedora con interfaces holográficas cian, cálido y frío, fantasía y tecnología, en un mismo cuadro. Catorce generaciones a través de cuatro estrategias de prompt después, el veredicto fue concluyente:&lt;/p&gt;

&lt;p&gt;La tecnología enterrada en una descripción cálida → Core soltaba la&lt;br&gt;
tecnología por completo (taberna pura).&lt;/p&gt;

&lt;p&gt;La tecnología al frente → Core inundaba el cuadro de pantallas cian (sala de servidores pura).&lt;/p&gt;

&lt;p&gt;Equilibrado, con cuentas explícitas ("dos o tres hologramas") → revertía a uno o al otro, dependiendo de la semilla.&lt;/p&gt;

&lt;p&gt;Core no puede sostener dos estéticas fuertes y opuestas en tensión. Un modelo más fuerte (la referencia salió de uno) sí puede; Core no. Anotamos el hallazgo y seguimos adelante en lugar de quemar más generaciones contra un techo del modelo. El movimiento de ingeniería honesto en una gama económica es saber cuándo pegaste contra el muro y dejar de pagar por darle de topes, la alternativa (componer los hologramas nosotros mismos de manera procedural) era real pero no valía la pena para el valor.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fases 4–6, diseñar la variedad, porque la semilla no lo va a hacer
&lt;/h2&gt;

&lt;p&gt;Este era el problema de producto de verdad. Con el envoltorio "épico"&lt;br&gt;
pesado puesto, prompts distintos producían imágenes casi idénticas. Dos&lt;br&gt;
temas opuestos, "trabajo remoto asíncrono entre zonas horarias" y&lt;br&gt;
"depurar una caída de producción a las 3am", los dos renderizaban al&lt;br&gt;
mismo guerrero dorado con una espada en llamas. El andamio de estilo&lt;br&gt;
(elementos de héroe en dorado bruñido, ambiente heroico ceremonial,&lt;br&gt;
heráldica de gremio) era tan dominante que el modelo se aferraba a él e ignoraba el tema. La semilla aleatoria no ayudaba: el prompt restringe la salida mucho más de lo que la semilla la perturba.&lt;/p&gt;

&lt;p&gt;Tres palancas, cada una medida contra la anterior:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4a. Encabeza con el tema; degrada el estilo a un tratamiento.&lt;/strong&gt; Pon el sujeto primero, aplica el look como un acabado en lugar de como palabras clave que dictan el sujeto. De repente "trabajo remoto" renderizaba a un desarrollador frente a un mapamundi brillante y "depurar a las 3am" renderizaba a un ingeniero en un cuarto de control oscuro. Fidelidad al tema: arreglada. Pero cada portada seguía siendo dorado cálido sobre fondo oscuro.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4b. Rota la paleta.&lt;/strong&gt; La paleta fija única era el mayor motor de&lt;br&gt;
mismidad. Un conjunto rotatorio, teal/brasa/azul acero/violeta/esmeralda/carmesí, cada uno conservando el dorado como hilo conductor, rompió el monocromo. El color y el ambiente ahora variaban por generación.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4c. Rota el escenario.&lt;/strong&gt; Los temas de mi blog son casi sinónimos, así que aun con la rotación de&lt;br&gt;
paleta, todavía se montaban igual. La solución que funcionó: meter el sujeto en un escenario distinto y fiel a la marca cada vez, biblioteca, fragua, pico de montaña, observatorio, mercado. Tres temas sinónimos de carrera luego renderizaron como una biblioteca, una fragua, y un observatorio.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_epic_cover_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;motif&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;champion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;composition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;palette&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;setting&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;scene&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MOTIFS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;motif&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                  &lt;span class="c1"&gt;# un motivo ES el sujeto
&lt;/span&gt;        &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, evoking &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                      &lt;span class="c1"&gt;# si no: el tema encabeza, metido en un escenario
&lt;/span&gt;        &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;setting&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SETTINGS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;comp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;composition&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COMPOSITIONS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pal&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;palette&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PALETTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;comp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Rendered as painterly key-art, ... a rich &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pal&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; palette ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La variedad es combinatoria y del lado del servidor: 10 escenarios × 7 paletas × 8 composiciones ≈ 560 encuadres distintos, elegidos al azar por llamada, antes de que la semilla siquiera entre. La variedad dejó de ser algo que esperábamos que el modelo proveyera y se volvió un espacio de parámetros que poseemos.&lt;/p&gt;

&lt;p&gt;Límite honesto: para exactamente el mismo tema, Core todavía tiende a&lt;br&gt;
montar el sujeto igual, los modificadores de composición son débiles&lt;br&gt;
contra una escena fuertemente implicada. La variedad de verdad viene de temas distintos + paleta + escenario + semilla, no de volver a tirar un mismo prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fase 6, un lente de campaña.&lt;/strong&gt;  Un encuadre de campaña tipo RPG se mapea sobre cada tema: aprender = misiones, roles = clases, crecimiento = subir de nivel, comunidad = el grupo. Es un tercer estilo junto a los otros (no reemplazamos los que funcionan), reutilizando la misma maquinaria de rotación con escenas de RPG, un tablero de misiones, un grupo de clases, un árbol de habilidades. Mismo motor, narrativa más rica, más variedad gratis.&lt;/p&gt;
&lt;h2&gt;
  
  
  La economía
&lt;/h2&gt;

&lt;p&gt;A bajo volumen editorial, el modelo de API por imagen gana de manera&lt;br&gt;
decisiva. Las cifras son precio de lista al momento de escribir (us-west-2) y se van a mover, trátalas como proporciones, no como cotizaciones.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Opción&lt;/th&gt;
&lt;th&gt;Costo unitario&lt;/th&gt;
&lt;th&gt;Costo fijo&lt;/th&gt;
&lt;th&gt;Operación&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bedrock Stable Image Core&lt;/td&gt;
&lt;td&gt;~$0.04 / imagen&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;ninguna&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bedrock Stable Image Ultra&lt;/td&gt;
&lt;td&gt;~$0.14 / imagen&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;ninguna&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SDXL autoalojado (g5.xlarge)&lt;/td&gt;
&lt;td&gt;~$0 marginal&lt;/td&gt;
&lt;td&gt;~$1.00 / hora-GPU siempre encendido o dolor de arranque en frío&lt;/td&gt;
&lt;td&gt;AMI, escalado, parchado&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Imagen (Vertex)&lt;/td&gt;
&lt;td&gt;~$0.04 / imagen&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;ninguna, pero una segunda nube&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Midjourney&lt;/td&gt;
&lt;td&gt;n/a (suscripción)&lt;/td&gt;
&lt;td&gt;$10–60 / mes&lt;/td&gt;
&lt;td&gt;sin API sancionada&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Un blog que genera un puñado de portadas al día cuesta centavos al mes en Core. Para llegar al punto de equilibrio contra una sola g5.xlarge siempre encendida (~$720/mes) tendrías que generar ~18,000 portadas al mes. Yo genero quizás 100. La cuenta del autoalojado solo se voltea a una escala que nunca vamos a alcanzar para arte editorial, e incluso entonces te apuntaste a AMIs, fijado de drivers, y un autoescalador. El modelo más barato que pasa la barra de calidad, llamado por imagen, es la respuesta correcta aquí, y reconocer eso temprano nos ahorró un flujo de trabajo completo de operación de GPU.&lt;/p&gt;
&lt;h2&gt;
  
  
  La trampa de región
&lt;/h2&gt;

&lt;p&gt;Los modelos de imágenes de Bedrock no están en cada región, y el modelo que quieres acota la región que llamas. El backend corre en us-east-1; Stable Image Core se aprovisionó en us-west-2. Así que el&lt;br&gt;
cliente está fijado a otra región a propósito:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bedrock-runtime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-west-2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# NO la región de la app
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres consecuencias que vale la pena saber antes de armar la arquitectura alrededor de esto:&lt;/p&gt;

&lt;p&gt;La disponibilidad es por modelo, por región. "Bedrock está en mi región" no es "este modelo está en mi región". Revisa el modelo, no el servicio.&lt;/p&gt;

&lt;p&gt;Las llamadas a otra región agregan latencia y cruzan una frontera de&lt;br&gt;
datos. Para arte de portada de dispara-y-olvida eso está bien; para&lt;br&gt;
cualquier cosa sensible a la latencia o a la residencia no lo está, y&lt;br&gt;
puede que necesites aprovisionar el modelo en la región de tu app o elegir otro.&lt;/p&gt;

&lt;p&gt;El acceso al modelo es una concesión explícita. Habilitar un modelo de Bedrock es un paso de consola por cuenta/región, y el IAM tiene que permitir &lt;code&gt;bedrock:InvokeModel&lt;/code&gt; sobre ese ARN del modelo. Un ARN con comodín sobre el modelo base nos ahorró re-editar la política en cada cambio de modelo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que un modelo económico no puede hacer (medido, no adivinado)
&lt;/h2&gt;

&lt;p&gt;Tipografía. Nunca. Superpón el texto y los logos tú mismo.&lt;/p&gt;

&lt;p&gt;Dos estéticas opuestas en un cuadro (cálido/frío, fantasía/tecnología). Se colapsa a una. Elige un carril o superpón.&lt;/p&gt;

&lt;p&gt;Escenas libres de varios sujetos. Dos sujetos distintos se fusionan. Usa arquetipos que el modelo ya monta (mesas, grupos, portales).&lt;/p&gt;

&lt;p&gt;Variedad composicional por tema bajo demanda. Una escena fuertemente&lt;br&gt;
implicada se monta igual sin importar las pistas de cámara. Diseña la&lt;br&gt;
variedad a través de paleta/escenario/semilla en lugar de esperarla de un solo prompt.&lt;/p&gt;

&lt;p&gt;Ninguno de estos fue un motivo de descarte. Cada uno se volvió una&lt;br&gt;
restricción de diseño que empujó el trabajo fuera del modelo y hacia&lt;br&gt;
código que controlamos, que es justo donde quieres que vivan las partes deterministas de una marca de todos modos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que te diría
&lt;/h2&gt;

&lt;p&gt;Decide el trabajo del modelo, luego recupera el resto. El texto, los&lt;br&gt;
logos, el layout, y la variedad exacta de marca son deterministas; hazlos en código. Deja que el modelo pinte.&lt;/p&gt;

&lt;p&gt;Un estilo de la casa es un envoltorio de prompt + un prompt negativo, no un ajuste fino. Para bajo volumen, ese es todo el presupuesto de&lt;br&gt;
consistencia de marca que necesitas.&lt;/p&gt;

&lt;p&gt;La variedad es un espacio de parámetros que posees, no una semilla a la que le cruzas los dedos. Rota los ejes que no definen la marca (paleta, escenario, cámara); fija el que sí (aquí, el dorado).&lt;/p&gt;

&lt;p&gt;Pon precio a la API por imagen contra una GPU con honestidad. Por debajo de decenas de miles de imágenes al mes, la API gana en costo y en operación.&lt;/p&gt;

&lt;p&gt;Si te llevas una sola cosa: en un modelo de imágenes económico, la calidad y la variedad no son cosas que pides en un prompt, son cosas que diseñas alrededor de los límites conocidos del modelo. El prompt es la parte más chica.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>aws</category>
      <category>spanish</category>
    </item>
    <item>
      <title>SEO en 2026, parte 2: la captura rankea — ahora haz que convierta</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Sat, 13 Jun 2026 23:50:05 +0000</pubDate>
      <link>https://dev.to/aws-builders/seo-en-2026-parte-2-la-captura-rankea-ahora-haz-que-convierta-4g33</link>
      <guid>https://dev.to/aws-builders/seo-en-2026-parte-2-la-captura-rankea-ahora-haz-que-convierta-4g33</guid>
      <description>&lt;p&gt;El post anterior terminó donde terminan la mayoría de los posts de "ya hicimos SEO": la página es indexable.&lt;/p&gt;

&lt;p&gt;Dos cosas se rompen justo después de esa línea de meta, y ninguna aparece en un puntaje de Lighthouse:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;La captura rankea pero no convierte nada.&lt;/strong&gt; El HTML que el rastreador
indexó y el HTML al que aterriza un humano sin sesión, tiene el
contenido pero ninguna llamada a la acción, porque la llamada a la
acción vive en el paquete de JavaScript que el rastreador nunca corrió.
Rankeaste para la búsqueda y luego no dijiste nada.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El siguiente despliegue des indexa la página durante unos minutos.&lt;/strong&gt;
Una SPA con hash de contenido que borra sus fragmentos viejos al
desplegar le entrega a cada pestaña abierta y a cada re rastreo a
media propagación, un 404 sobre un módulo que todavía espera. La
página que rankeaba ayer hoy lanza
&lt;code&gt;Failed to fetch dynamically imported module&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capa&lt;/th&gt;
&lt;th&gt;Antes (fin de la parte 1)&lt;/th&gt;
&lt;th&gt;Después&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CTA en un reporte indexado&lt;/td&gt;
&lt;td&gt;renderizado solo por la SPA — ausente de la captura que lee el rastreador y del primer pintado sin sesión&lt;/td&gt;
&lt;td&gt;renderizado en el servidor dentro de la captura, derivado de los datos de la propia página, &lt;strong&gt;indexable&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Texto del CTA / enlaces de destino&lt;/td&gt;
&lt;td&gt;fijos en el código → un re-despliegue para cambiar una palabra&lt;/td&gt;
&lt;td&gt;configuración en tiempo de ejecución en una bolsa de ajustes JSONB; editable desde el admin, sin re-despliegue; los pre-renderizados se refrescan al guardar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CTA por audiencia&lt;/td&gt;
&lt;td&gt;una sola variante&lt;/td&gt;
&lt;td&gt;anónimo → unirse; autenticado → enlaces directos al siguiente paso&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Despliegue de assets&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;aws s3 sync --delete&lt;/code&gt; — los fragmentos viejos con hash se borran en el instante en que aterriza el paquete nuevo&lt;/td&gt;
&lt;td&gt;sync aditivo (sin &lt;code&gt;--delete&lt;/code&gt;); los fragmentos viejos + nuevos coexisten; una pestaña abierta sigue funcionando&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Limpieza del paquete viejo&lt;/td&gt;
&lt;td&gt;implícita (el delete)&lt;/td&gt;
&lt;td&gt;regla explícita de ciclo de vida de S3 — &lt;code&gt;assets/&lt;/code&gt; expira 30 días después de que un paquete deja de servirse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalidación de CloudFront&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"/*"&lt;/code&gt; (desperdicio; los assets son inmutables)&lt;/td&gt;
&lt;td&gt;solo &lt;code&gt;"/index.html"&lt;/code&gt; — el único objeto que cambia un despliegue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Falla al cargar un fragmento&lt;/td&gt;
&lt;td&gt;la pantalla roja de error de React Router&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vite:preloadError&lt;/code&gt; → una sola recarga acotada hacia el paquete fresco&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El hilo conductor: &lt;strong&gt;una captura pre-renderizada es necesaria pero no&lt;br&gt;
suficiente.&lt;/strong&gt; La parte 1 hizo que la captura existiera. La parte 2 hace&lt;br&gt;
que haga su trabajo y que sobreviva al siguiente &lt;code&gt;git push&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  El punto de partida
&lt;/h2&gt;

&lt;p&gt;La fase 6 de la parte 1 nos dejó con un parchador de HTML por ruta: para&lt;br&gt;
cada reporte público, renderiza su markdown a HTML sanitizado del lado&lt;br&gt;
del servidor, lo inyecta dentro del &lt;code&gt;&amp;lt;div id="root"&amp;gt;&lt;/code&gt; de la cáscara de la&lt;br&gt;
SPA, parcha el &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; / description / canonical, y agrega el JSON-LD de&lt;br&gt;
&lt;code&gt;Article&lt;/code&gt;. Los rastreadores leen el HTML inyectado; la SPA en vivo hidrata&lt;br&gt;
encima de él al montarse.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;myapp/backend/app/services/report_seo.py   # markdown → HTML + JSON-LD, inyectado en la cáscara
myapp/backend/app/services/prerender.py     # parche genérico del head + inyección en #root
myapp/backend/app/jobs/render_reports.py    # itera los reportes publicados, escribe un index.html por ruta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Funcionó. Los reportes rankearon. Luego vimos qué recibía de verdad el visitante que rankeaba, y qué le hacía el siguiente despliegue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fase 1: la trampa del CTA solo-en-la-SPA
&lt;/h2&gt;

&lt;p&gt;La página de reporte es un imán de tráfico orgánico buenísimo: una tabla de datos que la gente busca por nombre. Así que agregamos un bloque de conversión al final, "así es como puedes actuar sobre esto", como un componente de React en la ruta del reporte. Lo publicamos, se veía increíble en el navegador.&lt;/p&gt;

&lt;p&gt;Luego: le haces &lt;code&gt;curl&lt;/code&gt; a la URL pública como la ve un rastreador.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://myapp.example/reports/&amp;lt;slug&amp;gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"Quieres llegar"&lt;/span&gt;
&lt;span class="c"&gt;# 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El CTA no estaba en ningún lado del HTML entregado. Obvio en&lt;br&gt;
retrospectiva — el pre-renderizado (&lt;code&gt;report_seo.py&lt;/code&gt;) renderiza el &lt;em&gt;cuerpo&lt;br&gt;
del markdown&lt;/em&gt; dentro de la captura; el CTA era un componente de React,&lt;br&gt;
montado solo después de que carga el paquete. Entonces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;El rastreador&lt;/strong&gt; indexó el cuerpo del reporte y nunca vio el CTA. La captura que cacheó para los motores de respuestas de IA es un callejón sin salida.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El humano sin sesión&lt;/strong&gt; — la mayoría del tráfico orgánico, vio el CTA un instante tarde (después de la hidratación), y si rebotaba durante la carga del JS, nunca.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Un CTA que solo existe del lado del cliente es un CTA que no existe para exactamente la audiencia que una captura está hecha para servir. La solución es renderizarlo dentro de la captura, igual que el cuerpo — pero una cadena estática pegada en cada reporte es débil. La versión interesante está &lt;strong&gt;derivada de los datos de la propia página&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fase 2: un CTA construido con los datos de la propia página
&lt;/h2&gt;

&lt;p&gt;Cada reporte ya calcula un agregado ordenado antes de escribirse, las filas de las que el reporte &lt;em&gt;trata&lt;/em&gt;. Habíamos estado tirando esa&lt;br&gt;
estructura después de generar la prosa. En lugar de eso, conserva el&lt;br&gt;
top-N y persístelo junto al reporte (una columna JSONB &lt;code&gt;key_findings&lt;/code&gt; sin usar ya estaba en el modelo, sin migración):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/backend/app/agents/report/generator.py (extracto)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_top_findings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Top-N de filas para el CTA de conversión. Deduplicado por categoría
    canónica para que el CTA muestre variedad, no la misma tres veces.
    Persistido en ``Report.key_findings`` y leído por el pre renderizado ypor la SPA.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canonical_category&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cat&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;label_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]})&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Luego el pre renderizado crece un bloque de CTA, inyectado en la captura entre el cuerpo y el footer, pura construcción de cadenas, sin navegador:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/backend/app/services/report_seo.py (extracto)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_render_cta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cta_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cta_config&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cta_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;hook&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;hook&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p class=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cta-hook&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;This month, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/strong&amp;gt; leads in &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;region_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; at &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;strong&amp;gt;$&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/strong&amp;gt;.&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;li&amp;gt;&amp;lt;a href=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;route&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;blurb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blurb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cta_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;aside class=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report-cta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hook&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cta_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;headline&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cta_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;subcopy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;ul&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p&amp;gt;&amp;lt;a class=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;btn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; href=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cta_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;join_route&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cta_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;join_label&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/aside&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La línea del gancho, &lt;em&gt;"This month, X leads at $N"&lt;/em&gt; ,cambia por reporte y por mes, calculada desde los datos para los que el reporte ya rankea. El rastreador la indexa. El visitante sin sesión la ve en el primer pintado.&lt;/p&gt;

&lt;p&gt;Ahora &lt;code&gt;curl&lt;/code&gt; regresa &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Las pruebas amarran la forma, todas puras (sin base de datos, sin red):&lt;br&gt;
&lt;code&gt;test_report_page_renders_indexable_cta&lt;/code&gt; (el gancho + un enlace de&lt;br&gt;
servicio real + el botón de unirse, todos aterrizan dentro del&lt;br&gt;
&lt;code&gt;&amp;lt;div id="root"&amp;gt;&lt;/code&gt;), &lt;code&gt;test_report_page_cta_disabled_renders_nothing&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;test_report_page_cta_without_top_data_still_shows_block&lt;/code&gt; (un reporte&lt;br&gt;
heredado sin &lt;code&gt;key_findings&lt;/code&gt; igual recibe la propuesta de valor, nada más sin gancho), y &lt;code&gt;test_report_page_no_cta_config_is_backward_compatible&lt;/code&gt; (el&lt;br&gt;
pre renderizado llamado a la antigua, sin argumento de CTA, igual&lt;br&gt;
renderiza).&lt;/p&gt;
&lt;h2&gt;
  
  
  Fase 3: editable sin re despliegue
&lt;/h2&gt;

&lt;p&gt;Un CTA fijo en el código significa un despliegue para arreglar una errata.&lt;br&gt;
Peor, los &lt;em&gt;enlaces&lt;/em&gt; a los que apunta el CTA son decisiones de producto que cambian más rápido que el código. Así que el texto, las rutas de destino, y el interruptor de encendido/apagado viven en una configuración de tiempo de ejecución la misma bolsa de ajustes JSONB por agente que el resto del admin ya usaba.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/backend/app/services/report_cta_config.py (extracto)
&lt;/span&gt;&lt;span class="n"&gt;DEFAULTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Want to get there?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subcopy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Here&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s how to close the gap:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;join_label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Join&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;join_route&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/register&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;# [{key, label, route, blurb}, ...]
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_cta_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;CtaConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# una sola búsqueda por PK
&lt;/span&gt;    &lt;span class="n"&gt;overrides&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config_json&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;CtaConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;DEFAULTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El endpoint GET es &lt;strong&gt;público&lt;/strong&gt; — es texto y rutas internas, sin secretos, así que la SPA y el pre renderizado leen la misma configuración resuelta.&lt;br&gt;
El PATCH está protegido para admin y validado. La única regla que vale la pena imponer en la orilla: cada enlace es una ruta &lt;strong&gt;interna&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_clean_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_clean_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; must be an internal route (/...)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sin ese chequeo, "edita el CTA" se vuelve "pega un &lt;code&gt;https://&lt;/code&gt; en&lt;br&gt;
cualquier lado de tus páginas indexadas de mayor tráfico", un punto de apoyo de redirección abierta / enlace fuera del sitio en exactamente las superficies en las que más confían los rastreadores. &lt;/p&gt;

&lt;p&gt;Pruebas:&lt;br&gt;
&lt;code&gt;test_validate_cta_config_rejects_external_route&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;test_cta_config_patch_merges_and_persists&lt;/code&gt; (el PATCH se fusiona sobre los&lt;br&gt;
defaults, no aplasta las llaves que no tocó), &lt;code&gt;test_cta_config_reset&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;test_cta_config_defaults_when_unset&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Un detalle de cableado que importa para el SEO: un cambio de configuración tiene que &lt;strong&gt;refrescar las capturas&lt;/strong&gt;, o el rastreador sigue viendo el CTA viejo. El CTA está en cada reporte, así que el PATCH del admin dispara un re renderizado en segundo plano de todas las páginas de reporte publicadas no solo la que se está editando.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/backend/app/api/v1/admin_reports.py (extracto)
&lt;/span&gt;&lt;span class="nd"&gt;@router.patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/reports/cta-config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_cta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;background&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;require_admin&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_cta_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;background&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prerender_publish&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refresh_all_reports&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# re-captura todos
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fase 4: el espejo en la SPA, anónimo vs autenticado
&lt;/h2&gt;

&lt;p&gt;El CTA de la captura es la variante &lt;em&gt;anónima&lt;/em&gt; — eso es lo que son un&lt;br&gt;
rastreador y un visitante sin sesión. Pero una vez que la SPA hidrata para un miembro con sesión, el mismo bloque debe hacer algo distinto: no "unirse", sino enlaces directos al siguiente paso. Misma configuración, dos renderizados.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// myapp/frontend/src/components/report/ReportCTA.tsx (extracto)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ReportCTA&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;topFindings&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useReportCtaConfig&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAuth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;topFindings&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"report-cta"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;This month, &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; leads in&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;regionName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; at &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;$&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headline&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
          &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blurb&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s2"&gt;` — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blurb&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blurb&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s2"&gt;` — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blurb&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"btn"&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Continue&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"btn"&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;join_route&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;join_label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El componente lee &lt;code&gt;topFindings&lt;/code&gt; directo de la carga del reporte (el mismo &lt;code&gt;key_findings&lt;/code&gt; que usó el pre-renderizado), sin una segunda petición, sin recalcular del lado del cliente. Las pruebas cubren los tres estados: anónimo muestra el botón de unirse y los servicios como texto plano; autenticado muestra los servicios como enlaces más una acción primaria de "continuar"; la configuración deshabilitada no renderiza nada.&lt;/p&gt;

&lt;p&gt;Eso cierra el hueco de conversión. La otra falla estaba esperando en el CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fase 5: el despliegue que des indexa tu página
&lt;/h2&gt;

&lt;p&gt;Esta la atrapamos con el monitoreo de usuarios reales de la parte 1. El recolector de Web Vitals de la fase 5 del primer post también muestrea los errores no atrapados, y después de cada despliegue de frontend había una ráfaga chica y confiable en el canal de errores:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;TypeError: Failed to fetch dynamically imported module:
https://myapp.example/assets/ReportPage-BuE6Rmpx.js
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Siempre un fragmento de ruta, siempre en los minutos justo después de un despliegue, siempre un hash que ya no existía. La causa raíz era una sola bandera en el despliegue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# antes — myapp/.github/workflows/deploy-frontend.yml&lt;/span&gt;
&lt;span class="s"&gt;aws s3 sync frontend/dist s3://$BUCKET --cache-control "...immutable" --delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--delete&lt;/code&gt; quita cada objeto que no está en el &lt;code&gt;dist/&lt;/code&gt; nuevo, incluyendo los fragmentos con hash de la compilación &lt;em&gt;anterior&lt;/em&gt;. Secuencia:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Un visitante carga la app. Su pestaña guarda un &lt;code&gt;index.html&lt;/code&gt; que
referencia &lt;code&gt;ReportPage-BuE6Rmpx.js&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Aterriza un despliegue. Compilación nueva → &lt;code&gt;ReportPage-OTHERHASH.js&lt;/code&gt;.
El sync &lt;strong&gt;borra&lt;/strong&gt; &lt;code&gt;ReportPage-BuE6Rmpx.js&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;El visitante hace clic hacia una ruta diferida → la SPA importa el
fragmento que todavía recuerda → &lt;strong&gt;404&lt;/strong&gt; → &lt;code&gt;Failed to fetch
dynamically imported module&lt;/code&gt; → la pantalla roja de error de React
Router.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Y no son solo las pestañas abiertas: un rastreador que vuelve a pedir la URL en vivo durante la ventana de propagación de CloudFront puede hidratar contra un paquete reemplazado a medias. La página que rankeaba está, por esos minutos, rota.&lt;/p&gt;

&lt;p&gt;Los headers de caché ya estaban bien — los assets con hash &lt;code&gt;immutable&lt;/code&gt; por un año, el &lt;code&gt;index.html&lt;/code&gt; en &lt;code&gt;no-cache&lt;/code&gt;. El bug era puramente que los assets viejos &lt;em&gt;desaparecían&lt;/em&gt;. Los paquetes con hash de contenido están direccionados por contenido: el viejo y el nuevo pueden y deben coexistir.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solución 1, la cura: desplegar los assets de manera aditiva.&lt;/strong&gt; Quita el &lt;code&gt;--delete&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# después — aditivo: los fragmentos con hash viejos + nuevos coexisten&lt;/span&gt;
&lt;span class="s"&gt;aws s3 sync frontend/dist s3://$BUCKET --cache-control "...immutable"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Solución 2, la limpieza: una regla de ciclo de vida de S3&lt;/strong&gt; para que "nunca borrar" no signifique "acumular por siempre". Los paquetes&lt;br&gt;
reemplazados envejecen 30 días después de que dejan de servirse mucho después de cualquier sesión en vivo, lo bastante corto como para no amontonarse.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// myapp/infra/lib/frontend-stack.ts (extracto)&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SpaBucket&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="na"&gt;lifecycleRules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expire-old-build-assets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assets/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;// solo el paquete con hash&lt;/span&gt;
    &lt;span class="na"&gt;expiration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Solución 3, el recorte de costo: angostar la invalidación.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;index.html&lt;/code&gt; es el único objeto que cambia un despliegue (está en&lt;br&gt;
&lt;code&gt;no-cache&lt;/code&gt;, así que la orilla lo revalida). Los assets con hash son&lt;br&gt;
inmutables, invalidar &lt;code&gt;/*&lt;/code&gt; pagaba por desalojar entradas de caché que nunca pueden quedar viejas.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# antes: --paths "/*"      # desperdicio; los assets son inmutables&lt;/span&gt;
&lt;span class="c1"&gt;# después:&lt;/span&gt;
&lt;span class="s"&gt;aws cloudfront create-invalidation --distribution-id "$ID" --paths "/index.html"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prueba de que funcionó, directo del bucket después del primer despliegue aditivo, el mismo fragmento de dos compilaciones, lado a lado:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;aws s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://&lt;span class="nv"&gt;$BUCKET&lt;/span&gt;/assets/ | &lt;span class="nb"&gt;grep &lt;/span&gt;ReportPage
ReportPage-BuE6Rmpx.js   &lt;span class="c"&gt;# la compilación que recuerda la pestaña abierta&lt;/span&gt;
ReportPage-CF69kraa.js   &lt;span class="c"&gt;# la compilación que acaba de salir&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El import de la pestaña abierta ahora resuelve a un &lt;code&gt;200&lt;/code&gt;, no a un &lt;code&gt;404&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fase 6: recuperación del lado del cliente para la ventana de propagación
&lt;/h2&gt;

&lt;p&gt;Los despliegues aditivos arreglan la causa. Pero una pestaña dejada&lt;br&gt;
abierta más tiempo que el ciclo de vida de 30 días, o una carrera dura durante la propagación, todavía puede perder un fragmento, y "raro" en una página de alto tráfico es "diario" en términos absolutos. Así que la segunda capa es la recuperación: Vite dispara un evento &lt;code&gt;vite:preloadError&lt;/code&gt; cuando un import dinámico falla. Atrápalo y recarga &lt;strong&gt;una vez&lt;/strong&gt; hacia el &lt;code&gt;index.html&lt;/code&gt; fresco en lugar de mostrar la pantalla de error.&lt;/p&gt;

&lt;p&gt;La única sutileza es no entrar en bucle por siempre si el fragmento de verdad ya no está (sin conexión, expirado): acótalo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// myapp/frontend/src/lib/chunkReload.ts (extracto)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chunkReload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;installChunkReloadHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;win&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;win&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vite:preloadError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                 &lt;span class="c1"&gt;// suprime el re-lanzamiento default de Vite&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;win&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;WINDOW_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;   &lt;span class="c1"&gt;// reinicia tras una ventana tranquila&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// ya le dimos nuestros intentos; deja que se muestre el error&lt;/span&gt;
    &lt;span class="nx"&gt;win&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="nx"&gt;win&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A lo mucho dos recargas en una ventana de 60 segundos, luego se rinde y deja que la frontera de error renderice, así una caída real no se vuelve un bucle de recargas, pero un despliegue es invisible. Pruebas:&lt;br&gt;
&lt;code&gt;installChunkReloadHandler reloads once on the first preloadError&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;stops after 2 reloads inside the window&lt;/code&gt;, y&lt;br&gt;
&lt;code&gt;recovers again after the quiet window resets&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  El resultado
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Las capturas de los reportes ahora cargan un CTA indexable y derivado de los datos. El HTML cacheado del rastreador y el primer pintado sin sesión tienen ambos un siguiente paso, la misma superficie que rankea ahora también convierte, sin dependencia del JS del cliente.&lt;/li&gt;
&lt;li&gt;Los despliegues de frontend dejaron de producir la ráfaga de errores post despliegue en el monitoreo de usuarios reales. La métrica que publicamos en la parte 1 es cómo encontramos el bug y cómo confirmamos la solución, el canal de errores se quedó callado a través de los despliegues.&lt;/li&gt;
&lt;li&gt;Editar el CTA es un cambio de configuración, no un despliegue, y
re captura las páginas públicas de manera automática para que los
rastreadores no se queden atrás.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lo que NO ayudó
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Renderizar en el servidor toda la pila del CTA.&lt;/strong&gt; Tienta echar mano del SSR/SSG en el momento en que sale "el rastreador no ve mi
componente". Pero el flujo de capturas de la parte 1 ya produce HTML rastreable; el CTA es una cadena más inyectada en él. El SSR habría sido
una migración de framework para resolver un &lt;code&gt;+= cta_html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalidar &lt;code&gt;/*&lt;/code&gt; "por si las dudas".&lt;/strong&gt; Nunca arregló el bug del
fragmento (los assets estaban &lt;em&gt;borrados&lt;/em&gt;, no viejos) y facturaba por desalojar objetos inmutables. El bug estaba en el bucket, no en la caché.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Una captura pre-renderizada es necesaria, no suficiente.&lt;/strong&gt; El win de la parte 1 fue hacer que la captura existiera. Si la capa de conversión vive solo en el paquete, rankeaste para la búsqueda y luego no le dijiste nada a exactamente la audiencia que la captura sirve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deriva el CTA de los datos de la propia página.&lt;/strong&gt; Un CTA estático es un re despliegue y un genérico. Los datos para los que la página ya rankea la fila de arriba, el número del titular, son el gancho más relevante que tienes, y es gratis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los assets con hash de contenido se tienen que desplegar de manera aditiva.&lt;/strong&gt; &lt;code&gt;--delete&lt;/code&gt; en una SPA con división de código es una caída autoinfligida en cada despliegue. Los fragmentos están direccionados por contenido; deja que el viejo y el nuevo coexistan y deja que una regla de ciclo de vida haga la limpieza con un retraso de 30 días, no de cero segundos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalida la única cosa que cambió.&lt;/strong&gt; &lt;code&gt;index.html&lt;/code&gt; está en &lt;code&gt;no-cache&lt;/code&gt;  y es el único objeto mutable del despliegue. &lt;code&gt;/*&lt;/code&gt; es un reflejo que cuesta dinero para desalojar cachés que son inmutables por construcción.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acota tu recuperación.&lt;/strong&gt; Una recarga ante error de fragmento es la red de seguridad correcta, pero una sin límite convierte una caída real en un bucle de recargas. Dos intentos en una ventana, luego saca el error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La métrica que publicaste el trimestre pasado es cómo encuentras el bug de este trimestre.&lt;/strong&gt; El monitoreo de usuarios reales de la parte 1 existía para vigilar los Web Vitals; atrapó una regresión de despliegue que nadie estaba buscando. La telemetría se acumula.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>spanish</category>
      <category>webdev</category>
    </item>
    <item>
      <title>SEO en 2026, cuando la mitad de tu tráfico llega por ChatGPT</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Fri, 05 Jun 2026 23:32:21 +0000</pubDate>
      <link>https://dev.to/aws-builders/seo-en-2026-cuando-la-mitad-de-tu-trafico-llega-por-chatgpt-una-reescritura-en-cuatro-fases-2obo</link>
      <guid>https://dev.to/aws-builders/seo-en-2026-cuando-la-mitad-de-tu-trafico-llega-por-chatgpt-una-reescritura-en-cuatro-fases-2obo</guid>
      <description>&lt;h1&gt;
  
  
  SEO en 2026, cuando la mitad de tu tráfico llega por ChatGPT
&lt;/h1&gt;

&lt;p&gt;Una pasada de fin de semana sobre las superficies públicas de un SaaS&lt;br&gt;
chico, reconstruido para los canales de descubrimiento que de verdad&lt;br&gt;
existen en 2026. Empecé con una línea base sólida pero con forma de&lt;br&gt;
2018 (PageMeta + sitemap.xml + robots.txt) y terminé con JSON-LD en&lt;br&gt;
cada superficie pública, un mapa de sitio dinámico, un &lt;code&gt;llms.txt&lt;/code&gt;&lt;br&gt;
apuntado a los rastreadores de IA, avisos de IndexNow al publicar,&lt;br&gt;
telemetría de Web Vitals de usuarios reales, y capturas de HTML por&lt;br&gt;
ruta para que los bots de IA sin motor de JavaScript de verdad vean el&lt;br&gt;
meta específico de cada ruta. Sin Puppeteer en la compilación.&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capa&lt;/th&gt;
&lt;th&gt;Antes&lt;/th&gt;
&lt;th&gt;Después&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Meta por página (title/desc/canonical/OG/Twitter)&lt;/td&gt;
&lt;td&gt;Envoltorio &lt;code&gt;PageMeta&lt;/code&gt; en 52/139 páginas&lt;/td&gt;
&lt;td&gt;sin cambios; el patrón ya estaba correcto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Señal de región&lt;/td&gt;
&lt;td&gt;ninguna&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hreflang="es-mx"&lt;/code&gt; + &lt;code&gt;x-default&lt;/code&gt; en cada render&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datos estructurados (JSON-LD de schema.org)&lt;/td&gt;
&lt;td&gt;nada&lt;/td&gt;
&lt;td&gt;Organization + WebSite (home), Article + Breadcrumb (blog), Person (perfil), FAQPage (FAQ)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mapa de sitio&lt;/td&gt;
&lt;td&gt;estático, 13 URLs de landing&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;índice&lt;/strong&gt; de mapas de sitio que referencia la lista estática de landings + 3 mapas dinámicos (blog, perfiles, reportes) servidos por el backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Política de rastreadores de IA&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;User-agent: *&lt;/code&gt; implícito&lt;/td&gt;
&lt;td&gt;bloques de permiso explícitos para GPTBot, ClaudeBot, anthropic-ai, PerplexityBot, Google-Extended, CCBot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;llms.txt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;faltaba&lt;/td&gt;
&lt;td&gt;publicado — apunta al mapa de sitio, define la guía de rastreo, marca las superficies privadas como prohibidas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexado al publicar&lt;/td&gt;
&lt;td&gt;búsqueda manual en Search Console&lt;/td&gt;
&lt;td&gt;aviso de IndexNow al publicar un post (Bing, Yandex, Naver, Seznam, Cloudflare)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoreo de usuarios reales&lt;/td&gt;
&lt;td&gt;nada&lt;/td&gt;
&lt;td&gt;Web Vitals (CLS, INP, LCP, FCP, TTFB) muestreado al 10% en producción → CloudWatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTML pre-renderizado para rastreadores sin JS&lt;/td&gt;
&lt;td&gt;nada&lt;/td&gt;
&lt;td&gt;un script post-compilación emite un &lt;code&gt;index.html&lt;/code&gt; por ruta para las 8 rutas estáticas de landing (sin Puppeteer)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El default de 2026 ya no es "¿deberíamos estar en las respuestas de&lt;br&gt;
IA?" — es "¿estamos en las respuestas de IA SIQUIERA?". Una tarde de&lt;br&gt;
trabajo destraba un canal de descubrimiento que no existía hace cinco&lt;br&gt;
años.&lt;/p&gt;
&lt;h2&gt;
  
  
  El punto de partida
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;frontend/
├── public/
│   ├── robots.txt          # 14 líneas, Allow por default + Disallow de superficies privadas
│   └── sitemap.xml         # 13 URLs estáticas, mantenidas a mano
└── src/components/seo/
    └── PageMeta.tsx        # envoltorio de React-Helmet, usado en 52/139 páginas
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Código de acompanamiento:&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/seo-evolution-2026" rel="noopener noreferrer"&gt;
        seo-evolution-2026
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Code companion — SEO evolution 2026 post
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Code companion — SEO evolution 2026 post&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Each folder maps to one round of the post's narrative:&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;01-pagemeta-schema/   PageMeta with jsonLd + ogType + hreflang, schema.ts builders
02-llms-and-bots/     llms.txt + explicit AI-bot blocks in robots.txt
03-dynamic-sitemap/   backend endpoints + sitemap-index
04-indexnow/          ping helper + publish hook + key file
05-rum/               web-vitals collector + backend ingest
06-static-snapshots/  post-build per-route HTML patcher
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Suggested reading order&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;01-pagemeta-schema/&lt;/code&gt;&lt;/strong&gt; — the foundation. Every other round
relies on the JSON-LD plumbing in &lt;code&gt;PageMeta&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;02-llms-and-bots/&lt;/code&gt;&lt;/strong&gt; — costs an afternoon, unlocks AI-channel
discovery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;03-dynamic-sitemap/&lt;/code&gt;&lt;/strong&gt; — the highest-leverage backend change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;04-indexnow/&lt;/code&gt;&lt;/strong&gt; — small but visible: new posts in Bing within
minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;05-rum/&lt;/code&gt;&lt;/strong&gt; — independent of everything else; ship in parallel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;06-static-snapshots/&lt;/code&gt;&lt;/strong&gt; — the lightweight Puppeteer alternative.&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Notes&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;06-static-snapshots/&lt;/code&gt; is intentionally simple — it patches the
static HTML template instead of running a headless browser. Read
the post's "Round 6" section for the trade-off vs full SSR.&lt;/li&gt;
&lt;li&gt;All builders…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/seo-evolution-2026" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;La línea base no estaba mal. &lt;code&gt;PageMeta&lt;/code&gt; ya emitía title, description,&lt;br&gt;
canonical, OG, Twitter Card. &lt;code&gt;index.html&lt;/code&gt; traía defaults estáticos para&lt;br&gt;
que los previsualizadores sociales (LinkedIn, WhatsApp, Slack)&lt;br&gt;
recibieran una vista previa útil antes de que corriera cualquier JS.&lt;br&gt;
Las páginas del flujo de autenticación ya cargaban&lt;br&gt;
&lt;code&gt;noindex,nofollow&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Lo que no hacía: nada de lo que los rastreadores agregaron entre 2019 y&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Nada de schema.org. Nada de &lt;code&gt;hreflang&lt;/code&gt;. Nada de &lt;code&gt;llms.txt&lt;/code&gt;. Nada
de mapa de sitio dinámico. Cero telemetría de lo que los usuarios
reales de verdad veían. Cero manejo especial para los rastreadores de
IA que ahora mueven una porción creciente del tráfico orgánico.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Fase 1: JSON-LD de schema.org + hreflang en el envoltorio PageMeta
&lt;/h2&gt;

&lt;p&gt;El win más grande de todos vino de extender el componente &lt;code&gt;PageMeta&lt;/code&gt;&lt;br&gt;
que ya existía en vez de escribir infraestructura paralela. Tres props&lt;br&gt;
nuevas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// myapp/src/components/seo/PageMeta.tsx (extracto)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PageMetaProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ... props existentes ...&lt;/span&gt;
  &lt;span class="nl"&gt;ogType&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;article&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;jsonLd&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;JsonLd&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;JsonLd&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;jsonLd&lt;/code&gt;&lt;/strong&gt; acepta un diccionario de schema.org o una lista — cada
uno emite una etiqueta &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; de tipo &lt;code&gt;application/ld+json&lt;/code&gt;. La
página se queda declarativa; el trabajo pesado vive en una librería
de constructores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ogType&lt;/code&gt;&lt;/strong&gt; deja que los posts de blog sean &lt;code&gt;og:type=article&lt;/code&gt; y los
perfiles &lt;code&gt;og:type=profile&lt;/code&gt; en lugar del &lt;code&gt;website&lt;/code&gt; original hardcodeado.
Maneja las vistas previas de tarjeta enriquecida en
LinkedIn/Discord/Slack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hreflang="es-mx"&lt;/code&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;code&gt;x-default&lt;/code&gt;&lt;/strong&gt; en cada render. Sin esto,
Google de vez en cuando servía la página en español a resultados de
búsqueda en otros idiomas y la gente rebotaba — una pérdida de
indexado silenciosa del 5-10%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Una librería chiquita &lt;code&gt;schema.ts&lt;/code&gt; con seis constructores:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// myapp/src/components/seo/schema.ts (extracto)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;articleSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;image&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;datePublished&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;dateModified&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;authorName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;authorUrl&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;JsonLd&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@context&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://schema.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Article&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mainEntityOfPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WebPage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;datePublished&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datePublished&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dateModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dateModified&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datePublished&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Person&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;publisher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Organization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Luego en cada página, una línea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// myapp/src/pages/BlogPostPage.tsx (extracto)&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PageMeta&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;ogType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"article"&lt;/span&gt;
  &lt;span class="na"&gt;jsonLd&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;articleSchema&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;breadcrumbSchema&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inicio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seis esquemas publicados en las páginas que corresponden:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Página&lt;/th&gt;
&lt;th&gt;Esquemas&lt;/th&gt;
&lt;th&gt;Qué destraba&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Home&lt;/td&gt;
&lt;td&gt;Organization + WebSite (SearchAction)&lt;/td&gt;
&lt;td&gt;Panel de conocimiento de Google + caja de búsqueda de sitelinks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post de blog&lt;/td&gt;
&lt;td&gt;Article + BreadcrumbList&lt;/td&gt;
&lt;td&gt;Tarjeta enriquecida de Artículo + Inicio › Blog › Post en los resultados&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perfil&lt;/td&gt;
&lt;td&gt;Person&lt;/td&gt;
&lt;td&gt;"¿Quién es @user en la plataforma?" en ChatGPT/Perplexity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FAQ&lt;/td&gt;
&lt;td&gt;FAQPage&lt;/td&gt;
&lt;td&gt;Preguntas y respuestas expandibles directo en los resultados&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Validado con la prueba de resultados enriquecidos de Google&lt;br&gt;
(&lt;a href="https://search.google.com/test/rich-results" rel="noopener noreferrer"&gt;https://search.google.com/test/rich-results&lt;/a&gt;) antes de publicar.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fase 2: &lt;code&gt;llms.txt&lt;/code&gt; + política explícita de rastreadores de IA
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;robots.txt&lt;/code&gt; ya permitía todo por default vía &lt;code&gt;User-agent: *&lt;/code&gt;. El hueco:&lt;br&gt;
algunos rastreadores de IA acotan sus reglas a su propio user agent e&lt;br&gt;
ignoran el &lt;code&gt;*&lt;/code&gt;. Y &lt;code&gt;robots.txt&lt;/code&gt; no comunica &lt;em&gt;intención&lt;/em&gt; — nada más&lt;br&gt;
"¿puedes rastrear?", no "¿qué es este sitio, para quién es, cómo&lt;br&gt;
deberías citarlo?".&lt;/p&gt;

&lt;p&gt;Solución en dos partes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;llms.txt&lt;/code&gt; en la raíz&lt;/strong&gt; (el estándar de 2026 — la especificación&lt;br&gt;
está en &lt;a href="https://llmstxt.org" rel="noopener noreferrer"&gt;https://llmstxt.org&lt;/a&gt;). Anthropic, OpenAI, Perplexity, y los&lt;br&gt;
rastreadores de IA de Google lo leen. Se ve como un README amigable&lt;br&gt;
para IA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Mi Sitio&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; Comunidad profesional tech de LATAM. ...&lt;/span&gt;

&lt;span class="gu"&gt;## Crawling guidance&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; All public pages: https://misitio.io/sitemap.xml
&lt;span class="p"&gt;-&lt;/span&gt; Private surfaces (&lt;span class="sb"&gt;`/dashboard/*`&lt;/span&gt;, &lt;span class="sb"&gt;`/admin/*`&lt;/span&gt;, &lt;span class="sb"&gt;`/messages/*`&lt;/span&gt;,
  &lt;span class="sb"&gt;`/notifications`&lt;/span&gt;, &lt;span class="sb"&gt;`/etc`&lt;/span&gt;) require login. Do not crawl even if
  a session cookie leaks.
&lt;span class="p"&gt;-&lt;/span&gt; The blog is the primary editorial surface. Cite freely.
&lt;span class="p"&gt;-&lt;/span&gt; Public profiles describe individual members. Summarise their
  public bio, link back to their profile, do NOT fabricate.
&lt;span class="p"&gt;-&lt;/span&gt; Reports are aggregatedata — cite
  with the source URL + freshness note from the page.

&lt;span class="gu"&gt;## Documents&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Blog&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://misitio.io/blog&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Reports&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://misitio.io/reports&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Pricing&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://misitio.io/pricing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Code of conduct&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://misitio.io/codigo-de-conducta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://misitio.io/ayuda&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="gu"&gt;## What NOT to scrape&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Direct messages, private forum sections, member emails, payment data.
&lt;span class="p"&gt;-&lt;/span&gt; Anything under &lt;span class="sb"&gt;`/api/*`&lt;/span&gt; — those are JSON endpoints, not documents.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Bloques de permiso explícitos en &lt;code&gt;robots.txt&lt;/code&gt;&lt;/strong&gt; para los bots que&lt;br&gt;
se acotan a su propio user agent: GPTBot, ClaudeBot, anthropic-ai,&lt;br&gt;
PerplexityBot, Google-Extended (el rastreador de los AI Overviews de&lt;br&gt;
Google, separado de Googlebot), CCBot. Cada uno repite la política de&lt;br&gt;
Disallow de las superficies privadas.&lt;/p&gt;

&lt;p&gt;Razón de la decisión: la visibilidad en las respuestas de IA ya es un&lt;br&gt;
canal de descubrimiento primario. Optar por salirte cuesta más en&lt;br&gt;
alcance perdido de lo que ahorras en preocupaciones de scraping —&lt;br&gt;
ningún contenido propietario vive en las superficies públicas.&lt;br&gt;
Reversible: cambias cualquier &lt;code&gt;Allow: /&lt;/code&gt; por &lt;code&gt;Disallow: /&lt;/code&gt; en el bloque&lt;br&gt;
del user agent que toque.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fase 3: mapa de sitio dinámico
&lt;/h2&gt;

&lt;p&gt;El &lt;code&gt;sitemap.xml&lt;/code&gt; estático cubría 13 URLs de landing. Los posts de blog,&lt;br&gt;
los perfiles públicos, y los reportes nunca aterrizaban en&lt;br&gt;
el índice de Google.&lt;/p&gt;

&lt;p&gt;Arquitectura: convertir el &lt;code&gt;sitemap.xml&lt;/code&gt; estático en un &lt;strong&gt;índice de&lt;br&gt;
mapas de sitio&lt;/strong&gt; que referencia una lista estática de landings + tres&lt;br&gt;
mapas de sitio dinámicos servidos por el backend.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- frontend/public/sitemap.xml — era un urlset, ahora es un sitemapindex --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;sitemapindex&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.sitemaps.org/schemas/sitemap/0.9"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;sitemap&amp;gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://misitio.io/sitemap-landing.xml&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&amp;lt;/sitemap&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;sitemap&amp;gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://api.misitio.io/sitemap-blog.xml&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&amp;lt;/sitemap&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;sitemap&amp;gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://api.misitio.io/sitemap-profiles.xml&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&amp;lt;/sitemap&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;sitemap&amp;gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://api.misitio.io/sitemap-reportes.xml&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&amp;lt;/sitemap&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/sitemapindex&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El backend sirve los dinámicos en la raíz (NO bajo &lt;code&gt;/api/v1&lt;/code&gt;) para que&lt;br&gt;
las URLs se vean naturales para los rastreadores:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/api/sitemap.py
&lt;/span&gt;&lt;span class="nd"&gt;@router.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/sitemap-blog.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;include_in_schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sitemap_blog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_published&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# noqa: E712
&lt;/span&gt;            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isnot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# tope de Google por mapa de sitio
&lt;/span&gt;        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nf"&gt;_url_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SITE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/blog/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;lastmod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;changefreq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weekly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;published_at&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_xml_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres trampas que amarran las pruebas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/tests/test_sitemap.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_sitemap_blog_skips_published_with_null_published_at&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt;
    &lt;span class="c1"&gt;# Filas a medio publicar (is_published=True pero published_at IS NULL)
&lt;/span&gt;    &lt;span class="c1"&gt;# rompen el contrato de lastmod — no deben filtrarse.
&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_sitemap_profiles_excludes_bots_and_inactive&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt;
    &lt;span class="c1"&gt;# Cuentas de bot (is_agent=True) y usuarios inactivos / pendientes
&lt;/span&gt;    &lt;span class="c1"&gt;# NO deben aparecer.
&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_sitemap_reportes_returns_empty_on_intel_outage&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt;
    &lt;span class="c1"&gt;# Los rastreadores cachean los 500 como "el sitio entero ya no está"
&lt;/span&gt;    &lt;span class="c1"&gt;# durante días. Un mapa de sitio vacío &amp;gt; un error.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Advertencia de cruce entre hosts&lt;/strong&gt;: servir desde &lt;code&gt;api.misitio.io&lt;/code&gt;&lt;br&gt;
mientras el dominio raíz es &lt;code&gt;misitio.io&lt;/code&gt; requiere que ambos hosts&lt;br&gt;
estén verificados en Google Search Console + Bing Webmaster Tools como&lt;br&gt;
la misma propiedad. ~5 minutos de trabajo en consola, documentado en el&lt;br&gt;
manual de operaciones.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fase 4: IndexNow al publicar
&lt;/h2&gt;

&lt;p&gt;Google descontinuó su endpoint &lt;code&gt;/ping?sitemap=...&lt;/code&gt; en 2023. Para&lt;br&gt;
Google, la estrategia es "incluye la URL en el mapa de sitio dinámico y&lt;br&gt;
confía en el descubrimiento de Search Console" — esa fue la fase&lt;br&gt;
anterior.&lt;/p&gt;

&lt;p&gt;Para todos los demás (Bing, Yandex, Naver, Seznam, Cloudflare), está&lt;br&gt;
IndexNow: haces POST de la URL nueva a&lt;br&gt;
&lt;code&gt;https://api.indexnow.org/IndexNow&lt;/code&gt; y queda en su índice en minutos.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/services/indexnow.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.example&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INDEXNOW_KEY&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urlList&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;INDEXNOW_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;indexnow.ping transport error: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La verificación de propiedad funciona con un archivo&lt;br&gt;
&lt;code&gt;{INDEXNOW_KEY}.txt&lt;/code&gt; en la raíz del sitio (lo publicamos bajo&lt;br&gt;
&lt;code&gt;frontend/public/&lt;/code&gt;). La llave es nada más una cadena hexadecimal de 32&lt;br&gt;
caracteres; el archivo contiene la misma cadena. IndexNow lo busca una&lt;br&gt;
vez antes de aceptar cualquier envío.&lt;/p&gt;

&lt;p&gt;Engancharlo al flujo de publicación:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/services/blog_service.py (extracto)
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;publish_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ... recompensas, notificaciones, registro de auditoría ...
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Mejor esfuerzo — nunca bloquea la publicación por un aviso fallido.
&lt;/span&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;myapp.services&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;indexnow&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;indexnow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APP_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/blog/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;try: ... except: pass&lt;/code&gt; es a propósito. La publicación tiene que&lt;br&gt;
salir bien aunque IndexNow esté caído — la observabilidad vive en los&lt;br&gt;
logs estructurados del ayudante, no en la ruta de publicación. Las&lt;br&gt;
pruebas amarran cada rama de salto silencioso:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/tests/test_indexnow.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_ping_noop_when_indexnow_key_unset&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_ping_noop_on_empty_url_list&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_ping_returns_false_on_transport_error&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_ping_returns_false_on_4xx_response&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_ping_caps_url_list_at_100&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un bug aquí es un bug silencioso — "el post nuevo no aparece en Bing&lt;br&gt;
durante una semana" — así que los asserts explícitos en cada rama&lt;br&gt;
importan.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fase 5: monitoreo de usuarios reales con Web Vitals
&lt;/h2&gt;

&lt;p&gt;Un puntaje de Lighthouse en la laptop de un dev es ficción. La pregunta&lt;br&gt;
correcta es "¿qué ve de verdad un admin cuando abre el dashboard sobre&lt;br&gt;
una conexión LTE inestable en Querétaro?".&lt;/p&gt;

&lt;p&gt;Web Vitals junta la respuesta: CLS, INP, LCP, FCP, TTFB, muestreados en&lt;br&gt;
cada vista de página, enviados vía &lt;code&gt;navigator.sendBeacon&lt;/code&gt; para que la&lt;br&gt;
carga sobreviva la navegación que la dispara.&lt;/p&gt;

&lt;p&gt;Frontend (~1KB de código encima del paquete &lt;code&gt;web-vitals&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// myapp/src/lib/vitals.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onCLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onFCP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onINP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onLCP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onTTFB&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;web-vitals&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reportWebVitals&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;navigation_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;navigationType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/rum/vitals&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/rum/vitals&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keepalive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;onCLS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;onINP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;onLCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;onFCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;onTTFB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// myapp/src/main.tsx&lt;/span&gt;
&lt;span class="nf"&gt;reportWebVitals&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEV&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backend (un solo endpoint, sin escritura a base de datos — los vitals&lt;br&gt;
son muestreados y efímeros, CloudWatch es el almacén correcto para&lt;br&gt;
"miles por día"):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/api/v1/rum.py
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VitalsPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LCP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FCP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TTFB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;good&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;needs-improvement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;navigation_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/rum/vitals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@limiter.limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;60/minute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ingest_vitals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;VitalsPayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web_vital name=%s value=%.2f rating=%s id=%s url=%s nav=%s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;navigation_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Consulta de CloudWatch Logs Insights para el p75 de LCP por página:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;"LCP"&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt; &lt;span class="n"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p75&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;sort&lt;/span&gt; &lt;span class="n"&gt;p75&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una alarma futura sobre &lt;code&gt;p75(LCP) &amp;gt; 3000ms&lt;/code&gt; atrapa una regresión a&lt;br&gt;
horas del despliegue — más temprano que cualquier auditoría mensual de&lt;br&gt;
Lighthouse.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fase 6: capturas de HTML por ruta sin Puppeteer
&lt;/h2&gt;

&lt;p&gt;El Googlebot moderno ejecuta JavaScript. Bingbot lo hace desde 2019.&lt;br&gt;
Pero la mayoría de los rastreadores de IA (Anthropic, OpenAI,&lt;br&gt;
Perplexity) y todos los previsualizadores sociales que probé (LinkedIn,&lt;br&gt;
WhatsApp, Slack, Discord) bajan el HTML crudo y leen las etiquetas meta&lt;br&gt;
antes de que corra cualquier JS.&lt;/p&gt;

&lt;p&gt;El &lt;code&gt;PageMeta&lt;/code&gt; en tiempo de ejecución (react-helmet-async) está correcto&lt;br&gt;
para los navegadores y los rastreadores que entienden JS, pero el HTML&lt;br&gt;
del primer byte que sirve la SPA son los defaults de la home sin&lt;br&gt;
importar la ruta pedida. Visitas &lt;code&gt;/nosotros&lt;/code&gt; en frío y las etiquetas&lt;br&gt;
meta describen la home, no la página de Nosotros.&lt;/p&gt;

&lt;p&gt;Probé primero &lt;code&gt;@prerenderer/rollup-plugin&lt;/code&gt; + Puppeteer. Funcionó pero&lt;br&gt;
la descarga de Chromium pesa ~300 MB y el corredor de CI autoalojado es&lt;br&gt;
una instancia ARM de 2 GB — cada compilación se quedaría sin memoria&lt;br&gt;
igual que el trabajo de pruebas cuando le tocan cuatro shards de pytest&lt;br&gt;
en paralelo. No vale la pena.&lt;/p&gt;

&lt;p&gt;En su lugar armé un script post-compilación de 100 líneas. Para cada&lt;br&gt;
ruta estática de landing, copia &lt;code&gt;dist/index.html&lt;/code&gt; y le parcha el&lt;br&gt;
&lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, el &lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt;, el &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt;,&lt;br&gt;
y las etiquetas OG, Twitter y hreflang específicas de la ruta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// myapp/frontend/scripts/build-seo-snapshots.mjs (extracto)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ROUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/nosotros&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Nosotros&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/ayuda&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Preguntas frecuentes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 6 más ...&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;patchHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;patches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HEAD_PATCHES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;title&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;title&amp;gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`&amp;lt;title&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;patches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/title&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;meta name="description" content="&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*" &lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`&amp;lt;meta name="description" content="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;patches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;" /&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... og:title, og:description, og:url, twitter:*, canonical, hreflang ...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ROUTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DIST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;patchHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conectado a la compilación:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vite build --mode production &amp;amp;&amp;amp; node scripts/build-seo-snapshots.mjs"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Salida:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[build-seo-snapshots] wrote 8 per-route HTML snapshots
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CloudFront sirve &lt;code&gt;dist/nosotros/index.html&lt;/code&gt; para &lt;code&gt;/nosotros&lt;/code&gt; (S3 sirve&lt;br&gt;
de manera automática el index de la ruta que coincide), no el fallback&lt;br&gt;
de la SPA. Pedir una ruta en frío ahora regresa el meta específico de&lt;br&gt;
la ruta en el primer byte.&lt;/p&gt;

&lt;p&gt;Compensación contra el SSR con Puppeteer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Capturas estáticas (esto)&lt;/th&gt;
&lt;th&gt;SSR con Puppeteer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Etiquetas meta&lt;/td&gt;
&lt;td&gt;Por ruta, primer byte&lt;/td&gt;
&lt;td&gt;Por ruta, primer byte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contenido del body&lt;/td&gt;
&lt;td&gt;Solo la cáscara de la SPA&lt;/td&gt;
&lt;td&gt;HTML completamente renderizado&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Costo de compilación&lt;/td&gt;
&lt;td&gt;100 líneas, ~0ms&lt;/td&gt;
&lt;td&gt;navegador de 300MB + 30s/ruta&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valor para rastreadores de IA&lt;/td&gt;
&lt;td&gt;Alto (prefieren datos estructurados sobre el texto del body)&lt;/td&gt;
&lt;td&gt;Alto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valor para Bingbot / Yandex&lt;/td&gt;
&lt;td&gt;Medio (ejecutan JS)&lt;/td&gt;
&lt;td&gt;Alto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vista previa en LinkedIn / Discord&lt;/td&gt;
&lt;td&gt;Alto (solo leen el meta)&lt;/td&gt;
&lt;td&gt;Alto&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Para una estrategia 2026 con IA primero, el enfoque de capturas&lt;br&gt;
estáticas captura la mayor parte del valor al 1% de la complejidad.&lt;/p&gt;

&lt;h2&gt;
  
  
  El resultado
&lt;/h2&gt;

&lt;p&gt;Seis fases de trabajo, sin Puppeteer en la compilación, cada cambio&lt;br&gt;
probado:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capa&lt;/th&gt;
&lt;th&gt;Antes&lt;/th&gt;
&lt;th&gt;Después&lt;/th&gt;
&lt;th&gt;Líneas de código nuevo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cobertura de schema.org&lt;/td&gt;
&lt;td&gt;0 esquemas&lt;/td&gt;
&lt;td&gt;6 (org, website, article, person, faq, breadcrumb)&lt;/td&gt;
&lt;td&gt;~180 (constructores) + ~30 (cableado)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Señal de región&lt;/td&gt;
&lt;td&gt;ninguna&lt;/td&gt;
&lt;td&gt;hreflang en cada render&lt;/td&gt;
&lt;td&gt;1 archivo, 4 líneas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Política de rastreadores de IA&lt;/td&gt;
&lt;td&gt;implícita&lt;/td&gt;
&lt;td&gt;permiso explícito para 6 bots mayores + llms.txt&lt;/td&gt;
&lt;td&gt;2 archivos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mapa de sitio&lt;/td&gt;
&lt;td&gt;estático, 13 URLs&lt;/td&gt;
&lt;td&gt;índice de mapas + 3 mapas dinámicos&lt;/td&gt;
&lt;td&gt;~140 líneas de backend, 1 archivo de frontend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexado al publicar&lt;/td&gt;
&lt;td&gt;manual&lt;/td&gt;
&lt;td&gt;aviso de IndexNow con try/except&lt;/td&gt;
&lt;td&gt;~90 líneas + 6 pruebas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoreo de usuarios reales&lt;/td&gt;
&lt;td&gt;nada&lt;/td&gt;
&lt;td&gt;5 vitals, muestreados al 10%, → CloudWatch&lt;/td&gt;
&lt;td&gt;~70 de frontend + ~50 de backend + 9 pruebas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Landing pages pre-renderizadas&lt;/td&gt;
&lt;td&gt;ninguna&lt;/td&gt;
&lt;td&gt;8 rutas con meta específico de ruta en el primer byte&lt;/td&gt;
&lt;td&gt;~150 líneas, sin Puppeteer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Lo que NO ayudó
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@prerenderer/rollup-plugin&lt;/code&gt; + Puppeteer&lt;/strong&gt;. La herramienta correcta
para SSR de verdad, la equivocada para nuestra infraestructura.
Descarga de Chromium de 300 MB en cada compilación, sin memoria en el
corredor de CI ARM de 2 GB. Reemplazado por el parchador de meta de
100 líneas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El endpoint &lt;code&gt;/ping?sitemap=&lt;/code&gt; de Google&lt;/strong&gt;. Descontinuado en 2023.
Para Google la estrategia es "inclúyelo en el mapa de sitio, registra
el mapa una vez en Search Console, confía en la cadencia de
descubrimiento".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Envío de mapa de sitio específico a Yandex / Baidu / DDG&lt;/strong&gt;. Yandex
y Baidu no son relevantes para el tráfico de LATAM; DuckDuckGo jala
de Bing (cubierto por IndexNow). Google + Bing por IndexNow basta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;meta name="keywords"&amp;gt;&lt;/code&gt;&lt;/strong&gt; y &lt;strong&gt;&lt;code&gt;&amp;lt;meta name="generator"&amp;gt;&lt;/code&gt;&lt;/strong&gt;.
Ignorados por toda función moderna de resultados de búsqueda.
Saltados.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Qué sí ayudaría a futuro (en orden de palanca)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SSR en el edge para páginas dinámicas&lt;/strong&gt;. Las capturas estáticas
cubren las landing pages; los posts de blog + perfiles todavía
sirven la cáscara de la SPA en el primer byte. Una Lambda@Edge o una
CloudFront Function que detecte los user agents de rastreadores y
los reenvíe a una variante renderizada en el servidor cerraría el
hueco para Bingbot + rastreadores de IA en el contenido dinámico.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarma de CloudWatch sobre LCP p75 &amp;gt; 3000ms&lt;/strong&gt;. Los datos ya
fluyen pero nada alerta sobre una regresión todavía. Una alarma
simple respaldada por Logs Insights es el siguiente paso.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auditoría de texto alternativo en las imágenes de los posts&lt;/strong&gt;.
Fuera de alcance para esta pasada — pero cada post de blog incrusta
imágenes y la ganancia de accesibilidad amigable para los modelos de
lenguaje es significativa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clúster de &lt;code&gt;hreflang&lt;/code&gt; cuando aterrice una variante en inglés&lt;/strong&gt;.
Agregar &lt;code&gt;hreflang="en"&lt;/code&gt; + alternates recíprocos en ambas regiones,
manteniendo el &lt;code&gt;x-default&lt;/code&gt; apuntando al canonical en español.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;El contenido SEO por default en 2026 ya no son títulos +
descripciones — son datos estructurados.&lt;/strong&gt; Los resultados
enriquecidos de Google, el formato de citas de ChatGPT, las tarjetas
de fuente de Perplexity, las vistas previas de LinkedIn: todos parsean
schema.org primero y el body del HTML después. JSON-LD es el nuevo
&lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un script de 100 líneas puede reemplazar un navegador de 300 MB
cuando sabes exactamente cómo tiene que verse la salida.&lt;/strong&gt; La trampa
del SSR con Puppeteer es tratar "quiero HTML pre-renderizado" como el
mismo problema que "quiero un motor de JS en mi compilación". Para
solo-meta-estático, gana el reemplazo de cadenas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El default de 2026 no es "¿deberíamos estar en las respuestas de
IA?" — es "¿estamos en las respuestas de IA SIQUIERA?".&lt;/strong&gt; &lt;code&gt;llms.txt&lt;/code&gt;

&lt;ul&gt;
&lt;li&gt;JSON-LD es una tarde de trabajo por un canal de descubrimiento que
no existía hace cinco años.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El aviso de mejor esfuerzo al publicar necesita un &lt;code&gt;try: pass&lt;/code&gt;,
nunca un &lt;code&gt;try: raise&lt;/code&gt;.&lt;/strong&gt; Publicar es la acción del usuario; IndexNow
es tu optimización. No confundas las dos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Muestrea-no-guardes para el monitoreo de usuarios reales.&lt;/strong&gt; Las
cargas de Web Vitals son estadísticas; un muestreo del 10% +
CloudWatch es lo correcto. Un muestreo del 100% + tabla en base de
datos es la factura de $300/mes de una empresa SaaS en tres meses.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>chatgpt</category>
      <category>spanish</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cuatro intentos para que una tarea programada se ejecute exactamente una vez: una evolución</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Tue, 02 Jun 2026 22:30:56 +0000</pubDate>
      <link>https://dev.to/aws-builders/cuatro-intentos-para-que-una-tarea-programada-se-ejecute-exactamente-una-vez-una-evolucion-5c6</link>
      <guid>https://dev.to/aws-builders/cuatro-intentos-para-que-una-tarea-programada-se-ejecute-exactamente-una-vez-una-evolucion-5c6</guid>
      <description>&lt;p&gt;Un backend de FastAPI corriendo en tres réplicas de Fargate manda una&lt;br&gt;
sola notificación de "quedaste en el top 3" + bono de Coins cada viernes a las 18:00 de México. Un miembro la recibió tres veces. La solución tomó cuatro intentos y un cambio arquitectónico chico antes de que el bug se quedara resuelto.  &lt;/p&gt;

&lt;p&gt;El codebase: FastAPI sobre AWS ECS Fargate, &lt;code&gt;desiredCount=3&lt;/code&gt;, un&lt;br&gt;
Postgres en RDS, y un ciclo de worker en segundo plano&lt;br&gt;
(&lt;code&gt;moderator_bot_worker&lt;/code&gt;) dentro de cada réplica que despierta cada&lt;br&gt;
15 minutos y reparte trabajo según la hora del reloj en Ciudad de&lt;br&gt;
México. Las tareas tipo cron (resumen semanal, cierre del leaderboard) viven como branches de wall-clock adentro del mismo&lt;br&gt;
ciclo del worker.&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Intento&lt;/th&gt;
&lt;th&gt;Qué arregló&lt;/th&gt;
&lt;th&gt;Qué se le escapó&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. &lt;code&gt;pg_try_advisory_xact_lock&lt;/code&gt; por ciclo&lt;/td&gt;
&lt;td&gt;Réplicas concurrentes dentro del mismo instante&lt;/td&gt;
&lt;td&gt;Ciclos secuenciales sobre la misma ranura — si el trabajo fallaba en silencio, cada ciclo posterior lo volvía a correr&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Centinela por título del hilo adentro de la tarea&lt;/td&gt;
&lt;td&gt;El chequeo común de "¿ya escribimos el hilo del resumen?"&lt;/td&gt;
&lt;td&gt;Dependía de que &lt;code&gt;bot_service.post_thread&lt;/code&gt; tuviera éxito. Cuando regresaba &lt;code&gt;None&lt;/code&gt; en silencio (sección faltante), el centinela nunca persistía&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Fila de reclamo en &lt;code&gt;worker_runs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Atómica, con alcance de la transacción: persiste con el trabajo, se revierte con el trabajo&lt;/td&gt;
&lt;td&gt;Sigue metiendo una decisión recurrente de cuándo correr dentro de cada réplica&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. &lt;code&gt;app/jobs/&lt;/code&gt; + objetivo de EventBridge (este post)&lt;/td&gt;
&lt;td&gt;Saca el "cuándo" de las réplicas por completo. AWS garantiza el disparo; la tarea es un contenedor efímero de ECS&lt;/td&gt;
&lt;td&gt;Latencia de arranque en frío de la tarea (~15-30s); un poco más de CDK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cada intento tapó un agujero más estrecho que el anterior. El último&lt;br&gt;
es estructural — en lugar de poner una capa más de bloqueos, elimina&lt;br&gt;
la pregunta "¿qué réplica dispara el cron?" al dejar de disparar crons&lt;br&gt;
dentro de las réplicas en primer lugar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Acá puedes encontrar el código de acompañaniento:&lt;/strong&gt;&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/fast-api-locking-evolution" rel="noopener noreferrer"&gt;
        fast-api-locking-evolution
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Four attempts to make a scheduled job run exactly once: an evolution
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Code companion — locking evolution post&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Each folder maps to one stage of the post's narrative:&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;00-the-bug/           the original race scenario, reproducible in 30 lines
01-advisory-lock/     pg_try_advisory_xact_lock per tick (attempt 1)
02-thread-sentinel/   select-by-title dedupe (attempt 2, the silent-fail trap)
03-claim-row/         worker_runs table + claim_run() helper (attempt 3)
04-cli-entrypoint/    jobs/ package + __main__.py + worker delegates
05-eventbridge/       phase-2 CDK sketch (not deployed yet)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Suggested reading order&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;00-the-bug/&lt;/code&gt;&lt;/strong&gt; — the minimal reproducer. Run two replicas of
the script against a shared Postgres and watch the duplicate
notifications land.&lt;/li&gt;
&lt;li&gt;Each stage folder in order. Each one is a self-contained
improvement; you can stop at any stage and still have something
functional. The post argues that stopping earlier than &lt;code&gt;03-claim-row/&lt;/code&gt;
leaves you exposed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;05-eventbridge/&lt;/code&gt;&lt;/strong&gt; — the structural ending. Not strictly needed
if you're happy with the worker model, but it removes the entire
"which replica fires the cron?" question.&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Notes&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;00-the-bug/&lt;/code&gt; is a self-contained…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/fast-api-locking-evolution" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  El incidente
&lt;/h2&gt;

&lt;p&gt;Viernes 18:00 de México, un miembro abre sus notificaciones y ve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🥉 ¡Quedaste en el top 3! +10 Coins por tu actividad esta semana. (hace 17 min)
🥉 ¡Quedaste en el top 3! +10 Coins por tu actividad esta semana. (hace 18 min)
🥉 ¡Quedaste en el top 3! +10 Coins por tu actividad esta semana. (hace 19 min)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres notificaciones idénticas, separadas por un minuto. Su wallet&lt;br&gt;
muestra &lt;code&gt;+30&lt;/code&gt; en lugar de &lt;code&gt;+10&lt;/code&gt;. Lo mismo le pasó al #1 y al #2 de la&lt;br&gt;
semana.&lt;/p&gt;

&lt;p&gt;El intervalo del worker es de 15 minutos. Las notificaciones están a un minuto de distancia. Eso no son 3 ciclos. Son 3 réplicas&lt;br&gt;
disparando la misma ranura una detrás de otra, el bloqueo de cada&lt;br&gt;
réplica liberado por la confirmación de la anterior, y la siguiente&lt;br&gt;
entrando antes de que cualquier centinela alcanzara a protegerla.&lt;/p&gt;
&lt;h2&gt;
  
  
  Intento 1: bloqueo por ciclo
&lt;/h2&gt;

&lt;p&gt;El primer instinto fue correcto: serializar las réplicas. Los bloqueos consultivos de Postgres son baratos y no requieren cambios de esquema.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/workers/scheduled_worker.py
&lt;/span&gt;&lt;span class="n"&gt;_TICK_LOCK_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x4D424F54&lt;/span&gt;  &lt;span class="c1"&gt;# entero arbitrario de 32 bits, único para este worker
&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_try_tick_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pg_try_advisory_xact_lock(:k)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_TICK_LOCK_KEY&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar_one&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_tick&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;AsyncSessionLocal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_try_tick_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# otra réplica ya está adentro de este ciclo
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;pg_try_advisory_xact_lock&lt;/code&gt;&lt;/strong&gt; no es bloqueante — regresa falso en&lt;br&gt;
lugar de esperar si otra sesión lo tiene. Se libera de manera&lt;br&gt;
automática cuando termina la transacción (commit o rollback). Dos&lt;br&gt;
réplicas pegando contra &lt;code&gt;_tick&lt;/code&gt; en el mismo instante: una se lleva&lt;br&gt;
&lt;code&gt;true&lt;/code&gt; y corre el trabajo; la otra se lleva &lt;code&gt;false&lt;/code&gt; y sale limpia.&lt;/p&gt;

&lt;p&gt;Esto se liberó a producción, y la manifestación obvia desapareció — ya no había disparos triples sincrónicos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lo que se me escapó.&lt;/strong&gt; El bloqueo tiene alcance de &lt;em&gt;transacción&lt;/em&gt;:&lt;br&gt;
vive nada más adentro de una transacción. Si el ciclo del worker&lt;br&gt;
es corto (10s) y el intervalo entre ciclos dentro de una sola réplica&lt;br&gt;
es de 15 min, todo bien. Pero tres réplicas haciendo ciclos en&lt;br&gt;
horarios escalonados — A en T=0, B en T+1min, C en T+2min — cada&lt;br&gt;
confirmación libera el bloqueo para la siguiente. El bloqueo mantiene&lt;br&gt;
honestos a los ciclos &lt;em&gt;simultáneos&lt;/em&gt;, no a los &lt;em&gt;secuenciales&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Para el cierre del leaderboard en un slot de 15 min, eso significa&lt;br&gt;
hasta tres entradas secuenciales, una por minuto, exactamente lo que&lt;br&gt;
mostraba la captura de pantalla.&lt;/p&gt;
&lt;h2&gt;
  
  
  Intento 2: centinela adentro de la tarea
&lt;/h2&gt;

&lt;p&gt;La solución anterior resolvió el problema de las réplicas.&lt;br&gt;
La función original ya tenía un chequeo de idempotencia distinto — "si ya existe un hilo con el título del resumen de esta semana, sal".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/workers/scheduled_worker.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_leaderboard_close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now_mx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🏆 Top 3 de la semana — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;now_mx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%d %b %Y&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# ya corrimos esta slot
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="c1"&gt;# otorgar Coins, escribir notificaciones, postear el hilo
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;bot_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;section_slug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;general&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body_md&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La suposición: una vez que el hilo del resumen existe, cada ciclo&lt;br&gt;
posterior lee el título y se sale por la corta. Funciona &lt;em&gt;si&lt;/em&gt;&lt;br&gt;
&lt;code&gt;post_thread&lt;/code&gt; escribe el hilo. No dice nada si &lt;code&gt;post_thread&lt;/code&gt; &lt;em&gt;no&lt;/em&gt; lo&lt;br&gt;
escribe.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bot_service.post_thread&lt;/code&gt; era de mejor esfuerzo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/services/bot_service.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;section_slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body_md&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;bot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_bot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bot&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post_thread: bot user missing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;-- falla silenciosa
&lt;/span&gt;    &lt;span class="n"&gt;section&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Section&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;section_slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;section&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post_thread: section %s missing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;section_slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;-- falla silenciosa
&lt;/span&gt;    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(...))&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;thread&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Intento 3: claim de &lt;code&gt;worker_runs&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;La solución de verdad tiene que ser atómica con el trabajo. Si el&lt;br&gt;
trabajo se confirma, el centinela se confirma. Si algo se revierte, el centinela se revierte. Misma transacción.&lt;/p&gt;

&lt;p&gt;Una tabla de una sola fila con clave primaria compuesta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;worker_runs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;key&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ran_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El helper para hacer claim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/jobs/_idempotency.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;claim_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Reserva (name, key). True si es nuestro, False si ya existía.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            INSERT INTO worker_runs (name, key)
            VALUES (:name, :key)
            ON CONFLICT (name, key) DO NOTHING
            RETURNING 1
            &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La tarea queda así:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now_mx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;iso_year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;iso_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now_mx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isocalendar&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;claim_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;leaderboard_close&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;iso_year&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-W&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;iso_week&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# alguien más es dueño de esta ranura
&lt;/span&gt;    &lt;span class="c1"&gt;# ... escribir notificaciones, transacciones, hilo ...
&lt;/span&gt;    &lt;span class="c1"&gt;# quien llama confirma o revierte
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING RETURNING 1&lt;/code&gt; es la pieza&lt;br&gt;
central. El &lt;code&gt;RETURNING 1&lt;/code&gt; nada más emite una fila cuando el insert&lt;br&gt;
realmente sucedió. La sentencia completa es atómica a nivel de fila:&lt;br&gt;
dos inserts concurrentes para el mismo &lt;code&gt;(name, key)&lt;/code&gt; ven exactamente&lt;br&gt;
un ganador.&lt;/p&gt;

&lt;p&gt;Lo crucial: la fila participa en la transacción &lt;strong&gt;de quien la&lt;br&gt;
llama&lt;/strong&gt;. Quien la llama (el ciclo del worker) confirma todo el&lt;br&gt;
paquete al final: notificaciones + transacciones + hilo + la fila de&lt;br&gt;
reclamo, o ninguno de ellos. Si &lt;code&gt;post_thread&lt;/code&gt; regresa &lt;code&gt;None&lt;/code&gt; callado&lt;br&gt;
y el hilo del resumen nunca aterriza, &lt;em&gt;el reclamo igual se revierte&lt;br&gt;
si quien lo llamó lo trata como un error&lt;/em&gt; — o, si se confirma de&lt;br&gt;
todos modos, &lt;em&gt;el siguiente ciclo igual ve el reclamo&lt;/em&gt; porque el&lt;br&gt;
reclamo se insertó antes de la llamada rota.&lt;/p&gt;

&lt;p&gt;Esta es la capa que los dos intentos anteriores no tenían. El&lt;br&gt;
bloqueo consultivo era a nivel de proceso; el centinela del título&lt;br&gt;
del hilo era a nivel de tarea pero consecuencia de efecto secundario;&lt;br&gt;
este es a nivel de transacción y primario, sentado entre la decisión&lt;br&gt;
de la ranura y cualquier efecto secundario.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cómo se ve la secuencia con las tres capas
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T=0   se dispara el ciclo de la réplica A
      ├─ pg_try_advisory_xact_lock → true (A gana el chequeo de concurrencia)
      ├─ claim_run('leaderboard_close', '2026-W22') → true (A gana la ranura)
      ├─ inserta 3 Notifications, 3 TokenTransactions, postea el Thread del resumen
      └─ COMMIT (la fila del reclamo + el trabajo aterrizan juntos)

T=60s  se dispara el ciclo de la réplica B
      ├─ pg_try_advisory_xact_lock → true (el bloqueo de A se liberó al confirmar A)
      ├─ claim_run('leaderboard_close', '2026-W22') → false (la fila ya existe)
      └─ return  ← lo que los intentos 1 + 2 no podían atrapar

T=120s se dispara el ciclo de la réplica C
      ├─ igual que B
      └─ return
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;El bloqueo evita que A y B simultáneas hagan el trabajo. La fila de&lt;br&gt;
reclamo evita que B y C posteriores lo vuelvan a hacer. Juntos van&lt;br&gt;
apretados.&lt;/p&gt;
&lt;h2&gt;
  
  
  ¿Qué pasa si el trabajo falla a la mitad?
&lt;/h2&gt;

&lt;p&gt;La transacción te protege. &lt;code&gt;claim_run&lt;/code&gt; inserta la fila adentro de la&lt;br&gt;
transacción de quien llama. La tarea que lo usa no es dueña de&lt;br&gt;
ningún commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# myapp/workers/scheduled_worker.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_tick&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;AsyncSessionLocal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_try_tick_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;friday_18_window&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now_mx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;leaderboard_close&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now_mx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# todo o nada
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si &lt;code&gt;leaderboard_close.run&lt;/code&gt; levanta una excepción después de insertar&lt;br&gt;
el reclamo, el &lt;code&gt;async with&lt;/code&gt; revierte. El reclamo desaparece. El&lt;br&gt;
siguiente ciclo se puede reintentar limpio. No hay una cola de&lt;br&gt;
muertos permanente que andar limpiando.&lt;/p&gt;

&lt;p&gt;Una prueba de regresión amarró este comportamiento:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.asyncio&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_claim_run_rollback_releases_slot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;myapp.jobs._idempotency&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;claim_run&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;claim_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;leaderboard_close&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-W42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1 FROM worker_runs WHERE name = :n AND key = :k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;leaderboard_close&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-W42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Revertir tiene que liberar la ranura. Si la fila sobreviviera, un &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ciclo fallido bloquearía permanentemente cada reintento.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  En este punto todo funciona. ¿Por qué seguir?
&lt;/h2&gt;

&lt;p&gt;Porque cada solución de arriba contesta "¿cómo hacemos idempotente el&lt;br&gt;
ciclo del worker que ya se disparó?" en lugar de "¿debería el&lt;br&gt;
worker estar disparando el ciclo?".&lt;/p&gt;

&lt;p&gt;El worker sigue:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;corriendo dentro de cada réplica de Fargate&lt;/li&gt;
&lt;li&gt;despertando cada 15 minutos sin importar si hay algo programado&lt;/li&gt;
&lt;li&gt;usando branches de wall-clock (&lt;code&gt;if weekday == 4 and hour == 18 and
minute &amp;lt; 15&lt;/code&gt;) para decidir qué hacer&lt;/li&gt;
&lt;li&gt;va a seguir necesitando el bloqueo consultivo y el claim
por siempre para tapar el desajuste fundamental de "tres réplicas,
una sola tarea"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La respuesta estructural es: dejar de meter lógica de cron dentro de&lt;br&gt;
las réplicas. AWS ya corre un planificador que maneja esto — varias&lt;br&gt;
veces, con reintentos, con semántica de a lo-más una vez del lado del&lt;br&gt;
consumidor vía idempotencia. Resumen: usarlo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Intento 4: separar el "cuándo" del "dónde"
&lt;/h2&gt;

&lt;p&gt;La refactorización que no es una solución encima de soluciones. Tres&lt;br&gt;
piezas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cada tarea ahora es invocable&lt;/strong&gt;. El cuerpo de &lt;code&gt;_leaderboard_close&lt;/code&gt;
se movió a &lt;code&gt;myapp/jobs/leaderboard_close.py&lt;/code&gt;, con esta forma:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="c1"&gt;# myapp/jobs/leaderboard_close.py
&lt;/span&gt;   &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now_mx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
       &lt;span class="n"&gt;iso_year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;iso_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now_mx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isocalendar&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
       &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;claim_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;leaderboard_close&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;iso_year&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-W&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;iso_week&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
           &lt;span class="k"&gt;return&lt;/span&gt;
       &lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La función nunca confirma. Escribe sobre la sesión que le&lt;br&gt;
   pasaron. Quien la llama (el ciclo del worker, o la CLI, o una prueba) es dueño del límite de la transacción.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Un punto de entrada por CLI&lt;/strong&gt;. &lt;code&gt;python -m myapp.jobs &amp;lt;name&amp;gt;&lt;/code&gt;
corre exactamente una tarea:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="c1"&gt;# myapp/jobs/__main__.py
&lt;/span&gt;   &lt;span class="n"&gt;JOBS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;leaderboard_close&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;leaderboard_close&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;squad_health&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;squad_health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weekly_digest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;weekly_digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;

   &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now_iso&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
       &lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JOBS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
       &lt;span class="n"&gt;now_mx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
           &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now_iso&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MX_TZ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now_iso&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MX_TZ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;AsyncSessionLocal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
           &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
               &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now_mx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
               &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
           &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
               &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Es la misma forma que el worker usa internamente — abrir una&lt;br&gt;
   sesión, correr la tarea, confirmar-o-revertir. La fase 1 de la&lt;br&gt;
   refactorización hace que el worker y la CLI pasen por aquí,&lt;br&gt;
   idénticamente.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;EventBridge Scheduler → ECS RunTask&lt;/strong&gt;. En CDK:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;   &lt;span class="c1"&gt;// infra/lib/jobs-stack.ts (borrador de fase 2)&lt;/span&gt;
   &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CfnSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LeaderboardClose&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="na"&gt;scheduleExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cron(0 18 ? * FRI *)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="na"&gt;scheduleExpressionTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;America/Mexico_City&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
       &lt;span class="na"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;arn:aws:scheduler:::aws-sdk:ecs:runTask&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="na"&gt;roleArn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;schedulerRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roleArn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
         &lt;span class="na"&gt;Cluster&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clusterArn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="na"&gt;TaskDefinition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;backendTaskDef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskDefinitionArn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="na"&gt;LaunchType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FARGATE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="na"&gt;Overrides&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
           &lt;span class="na"&gt;ContainerOverrides&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
             &lt;span class="na"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;backend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="na"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;python&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;myapp.jobs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaderboard_close&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
           &lt;span class="p"&gt;}],&lt;/span&gt;
         &lt;span class="p"&gt;},&lt;/span&gt;
       &lt;span class="p"&gt;}),&lt;/span&gt;
     &lt;span class="p"&gt;},&lt;/span&gt;
   &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EventBridge es dueño del "cuándo". ECS RunTask es dueño del&lt;br&gt;
   "dónde" — exactamente un contenedor efímero, sin réplicas&lt;br&gt;
   compitiendo. La fila de &lt;code&gt;claim_run&lt;/code&gt; se queda como tercera capa:&lt;br&gt;
   EventBridge tiene entrega de al-menos-una-vez del lado del&lt;br&gt;
   planificador, así que si un parpadeo de red reintenta la llamada&lt;br&gt;
   a &lt;code&gt;RunTask&lt;/code&gt;, el segundo contenedor es un no-op limpio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué se simplifica
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;_tick&lt;/code&gt; pierde las tres branches de wall-clock. Nada más corre
&lt;code&gt;_anniversary&lt;/code&gt; + &lt;code&gt;_zombie_threads&lt;/code&gt; (que sí necesitan dispararse
cada 15 minutos, no a una hora fija del reloj).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_INTERVAL_SECONDS&lt;/code&gt; ya no es un concepto de cron; es el intervalo
de sondeo para el trabajo que de verdad es de flujo continuo.&lt;/li&gt;
&lt;li&gt;Las tareas nuevas requieren: un archivo en &lt;code&gt;myapp/jobs/&lt;/code&gt;, una
entrada en &lt;code&gt;JOBS&lt;/code&gt;, un recurso de calendario en CDK. Se acaba el
"¿calculamos bien la frontera de &lt;code&gt;hour == 18 and minute &amp;lt; 15&lt;/code&gt;?".&lt;/li&gt;
&lt;li&gt;La función &lt;code&gt;_tick&lt;/code&gt; de 99 líneas con cinco asuntos (bloqueo
consultivo, aniversario, hilos zombi, tres branches de
wall-clock, commit) se vuelve un despachador de 30 líneas.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lo que NO ayudó
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Agregar un bloqueo distribuido basado en Redis.&lt;/strong&gt; Misma forma
que el bloqueo consultivo (exclusión mutua a nivel de réplica),
peor historia ante particiones (que Redis pierda la llave durante
un failover significa que una ranura se dispara dos veces). No
metas otra dependencia para resolver un problema que la base de
datos ya maneja de manera atómica.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poner el trabajo detrás de un solo líder electo.&lt;/strong&gt; "Una réplica
es el planificador" vía elección de líder (consul, etcd) necesita
renovación de leases, traspaso, y maquinaria de quórum que no vale
la pena para cuatro tareas cron.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mover la idempotencia a la capa de notificaciones.&lt;/strong&gt; "Si el
usuario ya recibió LEADERBOARD_TOP3 esta semana, salta." Suena
atractivo pero acopla cada consumidor a la preocupación del
planificador. La fila de &lt;code&gt;worker_runs&lt;/code&gt; está aguas arriba de cada
consumidor y mantiene el alcance contenido.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cada solución hizo el bug &lt;em&gt;menos probable&lt;/em&gt; hasta que un cambio
estructural hizo el bug &lt;em&gt;imposible en esta capa&lt;/em&gt;. No tomes el
cambio estructural como tu primer movimiento al primer reporte.
Sí tómalo cuando el tercer intento es "otro bloqueo más".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atómico a nivel de fila, atómico con el trabajo, atómico ante
fallas.&lt;/strong&gt; &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING RETURNING 1&lt;/code&gt; trae
esas tres propiedades de regalo. Échale mano antes de echar mano
de un servicio de bloqueo distribuido.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El mejor esfuerzo y la falla silenciosa no se llevan con la
idempotencia.&lt;/strong&gt; Si una función puede regresar &lt;code&gt;None&lt;/code&gt; para decir
"no hice mi trabajo", cada quien que llame y dependa de su efecto
secundario para deduplicar es un duplicado a futuro. O la función
levanta excepción, o quien la llama trata el None como falla, o
quien la llama usa su propio centinela atómico.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La branch de wall-clock dentro de un loop huele mal.&lt;/strong&gt; &lt;code&gt;if
weekday == 4 and hour == 18 and minute &amp;lt; 15&lt;/code&gt; es reinventar cron
con peor semántica, contra un sistema que ya tiene un
planificador.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Coordinar deploys de frontend y backend sin orquestado, usando Github Actions</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Thu, 28 May 2026 16:52:34 +0000</pubDate>
      <link>https://dev.to/aws-builders/coordinar-deploys-de-frontend-y-backend-sin-orquestado-usando-github-actions-4fp5</link>
      <guid>https://dev.to/aws-builders/coordinar-deploys-de-frontend-y-backend-sin-orquestado-usando-github-actions-4fp5</guid>
      <description>&lt;h2&gt;
  
  
  El Setup
&lt;/h2&gt;

&lt;p&gt;Un setup chiquito de SPA + API donde dos workflows de GitHub Actions&lt;br&gt;
salen en paralelo en cada push a &lt;code&gt;main&lt;/code&gt;. Probablemente un setup que no usaría en prod, pero algo que si uso para mis proyectos personales.&lt;/p&gt;

&lt;p&gt;Ahora bien esto trae un problema de coordinación (el frontend llega a los usuarios antes de que exista el endpoint de la API), cuatro opciones, y el gate de ~80 líneas de bash que fue el ganador para nuestro caso de uso&lt;/p&gt;

&lt;p&gt;El codebase: una SPA de React en CloudFront + S3, un backend FastAPI en AWS ECS Fargate, infraestructura en CDK. &lt;br&gt;
Dos workflows (&lt;code&gt;deploy-frontend.yml&lt;/code&gt;, &lt;code&gt;deploy-backend.yml&lt;/code&gt;) disparados por push a &lt;code&gt;main&lt;/code&gt; con path filters.&lt;/p&gt;

&lt;p&gt;Acá el código para seguir el post paso a paso:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/github-actions-combined-deploy-with-no-orchestrator" rel="noopener noreferrer"&gt;
        github-actions-combined-deploy-with-no-orchestrator
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Coordinating frontend and backend deploys without an orchestrator:
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Code companion — frontend/backend deploy coordination post&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Each folder maps to one stage of the post's narrative:&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;00-independent/         starting point — two workflows, no gate, the race exists
01-coupled-detection/   git diff vs the parent commit to know if backend changed
02-poll-by-sha/         GitHub Actions API filtered by head_sha
03-grace-window/        handle "backend run not registered yet" (race fix)
04-final/               full step with meaningful error surfacing
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Suggested reading order&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;00-independent/&lt;/code&gt;&lt;/strong&gt; — the baseline; what most projects start with.&lt;/li&gt;
&lt;li&gt;Each stage folder in order — the post's evolution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;04-final/&lt;/code&gt;&lt;/strong&gt; — drop this into your &lt;code&gt;deploy-frontend.yml&lt;/code&gt; as
the first step inside &lt;code&gt;build-and-deploy&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Notes&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;00-independent/&lt;/code&gt; is two separate files — they exist as &lt;code&gt;.yml&lt;/code&gt; so
you can diff them against your own workflows.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;04-final/&lt;/code&gt; contains the complete step block plus the workflow
permissions and checkout config it depends on. Copy all three
pieces or the gate will silently fail open.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;actions: read&lt;/code&gt; permission is…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/github-actions-combined-deploy-with-no-orchestrator" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;El approach simple aguanta mientras tu push esté dominado por&lt;br&gt;
cambios de un solo dominio. Deja de funcionar cuando la mayoría de los pushes tocan los dos o más.&lt;/p&gt;
&lt;h2&gt;
  
  
  El problema
&lt;/h2&gt;

&lt;p&gt;Dos workflows en la misma branch &lt;code&gt;main&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# deploy-frontend.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frontend/**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# deploy-backend.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;backend/**"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;infra/**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un push que toca los dos — digamos, "agrega el endpoint&lt;br&gt;
&lt;code&gt;/api/v1/leaderboard&lt;/code&gt; y la UI que lo llama" — dispara ambos workflows&lt;br&gt;
en paralelo. Los builds de frontend normalmente son más rápidos (sin&lt;br&gt;
Docker, sin rollout de ECS). Así que un usuario que refresca entre el&lt;br&gt;
minuto 2 (SPA subida) y el minuto 6 (backend sano en ECS) pega contra&lt;br&gt;
una SPA nuevecita apuntando a un endpoint que todavía no existe. La&lt;br&gt;
consola del browser muestra un 404. Sentry pega un brinco, se disparan alertas de Cloudwatch, el usuario recarga, pega contra el caché, y ve el mismo error.&lt;/p&gt;

&lt;p&gt;Difícil de cachar en &lt;code&gt;dev&lt;/code&gt; porque el stack local arranca los dos servicios juntos. Fácil de cachar en prod una vez que pasa.&lt;/p&gt;
&lt;h2&gt;
  
  
  Las opciones
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Opción 1: workflows paralelos independientes (el baseline de no hacer nada)
&lt;/h3&gt;

&lt;p&gt;Con lo que arrancamos. Cada workflow escucha sus propios paths y&lt;br&gt;
despliega en su propio tiempo. Cero coordinación.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ambos workflows&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;...&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pros: &lt;br&gt;
cero setup, deploys de un solo dominio lo más rápido posible,&lt;br&gt;
aislamiento total. &lt;br&gt;
Contras: &lt;br&gt;
la ventana de race de arriba. El costo solo aparece en los pushes acoplados.&lt;/p&gt;
&lt;h3&gt;
  
  
  Opción 2: colapsar en un solo workflow con orden explícito
&lt;/h3&gt;

&lt;p&gt;El fix más "obvio": escribir un &lt;code&gt;deploy.yml&lt;/code&gt; con &lt;code&gt;deploy-backend&lt;/code&gt; como &lt;code&gt;job 1&lt;/code&gt; y &lt;code&gt;deploy-frontend&lt;/code&gt; con &lt;code&gt;needs: [deploy-backend]&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy-backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;self-hosted&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;...&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;deploy-frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;deploy-backend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;self-hosted&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;...&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pros: &lt;br&gt;
modelo mental trivial; GitHub Actions maneja el orden. &lt;br&gt;
Contras:&lt;br&gt;
un push de solo-frontend ahora espera por un backend que no cambió (o&lt;br&gt;
necesita un &lt;code&gt;if:&lt;/code&gt; explícito para saltárselo, que es su propia&lt;br&gt;
complejidad). Un step de lint flaky en el backend bloquea el deploy de frontend que ni siquiera dependía de los cambios del backend. Perdimos la independencia de path filters que hacía deseables los workflows paralelos para empezar.&lt;/p&gt;
&lt;h3&gt;
  
  
  Opción 3: el frontend &lt;em&gt;gatea&lt;/em&gt; contra el SHA del backend (el gate simple)
&lt;/h3&gt;

&lt;p&gt;Conservar los dos workflows. Agregar un step al inicio del workflow de frontend: &lt;em&gt;si este commit también tocó backend, espera a que el&lt;br&gt;
workflow de backend en el mismo SHA termine bien&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Pros: &lt;br&gt;
cero overhead en pushes desacoplados (el caso común); se conserva&lt;br&gt;
el paralelismo del caso común; el gate son ~80 líneas de bash + un&lt;br&gt;
&lt;code&gt;curl&lt;/code&gt; a la API de GitHub Actions. Sin infra nueva, sin servicio&lt;br&gt;
orquestador, sin artifact pinning.&lt;/p&gt;

&lt;p&gt;Contras: &lt;br&gt;
bash. Polling. El gate corre en el runner del frontend, así&lt;br&gt;
que cuesta minutos de runner mientras espera (gratis en self-hosted,&lt;br&gt;
facturable en &lt;code&gt;ubuntu-latest&lt;/code&gt;).&lt;/p&gt;
&lt;h3&gt;
  
  
  Opción 4: pin del frontend a un artifact buildeado del backend
&lt;/h3&gt;

&lt;p&gt;La respuesta correcta de principio: cada build de frontend embebe la&lt;br&gt;
versión de backend contra la que se construyó; la SPA se niega a llamar una API que no haga match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// frontend&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REQUIRED_API_VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026.05.27.a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// inyectado en el build&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiHealthcheck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;REQUIRED_API_VERSION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;showStaleBanner&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pros: &lt;br&gt;
cero ventana de race incluso con rollouts totalmente independientes; el cliente puede caer a un banner de "refresca para actualizar"; funciona durante rollbacks. &lt;br&gt;
Contras: &lt;br&gt;
cada cambio de endpoint se vuelve un contrato versionado; necesitas un endpoint de discovery &lt;code&gt;/api/version&lt;/code&gt; y lógica en la SPA para manejar el mismatch; coordinar across clientes móviles eventualmente cuesta más que el gate.&lt;/p&gt;
&lt;h3&gt;
  
  
  Cómo elegir
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Patrón de push&lt;/th&gt;
&lt;th&gt;Mejor opción&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pushes mayormente de un solo dominio&lt;/td&gt;
&lt;td&gt;Opción 3 (gate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pushes mayormente acoplados&lt;/td&gt;
&lt;td&gt;Opción 2 (un solo workflow)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clientes de larga vida / móvil / offline / una app de escritorio que no puedes forzar a refrescar&lt;/td&gt;
&lt;td&gt;Opción 4 (contrato versionado)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nuestra distribución: ~80% solo-backend, ~15% solo-frontend, ~5%&lt;br&gt;
acoplado. La Opción 3 fue el match obvio. El resto del post es su&lt;br&gt;
evolución.&lt;/p&gt;
&lt;h2&gt;
  
  
  Etapa 0: el punto de partida
&lt;/h2&gt;

&lt;p&gt;Dos workflows, sin coordinación:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/deploy-frontend.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Frontend&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frontend/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.github/workflows/deploy-frontend.yml"&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;self-hosted&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm install --frozen-lockfile &amp;amp;&amp;amp; pnpm build&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws s3 sync dist s3://myapp-frontend&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws cloudfront create-invalidation --distribution-id $DIST_ID --paths '/*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push acoplado → race → 404s en producción. &lt;br&gt;
Fix: &lt;em&gt;gatear&lt;/em&gt; el deploy de frontend contra el de backend cuando el commit cambió código de backend.&lt;/p&gt;
&lt;h2&gt;
  
  
  Etapa 1: detectar el push acoplado
&lt;/h2&gt;

&lt;p&gt;Antes de hacer nada más, el gate tiene que responder: &lt;em&gt;¿este commit de verdad tocó el backend?&lt;/em&gt; Si no, no esperamos, procede de inmediato y no desperdicies un minuto de runner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Necesitamos ≥ 2 commits para diffear contra el padre.&lt;/span&gt;
    &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Detect coupled push&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;
    &lt;span class="s"&gt;changed=$(git diff --name-only HEAD~1 HEAD 2&amp;gt;/dev/null || true)&lt;/span&gt;
    &lt;span class="s"&gt;if ! echo "$changed" | grep -qE "^(backend/|infra/|\.github/workflows/deploy-backend\.yml$)"; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "No backend/infra changes — proceeding."&lt;/span&gt;
      &lt;span class="s"&gt;exit 0&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
    &lt;span class="s"&gt;echo "Backend changes detected — gate engaged."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El patrón de paths espeja el bloque &lt;code&gt;paths:&lt;/code&gt; de &lt;code&gt;deploy-backend.yml&lt;/code&gt;&lt;br&gt;
exactito — si un path dispara el workflow de backend, el gate tiene que esperarlo. La diferencia entre los dos es la causa #1 de false&lt;br&gt;
proceeds, así que mantenlos pegaditos en el code review.&lt;/p&gt;

&lt;p&gt;El &lt;code&gt;fetch-depth: 2&lt;/code&gt; es la trampa — &lt;code&gt;actions/checkout@v5&lt;/code&gt; viene por&lt;br&gt;
default en shallow &lt;code&gt;1&lt;/code&gt;, y &lt;code&gt;git diff HEAD~1&lt;/code&gt; en un checkout de depth-1&lt;br&gt;
regresa nada en silencio, lo cual el script lee como "no hay cambios de backend — procede". (Pegamos contra esto en el primer deploy después de &lt;em&gt;shipear&lt;/em&gt; el gate. Cáchalo con un guard, no nomás con documentación.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Etapa 2: hacer poll al run del backend por SHA
&lt;/h2&gt;

&lt;p&gt;Ahora sabemos que hay que esperar. El mecanismo es la REST API de&lt;br&gt;
GitHub Actions filtrada por &lt;code&gt;head_sha&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;api&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://api.github.com/repos/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GITHUB_REPOSITORY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/actions/workflows/deploy-backend.yml/runs"&lt;/span&gt;
&lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"head_sha=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GITHUB_SHA&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;per_page=1"&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 160&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;  &lt;span class="c"&gt;# techo de 40 min a 15s/poll&lt;/span&gt;
  &lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/vnd.github+json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;api&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.workflow_runs[0].status // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;conclusion&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.workflow_runs[0].conclusion // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"completed"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$conclusion&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
      &lt;/span&gt;success|skipped&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0 &lt;span class="p"&gt;;;&lt;/span&gt;
      &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::Backend &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;conclusion&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1 &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;esac&lt;/span&gt;
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;15
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::Timed out waiting for backend deploy."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El filtro &lt;code&gt;head_sha&lt;/code&gt; es el eje de todo — regresa el run de &lt;em&gt;este commit exacto&lt;/em&gt;, no "el último run en &lt;code&gt;main&lt;/code&gt;", que haría race con un push de fast-follow.&lt;/p&gt;

&lt;p&gt;El permiso &lt;code&gt;actions: read&lt;/code&gt; se tiene que agregar al bloque &lt;code&gt;permissions:&lt;/code&gt; del workflow — sin eso la API regresa 403 y el gate&lt;br&gt;
falla open en silencio.&lt;/p&gt;
&lt;h2&gt;
  
  
  Etapa 3: manejar el "todavía no se registra"
&lt;/h2&gt;

&lt;p&gt;El primer run en prod reveló un race: el workflow de frontend puede&lt;br&gt;
arrancar antes de que GitHub haya registrado el run del workflow de&lt;br&gt;
backend en el mismo SHA. La API regresa &lt;code&gt;total_count: 0&lt;/code&gt;, el script lee "no hay run de backend en este SHA, procede", y ya volvimos al problema original del 404.&lt;/p&gt;

&lt;p&gt;Fix: una grace window. Dale a GitHub hasta 30s para registrar el run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Grace window — espera hasta 30s a que aparezca el run de backend.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 6&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl ... | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.total_count'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;break
  sleep &lt;/span&gt;5
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los 30s son empíricos — medí unos cuantos pushes acoplados, el delay de registro siempre fue &amp;lt; 10s pero brincó a 18s una vez durante un&lt;br&gt;
incidente de GitHub. 30s es lo suficientemente generoso como para que&lt;br&gt;
no hagamos false-proceed; el timeout exterior de 40 minutos absorbe el costo.&lt;/p&gt;

&lt;p&gt;Si después de 30s el run sigue sin existir, el script &lt;em&gt;sí&lt;/em&gt; cae a "no&lt;br&gt;
hay run en este SHA — procede". Esa es la decisión correcta: o el&lt;br&gt;
workflow de backend no se disparó (el path filter excluyó los cambios), o GitHub está tan degradado que un deploy de frontend es el menor de nuestros problemas. No le busques tres pies al gato.&lt;/p&gt;
&lt;h2&gt;
  
  
  Etapa 4: superficies de error con sentido
&lt;/h2&gt;

&lt;p&gt;Dos modos de falla necesitan manejo explícito para que el dev que ve el build en rojo pueda actuar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. La API de GitHub regresa 4xx (auth, rate limit, etc.).&lt;/span&gt;
fetch_runs&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;response status body
  &lt;span class="nv"&gt;response&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;%{http_code}"&lt;/span&gt; ...&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'$d'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::GitHub API returned &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;curl -f&lt;/code&gt; sale con 22 sin body. El wrapper conserva el body para que el log de error diga "403 Forbidden: actions read permission missing" en lugar de "exit code 22, suerte".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 2. El deploy de backend falló — saca la conclusion tal cual.&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::Backend deploy &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;conclusion&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Abortando el deploy de frontend para que la SPA nunca apunte a una API ausente."&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los casos &lt;code&gt;failure|cancelled|timed_out&lt;/code&gt; todos colapsan a la misma&lt;br&gt;
acción (no deployar el frontend), pero imprimir la conclusion exacta te ahorra un click hacia el run del workflow de backend cuando estás&lt;br&gt;
investigando.&lt;/p&gt;

&lt;h2&gt;
  
  
  El resultado
&lt;/h2&gt;

&lt;p&gt;Un setup de dos workflows que:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cuesta &lt;strong&gt;0 segundos&lt;/strong&gt; en pushes desacoplados (el caso del 80%)&lt;/li&gt;
&lt;li&gt;Cuesta &lt;strong&gt;a lo mucho el wall time del deploy de backend&lt;/strong&gt; en pushes
acoplados&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falla closed&lt;/strong&gt; — si el deploy de backend falla, el frontend no
shipea (sin tormenta de 404s)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falla closed en el gate mismo&lt;/strong&gt; — mala respuesta de API, timeout,
permiso caído, todos salen con 1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Desde que lo shipeamos (medido sobre seis semanas):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;47 pushes acoplados deployados limpio&lt;/li&gt;
&lt;li&gt;3 pushes acoplados donde el gate cachó un deploy de backend fallido
antes de que el frontend saliera (habría sido un outage visible para
el usuario)&lt;/li&gt;
&lt;li&gt;0 casos de gate haciendo false-proceed&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lo que NO ayudó
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Intentar detectar "cambios de endpoint de API" desde el diff. Un cambio en un campo de schema de Pydantic basta), y los false negatives aquí son peores que los false positives. El check de path por git-diff es suficientemente bueno.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancel-in-progress en el workflow de frontend.&lt;/strong&gt; Se ve atractivo matar el deploy de frontend en vuelo si llega un push nuevo — pero el cancel pasa a media S3-sync, dejando el bundle a medio subir. Combinado con un caché stale de CloudFront esto es &lt;em&gt;peor&lt;/em&gt; que el race condition original. Lo dejamos en &lt;code&gt;cancel-in-progress: false&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Qué sí ayudaría a futuro
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Mover la lógica del gate a un action reutilizable
(&lt;code&gt;actions/wait-for-workflow@v1&lt;/code&gt;). Las ~80 líneas de bash funcionan
pero están medio copy-pasteadas entre proyectos. &lt;/li&gt;
&lt;li&gt;Sacar el conteo de pushes acoplados a un dashboard.** Si el ratio se va del 5% hacia el 30%, el gate simple deja de ser la
herramienta correcta.&lt;/li&gt;
&lt;li&gt;Hacer el deploy de backend más rápido para que la espera sea más
corta cuando sí se active.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Los path filters definen qué cosas tus workflows acuerdan que los
disparan. Mantenlos juntos y revísalos juntos.&lt;/li&gt;
&lt;li&gt;El filtro &lt;code&gt;head_sha&lt;/code&gt; en la API de Actions es lo más útil de todo
esto. Existe, es estable, está documentado, y convierte "¿en cuál run estoy esperando?" de un problema difícil a un solo query.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>githubactions</category>
      <category>ci</category>
    </item>
    <item>
      <title>Bajándole todos los minutos posibles al CI del backend con mas de 1000 tests</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Tue, 26 May 2026 05:11:47 +0000</pubDate>
      <link>https://dev.to/aws-builders/bajandole-todos-los-minutos-posibles-al-ci-del-backend-con-mas-de-1000-tests-5ecm</link>
      <guid>https://dev.to/aws-builders/bajandole-todos-los-minutos-posibles-al-ci-del-backend-con-mas-de-1000-tests-5ecm</guid>
      <description>&lt;p&gt;Esta es la historia de muchas horas de trabajo en un hoyo continuo tratando de elegir optimizaciones entre package managers, caches, paralelismo, optimizaciones de PostgreSQL advisory locks, y el golpe de realidad de darme cuenta que el cuello de botella no era nada de lo que había estado optimizando, si no algo que no había analizado.&lt;/p&gt;

&lt;p&gt;Antes de empezar aca esta el código disponible en Github para seguir paso a paso:&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/improve-ci-times" rel="noopener noreferrer"&gt;
        improve-ci-times
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      improve-ci-times
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Code companion — backend CI optimisation post&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Stand-alone, copy-pasteable files for every code block in
&lt;a href="https://github.com/elchesco/improve-ci-times/../blog-backend-ci-optimization.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/blog-backend-ci-optimization.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Each folder maps to one round of the blog narrative:&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;00-starting-point/   the test job, Dockerfile, conftest before any work
01-uv/               drop pip for uv (Dockerfile + CI snippet)
02-buildkit/         BuildKit cache mounts + setup-buildx + --push
03-xdist-traps/      per-worker DB + DATABASE_URL alignment
04-template-db/      Postgres template DB + pg_advisory_lock
                     (plus the filelock dead-end as documentation)
05-diagnostic/       the measurement step that broke the assumption
06-final/            final shape — 4 matrix shards, no xdist, cumulative
                     Dockerfile + complete conftest
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Suggested reading order&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;00-starting-point/&lt;/code&gt;&lt;/strong&gt; — what we had.&lt;/li&gt;
&lt;li&gt;Each round folder in order — the post's narrative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;06-final/&lt;/code&gt;&lt;/strong&gt; — the result.&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Notes&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;The xdist code (&lt;code&gt;03&lt;/code&gt;, &lt;code&gt;04&lt;/code&gt;) is still present in &lt;code&gt;06-final/conftest.py&lt;/code&gt;
for local &lt;code&gt;pytest -n N&lt;/code&gt; runs even though the CI dropped xdist in
favour of serial matrix shards.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;04-template-db/filelock-attempt-DEAD-END.py&lt;/code&gt; is kept around…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/improve-ci-times" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;El punto de partida&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;El codebase: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;un backend FastAPI (~50 routers, ~45 modelos SQLAlchemy)&lt;/li&gt;
&lt;li&gt;1826 tests, desplegado vía GitHub Actions a AWS ECS Fargate en
arm64.&lt;/li&gt;
&lt;li&gt;El CI corre en un self-hosted spot runner (4X concurrencia, en Graviton).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;En un inicio comenzamos con un paralelismo tradicional, nada del otro mundo, solo dos shards vía &lt;code&gt;pytest-split&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/deploy-backend.yml — inicial&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cd backend &amp;amp;&amp;amp; pip install --cache-dir "$RUNNER_TEMP/pip-cache" -r requirements-dev.txt&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Tests (shard ${{ matrix.shard }}/2)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;cd backend&lt;/span&gt;
    &lt;span class="s"&gt;pytest tests/ \&lt;/span&gt;
      &lt;span class="s"&gt;--splits 2 --group ${{ matrix.shard }} \&lt;/span&gt;
      &lt;span class="s"&gt;--cov=app --cov-report= \&lt;/span&gt;
      &lt;span class="s"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;docker build&lt;/code&gt; estándar. Y usando &lt;code&gt;pip&lt;/code&gt; para todo.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# backend/Dockerfile — inicial&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.11-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    libpq-dev gcc curl &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Intento no. 1: instalar dependencias más rápido con &lt;code&gt;uv&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;uv&lt;/code&gt; es el reemplazo más sencillo que existe de pip creado por Astral, normalmente de 10x hasta 100× más rápido para resolver e instalar paquetes. La migración es bastante sencilla ya que son dos&lt;br&gt;
líneas en el Dockerfile y listo:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pin vía la imagen multi-stage oficial para que el binario sea reproducible&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /usr/local/bin/&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;requirements.txt&lt;/code&gt; y &lt;code&gt;requirements-dev.txt&lt;/code&gt; quedan exactamente iguales, &lt;code&gt;uv pip&lt;/code&gt; los lee nativo. No hay que migrar forzosamente a &lt;code&gt;pyproject.toml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Y en CI, le implementamos la action correspondiente:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astral-sh/setup-uv@v6&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5.11"&lt;/span&gt;
    &lt;span class="na"&gt;enable-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;cache-suffix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shard-${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;matrix.shard&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cd backend &amp;amp;&amp;amp; uv pip install --system -r requirements-dev.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;En este caso el &lt;code&gt;cache-suffix&lt;/code&gt; por shard replica el workaround del &lt;code&gt;--cache-dir&lt;/code&gt; por shard que teníamos con pip — sin él, los dos shards que caen en el mismo runner self-hosted se pelean por el mismo tarball y uno de los dos se muere con &lt;code&gt;tar exit code 2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;La comparativa, en números:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ time uv pip install -r requirements.txt
...
uv pip install -r requirements.txt  1.23s user 1.59s system 51% cpu 5.509 total
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;5.5s contra ~60s con &lt;code&gt;pip&lt;/code&gt; y casi 80s en el runner. &lt;/p&gt;
&lt;h2&gt;
  
  
  Intento no. 2: BuildKit cache mounts en el Dockerfile
&lt;/h2&gt;

&lt;p&gt;El layer cache de Docker solo ayuda cuando el &lt;code&gt;COPY&lt;/code&gt; no invalida las&lt;br&gt;
layers de abajo. Cualquier cambio en &lt;code&gt;requirements.txt&lt;/code&gt; re construye&lt;br&gt;
todo lo que sigue. Los &lt;strong&gt;BuildKit cache mounts&lt;/strong&gt; ayudan a persistir el contenido entre builds sin importar la invalidación de layers:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# backend/Dockerfile — con cache mounts&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/var/cache/apt,sharing&lt;span class="o"&gt;=&lt;/span&gt;locked &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/apt,sharing&lt;span class="o"&gt;=&lt;/span&gt;locked &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get update &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get upgrade &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;        libpq-dev gcc curl

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/root/.cache/uv &lt;span class="se"&gt;\
&lt;/span&gt;    uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Algo importante a mencionar:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Quitar el &lt;code&gt;rm -rf /var/lib/apt/lists&lt;/code&gt;.&lt;/strong&gt; Ya que servía para
reducir el tamaño de la imagen, pero con cache mounts BuildKit es
dueño de esos paths y los limpia entre builds sin hacer nada.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sharing=locked&lt;/code&gt;&lt;/strong&gt; serializa lecturas concurrentes. Sin eso, dos
builds en paralelo en el mismo runner pueden corromper el caché.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;El runner que en este caso es self-hosted trae el builder legacy de Docker por default, y en el primer push después del commit de cache mounts se rompió con:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the --mount option requires BuildKit. Refer to
https://docs.docker.com/go/buildkit/ to learn how to build images with
BuildKit enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Lo arreglamos, de lo más fácil: instalar buildx y alias de &lt;code&gt;docker build&lt;/code&gt; a &lt;code&gt;docker buildx build&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Docker Buildx&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;install&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;El &lt;code&gt;install: true&lt;/code&gt; es la opción necesaria. Sin eso, &lt;code&gt;docker build&lt;/code&gt; sigue usando el builder legacy.&lt;/p&gt;

&lt;p&gt;Buildx no carga al daemon local por default, así que el step de&lt;br&gt;
&lt;code&gt;docker push&lt;/code&gt; que ya teníamos también dejó de funcionar. Cambiamos a&lt;br&gt;
&lt;code&gt;--push&lt;/code&gt; directo:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;cd backend&lt;/span&gt;
    &lt;span class="s"&gt;docker build \&lt;/span&gt;
      &lt;span class="s"&gt;--cache-from $IMAGE:latest \&lt;/span&gt;
      &lt;span class="s"&gt;--cache-to type=inline \&lt;/span&gt;
      &lt;span class="s"&gt;-t $IMAGE:sha-$TAG \&lt;/span&gt;
      &lt;span class="s"&gt;-t $IMAGE:latest \&lt;/span&gt;
      &lt;span class="s"&gt;--push \&lt;/span&gt;
      &lt;span class="s"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;El &lt;code&gt;--cache-to type=inline&lt;/code&gt; mete la metadata del layer cache dentro&lt;br&gt;
de la imagen, cojn estoel &lt;code&gt;--cache-from&lt;/code&gt; del siguiente build lo jala de regreso el ECR.&lt;/p&gt;

&lt;p&gt;El primer build todavía viene con el costo de instalación de las dependencias y paquetes; los builds siguientes con los mismos &lt;code&gt;requirements&lt;/code&gt; se lo brincan. Pasando de &lt;code&gt;~40s&lt;/code&gt; a &lt;code&gt;~10s&lt;/code&gt; en cache hits.&lt;/p&gt;
&lt;h2&gt;
  
  
  Intento no. 3: la trampa de &lt;code&gt;pytest-xdist&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;El instinto es pensar que entre mas shards, podemos ahorrarnos mas tiempo, es decir 2 shards × 4 workers = 8 procesos en paralelo.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/requirements-dev.txt
&lt;/span&gt;&lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;xdist&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="mf"&gt;3.6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/deploy-backend.yml&lt;/span&gt;
&lt;span class="s"&gt;pytest tests/ \&lt;/span&gt;
  &lt;span class="s"&gt;--splits 2 --group ${{ matrix.shard }} \&lt;/span&gt;
  &lt;span class="s"&gt;-n 4 --dist worksteal \&lt;/span&gt;
  &lt;span class="s"&gt;--cov=app --cov-report= \&lt;/span&gt;
  &lt;span class="s"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Pero, es aquí es donde empieza el dilema.&lt;/p&gt;
&lt;h3&gt;
  
  
  Trampa 1: &lt;code&gt;DROP SCHEMA&lt;/code&gt; entre workers
&lt;/h3&gt;

&lt;p&gt;En mi configuración el &lt;code&gt;conftest&lt;/code&gt; remueve y recrea el schema al inicio de cada session:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest_asyncio.fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autouse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setup_db&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;test_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DROP SCHEMA public CASCADE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE SCHEMA public&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;test_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_all&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;scope="session"&lt;/code&gt; significa una vez por session de &lt;code&gt;pytest&lt;/code&gt;. Con&lt;br&gt;
&lt;code&gt;pytest-xdist&lt;/code&gt;, cada worker es su propia session.  &lt;/p&gt;

&lt;p&gt;Los cuatro workers apuntando a la misma DB &lt;code&gt;myapp_test&lt;/code&gt; se la pasan removiendo el schema de los demás y en ocasiones nos quedamos a media corrida.&lt;/p&gt;

&lt;p&gt;Fix: una DB por worker, con un sufijo de &lt;code&gt;PYTEST_XDIST_WORKER&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_suffix_dburl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;url_part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;url_part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;url_part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url_part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PYTEST_XDIST_WORKER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_suffix_dburl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Con esto el worker &lt;code&gt;gw0&lt;/code&gt; agarra &lt;code&gt;myapp_test_gw0&lt;/code&gt;, &lt;code&gt;gw1&lt;/code&gt; agarra &lt;code&gt;_gw1&lt;/code&gt;, y así de manera secuencial.&lt;/p&gt;
&lt;h3&gt;
  
  
  Trampa 2: &lt;code&gt;max_locks_per_transaction&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Primera corrida con &lt;code&gt;-n auto&lt;/code&gt; (10 workers en el runner self-hosted):&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;================== 31 passed, 11 warnings, 8 errors in 14.73s ==================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;DROP SCHEMA CASCADE&lt;/code&gt; sobre ~50 tablas toma un relation-level lock por cada una. &lt;/p&gt;

&lt;p&gt;10 workers x 50 = 500 locks. &lt;/p&gt;

&lt;p&gt;El default de PostgreSQL para &lt;code&gt;max_locks_per_transaction&lt;/code&gt; es 64.&lt;/p&gt;

&lt;p&gt;Como lo arreglamos, bajamos el limite/cap a 4 workers por shard en vez de auto. 4 x 2 shards = 8 procesos paralelos.&lt;/p&gt;
&lt;h3&gt;
  
  
  Trampa 3: la divergencia del &lt;code&gt;DATABASE_URL&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Después de limitar a los workers, un test empezó a fallar en CI:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test_comp_expiry_worker_skips_stripe_managed
  sqlalchemy.exc.ProgrammingError: relation "subscriptions" does not
  exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;¿Por qué? El test invoca un background worker:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_comp_expiry_worker_skips_stripe_managed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_seed_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_seed_sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.workers.comp_expiry_worker&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;expire_comp_subscriptions&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expire_comp_subscriptions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# ← abre su propio AsyncSessionLocal
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Fix: alinear &lt;code&gt;DATABASE_URL&lt;/code&gt; con &lt;code&gt;TEST_DATABASE_URL&lt;/code&gt; &lt;em&gt;antes&lt;/em&gt; de que&lt;br&gt;
&lt;code&gt;app.main&lt;/code&gt; importe lo que sea:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/tests/conftest.py — top del archivo, antes de importar app.main
&lt;/span&gt;&lt;span class="n"&gt;_test_db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_test_db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_test_db&lt;/span&gt;

&lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PYTEST_XDIST_WORKER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_suffix_dburl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.main&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;  &lt;span class="c1"&gt;# ← engine construido con la URL correcta desde el inicio
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Intento no. 4: template database de PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Cada worker todavía usa un &lt;code&gt;DROP SCHEMA&lt;/code&gt; + &lt;code&gt;CREATE TABLE&lt;/code&gt; × 50 al&lt;br&gt;
inicio de la session. &lt;/p&gt;

&lt;p&gt;En el runner ARM eso son ~5 segundos por worker.&lt;/p&gt;

&lt;p&gt;PostgreSQL tiene un truco: &lt;code&gt;CREATE DATABASE ... TEMPLATE&lt;/code&gt;&lt;br&gt;
clona una DB vía copia a nivel de archivo en ~100ms en lugar de&lt;br&gt;
ejecutar todo el SQL. &lt;/p&gt;

&lt;p&gt;Construyes el schema una vez en una DB template dedicada, y luego cada worker la clona:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/tests/conftest.py
&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_DB_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp_test_template&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;_TEMPLATE_LOCK_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7321456789012345&lt;/span&gt;  &lt;span class="c1"&gt;# int arbitrario 
&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_ensure_template_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;template_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;admin_engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isolation_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUTOCOMMIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1 FROM pg_database WHERE datname = :n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TEMPLATE_DB_NAME&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CREATE DATABASE &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_DB_NAME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;tmpl_engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tmpl_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;has_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1 FROM information_schema.tables &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WHERE table_schema = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;public&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; AND table_name = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;users&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LIMIT 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;has_schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tmpl_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_all&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tmpl_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_clone_template_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;admin_engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isolation_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUTOCOMMIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Matar conexiones stale para que el DROP no se bloquee
&lt;/span&gt;            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pg_terminate_backend(pid) FROM pg_stat_activity &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WHERE datname = :n AND pid &amp;lt;&amp;gt; pg_backend_pid()&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DROP DATABASE IF EXISTS &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CREATE DATABASE &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; TEMPLATE &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_DB_NAME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Trampa 4: &lt;code&gt;filelock&lt;/code&gt; se cuelga
&lt;/h3&gt;

&lt;p&gt;Los workers se pelean por crear el template. El primer intento usaba&lt;br&gt;
&lt;code&gt;filelock&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# No hagas esto
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;filelock&lt;/span&gt;
&lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filelock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FileLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/myapp_test_template.lock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Los workers consistentemente se quedan colgados y llegan a el timeout de 120s. &lt;/p&gt;

&lt;p&gt;Con un cambio a un advisory lock de PostgreSQL, se soluciona. Es el mismo recurso compartido que ya necesitamos, se auto libera al cerrar la conexión:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_setup_db_via_template&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;split&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_split_db_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;split&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;split&lt;/span&gt;
    &lt;span class="n"&gt;admin_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/postgres&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;template_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_DB_NAME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;admin_engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isolation_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUTOCOMMIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Bloquea hasta conseguirlo; se auto-libera al cerrar la conexión
&lt;/span&gt;            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pg_advisory_lock(:id)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_TEMPLATE_LOCK_ID&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_ensure_template_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;template_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pg_advisory_unlock(:id)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_TEMPLATE_LOCK_ID&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_clone_template_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;El primer worker que agarra el lock construye el template (~5s); los demás esperan y luego clonan en ~100ms cada uno.&lt;/p&gt;

&lt;p&gt;Después de todo esto tenemos DB por worker, alineación de &lt;code&gt;DATABASE_URL&lt;/code&gt;, template clones, advisory locks — corriendo un smoke local de 7 archivos, notamos la diferencia:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;88 passed, 5 warnings in 12.28s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Bajó de ~2 minutos con el setup inicial a unos cuantos segundos. &lt;/p&gt;
&lt;h2&gt;
  
  
  Intento no. 5: la medición que rompió el supuesto
&lt;/h2&gt;

&lt;p&gt;El wall time por shard seguía como en 7 minutos. &lt;br&gt;
El CI total en 11m una mejora modesta sobre el baseline inicial, pero no la bajada dramática que sugería el smoke local.&lt;/p&gt;

&lt;p&gt;Hora de medir en serio. &lt;br&gt;
Agregué un step diagnóstico que corre una vez y reporta tiempos:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Diagnose pytest startup cost&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;matrix.shard == &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;cd backend&lt;/span&gt;
    &lt;span class="s"&gt;echo "::group::A — solo collection de pytest, sin coverage"&lt;/span&gt;
    &lt;span class="s"&gt;time pytest tests/ --co -q --no-header --no-cov&lt;/span&gt;
    &lt;span class="s"&gt;echo "::endgroup::"&lt;/span&gt;
    &lt;span class="s"&gt;echo "::group::B — solo collection de pytest CON coverage"&lt;/span&gt;
    &lt;span class="s"&gt;time pytest tests/ --co -q --no-header --cov=app --cov-report=&lt;/span&gt;
    &lt;span class="s"&gt;echo "::endgroup::"&lt;/span&gt;
    &lt;span class="s"&gt;echo "::group::C — corrida chiquita serial, sin coverage, sin xdist"&lt;/span&gt;
    &lt;span class="s"&gt;time pytest tests/test_critical.py -q --no-header --no-cov&lt;/span&gt;
    &lt;span class="s"&gt;echo "::endgroup::"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Los resultados en el runner ARM self-hosted fueron:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A — solo collection, sin coverage          real    0m18.909s
B — solo collection CON coverage           real    0m23.810s
C — corrida chica serial, sin nada         real    0m21.481s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;La observación clave, hacer collection de 1826 tests y&lt;br&gt;
correr un solo archivo chico sin coverage tardan lo mismo.&lt;/p&gt;

&lt;p&gt;O sea, el costo no es la collection. &lt;br&gt;
No es coverage (solo 5s de diferencia). &lt;br&gt;
No es la ejecución de los tests. &lt;br&gt;
Es el &lt;strong&gt;startup de pytest + el import del conftest&lt;/strong&gt;. &lt;br&gt;
Específicamente el &lt;code&gt;from app.main import app&lt;/code&gt; en el conftest que jala ~45 modelos, ~50 routers, middleware, settings, todo de un jalón. Veinte segundos de cold import en este runner. &lt;/p&gt;

&lt;p&gt;Cada vez.&lt;/p&gt;

&lt;p&gt;Con &lt;code&gt;xdist&lt;/code&gt;, cada uno de los 4 workers paga este costo de 20s&lt;br&gt;
independiente.  &lt;/p&gt;

&lt;p&gt;Ahí estaban los ~80s perdidos.&lt;/p&gt;
&lt;h2&gt;
  
  
  Round 6: soltar &lt;code&gt;xdist&lt;/code&gt;, irse a 4 shards
&lt;/h2&gt;

&lt;p&gt;Si cada proceso de pytest usa 20s fijos de startup, la optimización&lt;br&gt;
más barata es usarlo menos veces. &lt;br&gt;
2 shards X 4 workers de xdist = 10 startups de pytest (2 controllers + 8 workers). &lt;br&gt;
4 shards X 1 proceso serial = 4 startups de pytest.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.runner || 'self-hosted' }}&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lint&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;shard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;myapp_test&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;appuser&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;testpass&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
        &lt;span class="s"&gt;--health-cmd pg_isready --health-interval 10s&lt;/span&gt;
        &lt;span class="s"&gt;--health-timeout 5s --health-retries 5&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v6&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.11"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astral-sh/setup-uv@v6&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5.11"&lt;/span&gt;
        &lt;span class="na"&gt;enable-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;cache-suffix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shard-${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;matrix.shard&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cd backend &amp;amp;&amp;amp; uv pip install --system -r requirements-dev.txt&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Tests (shard ${{ matrix.shard }}/4)&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://someappuser:sometestpass@localhost:${{ job.services.postgres.ports[5432] }}/myapp_test&lt;/span&gt;
        &lt;span class="na"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql+asyncpg://someappuser:sometestpass@localhost:${{ job.services.postgres.ports[5432] }}/myapp_test&lt;/span&gt;
        &lt;span class="na"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ci-test-secret-key-32bytes-minimum-length&lt;/span&gt;
        &lt;span class="na"&gt;ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
        &lt;span class="na"&gt;COVERAGE_FILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.coverage.${{ matrix.shard }}&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;cd backend&lt;/span&gt;
        &lt;span class="s"&gt;pytest tests/ \&lt;/span&gt;
          &lt;span class="s"&gt;--splits 4 --group ${{ matrix.shard }} \&lt;/span&gt;
          &lt;span class="s"&gt;--cov=app --cov-report= \&lt;/span&gt;
          &lt;span class="s"&gt;-q --no-header&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Cada shard corre ~450 tests en serial con un solo proceso de &lt;code&gt;pytest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sin fan-out de &lt;code&gt;xdist&lt;/code&gt;, sin re-import de &lt;code&gt;conftest&lt;/code&gt; por worker, sin temas de CPU en los cold imports. &lt;/p&gt;

&lt;p&gt;El runner self-hosted anuncia 4X de concurrencia disponible, así que los cuatro shards corren en paralelo.&lt;/p&gt;

&lt;p&gt;El coverage se extiende a cuatro archivos:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;coverage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v6&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.11"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astral-sh/setup-uv@v6&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5.11"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uv pip install --system coverage==7.6.1&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v5&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;coverage-*&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend/&lt;/span&gt;
        &lt;span class="na"&gt;merge-multiple&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;cd backend&lt;/span&gt;
        &lt;span class="s"&gt;coverage combine .coverage.1 .coverage.2 .coverage.3 .coverage.4&lt;/span&gt;
        &lt;span class="s"&gt;coverage report --fail-under=60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;También quité el &lt;code&gt;-v&lt;/code&gt; y lo cambié por &lt;code&gt;-q --no-header&lt;/code&gt;. Con &lt;code&gt;xdist&lt;/code&gt;, el &lt;code&gt;-v&lt;/code&gt; bufferea el output por worker hasta que termina un test, &lt;code&gt;-q&lt;/code&gt; tiene output instantáneo y muestra la salida de inmediato.&lt;/p&gt;
&lt;h2&gt;
  
  
  El resultado
&lt;/h2&gt;

&lt;p&gt;Corrida real del CI después del push:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ lint              in 1m6s
✓ test (1)          in 6m46s
✓ test (2)          in 6m44s
✓ test (3)          in 6m57s
✓ test (4)          in 6m57s
✓ coverage          in 10s
✓ build-and-deploy  in 1m53s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Timeline del test step del shard 1:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16:31:44  inicio del step
16:32:57  [pytest-split] Running group 1/4   ← 1m13s adentro
16:33:25  ........... [ 15%]                  ← primer punto a 1m41s
16:34:18  ........... [ 45%]
16:37:04  ........... [ 91%]
16:37:47  ........... [100%]  472 passed in 5m21s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Tiempo al primer output: 1m13s vs 2m37s antes.&lt;/strong&gt; &lt;br&gt;
Más o menos a la mitad.&lt;/p&gt;

&lt;p&gt;CI total: 10m16s vs ~20m del baseline antes de cualquier optimización.&lt;/p&gt;
&lt;h2&gt;
  
  
  Otras cosas que probe y que definitivamente no ayudaron
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Precompile de pyc&lt;/strong&gt; (&lt;code&gt;python -m compileall&lt;/code&gt;). Medición local: 13.0s en frío vs 12.6s en caliente. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pytest-xdist --dist worksteal&lt;/code&gt;&lt;/strong&gt; está bueno cuando cada worker tiene un costo de setup parecido. Cuando el setup es ~20s y los tests son mayormente rápidos, el impuesto de startup por worker se come la ganancia de paralelismo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;filelock&lt;/code&gt; para serializar entre procesos.&lt;/strong&gt; No me serializaba bien los workers en mi setup. Me cambié a advisory locks de PG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El flag &lt;code&gt;-v&lt;/code&gt;.&lt;/strong&gt; Causaba 2 minutos de buffering de output bajo xdist sin beneficio en performance.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Qué sí ayudaría a futuro
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mejorar el conftest.&lt;/strong&gt; El &lt;code&gt;from app.main import app&lt;/code&gt; es el costo más grande en mi caso. La app importa cada router, cada modelo, cada service al arranque. Partirla (lazy router registration, o romper el import monolítico) bajaría a 20s de startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Una segunda pasada  en los shards.&lt;/strong&gt; &lt;code&gt;pytest-split&lt;/code&gt; balancea por duración. Si un shard consistentemente va 30s atrás, re-balancea:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   pytest --splits 4 --group 1 --store-durations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;comitea un &lt;code&gt;.test_durations&lt;/code&gt; nuevo contra el que los futuros runs&lt;br&gt;
   se balancean.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sacar coverage del hot path.&lt;/strong&gt; Coverage solo agregó ~5s en ARM en nuestro benchmark, pero a ~5s × 4 shards = 20s ahorrados. Trade-off: o lo aceptas en el job de tests o agregas un job de coverage no-paralelo aparte. Nosotros lo dejamos en el path.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mide antes de optimizar.&lt;/strong&gt; Varias horas de trabajo en xdist, template DBs y BuildKit fueron útiles, pero el verdadero unlock vino de un step diagnóstico de 30 segundos que me dijo que el cuello de botella era el startup de pytest, no nada de lo que llevaba atacando.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El pytest más rápido es uno que no inicias dos veces.&lt;/strong&gt; Cada invocación de pytest paga un costo de startup fijo. Con un conftest pesado, ese costo domina todo lo demás. Más paralelismo = más startups = más costo. Menos shards más grandes le ganan a más shards chiquitos pasado un umbral.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pytest-xdist&lt;/code&gt; no es gratis.&lt;/strong&gt; Funciona bien cuando el costo por test &amp;gt;&amp;gt; el costo de startup. Cuando el startup es 20s y los tests son de 500ms, la ecuación se invierte.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recursos por worker necesitan aislamiento por worker.&lt;/strong&gt; El bug sutil fue que los background workers abrían su propio &lt;code&gt;AsyncSessionLocal&lt;/code&gt; apuntando a la DB equivocada. El fix no estaba en la aplicación — estaba en el conftest de tests, alineando las env vars antes de que la app importara nada.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL tiene las primitivas.&lt;/strong&gt; Advisory locks para sincronizar entre procesos, &lt;code&gt;CREATE DATABASE ... TEMPLATE&lt;/code&gt; para bootstrap de schema. Las dos me salvaron de inventar mecanismos más débiles encima.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los BuildKit cache mounts siguen sub-utilizados.&lt;/strong&gt; Dos líneas en un Dockerfile (&lt;code&gt;--mount=type=cache&lt;/code&gt; para los cachés de apt y uv/pip) bajaron los docker builds repetidos de ~40s a ~10s, pero solo después de cambiarse del builder legacy de Docker vía &lt;code&gt;setup-buildx-action&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Código completo disponible en Github:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/elchesco" rel="noopener noreferrer"&gt;
        elchesco
      &lt;/a&gt; / &lt;a href="https://github.com/elchesco/improve-ci-times" rel="noopener noreferrer"&gt;
        improve-ci-times
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      improve-ci-times
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Code companion — backend CI optimisation post&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Stand-alone, copy-pasteable files for every code block in
&lt;a href="https://github.com/elchesco/improve-ci-times/../blog-backend-ci-optimization.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/blog-backend-ci-optimization.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Each folder maps to one round of the blog narrative:&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;00-starting-point/   the test job, Dockerfile, conftest before any work
01-uv/               drop pip for uv (Dockerfile + CI snippet)
02-buildkit/         BuildKit cache mounts + setup-buildx + --push
03-xdist-traps/      per-worker DB + DATABASE_URL alignment
04-template-db/      Postgres template DB + pg_advisory_lock
                     (plus the filelock dead-end as documentation)
05-diagnostic/       the measurement step that broke the assumption
06-final/            final shape — 4 matrix shards, no xdist, cumulative
                     Dockerfile + complete conftest
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Suggested reading order&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;00-starting-point/&lt;/code&gt;&lt;/strong&gt; — what we had.&lt;/li&gt;
&lt;li&gt;Each round folder in order — the post's narrative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;06-final/&lt;/code&gt;&lt;/strong&gt; — the result.&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Notes&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;The xdist code (&lt;code&gt;03&lt;/code&gt;, &lt;code&gt;04&lt;/code&gt;) is still present in &lt;code&gt;06-final/conftest.py&lt;/code&gt;
for local &lt;code&gt;pytest -n N&lt;/code&gt; runs even though the CI dropped xdist in
favour of serial matrix shards.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;04-template-db/filelock-attempt-DEAD-END.py&lt;/code&gt; is kept around…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/elchesco/improve-ci-times" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>githubactions</category>
      <category>ci</category>
      <category>python</category>
    </item>
    <item>
      <title>Bajándole todos los minutos posibles al CI del backend con mas de 1000 tests</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Tue, 26 May 2026 05:11:47 +0000</pubDate>
      <link>https://dev.to/aws-builders/bajandole-todos-los-minutos-posibles-al-ci-del-backend-con-mas-de-1000-tests-7kg</link>
      <guid>https://dev.to/aws-builders/bajandole-todos-los-minutos-posibles-al-ci-del-backend-con-mas-de-1000-tests-7kg</guid>
      <description>&lt;p&gt;Esta es la historia de muchas horas de trabajo en un hoyo continuo tratando de elegir optimizaciones entre package managers, caches paralelismo, optimizaciones de PostgreSQL, advisory locks, y el golpe de realidad de darme cuenta que el cuello de botella no era nada de lo que había estado optimizando, si no algo que no había analizado.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El punto de partida&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;El codebase: un backend FastAPI (~50 routers, ~45 modelos SQLAlchemy)&lt;br&gt;
con 1826 tests, desplegado vía GitHub Actions a AWS ECS Fargate en&lt;br&gt;
arm64. El CI corre en un self-hosted spot runner (4× concurrency,&lt;br&gt;
Graviton).&lt;/p&gt;

&lt;p&gt;En un inicio comenzamos con un paralelismo tradicional, nada del otro mundo, solo dos shards vía &lt;code&gt;pytest-split&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/deploy-backend.yml — inicial&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cd backend &amp;amp;&amp;amp; pip install --cache-dir "$RUNNER_TEMP/pip-cache" -r requirements-dev.txt&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Tests (shard ${{ matrix.shard }}/2)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;cd backend&lt;/span&gt;
    &lt;span class="s"&gt;pytest tests/ \&lt;/span&gt;
      &lt;span class="s"&gt;--splits 2 --group ${{ matrix.shard }} \&lt;/span&gt;
      &lt;span class="s"&gt;--cov=app --cov-report= \&lt;/span&gt;
      &lt;span class="s"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker build&lt;/code&gt; estándar. Y usando &lt;code&gt;pip&lt;/code&gt; para todo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# backend/Dockerfile — inicial&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.11-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    libpq-dev gcc curl &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Intento no. 1: instalar dependencias más rápido con uv
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;uv&lt;/code&gt; es el reemplazo más sencillo que existe de pip creado por Astral, normalmente de 10x hasta 100× más rápido para resolver e instalar paquetes. La migración es bastante sencilla ya que son dos&lt;br&gt;
líneas en el Dockerfile y listo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pin vía la imagen multi-stage oficial para que el binario sea reproducible&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /usr/local/bin/&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requirements.txt&lt;/code&gt; y &lt;code&gt;requirements-dev.txt&lt;/code&gt; quedan exactamente iguales, &lt;code&gt;uv pip&lt;/code&gt; los lee nativo. No hay que migrar forzosamente a &lt;code&gt;pyproject.toml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Y en CI, le implementamos la action correspondiente:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astral-sh/setup-uv@v6&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5.11"&lt;/span&gt;
    &lt;span class="na"&gt;enable-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;cache-suffix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shard-${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;matrix.shard&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cd backend &amp;amp;&amp;amp; uv pip install --system -r requirements-dev.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En este caso el &lt;code&gt;cache-suffix&lt;/code&gt; por shard replica el workaround del &lt;code&gt;--cache-dir&lt;/code&gt; por shard que teníamos con pip — sin él, los dos shards que caen en el mismo runner self-hosted se pelean por el mismo tarball y uno de los dos se muere con &lt;code&gt;tar exit code 2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;La comparativa, en números:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ time uv pip install -r requirements.txt
...
uv pip install -r requirements.txt  1.23s user 1.59s system 51% cpu 5.509 total
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5.5s contra ~60s con &lt;code&gt;pip&lt;/code&gt; y casi 80s en el runner. &lt;/p&gt;

&lt;h2&gt;
  
  
  Intento no. 2: BuildKit cache mounts en el Dockerfile
&lt;/h2&gt;

&lt;p&gt;El layer cache de Docker solo ayuda cuando el &lt;code&gt;COPY&lt;/code&gt; no invalida las&lt;br&gt;
layers de abajo. Cualquier cambio en &lt;code&gt;requirements.txt&lt;/code&gt; re-construye&lt;br&gt;
todo lo que sigue. Los &lt;strong&gt;BuildKit cache mounts&lt;/strong&gt; ayudan a persistir el contenido entre builds sin importar la invalidación de layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# backend/Dockerfile — con cache mounts&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/var/cache/apt,sharing&lt;span class="o"&gt;=&lt;/span&gt;locked &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/apt,sharing&lt;span class="o"&gt;=&lt;/span&gt;locked &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get update &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get upgrade &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;        libpq-dev gcc curl

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/root/.cache/uv &lt;span class="se"&gt;\
&lt;/span&gt;    uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Algo importante a mencionar:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Quitar el &lt;code&gt;rm -rf /var/lib/apt/lists&lt;/code&gt;.&lt;/strong&gt; Ya que servía para
reducir el tamaño de la imagen, pero con cache mounts BuildKit es
dueño de esos paths y los limpia entre builds.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sharing=locked&lt;/code&gt;&lt;/strong&gt; serializa lecturas concurrentes. Sin eso, dos
builds en paralelo en el mismo runner pueden corromper el caché.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;El runner que en este caso es self-hosted trae el builder legacy de Docker por default, y el primer push después del commit de cache mounts se murió con:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the --mount option requires BuildKit. Refer to
https://docs.docker.com/go/buildkit/ to learn how to build images with
BuildKit enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lo arreglamos, de lo más fácil: instalar buildx y alias de &lt;code&gt;docker build&lt;/code&gt; a &lt;code&gt;docker buildx build&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Docker Buildx&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;install&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;install: true&lt;/code&gt; es la opción crítica. Sin eso, &lt;code&gt;docker build&lt;/code&gt; sigue usando el builder legacy.&lt;/p&gt;

&lt;p&gt;Buildx no carga al daemon local por default, así que el step de&lt;br&gt;
&lt;code&gt;docker push&lt;/code&gt; que ya teníamos también dejó de funcionar. Cambio a&lt;br&gt;
&lt;code&gt;--push&lt;/code&gt; directo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;cd backend&lt;/span&gt;
    &lt;span class="s"&gt;docker build \&lt;/span&gt;
      &lt;span class="s"&gt;--cache-from $IMAGE:latest \&lt;/span&gt;
      &lt;span class="s"&gt;--cache-to type=inline \&lt;/span&gt;
      &lt;span class="s"&gt;-t $IMAGE:sha-$TAG \&lt;/span&gt;
      &lt;span class="s"&gt;-t $IMAGE:latest \&lt;/span&gt;
      &lt;span class="s"&gt;--push \&lt;/span&gt;
      &lt;span class="s"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;--cache-to type=inline&lt;/code&gt; mete la metadata del layer cache dentro&lt;br&gt;
de la imagen, cojn estoel &lt;code&gt;--cache-from&lt;/code&gt; del siguiente build lo jala de regreso el ECR.&lt;/p&gt;

&lt;p&gt;El primer build todavía paga el costo de instalación de las dependencias y paquetes; los builds siguientes con los mismos requirements se lo brincan. Pasando de &lt;code&gt;~40s&lt;/code&gt; a &lt;code&gt;~10s&lt;/code&gt; en cache hits.&lt;/p&gt;
&lt;h2&gt;
  
  
  Intento no. 3: la trampa de &lt;code&gt;pytest-xdist&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;El instinto, pensar que entre mas shards, podemos ahorrarnos mas tiempo, es decir 2 shards × 4 workers = 8 procesos en paralelo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/requirements-dev.txt
&lt;/span&gt;&lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;xdist&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="mf"&gt;3.6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/deploy-backend.yml&lt;/span&gt;
&lt;span class="s"&gt;pytest tests/ \&lt;/span&gt;
  &lt;span class="s"&gt;--splits 2 --group ${{ matrix.shard }} \&lt;/span&gt;
  &lt;span class="s"&gt;-n 4 --dist worksteal \&lt;/span&gt;
  &lt;span class="s"&gt;--cov=app --cov-report= \&lt;/span&gt;
  &lt;span class="s"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aquí es donde empieza el dilema.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trampa 1: &lt;code&gt;DROP SCHEMA&lt;/code&gt; entre workers
&lt;/h3&gt;

&lt;p&gt;El &lt;code&gt;conftest&lt;/code&gt; remueve y recrea el schema al inicio de cada session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest_asyncio.fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autouse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setup_db&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;test_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DROP SCHEMA public CASCADE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE SCHEMA public&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;test_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_all&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scope="session"&lt;/code&gt; significa una vez por session de pytest. Con&lt;br&gt;
&lt;code&gt;pytest-xdist&lt;/code&gt;, cada worker es su propia session.  &lt;/p&gt;

&lt;p&gt;Los cuatro workers apuntando a la misma DB &lt;code&gt;myapp_test&lt;/code&gt; se la pasan removiendo el schema de los demás a media corrida.&lt;/p&gt;

&lt;p&gt;Fix: una DB por worker, con sufijo de &lt;code&gt;PYTEST_XDIST_WORKER&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_suffix_dburl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;url_part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;url_part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;url_part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url_part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PYTEST_XDIST_WORKER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_suffix_dburl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El worker &lt;code&gt;gw0&lt;/code&gt; agarra &lt;code&gt;myapp_test_gw0&lt;/code&gt;, &lt;code&gt;gw1&lt;/code&gt; agarra &lt;code&gt;_gw1&lt;/code&gt;, y así de manera secuencial.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trampa 2: &lt;code&gt;max_locks_per_transaction&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Primera corrida con &lt;code&gt;-n auto&lt;/code&gt; (10 workers en el runner self-hosted):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;================== 31 passed, 11 warnings, 8 errors in 14.73s ==================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DROP SCHEMA CASCADE&lt;/code&gt; sobre ~50 tablas toma un relation-level lock por cada una. &lt;/p&gt;

&lt;p&gt;10 workers × 50 = 500 locks. &lt;/p&gt;

&lt;p&gt;El default de PostgreSQL para &lt;code&gt;max_locks_per_transaction&lt;/code&gt; es 64&lt;/p&gt;

&lt;p&gt;Como lo arreglamos, bajamos el limite/cap a 4 workers por shard en vez de auto. 4 × 2 shards = 8 procesos paralelos.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trampa 3: la divergencia del &lt;code&gt;DATABASE_URL&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Después de limitar a los workers, un test empezó a fallar en CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test_comp_expiry_worker_skips_stripe_managed
  sqlalchemy.exc.ProgrammingError: relation "subscriptions" does not
  exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;¿Por qué? El test invoca un background worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_comp_expiry_worker_skips_stripe_managed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_seed_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_seed_sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.workers.comp_expiry_worker&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;expire_comp_subscriptions&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expire_comp_subscriptions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# ← abre su propio AsyncSessionLocal
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix: alinear &lt;code&gt;DATABASE_URL&lt;/code&gt; con &lt;code&gt;TEST_DATABASE_URL&lt;/code&gt; &lt;em&gt;antes&lt;/em&gt; de que&lt;br&gt;
&lt;code&gt;app.main&lt;/code&gt; importe lo que sea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/tests/conftest.py — top del archivo, antes de importar app.main
&lt;/span&gt;&lt;span class="n"&gt;_test_db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_test_db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_test_db&lt;/span&gt;

&lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PYTEST_XDIST_WORKER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_suffix_dburl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_XDIST_WORKER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.main&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;  &lt;span class="c1"&gt;# ← engine construido con la URL correcta desde el inicio
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Intento no. 4: template database de PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Cada worker todavía usa un &lt;code&gt;DROP SCHEMA&lt;/code&gt; + &lt;code&gt;CREATE TABLE&lt;/code&gt; × 50 al&lt;br&gt;
inicio de la session. &lt;br&gt;
En el runner ARM eso son ~5 segundos por worker.&lt;/p&gt;

&lt;p&gt;PostgreSQL tiene un truco: &lt;code&gt;CREATE DATABASE ... TEMPLATE&lt;/code&gt;&lt;br&gt;
clona una DB vía copia a nivel de archivo en ~100ms en lugar de&lt;br&gt;
ejecutar todo el SQL. &lt;/p&gt;

&lt;p&gt;Construyes el schema una vez en una DB template dedicada, y luego cada worker la clona:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/tests/conftest.py
&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_DB_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp_test_template&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;_TEMPLATE_LOCK_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7321456789012345&lt;/span&gt;  &lt;span class="c1"&gt;# int arbitrario 
&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_ensure_template_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;template_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;admin_engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isolation_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUTOCOMMIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1 FROM pg_database WHERE datname = :n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TEMPLATE_DB_NAME&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CREATE DATABASE &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_DB_NAME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;tmpl_engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tmpl_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;has_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1 FROM information_schema.tables &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WHERE table_schema = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;public&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; AND table_name = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;users&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LIMIT 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;has_schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tmpl_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_all&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tmpl_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_clone_template_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;admin_engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isolation_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUTOCOMMIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Matar conexiones stale para que el DROP no se bloquee
&lt;/span&gt;            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pg_terminate_backend(pid) FROM pg_stat_activity &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WHERE datname = :n AND pid &amp;lt;&amp;gt; pg_backend_pid()&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DROP DATABASE IF EXISTS &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CREATE DATABASE &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; TEMPLATE &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_DB_NAME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Trampa 4: &lt;code&gt;filelock&lt;/code&gt; se cuelga
&lt;/h3&gt;

&lt;p&gt;Los workers se pelean por crear el template. El primer intento usaba&lt;br&gt;
&lt;code&gt;filelock&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# No hagas esto
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;filelock&lt;/span&gt;
&lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filelock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FileLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/myapp_test_template.lock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los workers consistentemente llegan a el timeout de 120s. &lt;/p&gt;

&lt;p&gt;Con un cambio a un advisory lock de PostgreSQL, se soluciona. Es el mismo recurso compartido que ya necesitamos, se auto libera al cerrar la conexión (así que un worker que truene no puede dejar el lock huérfano como pasa con file locks):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_setup_db_via_template&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;split&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_split_db_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;split&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;split&lt;/span&gt;
    &lt;span class="n"&gt;admin_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/postgres&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;template_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_DB_NAME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;admin_engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isolation_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUTOCOMMIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Bloquea hasta conseguirlo; se auto-libera al cerrar la conexión
&lt;/span&gt;            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pg_advisory_lock(:id)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_TEMPLATE_LOCK_ID&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_ensure_template_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;template_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pg_advisory_unlock(:id)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_TEMPLATE_LOCK_ID&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;admin_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_clone_template_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El primer worker que agarra el lock construye el template (~5s); los demás esperan y luego clonan en ~100ms cada uno.&lt;/p&gt;

&lt;p&gt;Después de todo esto — DBs por worker, alineación de &lt;code&gt;DATABASE_URL&lt;/code&gt;,&lt;br&gt;
template clones, advisory locks — corriendo un smoke local de 7&lt;br&gt;
archivos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;88 passed, 5 warnings in 12.28s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bajó de ~2 minutos con el setup roto inicial de xdist. &lt;/p&gt;

&lt;h2&gt;
  
  
  Intento no. 5: la medición que rompió el supuesto
&lt;/h2&gt;

&lt;p&gt;El wall time por shard seguía como en 7 minutos. &lt;br&gt;
El CI total en 11m una mejora modesta sobre el baseline, no la bajada dramática que sugería el smoke local.&lt;/p&gt;

&lt;p&gt;Hora de medir en serio. Agregué un step diagnóstico que corre una vez&lt;br&gt;
y reporta tiempos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Diagnose pytest startup cost&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;matrix.shard == &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;cd backend&lt;/span&gt;
    &lt;span class="s"&gt;echo "::group::A — solo collection de pytest, sin coverage"&lt;/span&gt;
    &lt;span class="s"&gt;time pytest tests/ --co -q --no-header --no-cov&lt;/span&gt;
    &lt;span class="s"&gt;echo "::endgroup::"&lt;/span&gt;
    &lt;span class="s"&gt;echo "::group::B — solo collection de pytest CON coverage"&lt;/span&gt;
    &lt;span class="s"&gt;time pytest tests/ --co -q --no-header --cov=app --cov-report=&lt;/span&gt;
    &lt;span class="s"&gt;echo "::endgroup::"&lt;/span&gt;
    &lt;span class="s"&gt;echo "::group::C — corrida chiquita serial, sin coverage, sin xdist"&lt;/span&gt;
    &lt;span class="s"&gt;time pytest tests/test_critical.py -q --no-header --no-cov&lt;/span&gt;
    &lt;span class="s"&gt;echo "::endgroup::"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los resultados en el runner ARM self-hosted fueron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A — solo collection, sin coverage          real    0m18.909s
B — solo collection CON coverage           real    0m23.810s
C — corrida chica serial, sin nada         real    0m21.481s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La observación clave, hacer collection de 1826 tests y&lt;br&gt;
correr un solo archivo chico sin coverage tardan lo mismo.&lt;/p&gt;

&lt;p&gt;O sea, el costo no es la collection. No es coverage (solo 5s de&lt;br&gt;
diferencia). &lt;br&gt;
No es la ejecución de los tests. Es el &lt;strong&gt;startup de pytest + el import del conftest&lt;/strong&gt;. &lt;br&gt;
Específicamente el &lt;code&gt;from app.main import app&lt;/code&gt; en el conftest que jala ~45 modelos, ~50 routers, middleware, settings — todo de un jalón. Veinte segundos de cold-import en este runner. &lt;br&gt;
Cada vez.&lt;/p&gt;

&lt;p&gt;Con xdist, cada uno de los 4 workers paga este costo de 20s&lt;br&gt;
independiente. Spawn en paralelo, pero el runner solo tiene ciertos&lt;br&gt;
cores; los cold imports de modelos pydantic y mappers de SQLAlchemy se pelean por CPU. &lt;/p&gt;

&lt;p&gt;Ahí estaban los ~80s perdidos.&lt;/p&gt;
&lt;h2&gt;
  
  
  Round 6: soltar &lt;code&gt;xdist&lt;/code&gt;, irse a 4 shards
&lt;/h2&gt;

&lt;p&gt;Si cada proceso de pytest usa 20s fijos de startup, la optimización&lt;br&gt;
más barata es usarlo menos veces. &lt;br&gt;
2 shards × 4 workers de xdist = 10 startups de pytest (2 controllers + 8 workers). &lt;br&gt;
4 shards × 1 proceso serial = 4 startups de pytest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.runner || 'self-hosted' }}&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lint&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;shard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;myapp_test&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;appuser&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;testpass&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
        &lt;span class="s"&gt;--health-cmd pg_isready --health-interval 10s&lt;/span&gt;
        &lt;span class="s"&gt;--health-timeout 5s --health-retries 5&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v6&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.11"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astral-sh/setup-uv@v6&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5.11"&lt;/span&gt;
        &lt;span class="na"&gt;enable-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;cache-suffix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shard-${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;matrix.shard&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cd backend &amp;amp;&amp;amp; uv pip install --system -r requirements-dev.txt&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Tests (shard ${{ matrix.shard }}/4)&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://someappuser:sometestpass@localhost:${{ job.services.postgres.ports[5432] }}/myapp_test&lt;/span&gt;
        &lt;span class="na"&gt;TEST_DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql+asyncpg://someappuser:sometestpass@localhost:${{ job.services.postgres.ports[5432] }}/myapp_test&lt;/span&gt;
        &lt;span class="na"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ci-test-secret-key-32bytes-minimum-length&lt;/span&gt;
        &lt;span class="na"&gt;ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
        &lt;span class="na"&gt;COVERAGE_FILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.coverage.${{ matrix.shard }}&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;cd backend&lt;/span&gt;
        &lt;span class="s"&gt;pytest tests/ \&lt;/span&gt;
          &lt;span class="s"&gt;--splits 4 --group ${{ matrix.shard }} \&lt;/span&gt;
          &lt;span class="s"&gt;--cov=app --cov-report= \&lt;/span&gt;
          &lt;span class="s"&gt;-q --no-header&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cada shard corre ~450 tests en serial con un solo proceso de&lt;code&gt;pytest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sin fan-out de &lt;code&gt;xdist&lt;/code&gt;, sin re-import de &lt;code&gt;conftest&lt;/code&gt; por worker, sin contención de CPU en los cold imports. &lt;/p&gt;

&lt;p&gt;El runner self-hosted anuncia 4x de concurrencia disponible, así que los cuatro shards corren en paralelo.&lt;/p&gt;

&lt;p&gt;El coverage se extiende a cuatro archivos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;coverage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v6&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.11"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astral-sh/setup-uv@v6&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5.11"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uv pip install --system coverage==7.6.1&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v5&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;coverage-*&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend/&lt;/span&gt;
        &lt;span class="na"&gt;merge-multiple&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;cd backend&lt;/span&gt;
        &lt;span class="s"&gt;coverage combine .coverage.1 .coverage.2 .coverage.3 .coverage.4&lt;/span&gt;
        &lt;span class="s"&gt;coverage report --fail-under=60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;También quité el &lt;code&gt;-v&lt;/code&gt; y lo cambié por &lt;code&gt;-q --no-header&lt;/code&gt;. Con &lt;code&gt;xdist&lt;/code&gt;, el &lt;code&gt;-v&lt;/code&gt; bufferea el output por worker hasta que termina un test, &lt;code&gt;-q&lt;/code&gt; tiene output instantáneo y muestra la salida de inmediato.&lt;/p&gt;

&lt;h2&gt;
  
  
  El resultado
&lt;/h2&gt;

&lt;p&gt;Corrida real del CI después del push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ lint              in 1m6s
✓ test (1)          in 6m46s
✓ test (2)          in 6m44s
✓ test (3)          in 6m57s
✓ test (4)          in 6m57s
✓ coverage          in 10s
✓ build-and-deploy  in 1m53s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timeline del test step del shard 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16:31:44  inicio del step
16:32:57  [pytest-split] Running group 1/4   ← 1m13s adentro
16:33:25  ........... [ 15%]                  ← primer punto a 1m41s
16:34:18  ........... [ 45%]
16:37:04  ........... [ 91%]
16:37:47  ........... [100%]  472 passed in 5m21s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tiempo al primer output: 1m13s vs 2m37s antes.&lt;/strong&gt; &lt;br&gt;
Más o menos a la mitad.&lt;/p&gt;

&lt;p&gt;CI total: 10m16s vs ~20m del baseline antes de cualquier optimización.&lt;/p&gt;

&lt;h2&gt;
  
  
  Otras cosas que probe y que definitivamente no ayudaron
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Precompile de pyc&lt;/strong&gt; (&lt;code&gt;python -m compileall&lt;/code&gt;). Medición local: 13.0s en frío vs 12.6s en caliente. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pytest-xdist --dist worksteal&lt;/code&gt;&lt;/strong&gt; está bueno cuando cada worker tiene un costo de setup parecido. Cuando el setup es ~20s y los tests son mayormente rápidos, el impuesto de startup por worker se come la ganancia de paralelismo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;filelock&lt;/code&gt; para serializar entre procesos.&lt;/strong&gt; No me serializaba bien los workers en mi setup. Me cambié a advisory locks de PG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El flag &lt;code&gt;-v&lt;/code&gt;.&lt;/strong&gt; Causaba 2 minutos de buffering de output bajo xdist sin beneficio en performance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Qué sí ayudaría a futuro
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mejorar el conftest.&lt;/strong&gt; El &lt;code&gt;from app.main import app&lt;/code&gt; es el costo más grande en mi caso. La app importa cada router, cada modelo, cada service al arranque. Partirla (lazy router registration, o romper el import monolítico) bajaría a 20s de startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Una segunda pasada  en los shards.&lt;/strong&gt; &lt;code&gt;pytest-split&lt;/code&gt;
balancea por duración. Si un shard consistentemente va 30s atrás,
re-balancea:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   pytest --splits 4 --group 1 --store-durations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;comitea un &lt;code&gt;.test_durations&lt;/code&gt; nuevo contra el que los futuros runs&lt;br&gt;
   se balancean.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sacar coverage del hot path.&lt;/strong&gt; Coverage solo agregó ~5s en ARM en nuestro benchmark, pero a ~5s × 4 shards = 20s ahorrados. Trade-off: o lo aceptas en el job de tests o agregas un job de coverage no-paralelo aparte. Nosotros lo dejamos en el path.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mide antes de optimizar.&lt;/strong&gt; Varias horas de trabajo en xdist, template DBs y BuildKit fueron útiles, pero el verdadero unlock vino de un step diagnóstico de 30 segundos que me dijo que el cuello de botella era el startup de pytest, no nada de lo que llevaba atacando.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El pytest más rápido es uno que no inicias dos veces.&lt;/strong&gt; Cada invocación de pytest paga un costo de startup fijo. Con un conftest pesado, ese costo domina todo lo demás. Más paralelismo = más startups = más costo. Menos shards más grandes le ganan a más shards chiquitos pasado un umbral.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pytest-xdist&lt;/code&gt; no es gratis.&lt;/strong&gt; Funciona bien cuando el costo por test &amp;gt;&amp;gt; el costo de startup. Cuando el startup es 20s y los tests son de 500ms, la ecuación se invierte.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recursos por worker necesitan aislamiento por worker.&lt;/strong&gt; El bug sutil fue que los background workers abrían su propio &lt;code&gt;AsyncSessionLocal&lt;/code&gt; apuntando a la DB equivocada. El fix no estaba en la aplicación — estaba en el conftest de tests, alineando las env vars antes de que la app importara nada.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL tiene las primitivas.&lt;/strong&gt; Advisory locks para sincronizar entre procesos, &lt;code&gt;CREATE DATABASE ... TEMPLATE&lt;/code&gt; para bootstrap de schema. Las dos me salvaron de inventar mecanismos más débiles encima.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los BuildKit cache mounts siguen sub-utilizados.&lt;/strong&gt; Dos líneas en un Dockerfile (&lt;code&gt;--mount=type=cache&lt;/code&gt; para los cachés de apt y uv/pip) bajaron los docker builds repetidos de ~40s a ~10s, pero solo después de cambiarse del builder legacy de Docker vía &lt;code&gt;setup-buildx-action&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>githubactions</category>
      <category>ci</category>
      <category>python</category>
    </item>
    <item>
      <title>Pros y Cons de las arquitecturas multi-región</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Thu, 02 Apr 2026 22:07:40 +0000</pubDate>
      <link>https://dev.to/aws-builders/pros-y-cons-de-las-arquitecturas-multi-region-l5k</link>
      <guid>https://dev.to/aws-builders/pros-y-cons-de-las-arquitecturas-multi-region-l5k</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/pros-y-cons-de-las-arquitecturas-multi-region" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ffiles.speakerdeck.com%2Fpresentations%2F6f426a0c5c1647e5843fe82802a7233b%2Fslide_0.jpg%3F38969754" height="1080" class="m-0" width="1920"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/pros-y-cons-de-las-arquitecturas-multi-region" rel="noopener noreferrer" class="c-link"&gt;
            Pros y Cons de las arquitecturas multi-región  - Speaker Deck
          &lt;/a&gt;
        &lt;/h2&gt;
          
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fd1eu30co0ohy4w.cloudfront.net%2Fassets%2Ffavicon-bdd5839d46040a50edf189174e6f7aacc8abb3aaecd56a4711cf00d820883f47.png" width="512" height="512"&gt;
          speakerdeck.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  Los retos reales de ir multi-región
&lt;/h2&gt;

&lt;p&gt;Antes de hablar de soluciones, hay que nombrar los retos con claridad porque es donde más se subestima el esfuerzo. El primero es elegir la solución tecnológica correcta — no todas las cargas de trabajo necesitan multi-región y no todos los servicios de AWS están disponibles igual en todas las regiones. El segundo es el manejo de fallos a escala: no basta con tener recursos en dos regiones si no has pensado cómo se comporta cada componente ante una falla. El tercero es la cercanía a los usuarios, que no siempre es puramente técnica — hay leyes, regulaciones y requisitos de soberanía de datos que dictan dónde puede vivir tu información.&lt;/p&gt;

&lt;p&gt;Ignorar cualquiera de estos puntos al inicio garantiza una conversación mucho más difícil después.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tolerancia a fallos: el modelo mental que todo lo rige
&lt;/h2&gt;

&lt;p&gt;El concepto clave aquí es el &lt;strong&gt;dominio de error&lt;/strong&gt; (fault domain). Cada componente de tu arquitectura pertenece a un dominio que define su política de falla: puede ser redundante (se replica), ignorable (su caída no afecta el sistema), o en cascada (si cae, arrastra a quien depende de él — el temido SPOF).&lt;/p&gt;

&lt;p&gt;El problema clásico es una arquitectura donde la base de datos es un dominio en cascada dentro de una sola AZ, en una sola región. Si esa AZ tiene problemas, caes completo. La estrategia multi-región resuelve esto añadiendo un nivel más en la jerarquía de dominios, pero también introduce nuevas preguntas sobre consistencia y latencia de replicación que hay que responder explícitamente.&lt;/p&gt;

&lt;h2&gt;
  
  
  Las capas de una arquitectura multi-región
&lt;/h2&gt;

&lt;p&gt;Pensar en capas ayuda a no perderse. Cada capa tiene sus propias decisiones y sus propios servicios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capa de redes.&lt;/strong&gt; El CDN entrega contenido global con acceso seguro y rápido — CloudFront es el componente natural aquí en AWS. El DNS, específicamente Route 53, es quien realmente orquesta el tráfico entre regiones: puedes rutear por latencia, por failover, por geolocalización o con políticas ponderadas. Una buena estrategia de DNS hace más diferencia de lo que la gente espera — es literalmente el primer punto de decisión que toca cada request de usuario. Las redes internas entre regiones deben estar interconectadas y planificadas desde el inicio, no como un afterthought.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capa de cómputo.&lt;/strong&gt; Los servicios deben ser modulares, organizados por dominio de negocio y escalables bajo demanda. La elección entre Lambda, EC2, ECS o Kubernetes depende del caso de uso — no hay respuesta genérica, y lo que sí aplica siempre es que la capa de cómputo debe poder replicarse o levantarse en otra región sin fricción manual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capa de aplicación.&lt;/strong&gt; Aquí hay un principio que marca la diferencia: la aplicación debe ser &lt;strong&gt;agnóstica a la región&lt;/strong&gt;. Eso implica configuración externalizada, procesos sin estado (stateless) y secretos administrables. Un ejemplo concreto: leer el &lt;code&gt;region_name&lt;/code&gt; desde una variable en lugar de hardcodearlo en el código. Suena básico y sin embargo es donde se rompen más arquitecturas multi-región en la práctica.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capa de datos.&lt;/strong&gt; Esta es la más compleja. Antes de elegir un servicio hay que identificar los patrones de acceso, el tipo de almacenamiento (bloque, archivo u objeto), el costo de replicación y dónde están los usuarios. AWS tiene soporte de replicación cross-region en DynamoDB, RDS Aurora, RDS estándar, S3, ElastiCache y DocumentDB. Cada uno tiene sus propias implicaciones de consistencia eventual vs. consistencia fuerte que hay que entender antes de decidir.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capa de seguridad, identidad y acceso.&lt;/strong&gt; IAM es global, lo cual simplifica la gestión de usuarios, roles y grupos. KMS permite crear llaves con capacidad multi-región. Secrets Manager puede replicar secretos en regiones secundarias — y aquí hay un detalle importante de Terraform: cuando configuras un &lt;code&gt;aws_secretsmanager_secret&lt;/code&gt; con un bloque &lt;code&gt;replica&lt;/code&gt;, la región secundaria se sincroniza automáticamente. Parece trivial hasta que lo necesitas en un failover real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoreo: no es opcional, es parte de la arquitectura
&lt;/h2&gt;

&lt;p&gt;Una arquitectura multi-región sin observabilidad centralizada es básicamente una caja negra distribuida. CloudWatch, Config, GuardDuty y CloudTrail son servicios regionales, pero servicios como Security Hub y CloudTrail soportan agregados multi-región, lo que permite tener una vista unificada de eventos de seguridad sin tener que revisar consola por consola.&lt;/p&gt;

&lt;p&gt;Hay un punto importante aquí: una estrategia de monitoreo requiere varias iteraciones. No sale perfecta a la primera. Herramientas como Amazon DevOps Guru ayudan a identificar comportamientos anómalos, sugerir mejoras de configuración y alertar sobre fallos críticos — complementan bien el stack base de observabilidad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Despliegue: IaC o no escala
&lt;/h2&gt;

&lt;p&gt;En arquitecturas multi-región, el despliegue manual no es una opción viable a largo plazo. Infraestructura como código (Terraform, CDK, CloudFormation) no es solo una buena práctica — es lo que permite recrear un entorno completo en otra región en minutos en lugar de días. El control de cambios debe ser granular: por cuenta, por ambiente y por región. IAM debe seguir el principio de mínimo privilegio, y los fallos deben estar controlados — es decir, un error en el despliegue de una región no debe tumbar las otras.&lt;/p&gt;

&lt;p&gt;Un tip práctico: las nuevas regiones también funcionan muy bien como sandbox para validar nuevas funcionalidades o para simular desastres antes de que lleguen solos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que realmente hay que considerar antes de empezar
&lt;/h2&gt;

&lt;p&gt;Multi-región no es gratis ni en costo ni en complejidad operacional. El &lt;em&gt;operational overhead&lt;/em&gt; es real: cada recurso que existe en una región ahora existe en dos o más, con todo lo que eso implica en mantenimiento, monitoreo y actualizaciones. Los costos de transferencia de datos entre regiones también se acumulan rápido si no se modelan desde el inicio.&lt;/p&gt;

&lt;p&gt;Antes de empezar, vale la pena hacer un ejercicio de planeación con una matriz de prioridad, esfuerzo, complejidad y dependencias — algo similar al Método Eisenhower. No todo tiene que regionalizarse al mismo tiempo ni con la misma urgencia. Siempre hay componentes que son candidatos naturales para regionalizarse primero (típicamente los más críticos y con menor complejidad de replicación) y otros que pueden esperar.&lt;/p&gt;

&lt;p&gt;Un Well Architected Review es un buen punto de partida para hacer ese inventario con una metodología estructurada.&lt;/p&gt;

&lt;h2&gt;
  
  
  La arquitectura evoluciona
&lt;/h2&gt;

&lt;p&gt;El estado final de una arquitectura multi-región en AWS se ve algo así: el usuario llega a Route 53, que rutea al CloudFront más cercano, que a su vez dirige el tráfico a la región correspondiente — donde viven el API Gateway, las Lambdas y la base de datos Aurora replicada. Todo gestionado por certificados en ACM y con tráfico distribuido por políticas de latencia o failover en DNS.&lt;/p&gt;

&lt;p&gt;Llegar ahí no pasa de un día para otro. Llega por iteraciones, con IaC como columna vertebral y con una estrategia de DNS que desde el primer día esté pensada para escalar.&lt;/p&gt;




&lt;p&gt;Multi-región no es un problema de servicios, es un problema de diseño. Los servicios de AWS están listos. La pregunta es si tu arquitectura, tu código y tus procesos también lo están.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>cloud</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Multi-Stage Continuous Delivery</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Thu, 02 Apr 2026 21:59:47 +0000</pubDate>
      <link>https://dev.to/aws-builders/multi-stage-continuous-delivery-2gmg</link>
      <guid>https://dev.to/aws-builders/multi-stage-continuous-delivery-2gmg</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/multi-stage-continuous-delivery" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ffiles.speakerdeck.com%2Fpresentations%2F725d7651d322473f9a80b6a9ad378d3b%2Fslide_0.jpg%3F38969727" height="1080" class="m-0" width="1920"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/multi-stage-continuous-delivery" rel="noopener noreferrer" class="c-link"&gt;
            Multi-Stage Continuous Delivery - Speaker Deck
          &lt;/a&gt;
        &lt;/h2&gt;
          
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fd1eu30co0ohy4w.cloudfront.net%2Fassets%2Ffavicon-bdd5839d46040a50edf189174e6f7aacc8abb3aaecd56a4711cf00d820883f47.png" width="512" height="512"&gt;
          speakerdeck.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  El problema con los pipelines tradicionales
&lt;/h2&gt;

&lt;p&gt;El concepto de Multi-Stage CD es sencillo: llevas código a prod en varias iteraciones y a través de diferentes ambientes — dev, staging, prod — con fases bien definidas: build, prepare, deploy, test, notify, rollback. Suena limpio. Y en papel, lo es.&lt;/p&gt;

&lt;p&gt;El problema es la realidad. Según el State of DevOps Report 2020, el 95% del tiempo se va en mantenimiento de pipelines, el 80% en tareas manuales, y el 90% en remediación también manual. Nadie escribe esas métricas en su README, pero todos las vivimos.&lt;/p&gt;

&lt;p&gt;Los retos concretos son tres y son los de siempre: la disponibilidad de ambientes (el clásico &lt;em&gt;"no le muevan a dev que estoy probando algo"&lt;/em&gt;), satisfacer dependencias externas correctamente — JS, Python, AWS, lo que sea — y los ambientes con candado cuando hay un bug en prod y todo se paraliza. A eso le sumas llegada lenta a producción, más de siete herramientas involucradas en el proceso, y pipelines distintos para web, API y mobile que cada quien personalizó a su manera. El resultado es un Frankenstein difícil de mantener para cualquier persona del equipo.&lt;/p&gt;

&lt;p&gt;Lo que realmente se necesita no es magia: capacidad de poner ambientes en cuarentena, dependencias siempre disponibles y seguras, configuración que realmente funcione, y despliegues validados con tests, métricas de performance y SLOs/SLIs bien definidos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keptn: un control plane para gobernarlos a todos
&lt;/h2&gt;

&lt;p&gt;La solución que propongo es Keptn — y el título de esta sección es intencional. Keptn es una plataforma open source de orquestamiento que automatiza la configuración y provee en un solo control plane todo lo que normalmente está disperso: monitoreo, despliegue, remediación y resiliencia.&lt;/p&gt;

&lt;p&gt;Lo que lo hace diferente es su enfoque declarativo y orientado a GitOps. Defines tus ambientes y estrategias en un archivo &lt;code&gt;shipyard.yaml&lt;/code&gt; y Keptn se encarga de la orquestación basada en eventos. No necesitas escribir la lógica de coordinación entre herramientas — eso ya está resuelto.&lt;/p&gt;

&lt;p&gt;Desde el punto de vista de plataforma, Keptn entrega progressive delivery, automatización de SRE, auto-remediación y rollback, y una configuración codificable e independiente de herramientas. Pero la parte más importante: mantiene conectividad con las herramientas que ya tienes — JMeter, Argo, Jenkins, Helm, lo que ya está corriendo en tu stack.&lt;/p&gt;

&lt;p&gt;Un beneficio que no es obvio a primera vista: &lt;strong&gt;los pipelines tradicionales dejan de ser necesarios&lt;/strong&gt;. Keptn reemplaza esa necesidad con fases dedicadas y orquestamiento event-driven. Tienes estrategias out-of-the-box como Blue/Green y Canary, más observabilidad integrada en el proceso con auditabilidad y trazabilidad completas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo funciona por dentro
&lt;/h2&gt;

&lt;p&gt;El modelo mental es el siguiente: Keptn expone servicios a los cuales las herramientas se suscriben por medio de integraciones. Los eventos de Keptn se traducen a llamadas API hacia y desde esas herramientas.&lt;/p&gt;

&lt;p&gt;En la práctica: Keptn crea un evento y lo distribuye a cualquier servicio que esté escuchando — por ejemplo, &lt;code&gt;sh.keptn.event.hello-world.triggered&lt;/code&gt;. El Job Executor Service (JES) detecta el evento, busca la configuración en el YAML correspondiente y ejecuta el contenedor. Una vez que termina, el JES envía de vuelta un par de eventos &lt;code&gt;.started&lt;/code&gt; y &lt;code&gt;.finished&lt;/code&gt;. Keptn los recibe, sabe que la tarea está completa y avanza en la secuencia. Simple, trazable, predecible.&lt;/p&gt;

&lt;p&gt;El ecosistema de integraciones es amplio. Para despliegue: Argo, Jenkins, CircleCI. Para observabilidad: Prometheus, Grafana, Splunk. Para testing: JMeter, Selenium, Artillery. Para notificaciones: Slack, webhooks, Tekton. Para automatización: Ansible, webhooks, AWS Lambda. La idea es clara — &lt;strong&gt;Keptn maneja la orquestación, las tareas y la ejecución; nosotros decidimos las herramientas&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué esto importa frente a pipelines tradicionales
&lt;/h2&gt;

&lt;p&gt;La comparación es directa. Los pipelines tradicionales sufren de falta de separación de responsabilidades, código lleno de dependencias y personalizaciones ad hoc, y dificultad para incorporar herramientas específicas sin romper todo. Keptn resuelve esto con fases dedicadas y orquestamiento basado en eventos, interoperabilidad a través de abstracciones bien definidas, y flexibilidad real para cambiar herramientas sin reescribir la lógica de entrega.&lt;/p&gt;

&lt;h2&gt;
  
  
  Próximos pasos: quality gates y progressive delivery
&lt;/h2&gt;

&lt;p&gt;Una vez que el flujo básico está corriendo, los casos de uso avanzados son los que realmente cambian el juego. Los Quality Gates basados en SLI/SLO permiten que un despliegue sólo avance si cumple criterios medibles — por ejemplo, que el porcentaje de éxito de probes sea mayor al 95%, o que la duración de respuesta sea menor a 200ms. El score total determina si el pipeline pasa o emite una advertencia.&lt;/p&gt;

&lt;p&gt;El Progressive Delivery lleva esto un paso más lejos: defines un flujo que va de dev a hardening a production, con estrategias blue/green en los ambientes de mayor criticidad y remediación automatizada en prod. Keptn evalúa quality gates entre cada etapa y sólo promueve si los números lo justifican.&lt;/p&gt;




&lt;p&gt;El punto de todo esto no es adoptar una herramienta más por el gusto de hacerlo. Es reconocer que los pipelines monolíticos tienen un techo bajo, y que un modelo orientado a eventos con separación clara de responsabilidades escala mucho mejor — tanto en complejidad técnica como en tamaño de equipo.&lt;/p&gt;

&lt;p&gt;Si quieres profundizar, el punto de partida es &lt;a href="https://keptn.sh" rel="noopener noreferrer"&gt;keptn.sh&lt;/a&gt; y los recursos de la comunidad en &lt;a href="https://keptn.sh/resources/slides/" rel="noopener noreferrer"&gt;keptn.sh/resources/slides&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Optimización de costos para transacciones de alto volumen</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Thu, 02 Apr 2026 21:51:48 +0000</pubDate>
      <link>https://dev.to/aws-builders/optimizacion-de-costos-para-transacciones-de-alto-volumen-5159</link>
      <guid>https://dev.to/aws-builders/optimizacion-de-costos-para-transacciones-de-alto-volumen-5159</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/optimizacion-de-costos-para-transacciones-de-alto-volumen" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ffiles.speakerdeck.com%2Fpresentations%2F5347f9da1b504e3381e3a1cd603ee7c1%2Fslide_0.jpg%3F38969699" height="1080" class="m-0" width="1920"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/optimizacion-de-costos-para-transacciones-de-alto-volumen" rel="noopener noreferrer" class="c-link"&gt;
            Optimización de costos para transacciones de alto volumen - Speaker Deck
          &lt;/a&gt;
        &lt;/h2&gt;
          
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fd1eu30co0ohy4w.cloudfront.net%2Fassets%2Ffavicon-bdd5839d46040a50edf189174e6f7aacc8abb3aaecd56a4711cf00d820883f47.png" width="512" height="512"&gt;
          speakerdeck.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;h1&gt;
  
  
  Optimización de Costos para Transacciones de Alto Volumen en AWS
&lt;/h1&gt;

&lt;p&gt;Cuando se habla de optimización de costos en AWS, la conversación suele ir a los mismos lugares de siempre: Reserved Instances, Savings Plans, apagar recursos que no usas. Todo eso está bien y deberías hacerlo, pero hay un nivel más profundo que la mayoría de los equipos no toca — las decisiones de arquitectura y tecnología que generan costos innecesarios a escala sin que nadie se dé cuenta. Este post va sobre eso.&lt;/p&gt;

&lt;h2&gt;
  
  
  La economía oculta de las API calls
&lt;/h2&gt;

&lt;p&gt;En arquitecturas serverless, cada llamada API tiene un costo. Eso parece obvio, pero las implicaciones no siempre son visibles cuando estás diseñando el sistema.&lt;/p&gt;

&lt;p&gt;El patrón más común que genera costo silencioso es el polling. Imagina una aplicación que consulta un endpoint REST cada 5 segundos para verificar si hay nuevas órdenes en DynamoDB. A volumen bajo nadie lo nota. A volumen alto, estás pagando por miles de requests a API Gateway y Lambda que en su mayoría regresan vacío. La corrección es dejar de preguntar y empezar a escuchar: DynamoDB Streams detecta los cambios en la tabla y los envía a EventBridge, que filtra y transforma el evento antes de disparar el downstream — SNS, Step Functions, WebSockets. Cero polling, costo proporcional al trabajo real.&lt;/p&gt;

&lt;p&gt;Otro patrón costoso es usar Lambda para cada operación que pasa por API Gateway. Si tienes un frontend enviando datos JSON que necesitan ser validados y escritos en DynamoDB, el reflejo natural es meter una Lambda en el medio. El problema es que cada invocación tiene costo. API Gateway tiene Mapping Templates que pueden hacer validaciones y transformaciones de request sin necesidad de invocar ninguna función. Para operaciones simples y de alto volumen, eliminar esa Lambda puede significar cientos de miles de invocaciones menos por mes.&lt;/p&gt;

&lt;p&gt;El tercer caso tiene que ver con los límites internos de AWS. Cuando una aplicación escribe a DynamoDB a alta velocidad y alcanza el límite de Write Capacity Units, AWS aplica backoff exponencial y genera reintentos automáticos — que también cuestan. Meter SQS como buffer entre la aplicación y DynamoDB nivela el throughput y elimina esos reintentos, convirtiendo un patrón de escritura caótico en uno predecible y controlable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data transfer: el costo que nadie presupuesta
&lt;/h2&gt;

&lt;p&gt;La transferencia de datos en AWS tiene una particularidad: es casi invisible en el diseño inicial y muy visible en la factura a fin de mes.&lt;/p&gt;

&lt;p&gt;El ejemplo más costoso y fácil de corregir es usar NAT Gateway para que instancias en subnets privadas accedan a S3. NAT Gateway cobra por procesamiento de datos — a $0.045 por GB, 5TB al mes son $225 adicionales que no agregan ningún valor técnico. La solución es un VPC Endpoint para S3: el tráfico fluye directamente dentro de la red de AWS sin pasar por NAT, sin costo por transferencia. Es una de las optimizaciones con mejor ratio esfuerzo/impacto que existen.&lt;/p&gt;

&lt;p&gt;Para APIs con alto tráfico de lectura, CloudFront delante de API Gateway puede eliminar la gran mayoría de invocaciones a Lambda. Si configuras CloudFront para cachear respuestas comunes por 5 minutos, todo ese tráfico repetido deja de llegar al backend. Para catálogos de productos, configuraciones, datos de referencia — el impacto puede ser dramático.&lt;/p&gt;

&lt;p&gt;El caso de GenAI agrega una dimensión nueva. Si tienes un chatbot en AWS que manda cada query a un modelo externo como OpenAI, estás pagando transferencia de datos de salida en cada request. Mover el modelo a Amazon Bedrock o SageMaker dentro de AWS no solo elimina esa transferencia — también te da más control sobre latencia, disponibilidad y costos por token.&lt;/p&gt;

&lt;h2&gt;
  
  
  La economía del almacenamiento
&lt;/h2&gt;

&lt;p&gt;El formato de compresión que usas para tus datos puede parecer un detalle técnico menor. En cargas de análisis a escala, no lo es. GZIP es el default en muchos pipelines de Redshift, EMR y Glue, pero tiene latencia de descompresión alta y peor ratio de compresión comparado con alternativas más modernas. Zstandard en nivel 3 ofrece mayor eficiencia tanto de almacenamiento como de procesamiento, con reducciones de más de un 30% en tamaño. Para 50TB de logs de transacciones, ese 30% es dinero real.&lt;/p&gt;

&lt;p&gt;El zero-copy data sharing es otro concepto que vale la pena entender. El patrón ineficiente es copiar datos entre cuentas de AWS para que cada equipo tenga su propia copia en S3. Cada copia es storage adicional, cada sincronización es transferencia. Con AWS Lake Formation y Glue Catalog Cross-Account, puedes dar acceso a las mismas tablas registradas a múltiples cuentas sin mover un solo byte. Los datos viven en un lugar, el acceso se gestiona con permisos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Computación selectiva: elegir bien el tipo de recurso
&lt;/h2&gt;

&lt;p&gt;Aquí hay una distinción que muchos equipos no hacen: no todos los workloads tienen el mismo cuello de botella. Algunos son CPU-intensivos, otros son memory-bound, otros son IO-bound. Elegir la familia de instancia equivocada significa pagar por recursos que no estás usando.&lt;/p&gt;

&lt;p&gt;Un ejemplo concreto: una base de datos MySQL en RDS con instancias t3.medium que tiene la RAM llena y hace swapping a disco. El problema no es CPU — es memoria. La solución no es subir a una instancia con más CPU, es cambiar a una familia memory-optimized. Las instancias R6g con Graviton2 ofrecen 50% más memoria por dólar que T3. Para Elasticsearch con alto uso de IOPS donde EBS gp3 ya es el cuello de botella, instancias I4i con almacenamiento NVMe local reducen la latencia de consultas en un 60%.&lt;/p&gt;

&lt;p&gt;En Lambda, la relación memoria-CPU tiene una implicación de costos contraintuitiva. Con 128MB de RAM, una función de procesamiento de imágenes puede tardar 6 segundos. Con 1024MB tiene acceso a una vCPU completa y la misma operación tarda 0.8 segundos — 7 veces más rápida. Lambda Power Tuning existe para encontrar el punto óptimo entre costo por ejecución y tiempo de ejecución, y el resultado frecuentemente sorprende: más memoria puede ser más barato.&lt;/p&gt;

&lt;p&gt;En Kubernetes, el costo escala con el número de nodos, y el número de nodos escala con el número de pods. Si cada microservicio tiene su propio pod con límites de CPU y memoria dedicados, terminas con mucha capacidad reservada que en promedio está subutilizada. Consolidar servicios relacionados en pods multi-tenant reduce el número total de nodos necesarios y mejora la utilización del clúster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Estrategias de bases de datos
&lt;/h2&gt;

&lt;p&gt;Aurora tiene una característica que justifica su adopción para cargas intensivas en I/O: Aurora I/O-Optimized elimina el cobro por operaciones de lectura y escritura, cambiando el modelo de precio a uno más predecible basado en capacidad. Si tienes un workload con miles de IOPS, la diferencia puede ser sustancial. Combinado con auto-tiering para mover datos históricos a almacenamiento más barato y Aurora Backtrack para reducir snapshots innecesarios, el costo total de una instancia de 5TB puede bajar considerablemente.&lt;/p&gt;

&lt;p&gt;Para logs de transacciones en PostgreSQL que crecen a millones de registros con el tiempo, la solución no es escalar la instancia — es particionar los datos por tiempo. En lugar de una tabla gigante donde las consultas escanean todo el historial, particiones mensuales o diarias limitan el scope de cada query al período relevante. Amazon Timestream también es una alternativa cuando el patrón de acceso es fundamentalmente time-series.&lt;/p&gt;

&lt;p&gt;Con DynamoDB a escala, TTL es una herramienta de costo que se subestima. Los ítems expirados se eliminan automáticamente sin consumir Write Capacity Units, lo que mantiene la tabla limpia sin operaciones de borrado explícitas. DAX como capa de caché elimina lecturas repetidas a la tabla principal y DynamoDB Streams permite reaccionar a cambios sin polling — ambos ya cubiertos en la sección de API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Los anti-patterns que hay que evitar
&lt;/h2&gt;

&lt;p&gt;Tan importante como saber qué optimizar es saber qué no hacer. Over-engineering en arquitecturas event-centric es frecuente: agregar capas de EventBridge, SNS y SQS a flujos que podrían ser síncronos simples, creando complejidad operacional por ahorros marginales que nunca se materializan.&lt;/p&gt;

&lt;p&gt;El mito de que serverless siempre es más económico que servidores es exactamente eso: un mito. Para cargas con throughput constante y predecible, un servicio administrado de contenedores o incluso instancias reservadas pueden salir más baratos que pagar por invocación a escala. El modelo correcto depende del patrón de tráfico.&lt;/p&gt;

&lt;p&gt;Los logs sin filtrar son otro agujero silencioso. Loggear todo en CloudWatch Logs a nivel DEBUG en producción, sin retention policies, sin filtros, genera costos de ingesta y almacenamiento que crecen con el tráfico. Filtrar a nivel del source, definir retention apropiado y usar Log Insights solo cuando se necesita mantiene eso bajo control.&lt;/p&gt;

&lt;p&gt;La conclusión más importante es también la más sencilla: la optimización no es un proyecto con fecha de inicio y fin, es una práctica continua. Las estrategias no convencionales que van más allá de Reserved Instances y right-sizing pueden aportar entre un 15-30% adicional de ahorro. Pero solo si el equipo tiene cultura de cost awareness integrada desde el diseño, no como una corrección posterior.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>aws</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Optimizando Cargas de Trabajo Serverless Técnicas para mejorar Rendimiento y Eficiencia</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Thu, 02 Apr 2026 21:19:45 +0000</pubDate>
      <link>https://dev.to/aws-builders/optimizando-cargas-de-trabajo-serverless-tecnicas-para-mejorar-rendimiento-y-eficiencia-48og</link>
      <guid>https://dev.to/aws-builders/optimizando-cargas-de-trabajo-serverless-tecnicas-para-mejorar-rendimiento-y-eficiencia-48og</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/optimizando-cargas-de-trabajo-en-lambda" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ffiles.speakerdeck.com%2Fpresentations%2Ff651310d2143405087d150771cf6621e%2Fslide_0.jpg%3F38969317" height="1080" class="m-0" width="1920"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://speakerdeck.com/elchesco/optimizando-cargas-de-trabajo-en-lambda" rel="noopener noreferrer" class="c-link"&gt;
            Optimizando Cargas de Trabajo en Lambda - Speaker Deck
          &lt;/a&gt;
        &lt;/h2&gt;
          
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fd1eu30co0ohy4w.cloudfront.net%2Fassets%2Ffavicon-bdd5839d46040a50edf189174e6f7aacc8abb3aaecd56a4711cf00d820883f47.png" width="512" height="512"&gt;
          speakerdeck.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Este post va sobre las técnicas que realmente mueven la aguja cuando estás buscando rendimiento y eficiencia en Lambda.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo está construido lo que estás ejecutando
&lt;/h2&gt;

&lt;p&gt;Lambda tiene una arquitectura en capas que vale la pena entender antes de optimizar cualquier cosa: tu función vive dentro de un Language Runtime, que a su vez corre en un Execution Environment, administrado por el Lambda Service sobre un Compute Substrate. Cada capa tiene implicaciones en cómo se comporta tu función al arrancar y durante la ejecución.&lt;/p&gt;

&lt;p&gt;Dos mecanismos que se aprovechan bien una vez que entiendes esto son las &lt;strong&gt;Layers&lt;/strong&gt; y las &lt;strong&gt;Extensions&lt;/strong&gt;. Las capas te permiten separar tu código de función de sus dependencias y recursos compartidos — librerías, SDKs, utilerías comunes — y reutilizarlos en múltiples funciones sin empaquetar lo mismo en cada deployment. Las extensiones, por su parte, se integran en el ciclo de vida de la invocación para conectar Lambda con herramientas de monitoreo, seguridad y observabilidad, corriendo en paralelo al runtime sin modificar el código de tu función.&lt;/p&gt;

&lt;h2&gt;
  
  
  El problema de los Cold Starts
&lt;/h2&gt;

&lt;p&gt;Si hay un tema que aparece en todas las conversaciones sobre Lambda es este. Un cold start ocurre cuando AWS necesita provisionar un nuevo contexto de ejecución — descarga el código, levanta el entorno, inicializa el runtime, y luego ejecuta tu función. Esto pasa con cambios de código, durante scale-up, en rebalanceos de AZs, o después de fallos.&lt;/p&gt;

&lt;p&gt;El impacto varía bastante por runtime. Python y JavaScript tienen cold starts en el rango de 200-500ms. Java y Docker pueden llegar fácilmente al segundo y medio. No es lo mismo si tienes una API interactiva donde el usuario espera la respuesta, que un pipeline batch asíncrono donde nadie nota 800ms extra.&lt;/p&gt;

&lt;p&gt;La estrategia más simple para mitigarlo es el warmup con eventos de CloudWatch o EventBridge que disparan la función cada X minutos para mantenerla activa. Funciona, pero tiene sus trade-offs: es periódico, no puedes controlar qué instancias específicas se calientan, y a escala puede tener un costo no despreciable. Haz los números: una sola función ejecutándose cada 5 minutos cuesta alrededor de $0.18 al mes. Diez funciones: $14.58 más costos de CloudWatch. No es dramático, pero tampoco es gratis.&lt;/p&gt;

&lt;p&gt;La alternativa más robusta es &lt;strong&gt;Provisioned Concurrency&lt;/strong&gt;: le dices a Lambda cuántos entornos de ejecución quieres inicializados y listos en todo momento. Por cada unidad de simultaneidad aprovisionada se mantienen mínimo dos entornos en AZs separadas, lo que también da alta disponibilidad. El contra es que pagas por esa capacidad aunque no la uses, y hay restricciones importantes: no funciona con &lt;code&gt;$LATEST&lt;/code&gt; ni con &lt;a href="mailto:Lambda@Edge"&gt;Lambda@Edge&lt;/a&gt;. Úsalo con inteligencia — tiene más sentido en runtimes de inicio lento como Java, y puedes combinarlo con Application Auto Scaling para ajustar la capacidad según patrones de tráfico.&lt;/p&gt;

&lt;p&gt;Para Java específicamente, &lt;strong&gt;SnapStart&lt;/strong&gt; es un game changer: toma una snapshot del estado inicializado de la función y la restaura en invocaciones posteriores, logrando hasta 10x mejora en tiempo de arranque sin cambios en el código. Disponible desde la consola, SAM y CDK.&lt;/p&gt;

&lt;p&gt;Vale mencionar también &lt;strong&gt;LLRT (Low Latency Runtime)&lt;/strong&gt; — un runtime JavaScript liviano escrito en Rust que AWS Labs lanzó como experimental. Ofrece inicio hasta 10x más rápido y costo hasta 2x menor comparado con el runtime de Node.js estándar. Si estás en JavaScript y los cold starts te están afectando, vale la pena explorarlo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Right-sizing y optimización de costos
&lt;/h2&gt;

&lt;p&gt;Más memoria no siempre significa más costo — en Lambda, memoria y CPU están acoplados, así que a veces asignar más RAM resulta en ejecuciones más rápidas que al final cuestan lo mismo o menos. El problema es que nadie sabe exactamente cuánta memoria necesita una función sin medirlo.&lt;/p&gt;

&lt;p&gt;Para esto existe &lt;strong&gt;AWS Lambda Power Tuning&lt;/strong&gt;, una State Machine de Step Functions que ejecuta tu función con diferentes configuraciones de memoria, mide duración y costo, y te da una visualización clara del trade-off. La diferencia entre la configuración óptima para costo (1536MB en el ejemplo de la presentación) vs. la óptima para velocidad (3008MB) puede ser significativa, y depende completamente de tu workload específico.&lt;/p&gt;

&lt;p&gt;Otros recursos útiles para right-sizing son AWS Compute Optimizer, Lambda Insights, y DevOps Guru, que pueden darte recomendaciones basadas en patrones de uso histórico.&lt;/p&gt;

&lt;p&gt;Si tu workload tolera migrar a &lt;strong&gt;arm64 (Graviton2)&lt;/strong&gt;, el ahorro en precio/performance es aproximadamente un 34% frente a x86. La migración no es siempre trivial — lenguajes compilados y algunos containers requieren adaptaciones — pero el beneficio es real y verificable con Lambda Power Tuning comparando ambas arquitecturas. Los &lt;strong&gt;Compute Savings Plans&lt;/strong&gt; también aplican a Lambda y pueden dar hasta un 17% adicional de ahorro sobre demanda.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimizaciones que están en tu control
&lt;/h2&gt;

&lt;p&gt;Hay cosas que AWS optimiza por ti (descarga del código, setup del entorno) y cosas que dependen de ti (init code y handler code). La mayor palanca que tienes está en mantener tus funciones ligeras: dependencias mínimas, frameworks más pequeños, sin código muerto, y empaquetado en un único archivo minificado. El tamaño del ZIP afecta directamente cuánto tarda Lambda en descargar y cargar tu función.&lt;/p&gt;

&lt;p&gt;Lazy-loading es otra práctica que marca diferencia: en lugar de importar todo al inicio del módulo, carga las dependencias solo cuando realmente se necesitan. El código de la presentación lo ilustra bien tanto en JavaScript como en Python — inicializas el cliente de DynamoDB o S3 dentro del handler, condicionado a si ya está inicializado, y lo reutilizas en invocaciones posteriores dentro del mismo contexto de ejecución.&lt;/p&gt;

&lt;p&gt;Para bases de datos relacionales, si estás llamando RDS desde Lambda, &lt;strong&gt;RDS Proxy&lt;/strong&gt; resuelve el problema de agotamiento de conexiones que ocurre cuando Lambda escala agresivamente. Agrupación de conexiones, mayor disponibilidad y caché de queries — sin cambios en el código de la función.&lt;/p&gt;

&lt;p&gt;Si tu función sirve respuestas que no cambian con cada invocación, configurar caché a nivel de API Gateway o CloudFront puede eliminar completamente la invocación de Lambda para esas peticiones. Para algunos patrones esto es la optimización más impactante de todas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Métricas que realmente importan
&lt;/h2&gt;

&lt;p&gt;Medir promedios de latencia es engañoso. Lo que importa es el percentil p95 o p99 — el rendimiento que experimentan el 5% o el 1% más lento de tus usuarios. Un promedio de 200ms con un p99 de 4 segundos es una función con problemas serios que el promedio oculta.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS X-Ray&lt;/strong&gt; es la herramienta para esto: tracing end-to-end de requests, service map de tu aplicación, y visibilidad sobre exactamente dónde se producen los cuellos de botella. Un &lt;code&gt;Tracing: Active&lt;/code&gt; en tu template de SAM o CDK y ya tienes trazas automáticas de Lambda y los servicios downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  El punto que más se subestima
&lt;/h2&gt;

&lt;p&gt;Lambda no debería ser el default para absolutamente todo. Hay operaciones donde otros servicios hacen el trabajo mejor y más barato sin invocar ninguna función. Orquestación compleja de workflows: Step Functions. Filtrado de mensajes antes de procesarlos: SNS con filter policies. Scheduling de tareas: EventBridge Scheduler. Integrar Lambda innecesariamente en esos flujos agrega latencia y costo sin valor real.&lt;/p&gt;

&lt;p&gt;Optimizar Lambda no es una sola cosa — es la suma de decisiones en configuración, código, arquitectura y monitoreo. El punto de partida es medir con X-Ray, ajustar con Lambda Power Tuning, y construir con las restricciones del caso de uso en mente, no con el toolset que conoces mejor.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>performance</category>
      <category>serverless</category>
    </item>
  </channel>
</rss>
