Saltar a contenido

Análisis de Concurrencia - BacklogService

Fecha: 2026-02-01 Archivo: Shared/Shared.Admin/Services/BacklogService.cs Problema: InvalidOperationException por uso concurrente de DbContext


1. Resumen Ejecutivo

Aspecto Estado
Inyección de DbContext Directa (campo _db)
Registro en DI AddScoped (correcto)
Problemas de concurrencia 3 ocurrencias identificadas
Severidad ALTA - puede causar corrupción de datos

2. Cómo se Inyecta el DbContext

Líneas 36-51: Constructor

public class BacklogService : IBacklogService
{
    private readonly NexusDbContext _db;  // Línea 36 - Inyección directa
    private readonly ILogger<BacklogService> _logger;
    private readonly IClickUpSyncService? _clickUpSyncService;
    private readonly SentryService? _sentryService;

    public BacklogService(
        NexusDbContext db,               // Línea 42 - Parámetro
        ILogger<BacklogService> logger,
        IClickUpSyncService? clickUpSyncService = null,
        SentryService? sentryService = null)
    {
        _db = db;                        // Línea 47 - Asignación
        _logger = logger;
        _clickUpSyncService = clickUpSyncService;
        _sentryService = sentryService;
    }
}

Registro en DI (correcto)

// Orchestrator.Api/Program.cs:171
builder.Services.AddScoped<IBacklogService, BacklogService>();

// Orchestrator.Workers/Program.cs:106
builder.Services.AddScoped<IBacklogService, BacklogService>();

El servicio está registrado correctamente como Scoped, lo cual es adecuado.


3. Puntos de Error de Concurrencia Identificados

PROBLEMA 1: ResolveAsync - Task.Run con DbContext compartido

Ubicación: Líneas 437-461 y 467-490

public async Task<BacklogItemDetailDto?> ResolveAsync(Guid id, string resolution, string? resolvedBy)
{
    var item = await _db.BacklogItems              // Línea 411 - Usa _db
        .Include(b => b.Project)
        .FirstOrDefaultAsync(b => b.Id == id);

    // ... modificaciones a item ...

    await _db.SaveChangesAsync();                  // Línea 427 - SaveChanges

    // PROBLEMA: Task.Run ejecuta en otro hilo pero usa _clickUpSyncService
    // que podría usar el mismo DbContext o tener dependencias con él

    if (!string.IsNullOrEmpty(item.ClickUpTaskId) && _clickUpSyncService != null)
    {
        syncTasks.Add(Task.Run(async () =>         // Línea 437 - PROBLEMA
        {
            try
            {
                // _clickUpSyncService podría usar el DbContext internamente
                var result = await _clickUpSyncService.UpdateTaskAsync(item);
                // ...
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error syncing BacklogItem {Id} to ClickUp", id);
            }
        }));
    }

    // PROBLEMA 2: Otro Task.Run para Sentry
    if (!string.IsNullOrEmpty(item.SentryIssueId) && _sentryService != null)
    {
        syncTasks.Add(Task.Run(async () =>         // Línea 467 - PROBLEMA
        {
            try
            {
                var resolved = await _sentryService.ResolveIssueAsync(
                    item.ProjectId.Value,
                    item.SentryIssueId);
                // ...
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error resolving Sentry issue");
            }
        }));
    }

    // Fire-and-forget sin await - los Tasks siguen ejecutando
    if (syncTasks.Count > 0)
    {
        _ = Task.WhenAll(syncTasks);               // Línea 495 - No awaited
    }

    return await GetByIdAsync(id);                 // Línea 498 - Usa _db nuevamente
}

Diagnóstico: 1. Se hace SaveChangesAsync() en línea 427 2. Se lanzan Task.Run() que pueden acceder a servicios con DbContext 3. Sin await, se retorna y llama GetByIdAsync(id) que usa _db 4. Si los Tasks en background aún están ejecutando y acceden a datos relacionados, puede haber conflicto

PROBLEMA 2: GetQuickItemsAsync - Múltiples consultas secuenciales

Ubicación: Líneas 141-212

