Files
Autoparts-DB/docs/performance_audit_2026.md
consultoria-as 175dda6212 FASE 7: Quick Wins de Performance — Optimización Fase 1
Cambios implementados:

1. Nginx:
   - gzip on (compresión JS/CSS/JSON)
   - Cache headers para assets estáticos (6M)
   - Proxy buffer tuning (10s connect, 30s read)

2. Frontend catalog.js:
   - Reemplazados 8x innerHTML += en loops por map+join
   - Event delegation en breadcrumb y cart (elimina memory leak)
   - AbortController en apiFetch (evita race conditions)
   - sessionStorage cache para years-all y brands por modo

3. Frontend templates HTML:
   - defer en todos los scripts POS (mejora First Paint)

4. Dashboard JS:
   - innerHTML += fix en dashboard.js y cuentas.js

5. Base de datos:
   - Índice parcial idx_wi_part_stock_positive en warehouse_inventory

6. Documentación:
   - docs/performance_audit_2026.md con análisis completo y roadmap

Tests: 73/73 pasando (compat + fase3 + fase5 + fase6)
2026-04-27 07:19:37 +00:00

21 KiB
Raw Blame History

Nexus Autoparts — Auditoría de Performance y Optimización

Fecha: 2026-04-26
Versión del sistema: FASE 6 completa + Opción C
Auditor realizado por: Análisis automatizado de codebase + métricas del sistema


1. Resumen Ejecutivo

El sistema presenta cuellos de botella severos concentrados en tres áreas:

Área Severidad Impacto estimado
Base de datos 🔴 Crítica Respuestas de catálogo de 500ms5s; riesgo de caída bajo carga concurrente
Frontend (POS) 🟠 Alta First paint lento, memory leaks, transferencia innecesaria de ~400 KB/página
Infraestructura 🟠 Alta Sin compresión, sin pooling de conexiones, Redis subutilizado
Arquitectura Python 🟡 Media Sync-only, cache por proceso, respuestas API no optimizadas

Métricas clave del sistema actual

Métrica Valor Umbral saludable
Tamaño tabla vehicle_parts 254 GB (~2.05B filas) < 50 GB o particionada
Índices vehicle_parts 149 GB (más que la tabla) < 50% del tamaño de tabla
Tamaño total DB maestra 257 GB
Redis utilizado 840 KB > 50 MB (cache activa)
JS transferido por página POS ~526 KB (sin comprimir) < 150 KB con gzip
CSS inline duplicado por página ~247 KB < 20 KB (compartido)
Connection pooling Ninguno Sí (min 2, max 1020)
Compresión HTTP (gzip/brotli) Deshabilitada

2. Base de Datos — Análisis Detallado

2.1 El elefante en la habitación: vehicle_parts (254 GB)

Esta tabla contiene la relación entre vehículos (model_year_engine) y piezas (parts). Es el corazón del catálogo y su tamaño es extremo.

Tabla:  105 GB
Índices: 149 GB
Total:   254 GB
Filas:   ~2,053,774,208 (estimado)

Índices actuales:

vehicle_parts_pkey        btree (id_vehicle_part)
idx_vehicle_parts_part    btree (part_id)
idx_vehicle_parts_mye     btree (model_year_engine_id)
uq_vehicle_part_mye_part  partial unique (model_year_engine_id, part_id)

Problemas:

  • Sin particionamiento por part_id o model_year_engine_id
  • Índices ocupan más espacio que la tabla (indicativo de índices anchos o muchos índices secundarios)
  • El índice idx_vehicle_parts_part probablemente se usa para queries que filtran por part_id = ANY(...), pero sin un índice compuesto (part_id, model_year_engine_id) forza lookups adicionales
  • COUNT(*) en esta tabla, aunque indexado, requiere verificar visibilidad de tuplas y puede ser lento a esta escala

Recomendación inmediata: Particionar vehicle_parts por rango de part_id (ej. cada 10M IDs) o por hash. Esto reduce el tamaño de índice por partición y permite paralelismo en consultas. Requiere planificación cuidadosa por el impacto en inserts.


2.2 Connection Pooling — Inexistente (🔴 Crítico)

Archivo: pos/tenant_db.py

def get_master_conn():
    return psycopg2.connect(MASTER_DB_URL)   # Conexión TCP nueva CADA request

