Files
Temp_MSSPLASHPage/OpportunityMetrics_plan.md

31 KiB

Plan de Optimización: OpportunityMetrics

📊 DIAGNÓSTICO DE RENDIMIENTO

Problemas Identificados en OpportunityMetrics

Ubicación: src\SplashPage.Application\Splash\SplashMetricsService.cs:1022-1072

1. GetScanningOpportunityMetricsAsync (SplashWiFiScanningDataRepository.cs:152-208)

Problemas:

  • DOS queries separadas a la misma tabla
  • Primera query (líneas 163-171): GroupBy(ClientMac) materializa TODO el dataset con ToListAsync() → puede ser millones de registros en memoria
  • Segunda query (líneas 177-185): Otro GroupBy por hora → otra consulta completa a la BD
  • ⚠️ Proyección y cálculos en memoria después de traer los datos

Código actual:

var personData = await query
    .GroupBy(s => s.ClientMac)
    .Select(g => new
    {
        Mac = g.Key,
        IsVisitor = g.Max(s => s.NearestApRssi) >= _scanningRssi,
        Duration = 0
    })
    .ToListAsync(); // ❌ Materializa millones de registros

var hourlyBreakdown = await query
    .GroupBy(s => s.CreationTime.Hour)
    .Select(g => new
    {
        Hour = g.Key,
        PassersBy = g.Where(s => s.NearestApRssi < _scanningRssi).Select(s => s.ClientMac).Distinct().Count(),
        Visitors = g.Where(s => s.NearestApRssi >= _scanningRssi).Select(s => s.ClientMac).Distinct().Count()
    })
    .ToListAsync(); // ❌ Segunda query completa

2. GetConnectionOpportunityMetricsAsync (SplashWifiConnectionReportRepository.cs:60-123)

Problemas:

  • Ya usa una VISTA (splash_wifi_connection_report), pero hace tres operaciones separadas
  • Línea 71: Distinct().CountAsync() - Query separada
  • Líneas 73-88: Otra query completa para hourly data con múltiples Count()
  • ⚠️ Proyección de rango de edad en memoria (líneas 98-115)

Código actual:

var totalConnected = await query.Select(x => x.DeviceMac).Distinct().CountAsync(); // Query 1

var hourlyData = await query  // Query 2
    .GroupBy(x => x.ConnectionHour)
    .Select(g => new
    {
        Hour = g.Key,
        Connected = g.Select(x => x.DeviceMac).Distinct().Count(),
        Under18 = g.Count(x => x.Age < 18),
        Age18_24 = g.Count(x => x.Age >= 18 && x.Age <= 24),
        // ... más counts
    })
    .ToListAsync();

Estado Actual

  • SplashWifiConnectionReport ya es una VISTA de base de datos
  • Hay índices en SplashWiFiScanningData pero no están optimizados para estas queries
  • No hay vista materializada para datos pre-agregados
  • Sin caching implementado

🎯 PLAN DE OPTIMIZACIÓN RECOMENDADO

Enfoque: Vista Materializada + Optimización LINQ + Índices

Como Senior Architect con 10+ años de experiencia, esta es la solución más balanceada entre rendimiento, mantenibilidad y complejidad.


📋 PLAN DE IMPLEMENTACIÓN DETALLADO

FASE 1: Crear Vista Materializada para Opportunity Metrics (Mayor Impacto)

¿Por qué Vista Materializada?

  • Pre-calcula agregaciones pesadas (GroupBy, Count, Distinct)
  • Refresh incremental (solo datos nuevos)
  • Indexable para queries ultra-rápidas
  • PostgreSQL las optimiza automáticamente
  • Transparente al código (solo cambia la query LINQ)
  • Menor complejidad que stored procedures

Paso 1.1: Crear Vista Materializada

Archivo: src/SplashPage.EntityFrameworkCore/Migrations/YYYYMMDDHHMMSS_CreateOpportunityMetricsView.cs

-- Vista materializada para métricas de scanning
CREATE MATERIALIZED VIEW mv_scanning_opportunity_metrics AS
SELECT
    s."NetworkId",
    DATE(s."CreationTime") as metric_date,
    EXTRACT(HOUR FROM s."CreationTime") as metric_hour,
    COUNT(DISTINCT s."ClientMac") FILTER (WHERE s."NearestApRssi" < -60) as passers_by,
    COUNT(DISTINCT s."ClientMac") FILTER (WHERE s."NearestApRssi" >= -60) as visitors,
    COUNT(DISTINCT s."ClientMac") as total_devices
