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 ✓¶
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:
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¶
- [x] Verificar registro de ClickUpSyncService y SentryService
- [x] Implementar IServiceScopeFactory para operaciones en background
- [ ] Agregar transacciones explícitas donde sea necesario
- [ ] Considerar patrón Outbox para sincronización asíncrona
- [ ] Agregar tests de concurrencia
10. CORRECCIÓN IMPLEMENTADA (2026-02-01)¶
Cambios Realizados en BacklogService.cs¶
1. Añadido Import de 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¶
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 |