public async Task<List<BacklogQuickItemDto>> GetQuickItemsAsync(Guid? projectId = null, int limit = 5)
{
    var query = _db.BacklogItems                   // Consulta 1
        .Where(b => b.Status == "pending" || b.Status == "in_progress")
        .AsQueryable();

    var backlogItems = await query
        .OrderBy(...)
        .Take(limit)
        .ToListAsync();                            // Línea 155

    var itemIds = backlogItems.Select(b => b.Id).ToList();
    var analyses = await _db.BacklogAIAnalyses     // Consulta 2 - Línea 159
        .Where(a => itemIds.Contains(a.BacklogItemId))
        .ToListAsync();

    // ...

    var agents = agentIds.Count > 0
        ? await _db.Agents.Where(a => agentIds.Contains(a.Slug)).ToListAsync()  // Consulta 3 - Línea 173
        : new List<Entities.Agent>();

    // ...
}

Diagnóstico: Este método hace 3 consultas secuenciales al DbContext. Si otro hilo accede al mismo DbContext entre estas consultas (por ejemplo, desde ResolveAsync con Task.Run), se produce el error de concurrencia.

PROBLEMA 3: GenerateFromFailedExecutionsAsync - Loop con múltiples operaciones

Ubicación: Líneas 550-574

public async Task<int> GenerateFromFailedExecutionsAsync(DateTime since)
{
    var failedExecutions = await _db.Executions    // Consulta inicial
        .Where(e => (e.Status == "failed" || e.Success == false) && e.StartedAt >= since)
        .Where(e => !_db.BacklogItems.Any(b => b.ExecutionId == e.Id))
        .ToListAsync();

    int created = 0;
    foreach (var exec in failedExecutions)
    {
        try
        {
            await CreateFromExecutionAsync(exec.Id);  // Usa _db internamente - Línea 562
            created++;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to create backlog from execution {Id}", exec.Id);
        }
    }
    // ...
}

Diagnóstico: El método CreateFromExecutionAsync hace múltiples operaciones con _db: - Consulta _db.Executions.Include(...).FirstOrDefaultAsync() - Consulta _db.BacklogItems.AnyAsync() - Inserta _db.BacklogItems.Add() - Guarda _db.SaveChangesAsync()

Si este método se llama concurrentemente (ej: desde un worker y desde la API), ambas instancias del servicio podrían compartir el mismo DbContext si están en el mismo scope.


4. Patrones que Causan Compartir DbContext entre Hilos

Patrón 1: Task.Run con servicios inyectados

// INCORRECTO - Task.Run hereda el contexto del hilo padre pero el DbContext no es thread-safe
syncTasks.Add(Task.Run(async () =>
{
    await _clickUpSyncService.UpdateTaskAsync(item);  // ClickUpSyncService puede usar DbContext
}));

Patrón 2: Fire-and-forget sin nuevo scope

// INCORRECTO - No se espera el resultado, pero el Task sigue usando recursos compartidos
_ = Task.WhenAll(syncTasks);
return await GetByIdAsync(id);  // DbContext puede estar en uso por los Tasks

Patrón 3: Servicios Singleton que usan servicios Scoped

Si ClickUpSyncService o SentryService son Singleton y tienen dependencia indirecta con DbContext, hay problemas.


5. Verificación de Servicios Relacionados

ClickUpSyncService - SCOPED ✓

// Orchestrator.Api/Program.cs:320
builder.Services.AddScoped<IClickUpSyncService>(sp => ...);

// Orchestrator.Workers/Program.cs:111
builder.Services.AddScoped<IClickUpSyncService>(sp => ...);

Estado: Registrado como Scoped - PROBLEMA CONFIRMADO

Cuando Task.Run ejecuta _clickUpSyncService.UpdateTaskAsync(item), el servicio ClickUpSyncService ya fue resuelto del scope original. Si ClickUpSyncService usa NexusDbContext internamente, comparte el mismo DbContext con el hilo principal, causando la excepción.

SentryService - SCOPED ✓

// Orchestrator.Api/Program.cs:404
builder.Services.AddScoped<SentryService>();

Estado: Registrado como Scoped - PROBLEMA CONFIRMADO

Mismo problema que ClickUpSyncService cuando se usa dentro de Task.Run.


6. Resumen de Líneas Problemáticas

Línea Método Problema
437 ResolveAsync Task.Run con ClickUpSyncService
467 ResolveAsync Task.Run con SentryService
495 ResolveAsync Fire-and-forget sin await
498 ResolveAsync GetByIdAsync mientras Tasks ejecutan
159 GetQuickItemsAsync Múltiples consultas secuenciales
173 GetQuickItemsAsync Tercera consulta sin transacción
562 GenerateFromFailedExecutionsAsync Loop con múltiples operaciones DB