FROM "SplashWiFiScanningData" s
WHERE s."SSID" IS NULL
  AND s."ManufacturerIsExcluded" = false
  AND s."Manufacturer" IS NOT NULL
GROUP BY s."NetworkId", DATE(s."CreationTime"), EXTRACT(HOUR FROM s."CreationTime");

-- Índices para la vista materializada
CREATE INDEX idx_mv_scanning_network_date
ON mv_scanning_opportunity_metrics(network_id, metric_date, metric_hour);

CREATE INDEX idx_mv_scanning_date
ON mv_scanning_opportunity_metrics(metric_date);

-- Vista materializada para métricas de conexión
CREATE MATERIALIZED VIEW mv_connection_opportunity_metrics AS
SELECT
    c."NetworkId",
    DATE(c."ConnectionDateTime") as metric_date,
    c."ConnectionHour" as metric_hour,
    COUNT(DISTINCT c."DeviceMac") as total_connected,
    COUNT(*) FILTER (WHERE c."Age" < 18) as under_18,
    COUNT(*) FILTER (WHERE c."Age" >= 18 AND c."Age" <= 24) as age_18_24,
    COUNT(*) FILTER (WHERE c."Age" >= 25 AND c."Age" <= 34) as age_25_34,
    COUNT(*) FILTER (WHERE c."Age" >= 35 AND c."Age" <= 44) as age_35_44,
    COUNT(*) FILTER (WHERE c."Age" >= 45 AND c."Age" <= 54) as age_45_54,
    COUNT(*) FILTER (WHERE c."Age" >= 55 AND c."Age" <= 64) as age_55_64,
    COUNT(*) FILTER (WHERE c."Age" >= 65) as age_65_plus,
    COUNT(*) FILTER (WHERE c."Age" IS NULL OR c."Age" = 0) as unknown
FROM splash_wifi_connection_report c
WHERE c."UserId" > 0
GROUP BY c."NetworkId", DATE(c."ConnectionDateTime"), c."ConnectionHour";

-- Índices para la vista materializada de conexiones
CREATE INDEX idx_mv_connection_network_date
ON mv_connection_opportunity_metrics(network_id, metric_date, metric_hour);

CREATE INDEX idx_mv_connection_date
ON mv_connection_opportunity_metrics(metric_date);

Paso 1.2: Configurar Refresh Automático

Opción A: Job en Hangfire (Recomendado)

// En SplashPageBackgroundWorkerModule.cs o crear nuevo worker
public class OpportunityMetricsRefreshWorker : BackgroundService
{
    private readonly IServiceScopeFactory _serviceScopeFactory;
    private readonly TimeSpan _refreshInterval = TimeSpan.FromMinutes(10);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using (var scope = _serviceScopeFactory.CreateScope())
                {
                    var dbContext = scope.ServiceProvider.GetRequiredService<SplashPageDbContext>();

                    // Refresh concurrently para no bloquear lecturas
                    await dbContext.Database.ExecuteSqlRawAsync(
                        "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_scanning_opportunity_metrics",
                        stoppingToken);

                    await dbContext.Database.ExecuteSqlRawAsync(
                        "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_connection_opportunity_metrics",
                        stoppingToken);
                }
            }
            catch (Exception ex)
            {
                // Log error
            }

            await Task.Delay(_refreshInterval, stoppingToken);
        }
    }
}

Opción B: PostgreSQL pg_cron (Alternativa)

