La implementación de un sistema de programación de tareas robusto en .NET requiere una abstracción clara sobre el motor de ejecución. A continuación, se presenta una arquitectura utilitaria basada en Quartz.NET que centraliza la creación, modificación y ciclo de vida de los trabajos, integrando persistencia de configuraciones y gestión de entornos alojados en IIS.
Configuración del Planificador y Fábrica de Instancisa
El núcleo del sistema reside en una clase estática que inicializa el ISchedulerFactory con parámetros optimizados para el grupo de subprocesos y la identidad de la instancia. Se encapsulan las operaciones de registro y control de disparadores, aprovechando patrones asíncronos nativos del motor.
using Quartz;
using Quartz.Impl;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Infraestructura.Programacion
{
public static class GestorQuartz
{
private static readonly ISchedulerFactory _fabrica;
static GestorQuartz()
{
var configuracion = new System.Collections.Specialized.NameValueCollection
{
["quartz.scheduler.instanceName"] = "PlanificadorCentral",
["quartz.threadPool.type"] = "Quartz.Simpl.SimpleThreadPool, Quartz",
["quartz.threadPool.threadCount"] = "25",
["quartz.threadPool.threadPriority"] = "Normal"
};
_fabrica = new StdSchedulerFactory(configuracion);
}
public static async Task<bool> RegistrarTrabajoCronAsync(Type tipoClase, string identificador, string grupo, string cron, DateTime? inicio = null, DateTime? fin = null, IDictionary<string object=""> datos = null)
{
if (!typeof(IJob).IsAssignableFrom(tipoClase) || string.IsNullOrWhiteSpace(cron)) return false;
try
{
var scheduler = await _fabrica.GetScheduler();
var fechaInicio = DateBuilder.NextGivenSecondDate(inicio ?? DateTime.Now, 1);
var fechaFin = fin.HasValue ? DateBuilder.NextGivenSecondDate(fin.Value, 1) : (DateTimeOffset?)null;
var trabajo = JobBuilder.Create(tipoClase)
.WithIdentity(identificador, grupo)
.UsingJobData(new JobDataMap(datos ?? new Dictionary<string object="">()))
.Build();
var disparador = TriggerBuilder.Create()
.WithIdentity(identificador, grupo)
.StartAt(fechaInicio)
.EndAt(fechaFin)
.WithCronSchedule(cron)
.Build();
await scheduler.ScheduleJob(trabajo, disparador);
return true;
}
catch { return false; }
}
public static async Task<bool> RegistrarTrabajoSimpleAsync(Type tipoClase, string identificador, string grupo, int repeticiones, int intervaloSegundos, DateTime? inicio = null, DateTime? fin = null, IDictionary<string object=""> datos = null)
{
if (!typeof(IJob).IsAssignableFrom(tipoClase)) return false;
intervaloSegundos = Math.Max(1, intervaloSegundos);
repeticiones = Math.Max(-1, repeticiones);
try
{
var scheduler = await _fabrica.GetScheduler();
var fechaInicio = DateBuilder.NextGivenSecondDate(inicio ?? DateTime.Now, 1);
var fechaFin = fin.HasValue ? DateBuilder.NextGivenSecondDate(fin.Value, 1) : (DateTimeOffset?)null;
var trabajo = JobBuilder.Create(tipoClase)
.WithIdentity(identificador, grupo)
.UsingJobData(new JobDataMap(datos ?? new Dictionary<string object="">()))
.Build();
var disparador = TriggerBuilder.Create()
.WithIdentity(identificador, grupo)
.StartAt(fechaInicio)
.EndAt(fechaFin)
.WithSimpleSchedule(x => x.WithIntervalInSeconds(intervaloSegundos).WithRepeatCount(repeticiones))
.Build();
await scheduler.ScheduleJob(trabajo, disparador);
return true;
}
catch { return false; }
}
public static async Task<bool> ActualizarCronAsync(string identificador, string grupo, string nuevoCron)
{
try
{
var scheduler = await _fabrica.GetScheduler();
var claveDisparador = new TriggerKey(identificador, grupo);
var disparadorActual = await scheduler.GetTrigger(claveDisparador) as ICronTrigger;
if (disparadorActual == null || disparadorActual.CronExpressionString == nuevoCron) return false;
await scheduler.PauseTrigger(claveDisparador);
disparadorActual.CronExpressionString = nuevoCron;
await scheduler.RescheduleJob(claveDisparador, disparadorActual);
return true;
}
catch { return false; }
}
public static async Task<bool> EliminarTrabajoAsync(string identificador, string grupo)
{
try
{
var scheduler = await _fabrica.GetScheduler();
var claveDisparador = new TriggerKey(identificador, grupo);
await scheduler.PauseTrigger(claveDisparador);
await scheduler.UnscheduleJob(claveDisparador);
return await scheduler.DeleteJob(new JobKey(identificador, grupo));
}
catch { return false; }
}
public static async Task<triggerstate> ObtenerEstadoAsync(string identificador, string grupo)
{
try
{
var scheduler = await _fabrica.GetScheduler();
return scheduler.IsStarted ? await scheduler.GetTriggerState(new TriggerKey(identificador, grupo)) : TriggerState.None;
}
catch { return TriggerState.None; }
}
public static async Task IniciarPlanificadorAsync() => await (await _fabrica.GetScheduler()).Start();
public static async Task DetenerPlanificadorAsync(bool esperarFinalizacion) => await (await _fabrica.GetScheduler()).Shutdown(esperarFinalizacion);
}
}</triggerstate></bool></bool></string></string></bool></string></string></bool>
Orquestación y Persistencia de Configuraciones
Para desacoplar la lógica de negocio del motor de programación, se implementa una capa de gestión que valida los metadatos, construye las expresiones de tiempo y sincroniza el estado con el almacén de datos. Esta capa actúa como puente entre la interfaz de administración y el planificador.
using Infraestructura.Programacion;
using System;
using System.Linq;
using System.Text;
namespace Servicios.Tareas
{
public static class OrquestadorDeTareas
{
public static bool ConfigurarYIniciarCron(RegistroTarea config, out string mensaje)
{
mensaje = string.Empty;
if (!ValidarConfiguracionBase(config, out mensaje)) return false;
var tipoJob = Type.GetType(config.NombreClaseEjecutora);
if (tipoJob == null || !typeof(IJob).IsAssignableFrom(tipoJob))
{
mensaje = "La clase ejecutora no es válida o no implementa IJob.";
return false;
}
config.Grupo = string.IsNullOrWhiteSpace(config.Grupo) ? config.Nombre : config.Grupo;
using var contexto = new ContextoBaseDatos();
if (contexto.Tareas.Any(t => t.Nombre == config.Nombre && t.Grupo == config.Grupo))
{
mensaje = $"Ya existe una tarea registrada en el grupo '{config.Grupo}'.";
return false;
}
config.EsTareaSimple = false;
config.FechaRegistro = DateTime.Now;
if (config.Activo)
{
var cronFinal = ConstruirExpresionCron(config);
var exito = GestorQuartz.RegistrarTrabajoCronAsync(tipoJob, config.Nombre, config.Grupo, cronFinal, config.Inicio, config.Fin).Result;
config.Estado = exito ? EstadoEjecucion.Activo : EstadoEjecucion.EnEspera;
if (!exito) mensaje = "Error al vincular la tarea con el planificador.";
}
else
{
config.Estado = EstadoEjecucion.EnEspera;
}
contexto.Tareas.Add(config);
contexto.SaveChanges();
return string.IsNullOrEmpty(mensaje);
}
public static bool ConfigurarYIniciarSimple(RegistroTarea config, out string mensaje)
{
mensaje = string.Empty;
if (!ValidarConfiguracionBase(config, out mensaje)) return false;
if (!config.Fin.HasValue)
{
mensaje = "Las tareas simples requieren una fecha de finalización explícita.";
return false;
}
var tipoJob = Type.GetType(config.NombreClaseEjecutora);
if (tipoJob == null || !typeof(IJob).IsAssignableFrom(tipoJob))
{
mensaje = "Tipo de ejecutor inválido.";
return false;
}
config.Repeticiones ??= 0;
config.IntervaloSegundos ??= 1;
config.Grupo = string.IsNullOrWhiteSpace(config.Grupo) ? config.Nombre : config.Grupo;
config.EsTareaSimple = true;
config.FechaRegistro = DateTime.Now;
using var contexto = new ContextoBaseDatos();
if (contexto.Tareas.Any(t => t.Nombre == config.Nombre && t.Grupo == config.Grupo))
{
mensaje = "Conflicto de identidad: tarea duplicada.";
return false;
}
if (config.Activo)
{
var exito = GestorQuartz.RegistrarTrabajoSimpleAsync(tipoJob, config.Nombre, config.Grupo, config.Repeticiones.Value, config.IntervaloSegundos.Value, config.Inicio, config.Fin).Result;
config.Estado = exito ? EstadoEjecucion.Activo : EstadoEjecucion.EnEspera;
if (!exito) mensaje = "Fallo en la programación del disparador simple.";
}
else
{
config.Estado = EstadoEjecucion.EnEspera;
}
contexto.Tareas.Add(config);
contexto.SaveChanges();
return string.IsNullOrEmpty(mensaje);
}
public static void SincronizarTareasActivas()
{
using var db = new ContextoBaseDatos();
var pendientes = db.Tareas.Where(t => t.Activo).ToList();
foreach (var item in pendientes)
{
var tipo = Type.GetType(item.NombreClaseEjecutora);
if (tipo == null)
{
item.MensajeError = "Clase no encontrada en el ensamblado actual.";
item.Estado = EstadoEjecucion.EnEspera;
continue;
}
bool resultado;
if (!item.EsTareaSimple)
{
resultado = GestorQuartz.RegistrarTrabajoCronAsync(tipo, item.Nombre, item.Grupo, ConstruirExpresionCron(item), item.Inicio, item.Fin).Result;
}
else
{
if (!item.Fin.HasValue || item.Fin.Value <= DateTime.Now)
{
item.MensajeError = "Ventana de ejecución expirada.";
item.Activo = false;
item.Estado = EstadoEjecucion.EnEspera;
continue;
}
resultado = GestorQuartz.RegistrarTrabajoSimpleAsync(tipo, item.Nombre, item.Grupo, item.Repeticiones ?? 0, item.IntervaloSegundos ?? 1, item.Inicio, item.Fin).Result;
}
item.Estado = resultado ? EstadoEjecucion.Activo : EstadoEjecucion.EnEspera;
item.MensajeError = resultado ? null : "Error de inicialización.";
item.FechaModificacion = DateTime.Now;
}
db.SaveChanges();
GestorQuartz.IniciarPlanificadorAsync().Wait();
}
private static bool ValidarConfiguracionBase(RegistroTarea cfg, out string error)
{
error = string.Empty;
if (cfg == null) { error = "Configuración nula."; return false; }
if (string.IsNullOrWhiteSpace(cfg.Nombre)) { error = "Identificador de tarea obligatorio."; return false; }
if (string.IsNullOrWhiteSpace(cfg.NombreClaseEjecutora)) { error = "Nombre de clase ejecutora obligatorio."; return false; }
return true;
}
private static string ConstruirExpresionCron(RegistroTarea cfg)
{
var sb = new StringBuilder();
sb.Append(string.IsNullOrWhiteSpace(cfg.CronSegundo) ? "*" : cfg.CronSegundo).Append(" ");
sb.Append(string.IsNullOrWhiteSpace(cfg.CronMinuto) ? "*" : cfg.CronMinuto).Append(" ");
sb.Append(string.IsNullOrWhiteSpace(cfg.CronHora) ? "*" : cfg.CronHora).Append(" ");
sb.Append(string.IsNullOrWhiteSpace(cfg.CronDia) ? "*" : cfg.CronDia).Append(" ");
sb.Append(string.IsNullOrWhiteSpace(cfg.CronMes) ? "*" : cfg.CronMes).Append(" ");
sb.Append(string.IsNullOrWhiteSpace(cfg.CronSemana) ? "?" : cfg.CronSemana).Append(" ");
sb.Append(string.IsNullOrWhiteSpace(cfg.CronAnio) ? "" : cfg.CronAnio);
return sb.ToString().Trim();
}
}
}
Mantenimiento Automático y Modelo de Datos
Las tareas temporales o de un solo uso generan registros históricos que deben depurarse periódicamente para evitar la saturación de la tabla de configuración. Se implementa un trabajo dedicado a esta limpieza, junto con el modelo de entidad y su mapeo relacional.
using Quartz;
using System.Linq;
using System.Threading.Tasks;
namespace Servicios.Tareas.Mantenimiento
{
[DisallowConcurrentExecution]
public class JobDepuracionHistorial : IJob
{
public async Task Execute(IJobExecutionContext context)
{
using var db = new ContextoBaseDatos();
var limite = System.DateTime.Now.AddDays(-30);
var obsoletos = db.Tareas.Where(t => t.EsTareaSimple && t.FechaRegistro < limite).ToList();
if (obsoletos.Any())
{
db.Tareas.RemoveRange(obsoletos);
await db.SaveChangesAsync();
}
}
}
}
using System;
using System.ComponentModel.DataAnnotations;
using System.Data.Entity.ModelConfiguration;
namespace Dominio.Modelos
{
public enum EstadoEjecucion
{
EnEspera = 0,
Activo = 10,
Pausado = 20
}
public class RegistroTarea
{
[Key]
public int Id { get; set; }
[Required, StringLength(50)]
public string Nombre { get; set; }
[Required, StringLength(50)]
public string Grupo { get; set; }
[Required, StringLength(200)]
public string NombreClaseEjecutora { get; set; }
[StringLength(500)]
public string Descripcion { get; set; }
public EstadoEjecucion Estado { get; set; }
public bool Activo { get; set; } = true;
public bool EsTareaSimple { get; set; }
public DateTime? Inicio { get; set; }
public DateTime? Fin { get; set; }
[StringLength(50)] public string CronSegundo { get; set; }
[StringLength(50)] public string CronMinuto { get; set; }
[StringLength(50)] public string CronHora { get; set; }
[StringLength(50)] public string CronDia { get; set; }
[StringLength(50)] public string CronMes { get; set; }
[StringLength(50)] public string CronSemana { get; set; }
[StringLength(50)] public string CronAnio { get; set; }
[StringLength(500)]
public string MensajeError { get; set; }
public int? Repeticiones { get; set; }
public int? IntervaloSegundos { get; set; }
public DateTime FechaRegistro { get; set; } = DateTime.Now;
public DateTime? FechaModificacion { get; set; }
}
public class MapaRegistroTarea : EntityTypeConfiguration<registrotarea>
{
public MapaRegistroTarea()
{
ToTable("ConfiguracionTareasProgramadas");
Property(p => p.Descripcion).IsOptional();
Property(p => p.CronAnio).IsOptional();
Property(p => p.MensajeError).IsOptional();
Property(p => p.Repeticiones).IsOptional();
Property(p => p.IntervaloSegundos).IsOptional();
Property(p => p.Inicio).IsOptional();
Property(p => p.Fin).IsOptional();
}
}
}</registrotarea>
Consideraciones para Entornos IIS
Cuando el planificador se ejecuta dentro de una aplicación web alojada en Internet Information Services, el ciclo de vida del grupo de aplicaciones puede interrumpir la ejecución de las tareas programadas. Para garantizar la continuidad del servicio, es necesario ajustar la configuración del Application Pool asociado al sitio:
- Reciclaje por intervalo fijo: Establecer en
0para desactivar el reinicio automático por tiempo. - Límite de memoria virtual/privada: Configurar en
0para evitar reciclajes basados en consumo de RAM. - Tiempo de espera por inactividad (Idle Timeout): Fijar en
0en la sección de modelo de proceso, impidiendo que el worker process se detenga cuando no hay solicitudes HTTP entrantes.
Estos ajustes aseguran que el hilo del planificador permanezca activo independientemente del tráfico web, manteniendo la precisión de los disparadores Cron y Simple.