Paso 2/5: Verificación de Configuración de DbContext

Registro de NexusDbContext en Program.cs

Archivo: Orchestrator/src/Orchestrator.Api/Program.cs

// Líneas 114-128 - AddDbContext (Scoped por defecto)
builder.Services.AddDbContext<NexusDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("NexusDb"),
        npgsqlOptions =>
        {
            npgsqlOptions.MigrationsAssembly("Orchestrator.Api");
            npgsqlOptions.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorCodesToAdd: null);
            npgsqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
        }));

// Líneas 132-145 - Factory con Scoped explícito
builder.Services.AddDbContextFactory<NexusDbContext>(options =>
    options.UseNpgsql(...),
    ServiceLifetime.Scoped);  // ✅ Scoped explícito

Estado: ✅ CORRECTO - AddDbContext registra el DbContext como Scoped por defecto.

Tabla de Verificación de Servicios

Servicio Lifetime Archivo:Línea Estado
NexusDbContext Scoped Program.cs:114 ✅ Correcto
IDbContextFactory<> Scoped Program.cs:132 ✅ Correcto
BacklogService Scoped Program.cs:171 ✅ Correcto
ClickUpSyncService Scoped Program.cs:320 ✅ Correcto
SentryService Scoped Program.cs:404 ✅ Correcto
DbScheduleService Singleton Program.cs:148 ✅ Usa ScopeFactory
DbExecutionHistoryService Singleton Program.cs:149 ✅ Usa ScopeFactory
DbProjectService Singleton Program.cs:150 ✅ Usa ScopeFactory

⚠️ Conclusión del Paso 2

El problema NO está en la configuración de DI.

Todos los servicios están registrados correctamente: - NexusDbContext es Scoped (correcto para EF Core) - Los servicios que lo usan son Scoped (comparten el mismo ciclo de vida) - Los servicios Singleton usan IServiceScopeFactory (patrón correcto)

El problema está en el CÓDIGO de BacklogService.ResolveAsync:

Cuando el DI resuelve BacklogService: 1. DI crea NexusDbContext (Scoped - 1 instancia por request) 2. DI crea ClickUpSyncService con el MISMO NexusDbContext 3. DI crea BacklogService con el MISMO NexusDbContext

Todos comparten la misma instancia de DbContext. Al ejecutar Task.Run, los servicios inyectados siguen usando esa instancia compartida, causando acceso concurrente desde múltiples hilos.

Ejemplo de Servicio Singleton Correcto (DbScheduleService)

// DbScheduleService.cs:44-47 - PATRÓN CORRECTO PARA SINGLETON
public async Task<List<ScheduledTask>> GetEnabledTasksAsync()
{
    using var scope = _scopeFactory.CreateScope();  // Nuevo scope
    var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();  // Nuevo DbContext
    return await db.ScheduledTasks.ToListAsync();
}

Este patrón debe aplicarse en BacklogService.ResolveAsync para los Task.Run.


7. Recomendaciones

Opción A: Usar IServiceScopeFactory para Task.Run

// Inyectar IServiceScopeFactory en el constructor
private readonly IServiceScopeFactory _scopeFactory;

// En ResolveAsync
syncTasks.Add(Task.Run(async () =>
{
    using var scope = _scopeFactory.CreateScope();
    var clickUpService = scope.ServiceProvider.GetRequiredService<IClickUpSyncService>();
    await clickUpService.UpdateTaskAsync(item);
}));

Opción B: Usar Background Service dedicado

Crear un servicio de sincronización en background que procese una cola:

await _syncQueue.EnqueueAsync(new SyncClickUpTask(item.Id));

Opción C: Await explícito con manejo de errores

// En lugar de fire-and-forget
try
{
    if (syncTasks.Count > 0)
    {
        await Task.WhenAll(syncTasks);
    }
}
catch (Exception ex)
{
    _logger.LogError(ex, "Error in sync tasks");
}

8. Impacto del Error

Síntoma Descripción
InvalidOperationException "A second operation was started on this context instance before a previous operation completed"
Datos corruptos Cambios parciales si una operación falla a mitad
Deadlocks Si dos hilos esperan recursos del otro
Memory leaks DbContext no liberado correctamente

9. Próximos Pasos

  1. [x] Verificar registro de ClickUpSyncService y SentryService
  2. [x] Implementar IServiceScopeFactory para operaciones en background
  3. [ ] Agregar transacciones explícitas donde sea necesario
  4. [ ] Considerar patrón Outbox para sincronización asíncrona
  5. [ ] Agregar tests de concurrencia