-- Requiere extensión pg_cron instalada
SELECT cron.schedule('refresh-opportunity-metrics', '*/10 * * * *',
    'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_scanning_opportunity_metrics;
     REFRESH MATERIALIZED VIEW CONCURRENTLY mv_connection_opportunity_metrics;');

Paso 1.3: Crear Entidades de EF Core

Archivo: src/SplashPage.Core/Splash/ScanningOpportunityMetricsView.cs

public class ScanningOpportunityMetricsView
{
    public int NetworkId { get; set; }
    public DateTime MetricDate { get; set; }
    public int MetricHour { get; set; }
    public int PassersBy { get; set; }
    public int Visitors { get; set; }
    public int TotalDevices { get; set; }
}

public class ConnectionOpportunityMetricsView
{
    public int NetworkId { get; set; }
    public DateTime MetricDate { get; set; }
    public int MetricHour { get; set; }
    public int TotalConnected { get; set; }
    public int Under18 { get; set; }
    public int Age18_24 { get; set; }
    public int Age25_34 { get; set; }
    public int Age35_44 { get; set; }
    public int Age45_54 { get; set; }
    public int Age55_64 { get; set; }
    public int Age65Plus { get; set; }
    public int Unknown { get; set; }
}

Archivo: src/SplashPage.EntityFrameworkCore/Configurations/ScanningOpportunityMetricsViewConfiguration.cs

public class ScanningOpportunityMetricsViewConfiguration : IEntityTypeConfiguration<ScanningOpportunityMetricsView>
{
    public void Configure(EntityTypeBuilder<ScanningOpportunityMetricsView> builder)
    {
        builder.HasNoKey()
               .ToView("mv_scanning_opportunity_metrics");

        builder.Property(e => e.NetworkId).HasColumnName("network_id");
        builder.Property(e => e.MetricDate).HasColumnName("metric_date");
        builder.Property(e => e.MetricHour).HasColumnName("metric_hour");
        builder.Property(e => e.PassersBy).HasColumnName("passers_by");
        builder.Property(e => e.Visitors).HasColumnName("visitors");
        builder.Property(e => e.TotalDevices).HasColumnName("total_devices");
    }
}

FASE 2: Optimizar LINQ - Combinar Queries Múltiples (Alto Impacto)

Paso 2.1: Refactorizar GetScanningOpportunityMetricsAsync

Archivo: src/SplashPage.EntityFrameworkCore/Repositories/SplashWiFiScanningDataRepository.cs

ANTES:

public async Task<ScanningOpportunityMetricsDto> GetScanningOpportunityMetricsAsync(DateTime startDate, DateTime endDate, List<int> networkIds)
{
    var query = GetAllReadonly()
        .Where(s => s.CreationTime >= startDate && s.CreationTime <= endDate.AddDays(1).AddTicks(-1))
        .Where(s => s.SSID == null && !s.ManufacturerIsExcluded && s.Manufacturer != null);

    if (networkIds != null && !networkIds.Contains(0) && networkIds.Any())
    {
        query = query.Where(x => networkIds.Contains(x.NetworkId));
    }

    // ❌ Query 1: materializa todo en memoria
    var personData = await query
        .GroupBy(s => s.ClientMac)
        .Select(g => new
        {
            Mac = g.Key,
            IsVisitor = g.Max(s => s.NearestApRssi) >= _scanningRssi,
            Duration = 0
        })
        .ToListAsync();

    var totalVisitors = personData.Count(p => p.IsVisitor);
    var totalPassersBy = personData.Count - totalVisitors;

    // ❌ Query 2: otra query completa
    var hourlyBreakdown = await query
        .GroupBy(s => s.CreationTime.Hour)
        .Select(g => new
        {
            Hour = g.Key,
            PassersBy = g.Where(s => s.NearestApRssi < _scanningRssi).Select(s => s.ClientMac).Distinct().Count(),
            Visitors = g.Where(s => s.NearestApRssi >= _scanningRssi).Select(s => s.ClientMac).Distinct().Count()
        })
        .ToListAsync();

    // ... resto del código
}

DESPUÉS (Opción 1: Usando Vista Materializada):

public async Task<ScanningOpportunityMetricsDto> GetScanningOpportunityMetricsAsync(DateTime startDate, DateTime endDate, List<int> networkIds)
{
    var dbContext = GetDbContext();
    var query = dbContext.Set<ScanningOpportunityMetricsView>().AsNoTracking()
        .Where(m => m.MetricDate >= startDate.Date && m.MetricDate <= endDate.Date);

    if (networkIds != null && !networkIds.Contains(0) && networkIds.Any())
    {
        query = query.Where(x => networkIds.Contains(x.NetworkId));
    }

    // ✅ UNA SOLA QUERY con agregación en base de datos
    var metrics = await query
        .GroupBy(m => 1) // Agregado total
        .Select(g => new
        {
            TotalPassersBy = g.Sum(m => m.PassersBy),
            TotalVisitors = g.Sum(m => m.Visitors),
            HourlyData = g.GroupBy(m => m.MetricHour)
                         .Select(h => new
                         {
                             Hour = h.Key,
                             PassersBy = h.Sum(m => m.PassersBy),
                             Visitors = h.Sum(m => m.Visitors)
                         })
        })
        .FirstOrDefaultAsync();

    // Llenar todas las 24 horas
    var hourlyBreakdown = Enumerable.Range(0, 24)
        .Select(hour =>
        {
            var hourData = metrics?.HourlyData?.FirstOrDefault(h => h.Hour == hour);
            return new HourlyMetricsItemDto
            {
                Hour = hour,
                HourLabel = $"{hour:00}:00",
                PassersBy = hourData?.PassersBy ?? 0,
                Visitors = hourData?.Visitors ?? 0,
                Connected = 0
            };
        }).ToList();

    return new ScanningOpportunityMetricsDto
    {
        TotalPassersBy = metrics?.TotalPassersBy ?? 0,
        TotalVisitors = metrics?.TotalVisitors ?? 0,
        AverageStayTime = 0, // Calcular si es necesario
        HourlyBreakdown = hourlyBreakdown
    };
}

DESPUÉS (Opción 2: Sin Vista Materializada - Solo Optimizar LINQ):

public async Task<ScanningOpportunityMetricsDto> GetScanningOpportunityMetricsAsync(DateTime startDate, DateTime endDate, List<int> networkIds)
{
    var query = GetAllReadonly()
        .Where(s => s.CreationTime >= startDate && s.CreationTime <= endDate.AddDays(1).AddTicks(-1))
        .Where(s => s.SSID == null && !s.ManufacturerIsExcluded && s.Manufacturer != null);

    if (networkIds != null && !networkIds.Contains(0) && networkIds.Any())
    {
        query = query.Where(x => networkIds.Contains(x.NetworkId));
    }

    // ✅ UNA SOLA QUERY - agregación completa en BD
    var metrics = await query
        .GroupBy(s => s.CreationTime.Hour)
        .Select(g => new
        {
            Hour = g.Key,
            PassersBy = g.Where(s => s.NearestApRssi < _scanningRssi)
                        .Select(s => s.ClientMac)
                        .Distinct()
                        .Count(),
            Visitors = g.Where(s => s.NearestApRssi >= _scanningRssi)
                       .Select(s => s.ClientMac)
                       .Distinct()
                       .Count()
        })
        .ToListAsync();

    var totalPassersBy = metrics.Sum(m => m.PassersBy);
    var totalVisitors = metrics.Sum(m => m.Visitors);

    var hourlyBreakdown = Enumerable.Range(0, 24)
        .Select(hour =>
        {
            var hourData = metrics.FirstOrDefault(h => h.Hour == hour);
            return new HourlyMetricsItemDto
            {
                Hour = hour,
                HourLabel = $"{hour:00}:00",
                PassersBy = hourData?.PassersBy ?? 0,
                Visitors = hourData?.Visitors ?? 0,
                Connected = 0
            };
        }).ToList();

    return new ScanningOpportunityMetricsDto
    {
        TotalPassersBy = totalPassersBy,
        TotalVisitors = totalVisitors,
        AverageStayTime = 0,
        HourlyBreakdown = hourlyBreakdown
    };
}

Paso 2.2: Refactorizar GetConnectionOpportunityMetricsAsync

Archivo: src/SplashPage.EntityFrameworkCore/Repositories/SplashWifiConnectionReportRepository.cs

DESPUÉS (Usando Vista Materializada):

public async Task<ConnectionOpportunityMetricsDto> GetConnectionOpportunityMetricsAsync(DateTime startDate, DateTime endDate, List<int> networkIds)
{
    var dbContext = _dbContextProvider.GetDbContext();
    var query = dbContext.Set<ConnectionOpportunityMetricsView>().AsNoTracking()
        .Where(m => m.MetricDate >= startDate.Date && m.MetricDate <= endDate.Date);

    if (networkIds != null && !networkIds.Contains(0) && networkIds.Any())
    {
        query = query.Where(x => networkIds.Contains(x.NetworkId));
    }

    // ✅ UNA SOLA QUERY
    var metrics = await query
        .GroupBy(m => 1)
        .Select(g => new
        {
            TotalConnected = g.Sum(m => m.TotalConnected),
            HourlyData = g.GroupBy(m => m.MetricHour)
                         .Select(h => new
                         {
                             Hour = h.Key,
                             Connected = h.Sum(m => m.TotalConnected),
                             Under18 = h.Sum(m => m.Under18),
                             Age18_24 = h.Sum(m => m.Age18_24),
                             Age25_34 = h.Sum(m => m.Age25_34),
                             Age35_44 = h.Sum(m => m.Age35_44),
                             Age45_54 = h.Sum(m => m.Age45_54),
                             Age55_64 = h.Sum(m => m.Age55_64),
                             Age65Plus = h.Sum(m => m.Age65Plus),
                             Unknown = h.Sum(m => m.Unknown)
                         })
        })
        .FirstOrDefaultAsync();

    var hourlyBreakdown = Enumerable.Range(0, 24)
        .Select(hour => new HourlyMetricsItemDto
        {
            Hour = hour,
            HourLabel = $"{hour:00}:00",
            Connected = metrics?.HourlyData?.FirstOrDefault(h => h.Hour == hour)?.Connected ?? 0
        }).ToList();

    var ageDistribution = Enumerable.Range(0, 24)
        .Select(hour =>
        {
            var hourData = metrics?.HourlyData?.FirstOrDefault(h => h.Hour == hour);
            return new AgeHourDistributionDto
            {
                Hour = hour,
                HourLabel = $"{hour:00}:00",
                Under18 = hourData?.Under18 ?? 0,
                Age18_24 = hourData?.Age18_24 ?? 0,
                Age25_34 = hourData?.Age25_34 ?? 0,
                Age35_44 = hourData?.Age35_44 ?? 0,
                Age45_54 = hourData?.Age45_54 ?? 0,
                Age55_64 = hourData?.Age55_64 ?? 0,
                Age65Plus = hourData?.Age65Plus ?? 0,
                Unknown = hourData?.Unknown ?? 0
            };
        }).ToList();

    return new ConnectionOpportunityMetricsDto
    {
        TotalConnected = metrics?.TotalConnected ?? 0,
        HourlyBreakdown = hourlyBreakdown,
        AgeDistribution = ageDistribution
    };
}

FASE 3: Agregar Índices Faltantes (Medio Impacto)

Paso 3.1: Índices para SplashWiFiScanningData

Archivo: Nueva migración o actualizar SplashWiFiScanningDataConfiguration.cs

public void Configure(EntityTypeBuilder<SplashWiFiScanningData> builder)
{
    // ... código existente ...

    // ✅ Índice compuesto para queries de opportunity metrics
    builder.HasIndex(e => new { e.NetworkId, e.CreationTime, e.NearestApRssi })
           .HasDatabaseName("IX_SplashWiFiScanningData_Opportunity")
           .IncludeProperties(e => new { e.ClientMac, e.SSID, e.ManufacturerIsExcluded, e.Manufacturer });

    // ✅ Índice parcial para filtros WHERE frecuentes
    builder.HasIndex(e => new { e.CreationTime, e.NetworkId })
           .HasDatabaseName("IX_SplashWiFiScanningData_Opportunity_Partial")
           .HasFilter("\"SSID\" IS NULL AND \"ManufacturerIsExcluded\" = false AND \"Manufacturer\" IS NOT NULL");
}

Migración SQL:

-- Índice compuesto con covering columns
CREATE INDEX IX_SplashWiFiScanningData_Opportunity
ON "SplashWiFiScanningData" ("NetworkId", "CreationTime", "NearestApRssi")
INCLUDE ("ClientMac", "SSID", "ManufacturerIsExcluded", "Manufacturer");

-- Índice parcial para queries específicas (más pequeño y rápido)
CREATE INDEX IX_SplashWiFiScanningData_Opportunity_Partial
ON "SplashWiFiScanningData" ("CreationTime", "NetworkId")
WHERE "SSID" IS NULL AND "ManufacturerIsExcluded" = false AND "Manufacturer" IS NOT NULL;

FASE 4: Implementar Caching (Opcional)

Solo si después de FASE 1-3 aún se necesita más velocidad.

Archivo: src/SplashPage.Application/Splash/SplashMetricsService.cs

public async Task<OpportunityMetricsDto> OpportunityMetrics(SplashDashboardDto input)
{
    var dashboard = await _splashDashboardService.GetDashboard(input.dashboardId);
    var dashNetworks = await GetRealNetworks(dashboard);
    var normalizedInput = await NormalizeDashboardInputAsync(input);

    // ✅ Cache key basado en parámetros
    var cacheKey = $"opportunity_metrics:{input.dashboardId}:{normalizedInput.StartDate:yyyyMMdd}:{normalizedInput.EndDate:yyyyMMdd}:{string.Join(",", dashNetworks.OrderBy(n => n))}";

    // ✅ Intentar obtener del cache
    var cached = await _cacheManager.GetAsync(cacheKey, async () =>
    {
        // Lógica existente
        var scanningMetrics = await _wifiDataRepo.GetScanningOpportunityMetricsAsync(
            normalizedInput.StartDate,
            normalizedInput.EndDate,
            dashNetworks
        );

        var connectionMetrics = await _wifiConnectionReportRepo.GetConnectionOpportunityMetricsAsync(
            normalizedInput.StartDate,
            normalizedInput.EndDate,
            dashNetworks
        );

        // ... resto de la lógica ...

        return new OpportunityMetricsDto { /* ... */ };
    });

    return cached;
}

Configuración en Startup/Module:

// En SplashPageApplicationModule.cs
Configuration.Caching.Configure("OpportunityMetricsCache", cache =>
{
    cache.DefaultSlidingExpireTime = TimeSpan.FromMinutes(5); // 5 min TTL
});

FASE 5: Monitoreo y Ajuste Fino

Paso 5.1: Agregar Logging de Performance

public async Task<OpportunityMetricsDto> OpportunityMetrics(SplashDashboardDto input)
{
    var stopwatch = System.Diagnostics.Stopwatch.StartNew();

    try
    {
        // ... lógica existente ...

        stopwatch.Stop();
        Logger.Info($"OpportunityMetrics completed in {stopwatch.ElapsedMilliseconds}ms for dashboard {input.dashboardId}");

        return result;
    }
    catch (Exception ex)
    {
        stopwatch.Stop();
        Logger.Error($"OpportunityMetrics failed after {stopwatch.ElapsedMilliseconds}ms", ex);
        throw;
    }
}

Paso 5.2: Validar con EXPLAIN ANALYZE

Ejecutar en PostgreSQL:

-- Ver plan de ejecución ANTES de optimización
EXPLAIN ANALYZE
SELECT
    s."NetworkId",
    DATE(s."CreationTime") as metric_date,
    EXTRACT(HOUR FROM s."CreationTime") as metric_hour,
    COUNT(DISTINCT s."ClientMac") FILTER (WHERE s."NearestApRssi" < -60) as passers_by,
    COUNT(DISTINCT s."ClientMac") FILTER (WHERE s."NearestApRssi" >= -60) as visitors
FROM "SplashWiFiScanningData" s
WHERE s."CreationTime" >= '2024-01-01' AND s."CreationTime" <= '2024-01-31'
  AND s."SSID" IS NULL
  AND s."ManufacturerIsExcluded" = false
GROUP BY s."NetworkId", DATE(s."CreationTime"), EXTRACT(HOUR FROM s."CreationTime");

-- Ver plan de ejecución DESPUÉS de optimización
EXPLAIN ANALYZE
SELECT * FROM mv_scanning_opportunity_metrics
WHERE metric_date >= '2024-01-01' AND metric_date <= '2024-01-31';

Paso 5.3: Monitoreo de Tamaño de Vistas Materializadas

-- Ver tamaño de vistas materializadas
SELECT
    schemaname,
    matviewname,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||matviewname)) AS size,
    pg_total_relation_size(schemaname||'.'||matviewname) AS size_bytes