def get_tenant_conn(tenant_id):
    ...
    return psycopg2.connect(TENANT_DB_URL_TEMPLATE.format(db_name=db_name))

Impacto:

  • Cada request HTTP abre mínimo 1 conexión TCP (a veces 2: master + tenant)
  • Overhead de handshake TCP + SSL + auth de PostgreSQL (~520ms por conexión)
  • Bajo carga concurrente (> 10 usuarios), PostgreSQL puede agotar max_connections (default 100)
  • Gunicorn corre con workers = cpu_count * 2 + 1 (~15 workers). Cada worker puede tener múltiples requests concurrentes = explosión de conexiones

Fix: Implementar psycopg2.pool.ThreadedConnectionPool:

from psycopg2 import pool

_master_pool = None
_tenant_pools = {}

def get_master_conn():
    global _master_pool
    if _master_pool is None:
        _master_pool = pool.ThreadedConnectionPool(
            minconn=2, maxconn=10, dsn=MASTER_DB_URL
        )
    return _master_pool.getconn()

def release_master_conn(conn):
    if _master_pool:
        _master_pool.putconn(conn)

Impacto esperado: 3050% reducción en latencia de API, eliminación de errores de "too many connections".


2.3 Full Table Scans en warehouse_inventory (🔴 Crítico)

Archivo: pos/services/catalog_service.py:964-969

stock_per_oem AS (
    SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price,
           SUM(stock_quantity) AS total_stock
    FROM warehouse_inventory
    WHERE stock_quantity > 0
    GROUP BY part_id
)

Esta CTE corre en cada página del catálogo Local. Aunque warehouse_inventory no sea gigante hoy, escanea toda la tabla sin índice adecuado.

Fix inmediato:

-- Índice parcial para stock positivo
CREATE INDEX idx_wi_part_stock_positive 
ON warehouse_inventory(part_id) 
WHERE stock_quantity > 0;

Además, reemplazar la CTE por una subquery que filtre por part_id = ANY(...):

SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price,
       SUM(stock_quantity) AS total_stock
FROM warehouse_inventory
WHERE part_id = ANY(%s) AND stock_quantity > 0
GROUP BY part_id

2.4 Agregaciones sin límites en inventory_operations (🟠 Alta)

Múltiples archivos: inventory_engine.py, inventory_vehicle_compat.py, peer_service.py

Patrón recurrente:

SELECT inventory_id, SUM(quantity) as stock
FROM inventory_operations
GROUP BY inventory_id

inventory_operations es append-only. Cada venta, compra, ajuste, transferencia agrega filas. Esta agregación escanea todas las operaciones históricas.

Fix recomendado:

  1. Corto plazo: Agregar índice compuesto y filtrar por fecha reciente si el negocio no requiere stock histórico:

    CREATE INDEX idx_inv_ops_inventory_branch_created 
    ON inventory_operations(inventory_id, branch_id, created_at DESC);
    
  2. Mediano plazo: Mantener una tabla de inventory_stock_summary actualizada por triggers:

    CREATE TABLE inventory_stock_summary (
        inventory_id INT PRIMARY KEY,
        branch_id INT,
        stock INT NOT NULL DEFAULT 0,
        last_updated TIMESTAMPTZ DEFAULT NOW()
    );
    

    Trigger en inventory_operations que actualiza el sumario en tiempo real. Las lecturas pasan de O(n operaciones) a O(1).


2.5 smart_search() — DISTINCT ON sobre 2B filas (🔴 Crítico)

Archivo: pos/services/catalog_service.py:1339-1349

SELECT DISTINCT ON (vp.part_id)
       vp.part_id, b.name_brand, m.name_model, y.year_car
FROM vehicle_parts vp
JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
WHERE vp.part_id = ANY(%s)
ORDER BY vp.part_id, y.year_car DESC

Para búsquedas que retornan 2050 parts, esta query hace JOIN con una tabla de 2B filas para obtener "el vehículo más reciente" de cada pieza.

Fix: Precalcular esta relación en una tabla materializada o cachear en Redis:

CREATE MATERIALIZED VIEW part_vehicle_preview AS
SELECT DISTINCT ON (vp.part_id)
       vp.part_id, b.name_brand, m.name_model, y.year_car
FROM vehicle_parts vp
JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
ORDER BY vp.part_id, y.year_car DESC;

CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id);
REFRESH MATERIALIZED VIEW CONCURRENTLY part_vehicle_preview;

