Incidente: Colas de sincronización atascadas (Junio 2026)¶
Post-mortem técnico. Jobs de salida (WooCommerce, TiendaNaranja, Contimarket, Medusa, Algolia, UMarket) acumulándose por miles sin avanzar, con CPU y RAM ociosas. Documenta qué se encontró, cómo se diagnosticó, cómo se resolvió y qué queda por optimizar.
- Fecha: Junio 2026
- Severidad: Alta (sincronización de productos hacia canales detenida ~horas)
- Commits de la solución:
9a6959a(scoped lookup),3da7da2(política de reintentos)
1. Resumen ejecutivo¶
Las colas de salida acumulaban 1500+ jobs cada una sin procesarse. Horizon
estaba corriendo y con workers activos, pero los jobs tardaban 50–70s y morían
por timeout (MaxAttemptsExceededException), reintentándose en bucle.
Hubo dos causas raíz independientes que se potenciaban:
- Lookup lentísimo de
product_item_view— el filtro poridse aplicaba por fuera de una vista conGROUP BY, obligando a MariaDB a materializar y ordenar la vista entera en cada consulta (decenas de segundos por producto). - Llamadas síncronas a Medusa dentro de cada job — la consulta de precio hacía hasta 2 requests HTTP secuenciales con timeout de 25s c/u, de modo que un solo job podía gastar ~50s y superar el timeout de 60s del worker.
Se resolvió encapsulando el lookup en ProductItemView::forProduct() (query
scopeada, ~1ms) y endureciendo la política de reintentos de los jobs.
2. Síntomas observados¶
- Colas
woocommerce_products,tn_products,contimarket,medusa_products,algolia,umarketcon 800–1500+ jobs en espera, sin drenar. - CPU del servidor ociosa, RAM sin saturar — los workers no estaban calculando, estaban bloqueados en I/O (red a Medusa + queries lentas).
- Jobs visibles como
delayeden Horizon (auto-reintentándose). - Productos recientemente actualizados sin reflejarse en los canales.
3. Cómo se diagnosticó¶
Metodología: descartar de afuera hacia adentro (infra → workers → código → DB).
3.1 ¿Horizon vivo? — sí¶
php artisan horizon:status # "Horizon is running"
php artisan horizon:supervisors # todos los supervisores running, con workers
systemctl status horizon # active (running)
Esto descartó "Horizon caído / supervisor sin levantar".
3.2 ¿Por qué fallan los jobs? — timeout¶
php artisan tinker --execute="echo \DB::table('failed_jobs')->latest('failed_at')->value('exception');"
# => Illuminate\Queue\MaxAttemptsExceededException ... markJobAsFailedIfAlreadyExceedsMaxAttempts
MaxAttemptsExceededException por markJobAsFailedIfAlreadyExceedsMaxAttempts
es la firma de un job que excede el timeout del worker (60s): el proceso
se mata a mitad de ejecución y al reintentar ya pasó el máximo de intentos.
El log de Horizon confirmó duraciones de 50s, 1m8s, 1m10s → FAIL.
3.3 ¿Dónde se va el tiempo? — Medusa + vista¶
Rastreando SyntToTiendaNaranjaJob / SyncToMedusaJob se llegó a
ProductPriceService::getProductPriceInfo(), usado por todos los canales de
salida vía sus transformers/services. Esa función:
- Hace hasta 2 requests HTTP secuenciales a Medusa (store + admin price list),
con
timeout=25syconnect_timeout=10scada uno → peor caso ~50s. - Internamente consulta
product_item_view, que resultó ser el otro cuello.
3.4 La query de la vista — EXPLAIN y cronometraje¶
Scripts de auditoría en audit/ confirmaron el problema de la vista:
php audit/audit_scoped.php 30 # cronometra la query scopeada + EXPLAIN
El patrón viejo (WHERE id=? por fuera de la vista con GROUP BY) producía
Creating sort index sobre la vista entera. La query con vp.id=? inyectado
adentro del cuerpo bajaba de decenas de segundos a ~1ms, con EXPLAIN
arrancando por vp con key=PRIMARY rows=1.
4. Causas raíz¶
4.1 Lookup de product_item_view no scopeado (principal)¶
product_item_view tiene GROUP BY. Al hacer:
$productItem->productItemView()->where('channel', 'Compulandia')->first();
el WHERE id = ? se aplica después de que la vista se materializó completa.
MariaDB no puede empujar ese filtro adentro del GROUP BY, así que construye
y ordena la vista entera en cada lookup. Como cada job de salida hace ese
lookup (precio, stock, payload), el costo se multiplicaba por miles de jobs.
4.2 Consulta síncrona a Medusa por producto (principal)¶
Cada job de salida llamaba a getProductPriceInfo(), que con
id_medusa_product presente hace 2 requests HTTP a Medusa (timeout 25s c/u).
Con Medusa lento/saturado, cada job gastaba 25–50s sólo en red → superaba el
timeout=60s del worker → MaxAttemptsExceededException → reintento → cascada.
4.3 Causas secundarias (amplificadores)¶
- Sin
$timeoutexplícito en los jobs → heredaban los 60s del worker, que era menor al trabajo real. - Reintentos planos:
$this->release(60)fijo en loscatch, ignorando el$backoffdeclarado. Los workers re-procesaban los mismos fallos cada 60s, ocupando slots y sin dejar pasar a los jobs que sí podían completar. - Contimarket re-encolaba sin chequear
attempts()→ quemaba los 3 intentos siempre, incluso ante caídas persistentes. - Lock de precio inefectivo:
product_price_lock:{id}con TTL de 10s envolvía un trabajo de hasta 50s → expiraba a mitad y no deduplicaba nada.
5. Soluciones aplicadas¶
5.1 ProductItemView::forProduct() — lookup scopeado (9a6959a)¶
Nuevo método estático en app/Models/ProductItemView.php
que replica exactamente la definición de la vista pero con vp.id = ?
inyectado en el WHERE (antes del GROUP BY). El optimizador arranca por la PK
de product_items y resuelve en ~1ms. Resultado idéntico a la vista.
// Antes:
$view = $productItem->productItemView()->where('channel', 'Compulandia')->first();
// Después:
$view = ProductItemView::forProduct($productItem->id, 'Compulandia');
Call sites migrados: Contimarket, Medusa (inventory), ProductPriceService (local + fallback), TiendaNaranja, UMarket, WooCommerce.
⚠️ El SQL del método debe mantenerse sincronizado con la definición de la vista (
SHOW CREATE VIEW product_item_view). Si la vista cambia, actualizarscopedSelectSql(). La auditoría de equivalencia (§6.2) detecta divergencias.
5.2 Política de reintentos de jobs de salida (3da7da2)¶
En Medusa, Contimarket, WooCommerce, UMarket y TiendaNaranja:
$timeout = 120explícito (antes heredaban 60s).$backoffexponencial[60, 300, 900](1/5/15 min) en vez de plano.- Nuevo trait RetriesWithBackoff:
retryDelay()respeta el headerRetry-After(429/503) y la escala de backoff. - Los
release(60)fijos →release($this->retryDelay(...)). - Contimarket: agregado el guard
attempts() < triesque faltaba. config/queue.php:retry_after90 → 150 en las 5 conexiones de salida. Obligatorio: contimeout=120 > retry_after=90la cola re-despachaba el job mientras seguía corriendo → doble ejecución. Ahoraretry_after > timeout.
Tras desplegar:
php artisan config:clear && php artisan horizon:terminate(sin elconfig:clear,retry_afterqueda cacheado en 90).
6. Cómo auditar / verificar¶
6.1 Performance de la query¶
php audit/audit_scoped.php 30
p50 en milisegundos; EXPLAIN con vp ... key=PRIMARY rows=1;
sin Creating sort index.
6.2 Correctness (equivalencia con la vista)¶
php audit/audit_scoped_equiv.php 50
forProduct() contra la vista real fila por fila. Esperado:
Presencia ≠ = 0 y Valores ≠ = 0. Cualquier discrepancia = el SQL inlined
divergió de la vista.
6.3 Salud de las colas (producción)¶
php artisan horizon:supervisors # workers activos por cola
# duración de jobs en el panel /horizon -> deben bajar de ~minutos a segundos
php artisan tinker --execute="echo \DB::table('failed_jobs')->where('failed_at','>',now()->subHour())->count();"
failed_jobs deja de crecer.
7. Cómo seguir optimizando¶
Pendientes ordenados por impacto/esfuerzo:
- Bajar el timeout HTTP de Medusa (
MEDUSA_HTTP_TIMEOUT, hoy 25s → sugerido 5–8s;MEDUSA_HTTP_CONNECT_TIMEOUT10 → 3). Hace que un Medusa lento corte rápido y el job use el fallback local dentro del timeout. (Mitiga; no elimina la dependencia.) - No llamar a Medusa de forma síncrona por producto en cada job de salida. Pre-calcular/pre-cachear precios de Medusa en un job batch previo, o leer el precio especial desde datos locales sincronizados. Es el fix de fondo.
- Subir el TTL del caché de precios (
ProductPriceService::CACHE_TTL, hoy 60s → varios minutos). En una corrida de miles de productos casi nunca pega. - Arreglar el lock de precio: alinear
LOCK_TIMEOUT_SECONDScon el trabajo real (o reducir el trabajo) para que deduplique de verdad; hoy es inefectivo. - Investigar por qué Medusa responde lento (carga del server, admin price list API). Es el gatillo de fondo de toda la cascada.
- Materializar
product_item_viewcomo tabla indexada (o reemplazar la vista) si más lugares necesitan el dato.forProduct()resuelve el acceso de a uno, pero la vista entera sigue siendo cara para listados. - Alinear
uniqueForde los jobs con su ciclo real de reintentos para que el dedup deShouldBeUnique*sea efectivo.
8. Lecciones / anti-patrones¶
- No filtrar por fuera de una vista con
GROUP BY— empujar el filtro adentro del cuerpo o no usar vista para acceso por PK. - Todo job que hace HTTP externo debe declarar
$timeout, yretry_afterde la conexión debe ser mayor que ese$timeout. MaxAttemptsExceededExceptioncasi siempre = job que excede el timeout del worker, no un bug de lógica. Mirar duraciones antes que el stack trace.- CPU ociosa + colas llenas = workers bloqueados en I/O (red/DB lenta), no falta de capacidad. No se arregla agregando workers.
- Cuidado al mergear ramas de deploy: en este incidente casi se despliega la
rama de retry sin el fix de la vista por un merge de la rama equivocada.
Verificar con
git merge-base --is-ancestor <commit> HEADantes de desplegar.