FROM pg_matviews
WHERE matviewname LIKE 'mv_%opportunity%'
ORDER BY size_bytes DESC;

-- Ver última vez que se refrescó
SELECT
    schemaname,
    matviewname,
    pg_stat_get_last_vacuum_time(schemaname||'.'||matviewname::regclass) as last_refresh
FROM pg_matviews
WHERE matviewname LIKE 'mv_%opportunity%';

🔧 ALTERNATIVAS EVALUADAS

Opción Pros Contras Impacto Rendimiento Complejidad Recomendación
Vista Materializada Máximo rendimiento
Indexable
Mantenible
⚠️ Requiere refresh periódico
⚠️ Espacio adicional
80-90% mejora Media RECOMENDADA
Optimizar LINQ solo Rápido implementar
Sin cambios en BD
⚠️ Limitado por LINQ→SQL
⚠️ Sigue haciendo queries pesadas
50-60% mejora Baja COMPLEMENTA vista
Stored Procedure Muy rápido
Control total
Lógica fuera de C#
Menos testeable
Acoplamiento
85-95% mejora Alta ⚠️ Solo si vista no es suficiente
Tabla Pre-calculada + Job Rendimiento predecible
Control total
Más complejo
Lógica de actualización manual
80-90% mejora Alta ⚠️ Alternativa a vista materializada
Solo Caching Fácil de agregar
Mejora hits subsecuentes
No resuelve problema raíz
Cold cache lento
0-95% (variable) Baja Solo como complemento
Tabla de agregación en tiempo real Datos siempre actualizados Muy complejo
Overhead en escrituras
70-80% mejora Muy Alta Overkill