2.6 N+1 Queries en ventas (🟠 Alta)

Archivo: pos/services/pos_engine.py:331-333

for item in totals['items']:
    cur.execute("SELECT retail_price FROM inventory WHERE id = %s", (item['inventory_id'],))
    rp_row = cur.fetchone()

Una venta de 20 ítems = 20 queries adicionales.

Fix: Cargar todos los precios en una sola query antes del loop:

inv_ids = [item['inventory_id'] for item in totals['items']]
cur.execute("SELECT id, retail_price FROM inventory WHERE id = ANY(%s)", (inv_ids,))
price_map = {row[0]: row[1] for row in cur.fetchall()}

2.7 Índices faltantes críticos

Tabla Índice faltante Queries afectadas
warehouse_inventory (part_id) WHERE stock_quantity > 0 get_parts_local, get_part_detail
warehouse_inventory (part_id, stock_quantity) Todas las bodega queries
inventory_operations (inventory_id, branch_id, created_at DESC) Stock + historial
inventory (branch_id, is_active, part_number) Stock local lookups
parts (name_part text_pattern_ops) get_part_types, _shop_supplies

3. Frontend — Análisis Detallado

3.1 JavaScript — Problemas críticos

innerHTML += en loops (🔴 Crítico)

Archivo: catalog.js líneas ~1578, 1584, 1608, etc.

years.forEach(function (y) {
    vsYear.innerHTML += '<option value="' + y.id_year + '">' + y.year_car + '</option>';
});

Cada iteración fuerza al browser a reparsear y re-renderizar todo el subtree. Complejidad O(N²).

Fix:

vsYear.innerHTML = '<option value="">Año...</option>' +
    years.map(y => `<option value="${y.id_year}">${y.year_car}</option>`).join('');

Memory leaks por event listeners (🔴 Crítico)

Archivo: catalog.js líneas 261275 (breadcrumb), 14671475 (cart)

Cada vez que se re-renderiza el breadcrumb o el carrito, se reemplaza innerHTML y se adjuntan nuevos listeners. Los listeners antiguos quedan huérfanos en memoria.

Fix: Event delegation — adjuntar UNA vez en el contenedor padre:

breadcrumb.addEventListener('click', function (e) {
    var el = e.target.closest('[data-bc-action]');
    if (!el) return;
    // dispatch
});

Race conditions por falta de AbortController (🟠 Alta)

Navegación rápida por catálogo + red lenta = respuestas desordenadas sobrescriben la UI.

Fix:

var currentAbort = null;
function apiFetch(url) {
    if (currentAbort) currentAbort.abort();
    currentAbort = new AbortController();
    return fetch(url, { signal: currentAbort.signal }).then(r => r.json());
}

3.2 CSS Inline masivo (🔴 Crítico)

Cada template HTML duplica CSS:

Página Tamaño HTML CSS inline estimado
catalog.html 49 KB ~570 líneas
inventory.html 73 KB ~2,000 líneas
pos.html 73 KB ~1,000 líneas

Impacto:

  • Cambiar un color requiere editar 10+ archivos
  • CSS no se cachea entre páginas (va dentro del HTML)
  • First paint retrasado parseando CSS redundante

Fix: Extraer todo CSS a pos/static/css/common.css y archivos temáticos. Reducción estimada: 6080% del payload HTML.

3.3 Assets sin optimizar (🟠 Alta)

  • Sin minificación: 526 KB de JS sin comprimir
  • Sin gzip/brotli en nginx: Reducción de 4060% gratis
  • Sin defer/async: 10 scripts bloquean el parser HTML
  • Sin lazy loading de imágenes: Todas las imágenes de piezas se cargan inmediatamente
  • Cache-busting destructivo: ?t=Date.now() en imágenes de inventario anula cache del browser

Fix rápido (1 hora):

# En nginx/nexus-pos.conf
gzip on;
gzip_types text/css application/javascript application/json;
gzip_min_length 1024;
<!-- En templates HTML -->
<script src="/pos/static/js/catalog.js" defer></script>
<img src="..." loading="lazy" decoding="async">

3.4 Polling innecesario (🟡 Media)

Módulo Intervalo Problema
WhatsApp status 3s Corre aunque el tab esté oculto
WhatsApp mensajes 5s Corre aunque el tab esté oculto
Auto-print queue 15s Siempre activo
Dashboard clock 1s Actualiza reloj cada segundo

