Initial version
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EonaCat.FluentScheduler\EonaCat.FluentScheduler.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using EonaCat.FluentScheduler;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task Main()
|
||||
{
|
||||
// Configure logger
|
||||
Scheduler.SetLogger(Console.WriteLine);
|
||||
|
||||
// Fluent tasks
|
||||
FluentScheduler.Schedule(async () =>
|
||||
{
|
||||
Console.WriteLine($"Daily task executed at {DateTime.Now}");
|
||||
}).EveryDayAt(8, 30);
|
||||
|
||||
FluentScheduler.Schedule(async () =>
|
||||
{
|
||||
Console.WriteLine($"Weekly task executed at {DateTime.Now}");
|
||||
}).EveryWeekOn(DayOfWeek.Monday, 9, 0);
|
||||
|
||||
// Cron task
|
||||
FluentScheduler.ScheduleCron("Every5Sec", "*/5 * * * * *", async () =>
|
||||
{
|
||||
Console.WriteLine($"Cron task executed every 5 sec at {DateTime.Now}");
|
||||
});
|
||||
|
||||
// Start scheduler
|
||||
Scheduler.Start();
|
||||
|
||||
// Remove task dynamically after 20 seconds
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(20000);
|
||||
Scheduler.RemoveTask("Every5Sec");
|
||||
});
|
||||
|
||||
Console.WriteLine("Scheduler running. Press any key to stop...");
|
||||
Console.ReadKey();
|
||||
Scheduler.Stop();
|
||||
}
|
||||
}
|
||||
4
EonaCat.FluentScheduler/EonaCat.FluentScheduler.slnx
Normal file
4
EonaCat.FluentScheduler/EonaCat.FluentScheduler.slnx
Normal file
@@ -0,0 +1,4 @@
|
||||
<Solution>
|
||||
<Project Path="EonaCat.FluentScheduler.Tester/EonaCat.FluentScheduler.Tester.csproj" />
|
||||
<Project Path="EonaCat.FluentScheduler/EonaCat.FluentScheduler.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
|
||||
namespace EonaCat.FluentScheduler
|
||||
{
|
||||
public static class CronHelper
|
||||
{
|
||||
public static DateTime GetNextOccurrence(string cronExpression, TimeZoneInfo timeZone)
|
||||
{
|
||||
var parts = cronExpression.Split(' ');
|
||||
if (parts.Length != 6) throw new Exception("Cron must have 6 parts: sec min hour day month weekday");
|
||||
DateTime dt = DateTime.Now;
|
||||
|
||||
for (int i = 0; i < 1000000; i++)
|
||||
{
|
||||
dt = dt.AddSeconds(1);
|
||||
if (Matches(parts, dt)) return TimeZoneInfo.ConvertTime(dt, timeZone);
|
||||
}
|
||||
throw new Exception("Could not find next occurrence");
|
||||
}
|
||||
|
||||
private static bool Matches(string[] parts, DateTime dt)
|
||||
{
|
||||
return MatchesPart(parts[0], dt.Second) &&
|
||||
MatchesPart(parts[1], dt.Minute) &&
|
||||
MatchesPart(parts[2], dt.Hour) &&
|
||||
MatchesPart(parts[3], dt.Day) &&
|
||||
MatchesPart(parts[4], dt.Month) &&
|
||||
MatchesPart(parts[5], (int)dt.DayOfWeek);
|
||||
}
|
||||
|
||||
private static bool MatchesPart(string part, int value)
|
||||
{
|
||||
if (part == "*" || string.IsNullOrEmpty(part)) return true;
|
||||
|
||||
foreach (var p in part.Split(','))
|
||||
{
|
||||
if (p.Contains("/"))
|
||||
{
|
||||
var step = p.Split('/');
|
||||
int stepVal = int.Parse(step[1]);
|
||||
if (MatchesPart(step[0], value) && value % stepVal == 0) return true;
|
||||
}
|
||||
else if (p.Contains("-"))
|
||||
{
|
||||
var range = p.Split('-');
|
||||
int start = int.Parse(range[0]);
|
||||
int end = int.Parse(range[1]);
|
||||
if (value >= start && value <= end) return true;
|
||||
}
|
||||
else if (int.TryParse(p, out int n) && n == value) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
|
||||
<PackageId>EonaCat.FluentScheduler</PackageId>
|
||||
<Title>EonaCat.FluentScheduler</Title>
|
||||
<Authors>EonaCat (Jeroen Saey)</Authors>
|
||||
<Company>EonaCat</Company>
|
||||
<Product>EonaCat.FluentScheduler</Product>
|
||||
<Description>Fluent Task Scheduler</Description>
|
||||
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||
<PackageProjectUrl>https://git.saey.me/EonaCat/EonaCat.FluentTaskScheduler</PackageProjectUrl>
|
||||
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.FluentTaskScheduler</RepositoryUrl>
|
||||
<PackageIcon>icon.png</PackageIcon>
|
||||
<PackageReadmeFile>Readme.md</PackageReadmeFile>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageTags>Task;Jeroen;Saey;EonaCat;Scheduler;Scron;Job;Repeat;Automatic</PackageTags>
|
||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\icon.png">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
<None Include="..\..\LICENSE">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
<None Include="..\..\Readme.md">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EonaCat.Json" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EonaCat.FluentScheduler
|
||||
{
|
||||
|
||||
public static class FluentScheduler
|
||||
{
|
||||
public static FluentSchedulerBuilder Schedule(Func<Task> action)
|
||||
{
|
||||
return new FluentSchedulerBuilder(action);
|
||||
}
|
||||
|
||||
public static void ScheduleCron(string name, string cronExpression, Func<Task> action)
|
||||
{
|
||||
Scheduler.RegisterTaskAction(name, action);
|
||||
var task = new ScheduledTask(name, cronExpression, action);
|
||||
Scheduler.AddTask(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EonaCat.FluentScheduler
|
||||
{
|
||||
public class FluentSchedulerBuilder
|
||||
{
|
||||
private readonly Func<Task> action;
|
||||
|
||||
public FluentSchedulerBuilder(Func<Task> action)
|
||||
{
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public void EveryDayAt(int hour, int minute)
|
||||
{
|
||||
string name = $"EveryDayAt_{hour}_{minute}";
|
||||
Scheduler.RegisterTaskAction(name, action);
|
||||
var next = DateTime.Today.AddHours(hour).AddMinutes(minute);
|
||||
if (next <= DateTime.Now) next = next.AddDays(1);
|
||||
Scheduler.AddTask(new ScheduledTask(name, "* * * * * *", action) { NextRun = next });
|
||||
}
|
||||
|
||||
public void EveryWeekOn(DayOfWeek day, int hour, int minute)
|
||||
{
|
||||
string name = $"EveryWeekOn_{day}_{hour}_{minute}";
|
||||
Scheduler.RegisterTaskAction(name, action);
|
||||
var now = DateTime.Now;
|
||||
int daysUntil = ((int)day - (int)now.DayOfWeek + 7) % 7;
|
||||
if (daysUntil == 0 && (now.Hour > hour || (now.Hour == hour && now.Minute >= minute))) daysUntil = 7;
|
||||
var next = new DateTime(now.Year, now.Month, now.Day, hour, minute, 0).AddDays(daysUntil);
|
||||
Scheduler.AddTask(new ScheduledTask(name, "* * * * * *", action) { NextRun = next });
|
||||
}
|
||||
|
||||
public void EveryMonthOn(int day, int hour, int minute)
|
||||
{
|
||||
string name = $"EveryMonthOn_{day}_{hour}_{minute}";
|
||||
Scheduler.RegisterTaskAction(name, action);
|
||||
var now = DateTime.Now;
|
||||
int daysInMonth = DateTime.DaysInMonth(now.Year, now.Month);
|
||||
day = Math.Min(day, daysInMonth);
|
||||
var next = new DateTime(now.Year, now.Month, day, hour, minute, 0);
|
||||
if (next <= now) next = next.AddMonths(1);
|
||||
Scheduler.AddTask(new ScheduledTask(name, "* * * * * *", action) { NextRun = next });
|
||||
}
|
||||
|
||||
public void EveryYearOn(int month, int day, int hour, int minute)
|
||||
{
|
||||
string name = $"EveryYearOn_{month}_{day}_{hour}_{minute}";
|
||||
Scheduler.RegisterTaskAction(name, action);
|
||||
var now = DateTime.Now;
|
||||
var next = new DateTime(now.Year, month, day, hour, minute, 0);
|
||||
if (next <= now) next = next.AddYears(1);
|
||||
Scheduler.AddTask(new ScheduledTask(name, "* * * * * *", action) { NextRun = next });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EonaCat.FluentScheduler
|
||||
{
|
||||
public class ScheduledTask
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string CronExpression { get; set; } = "* * * * * *";
|
||||
public Func<Task> Action { get; set; } = null!;
|
||||
public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Local;
|
||||
public DateTime NextRun { get; set; }
|
||||
public int RetryCount { get; set; } = 3;
|
||||
public int RetryDelaySeconds { get; set; } = 5;
|
||||
|
||||
// Enterprise features
|
||||
public TaskStatus Status { get; private set; } = TaskStatus.Pending;
|
||||
public DateTime? LastRun { get; private set; }
|
||||
public string? LastError { get; private set; }
|
||||
|
||||
public ScheduledTask(string name, string cron, Func<Task> action, TimeZoneInfo? timeZone = null)
|
||||
{
|
||||
Name = name;
|
||||
CronExpression = cron;
|
||||
Action = action;
|
||||
TimeZone = timeZone ?? TimeZoneInfo.Local;
|
||||
NextRun = CronHelper.GetNextOccurrence(cron, TimeZone);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(Action<string>? logger = null)
|
||||
{
|
||||
Status = TaskStatus.Running;
|
||||
LastRun = DateTime.Now;
|
||||
int retries = 0;
|
||||
while (retries <= RetryCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger?.Invoke($"[INFO] Executing task {Name} at {DateTime.Now}");
|
||||
await Action();
|
||||
Status = TaskStatus.Success;
|
||||
LastError = null;
|
||||
logger?.Invoke($"[INFO] Task {Name} completed successfully at {DateTime.Now}");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = ex.Message;
|
||||
Status = TaskStatus.Failed;
|
||||
retries++;
|
||||
logger?.Invoke($"[ERROR] Task {Name} failed: {ex.Message}. Retry {retries}/{RetryCount}");
|
||||
if (retries <= RetryCount)
|
||||
await Task.Delay(RetryDelaySeconds * 1000);
|
||||
}
|
||||
}
|
||||
NextRun = CronHelper.GetNextOccurrence(CronExpression, TimeZone);
|
||||
}
|
||||
|
||||
public ScheduledTaskMetadata ToMetadata() => new ScheduledTaskMetadata(NextRun)
|
||||
{
|
||||
Name = Name,
|
||||
CronExpression = CronExpression
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace EonaCat.FluentScheduler
|
||||
{
|
||||
public record ScheduledTaskMetadata(DateTime NextRun)
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string CronExpression { get; set; } = "* * * * * *";
|
||||
}
|
||||
}
|
||||
92
EonaCat.FluentScheduler/EonaCat.FluentScheduler/Scheduler.cs
Normal file
92
EonaCat.FluentScheduler/EonaCat.FluentScheduler/Scheduler.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using EonaCat.Json;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EonaCat.FluentScheduler
|
||||
{
|
||||
public static class Scheduler
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, ScheduledTask> tasks = new();
|
||||
private static Timer? timer;
|
||||
private static readonly string persistenceFile = "tasks.json";
|
||||
private static Action<string>? logger;
|
||||
|
||||
public static void SetLogger(Action<string> logAction)
|
||||
{
|
||||
logger = logAction;
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Func<Task>> taskRegistry = new();
|
||||
|
||||
public static void RegisterTaskAction(string name, Func<Task> action)
|
||||
{
|
||||
taskRegistry[name] = action;
|
||||
}
|
||||
|
||||
public static void AddTask(ScheduledTask task)
|
||||
{
|
||||
tasks[task.Name] = task;
|
||||
RegisterTaskAction(task.Name, task.Action);
|
||||
logger?.Invoke($"[INFO] Task {task.Name} added with next run at {task.NextRun}");
|
||||
}
|
||||
|
||||
public static bool RemoveTask(string name)
|
||||
{
|
||||
var removed = tasks.TryRemove(name, out _);
|
||||
if (removed) logger?.Invoke($"[INFO] Task {name} removed");
|
||||
return removed;
|
||||
}
|
||||
|
||||
public static void Start()
|
||||
{
|
||||
LoadTasks();
|
||||
timer = new Timer(async _ =>
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var dueTasks = tasks.Values.Where(t => t.NextRun <= now).ToList();
|
||||
var runningTasks = dueTasks.Select(t => Task.Run(() => t.ExecuteAsync(logger))).ToArray();
|
||||
await Task.WhenAll(runningTasks);
|
||||
SaveTasks();
|
||||
}, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
|
||||
logger?.Invoke("[INFO] Scheduler started");
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
timer?.Dispose();
|
||||
SaveTasks();
|
||||
logger?.Invoke("[INFO] Scheduler stopped");
|
||||
}
|
||||
|
||||
private static void SaveTasks()
|
||||
{
|
||||
var metadata = tasks.Values.Select(t => t.ToMetadata()).ToList();
|
||||
File.WriteAllText(persistenceFile, JsonHelper.ToJson(metadata, Formatting.Indented));
|
||||
}
|
||||
|
||||
private static void LoadTasks()
|
||||
{
|
||||
if (!File.Exists(persistenceFile)) return;
|
||||
|
||||
var json = File.ReadAllText(persistenceFile);
|
||||
var metadataList = JsonHelper.ToObject<List<ScheduledTaskMetadata>>(json);
|
||||
if (metadataList == null) return;
|
||||
|
||||
foreach (var meta in metadataList)
|
||||
{
|
||||
if (taskRegistry.TryGetValue(meta.Name, out var action))
|
||||
{
|
||||
var task = new ScheduledTask(meta.Name, meta.CronExpression, action) { NextRun = meta.NextRun };
|
||||
tasks[task.Name] = task;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<ScheduledTask> GetAllTasks() => tasks.Values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace EonaCat.FluentScheduler
|
||||
{
|
||||
public enum TaskStatus
|
||||
{
|
||||
Pending,
|
||||
Running,
|
||||
Success,
|
||||
Failed
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user