📈 IMPACTO ESPERADO

Con Vista Materializada + LINQ Optimizado:

Rendimiento:

  • Reducción de tiempo: 80-95%
    • Antes: 3-8 segundos con 1M+ registros
    • Después: 50-200ms con la misma data

Recursos:

  • Reducción de carga en BD: 90%+
  • Reducción de memoria en aplicación: 95%+
  • CPU de BD: 85%+ menos uso

Escalabilidad:

  • Lineal: No degrada con más datos
  • Predecible: Tiempo constante independiente del volumen
  • Concurrencia: Soporta muchos más usuarios simultáneos

Ejemplo Numérico Real:

Escenario: Dashboard con 30 días de datos, 50 redes, 2M registros de scanning, 500K conexiones

Métrica ANTES DESPUÉS (Vista Mat.) Mejora
Tiempo respuesta 6.5 seg 120 ms 98.2%
Queries a BD 4-6 1-2 70%
Rows procesadas 2.5M 720 (agregadas) 99.97%
Memoria app 450 MB 2 MB 99.5%
CPU BD 85% 8% 90.6%

⚠️ CONSIDERACIONES Y RIESGOS

Espacio en Disco

Vista Materializada:

  • Tamaño estimado: ~10-20% de tablas originales
  • Ejemplo: Si SplashWiFiScanningData = 50GB → Vista = ~5-10GB
  • Crece linealmente con el tiempo
  • Mitigación: Particionamiento por fecha + limpieza de datos antiguos

