Initial version

This commit is contained in:
2025-12-15 20:29:25 +01:00
parent 40d135d978
commit 2f7427bef1
14 changed files with 706 additions and 43 deletions

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -0,0 +1,4 @@
<Solution>
<Project Path="EonaCat.FluentScheduler.Tester/EonaCat.FluentScheduler.Tester.csproj" />
<Project Path="EonaCat.FluentScheduler/EonaCat.FluentScheduler.csproj" />
</Solution>

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -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 });
}
}
}

View File

@@ -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
};
}
}

View File

@@ -0,0 +1,10 @@
using System;
namespace EonaCat.FluentScheduler
{
public record ScheduledTaskMetadata(DateTime NextRun)
{
public string Name { get; set; } = "";
public string CronExpression { get; set; } = "* * * * * *";
}
}

View 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;
}
}

View File

@@ -0,0 +1,10 @@
namespace EonaCat.FluentScheduler
{
public enum TaskStatus
{
Pending,
Running,
Success,
Failed
}
}