Fix: Pausar polling cuando document.hidden:

function createVisiblePoller(fn, intervalMs) {
    return setInterval(() => { if (!document.hidden) fn(); }, intervalMs);
}

4. Infraestructura — Análisis Detallado

4.1 Nginx — Configuración mínima

Archivo: nginx/nexus-pos.conf

Problemas encontrados:

  • Sin gzip on
  • Sin expires headers para assets estáticos
  • Sin client_body_buffer_size / proxy_buffer_size tuning
  • Proxy timeouts default (60s) — podrían ser más agresivos para requests lentos
  • Sin rate limiting general (solo en login)

Fix recomendado:

# Dentro de cada server block
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json 
           application/javascript application/rss+xml 
           application/atom+xml image/svg+xml;

# Cache assets estáticos
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
    expires 6M;
    add_header Cache-Control "public, immutable";
}

# Proxy tuning
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;

4.2 Gunicorn — Sync workers

Archivo: pos/gunicorn.conf.py

workers = multiprocessing.cpu_count() * 2 + 1  # ~15 workers
worker_class = "sync"
timeout = 120

Con sync workers, cada worker maneja exactamente 1 request a la vez. Si un request al catálogo tarda 3 segundos (por DB lenta), ese worker está bloqueado.

15 workers × 1 request = 15 requests concurrentes máximo.

Fix: Migrar a gevent o gthread:

worker_class = "gthread"
threads = 4
workers = 4  # Menos workers, más threads por worker

Esto permite que un worker maneje múltiples requests concurrentes sin bloquearse en I/O de DB.

4.3 Redis — Subutilizado (🟠 Alta)

Métrica actual: 840 KB utilizados

Redis solo se usa para:

  • Cache de tasas de cambio (currency.py)
  • Cache de stock (redis_stock_cache.py)

NO se usa para:

  • Cache de queries de catálogo (la más costosa)
  • Cache de clasificación Nexpart (_classify_cache es in-memory por proceso)
  • Cache de búsquedas
  • Sesiones compartidas entre workers

Fix: Mover _classify_cache (Nexpart classification) a Redis:

# En catalog_service.py
import json
import redis
r = redis.from_url(os.environ.get('REDIS_URL', 'redis://localhost:6379/0'))

def _classify_cache_get(mye_id):
    val = r.get(f"nexus:classify:{mye_id}")
    return json.loads(val) if val else None

def _classify_cache_set(mye_id, data):
    r.setex(f"nexus:classify:{mye_id}", 300, json.dumps(data))

Beneficio: cache compartido entre los ~15 workers de gunicorn, no 15 cachés separados.

4.4 Flask — Sincrónico

Todo el backend es síncrono (Flask sync + psycopg2 sync). Las queries a vehicle_parts bloquean el thread del worker.

Consideración a futuro: Para endpoints de catálogo, evaluar migrar a un worker queue (Celery + Redis) para queries pesadas, o usar asyncpg + Quart/FastAPI para endpoints específicos.


5. Backend Python — Análisis Detallado

5.1 Cache por proceso vs. compartida

El cache de clasificación Nexpart (_CLASSIFY_CACHE) vive en memoria de cada proceso de gunicorn. Con 15 workers:

  • Cada worker tiene su propio cache de 500 entradas máximo
  • Un mye_id cacheado en worker A no está disponible en worker B
  • Cache hit rate teórico máximo: ~6.7% (1/15) si la carga se distribuye uniformemente

Fix: Redis, como se describe en 4.3.

5.2 Serialización JSON ineficiente

Flask jsonify() usa el encoder estándar de Python. Para respuestas grandes (listas de 30 parts con múltiples campos), esto puede ser lento.

Fix: Considerar orjson como encoder de Flask:

from flask import Flask
import orjson

class OrjsonProvider:
    def dumps(self, obj, **kwargs):
        return orjson.dumps(obj).decode('utf-8')
    def loads(self, s, **kwargs):
        return orjson.loads(s)

app = Flask(__name__)
app.json = OrjsonProvider()

orjson es 210× más rápido que json estándar.

5.3 Respuestas API sin paginación en algunos endpoints

Algunos endpoints retornan arrays completos sin límite:

  • get_categories() puede retornar todas las categorías (aceptable)
  • get_stock_bulk() escanea TODO inventory_operations
  • Historial de movimientos sin paginación en inventory_bp