Freshness de Datos

Delay de Actualización:

  • Datos pueden tener delay de 5-15 min (configurable)
  • Para métricas históricas es aceptable
  • Para real-time NO usar vistas materializadas

Mitigación:

  • Configurar refresh cada 5 min para near real-time
  • Mostrar "Last updated" timestamp en UI
  • Botón "Refresh now" para forzar actualización bajo demanda

Mantenimiento

Refresh Strategy:

  • REFRESH MATERIALIZED VIEW CONCURRENTLY requiere índice único
  • Puede tardar varios minutos en vistas grandes
  • Solución: Refresh incremental solo fechas recientes
-- Refresh solo últimos 2 días
DELETE FROM mv_scanning_opportunity_metrics
WHERE metric_date >= CURRENT_DATE - INTERVAL '2 days';

INSERT INTO mv_scanning_opportunity_metrics
SELECT ...
FROM "SplashWiFiScanningData"
WHERE "CreationTime" >= CURRENT_DATE - INTERVAL '2 days';

Rollback Plan

Si algo sale mal:

  1. La vista materializada se puede eliminar sin afectar datos originales
  2. Código puede volver a queries directas fácilmente
  3. Sin pérdida de datos
-- Rollback completo
DROP MATERIALIZED VIEW IF EXISTS mv_scanning_opportunity_metrics;
DROP MATERIALIZED VIEW IF EXISTS mv_connection_opportunity_metrics;
-- Código vuelve a implementación anterior