10. CORRECCIÓN IMPLEMENTADA (2026-02-01)

Cambios Realizados en BacklogService.cs

1. Añadido Import de Microsoft.Extensions.DependencyInjection

using Microsoft.Extensions.DependencyInjection;

2. Modificado Constructor para Inyectar IServiceScopeFactory

private readonly IServiceScopeFactory _scopeFactory;

public BacklogService(
    NexusDbContext db,
    ILogger<BacklogService> logger,
    IServiceScopeFactory scopeFactory,  // NUEVO: Para crear scopes en background tasks
    IClickUpSyncService? clickUpSyncService = null,
    SentryService? sentryService = null)
{
    _db = db;
    _logger = logger;
    _scopeFactory = scopeFactory;  // NUEVO
    _clickUpSyncService = clickUpSyncService;
    _sentryService = sentryService;
}

3. Refactorizado ResolveAsync

El método ResolveAsync ahora: 1. Captura los valores necesarios de la entidad ANTES de lanzar tareas en background 2. Llama a un nuevo método StartBackgroundSyncTasks que crea scopes independientes

public async Task<BacklogItemDetailDto?> ResolveAsync(Guid id, string resolution, string? resolvedBy)
{
    // ... operaciones en DbContext principal ...

    // Capturar valores ANTES de lanzar background tasks
    var clickUpTaskId = item.ClickUpTaskId;
    var sentryIssueId = item.SentryIssueId;
    var projectId = item.ProjectId;

    // Iniciar tareas en background con scopes independientes
    StartBackgroundSyncTasks(id, clickUpTaskId, sentryIssueId, projectId, ...);

    return await GetByIdAsync(id);
}

4. Nuevo Método StartBackgroundSyncTasks

private void StartBackgroundSyncTasks(
    Guid backlogItemId,
    string? clickUpTaskId,
    string? sentryIssueId,
    Guid? projectId,
    ...)
{
    // Para ClickUp
    if (!string.IsNullOrEmpty(clickUpTaskId))
    {
        _ = Task.Run(async () =>
        {
            // NUEVO: Crear scope independiente
            using var scope = _scopeFactory.CreateScope();
            var clickUpService = scope.ServiceProvider.GetService<IClickUpSyncService>();
            var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<NexusDbContext>>();
            var logger = scope.ServiceProvider.GetRequiredService<ILogger<BacklogService>>();

            // NUEVO: Obtener entidad fresca con nuevo DbContext
            await using var db = await dbContextFactory.CreateDbContextAsync();
            var item = await db.BacklogItems.FindAsync(backlogItemId);

            // Usar servicio con entidad fresca
            var result = await clickUpService.UpdateTaskAsync(item);
        });
    }

    // Similar para Sentry...
}

Patrón Correcto Implementado

┌──────────────────────────────────────────────────────────────────┐
│                    REQUEST SCOPE                                  │
│  ┌─────────────┐                                                  │
│  │ DbContext   │ ◄── Usado por métodos síncronos                  │
│  └─────────────┘                                                  │
│                                                                   │
│  await _db.SaveChangesAsync();  ✅ Completo antes de Task.Run    │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘
       │  StartBackgroundSyncTasks()
┌──────────────────────────────────────────────────────────────────┐
│               BACKGROUND TASK SCOPE 1                             │
│  ┌─────────────────────┐                                         │
│  │ scope.CreateScope() │                                         │
│  └─────────────────────┘                                         │
│         │                                                         │
│         ▼                                                         │
│  ┌──────────────────────┐   ┌──────────────────────┐             │
│  │ NUEVO DbContext      │   │ NUEVO ClickUpService │             │
│  └──────────────────────┘   └──────────────────────┘             │
│         │                           │                             │
│         └───────────────────────────┘                             │
│                    │                                              │
│                    ▼                                              │
│           ✅ Sin conflicto de concurrencia                        │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

Compilación

Build succeeded.
    0 Error(s)
    12 Warning(s) (preexistentes, no relacionados con estos cambios)

Estado Final

Problema Líneas Originales Estado
Task.Run con ClickUpSyncService 437-461 ✅ CORREGIDO
Task.Run con SentryService 467-490 ✅ CORREGIDO
Fire-and-forget sin await 495 ✅ CORREGIDO
DbContext usado mientras Tasks ejecutan 498 ✅ CORREGIDO