Verificar: Que ningún endpoint retorne datasets sin bounds.


6. Roadmap de Optimización Priorizado

Fase 1 — Quick Wins (12 días, alto impacto)

# Tarea Archivos Impacto estimado
1.1 Agregar gzip en nginx nginx/nexus-pos.conf -4060% transfer size
1.2 Agregar defer a scripts Templates HTML -200500ms TTI
1.3 Reemplazar innerHTML += por map+join catalog.js, dashboard.js Elimina reflow storm
1.4 Event delegation en breadcrumb/cart catalog.js Elimina memory leak
1.5 AbortController en API calls catalog.js, inventory.js, pos.js Elimina race conditions
1.6 Agregar índice parcial warehouse_inventory(part_id) WHERE stock_quantity > 0 SQL Elimina seq scan
1.7 Cache de years/brands en sessionStorage catalog.js -2 API calls/página

Fase 2 — DB Performance (35 días, impacto masivo)

# Tarea Archivos Impacto estimado
2.1 Implementar connection pooling tenant_db.py, dashboard/auth.py -3050% latencia, sin connection errors
2.2 Crear tabla inventory_stock_summary con triggers SQL + inventory_engine.py Stock: O(n) → O(1)
2.3 Precalcular part_vehicle_preview materialized view SQL Búsqueda: -500ms2s
2.4 N+1 fix en process_sale pos_engine.py -20 queries por venta
2.5 Optimizar CTE stock_per_oem catalog_service.py Elimina full scan
2.6 Agregar índices faltantes críticos SQL Mejora plan de queries

Fase 3 — Cache & Infra (57 días)

# Tarea Archivos Impacto estimado
3.1 Mover _classify_cache a Redis catalog_service.py Cache hit: 6% → 80%+
3.2 Cachear queries de catálogo en Redis catalog_bp.py, catalog_service.py -300ms1s por query
3.3 Migrar gunicorn a gthread gunicorn.conf.py 4× concurrency
3.4 Agregar loading="lazy" a imágenes catalog.js -60% image bytes inicial
3.5 Extraer CSS inline a archivos compartidos Todos los .html -6080% payload HTML
3.6 Minificar JS/CSS en deploy CI/CD script -4060% transfer

Fase 4 — Arquitectura (24 semanas, inversión mayor)

# Tarea Archivos Impacto estimado
4.1 Particionar vehicle_parts SQL Escalabilidad ilimitada
4.2 Evaluar asyncpg + Quart para endpoints de catálogo Blueprints I/O no bloqueante
4.3 Migrar serialización a orjson app.py 210× faster JSON
4.4 Implementar virtual scroll en tablas grandes inventory.js, dashboard.js Smooth 1000+ rows
4.5 Worker queue (Celery) para tareas pesadas Nuevo módulo No bloquear requests HTTP

7. Métricas de éxito propuestas

Después de implementar Fase 1 + Fase 2, el sistema debería alcanzar:

Métrica Antes Objetivo Cómo medir
Latencia catálogo Local (p95) 500ms3s < 200ms Gunicorn access logs
Latencia búsqueda (p95) 500ms2s < 150ms Gunicorn access logs
Tiempo First Paint ~1.5s < 800ms Lighthouse
Transferencia JS por página 526 KB < 200 KB DevTools Network
Conexiones DB por request 12 nuevas 0 (pool) pg_stat_activity
Redis hit rate < 1% > 70% INFO stats
Memory leak (30 min browsing) +50100 MB < +10 MB Chrome DevTools

8. Riesgos y Consideraciones

Riesgo Mitigación
Particionar vehicle_parts es complejo y riesgoso Hacer en staging primero; usar pg_partman; mantener backup
Connection pooling requiere manejar putconn en todos los blueprints Refactorizar tenant_db.py para usar context managers (with get_conn():)
Migrar a gthread puede exponer race conditions en código no thread-safe Pruebas exhaustivas; gthread es más seguro que gevent para código sync existente
Cache Redis agrega punto de falla Siempre tener fallback a DB directo; Redis no es crítico para operación
inventory_stock_summary requiere triggers correctos Tests de integración para todas las operaciones de stock

Documento generado automáticamente a partir del análisis de codebase, métricas de sistema y mejores prácticas de performance.