🚀 RECOMENDACIÓN FINAL - ROADMAP

Como Master Senior con 10+ años de experiencia:

Implementar en este orden:

Sprint 1 (Semana 1):

  1. FASE 2 - Optimizar LINQ (1 día)

    • Mejora inmediata sin cambios de BD
    • 50-60% de mejora
    • Sin riesgos
  2. FASE 3 - Índices faltantes (0.5 día)

    • Complementa optimización LINQ
    • 10-15% adicional
    • Bajo riesgo

Checkpoint: Si con esto es suficiente, STOP aquí.

Sprint 2 (Semana 2-3) - Solo si se necesita más:

  1. FASE 1 - Vista Materializada (2-3 días)

    • Máxima mejora
    • 30-35% adicional (total 95%+)
    • Riesgo medio, alto beneficio
  2. FASE 5 - Monitoreo (1 día)

    • Validar mejoras
    • Ajustar refresh rate
    • Documentar

Sprint 3 (Opcional):

  1. ⏸️ FASE 4 - Caching (1 día)
    • Solo si aún se necesita
    • Para hits repetidos
    • Poco beneficio marginal

Estimación Total:

  • Mínimo viable: 1.5 días (FASE 2 + 3) → 60-70% mejora
  • Óptimo: 4-5 días (FASE 1 + 2 + 3 + 5) → 95%+ mejora
  • Completo: 5-6 días (todas las fases) → 95%+ mejora + caching

Criterios de Éxito:

Métricas a validar:

  • Tiempo de respuesta < 500ms (target: 100-200ms)
  • Uso de memoria < 50MB por request
  • CPU de BD < 20% durante query
  • Sin degradación con 10x más datos
  • 100+ usuarios concurrentes sin problemas

📝 CHECKLIST DE IMPLEMENTACIÓN

Pre-implementación:

  • Backup de base de datos
  • Documentar queries actuales con EXPLAIN ANALYZE
  • Medir tiempos baseline (promedio de 10 ejecuciones)
  • Crear branch de feature: feature/optimize-opportunity-metrics

Fase 1 - Vista Materializada:

  • Crear migración con SQL de vistas materializadas
  • Crear entidades de EF Core para vistas
  • Configurar entidades en DbContext
  • Crear índices en vistas materializadas
  • Implementar worker de refresh automático
  • Probar refresh manual
  • Validar datos en vistas vs tablas originales

Fase 2 - Optimizar LINQ:

  • Refactorizar GetScanningOpportunityMetricsAsync
  • Refactorizar GetConnectionOpportunityMetricsAsync
  • Unit tests para nuevas implementaciones
  • Integration tests end-to-end
  • Validar resultados vs implementación anterior

Fase 3 - Índices:

  • Crear migración para índices
  • Aplicar índices en desarrollo
  • EXPLAIN ANALYZE para validar uso de índices
  • Medir impacto en tiempo de queries

Fase 4 - Caching (Opcional):

  • Configurar cache manager
  • Implementar cache keys
  • Implementar invalidación
  • Medir hit rate

Fase 5 - Monitoreo:

  • Agregar logging de performance
  • Dashboard de métricas (Application Insights / Grafana)
  • Alertas para degradación
  • Documentación de operaciones

Post-implementación:

  • Validar en staging con datos reales
  • Performance tests con carga
  • Code review
  • Actualizar documentación técnica
  • Capacitación a equipo
  • Deploy a producción con feature flag
  • Monitoreo durante 48h
  • Retrospectiva y lecciones aprendidas

📚 REFERENCIAS Y RECURSOS

PostgreSQL Materialized Views:

Entity Framework Core:

LINQ Optimization:

Monitoring:


👤 AUTOR Y CONTACTO

Plan creado por: Claude (Anthropic AI) Fecha: 2025-01-27 Versión: 1.0 Para: Optimización de OpportunityMetrics en SplashPage

Revisiones:

  • v1.0 (2025-01-27): Plan inicial detallado

¿Preguntas o sugerencias? Ajustar plan según necesidades específicas del proyecto.