This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
namespace CvCleanupJob.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for the CvStorageCleanup scheduled task (bound from Jobs:Tasks:n:Parameters).
|
||||
/// </summary>
|
||||
public sealed class CvStorageCleanupParameters
|
||||
{
|
||||
/// <summary>Maximum total size of retained CV files (defaults to 40 MiB).</summary>
|
||||
public double MaxTotalSizeMegabytes { get; set; } = 40;
|
||||
|
||||
/// <summary>File glob within the storage directory (default matches cached CV PDFs).</summary>
|
||||
public string SearchPattern { get; set; } = "*.pdf";
|
||||
|
||||
/// <summary>
|
||||
/// When true, only files whose base name is alphanumeric (same convention as API CV cache) are considered.
|
||||
/// Set false if you store other PDFs under the same folder and want the glob to apply to all of them.
|
||||
/// </summary>
|
||||
public bool RestrictToCvStyleFileNamesOnly { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using CvCleanupJob.Tasks;
|
||||
using JobScheduler.Scheduling;
|
||||
using JobScheduler.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Models.Settings;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||
|
||||
builder.Services.AddSingleton<CvStorageCleanupJobTask>();
|
||||
builder.Services.AddSingleton<IEnumerable<IJobTask>>(sp => new IJobTask[]
|
||||
{
|
||||
sp.GetRequiredService<CvStorageCleanupJobTask>(),
|
||||
});
|
||||
|
||||
builder.Services.AddHostedService<JobSchedulerHostedService>();
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
@@ -0,0 +1,134 @@
|
||||
using CvCleanupJob.Models;
|
||||
using JobScheduler.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Models.Settings;
|
||||
|
||||
namespace CvCleanupJob.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Deletes oldest cached CV files until the total size of remaining files is at or below the configured budget.
|
||||
/// </summary>
|
||||
public sealed class CvStorageCleanupJobTask : IJobTask
|
||||
{
|
||||
private readonly FileStorageSettings _fileStorage;
|
||||
private readonly ILogger<CvStorageCleanupJobTask> _logger;
|
||||
|
||||
public CvStorageCleanupJobTask(IOptions<FileStorageSettings> fileStorage, ILogger<CvStorageCleanupJobTask> logger)
|
||||
{
|
||||
_fileStorage = fileStorage.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string TaskType => "CvStorageCleanup";
|
||||
|
||||
public Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var parameters = parametersSection.Get<CvStorageCleanupParameters>()
|
||||
?? new CvStorageCleanupParameters();
|
||||
|
||||
if (parameters.MaxTotalSizeMegabytes <= 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"CvStorageCleanup skipped: MaxTotalSizeMegabytes must be positive (got {Value}).",
|
||||
parameters.MaxTotalSizeMegabytes);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var root = ResolveStorageRoot(_fileStorage.Path);
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
_logger.LogDebug("CvStorageCleanup: directory does not exist yet: {Path}", root);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var maxBytes = (long)Math.Round(parameters.MaxTotalSizeMegabytes * 1024d * 1024d, MidpointRounding.AwayFromZero);
|
||||
|
||||
var candidatePaths = Directory
|
||||
.EnumerateFiles(root, parameters.SearchPattern, SearchOption.TopDirectoryOnly)
|
||||
.Where(p => !parameters.RestrictToCvStyleFileNamesOnly || IsCvStyleFileName(Path.GetFileName(p)))
|
||||
.Select(p => new FileInfo(p))
|
||||
.Where(f => f.Exists)
|
||||
.ToList();
|
||||
|
||||
if (candidatePaths.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("CvStorageCleanup: no files matched under {Path}.", root);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
long totalBytes = candidatePaths.Sum(f => f.Length);
|
||||
if (totalBytes <= maxBytes)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"CvStorageCleanup: within budget ({TotalMb:F2} MiB / {MaxMb:F2} MiB). No files removed.",
|
||||
totalBytes / (1024d * 1024d),
|
||||
parameters.MaxTotalSizeMegabytes);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var orderedOldestFirst = candidatePaths
|
||||
.OrderBy(f => f.LastWriteTimeUtc)
|
||||
.ThenBy(f => f.FullName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
long remaining = totalBytes;
|
||||
var deleted = 0;
|
||||
long freedBytes = 0;
|
||||
|
||||
while (remaining > maxBytes && orderedOldestFirst.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var oldest = orderedOldestFirst[0];
|
||||
orderedOldestFirst.RemoveAt(0);
|
||||
|
||||
try
|
||||
{
|
||||
var len = oldest.Length;
|
||||
oldest.Delete();
|
||||
remaining -= len;
|
||||
freedBytes += len;
|
||||
deleted++;
|
||||
_logger.LogInformation(
|
||||
"CvStorageCleanup: deleted oldest file {File} ({SizeKb} KiB, remaining aggregate ~{RemainMb:F2} MiB).",
|
||||
oldest.Name,
|
||||
len / 1024d,
|
||||
remaining / (1024d * 1024d));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "CvStorageCleanup: could not delete {File}", oldest.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"CvStorageCleanup finished: removed {Deleted} file(s), freed ~{FreedMb:F2} MiB; size now ~{RemainMb:F2} MiB (budget {MaxMb:F2} MiB).",
|
||||
deleted,
|
||||
freedBytes / (1024d * 1024d),
|
||||
remaining / (1024d * 1024d),
|
||||
parameters.MaxTotalSizeMegabytes);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Matches API behavior for cached CV paths (alphanumeric stem).</summary>
|
||||
internal static bool IsCvStyleFileName(string fileName)
|
||||
{
|
||||
var stem = Path.GetFileNameWithoutExtension(fileName);
|
||||
return stem.Length > 0 && stem.All(char.IsLetterOrDigit);
|
||||
}
|
||||
|
||||
internal static string ResolveStorageRoot(string configuredPath)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
{
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), configuredPath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"FileStorage": {
|
||||
"Path": "Files"
|
||||
},
|
||||
"Jobs": {
|
||||
"Tasks": [
|
||||
{
|
||||
"TaskType": "CvStorageCleanup",
|
||||
"Enabled": true,
|
||||
"Interval": "01:00:00",
|
||||
"Parameters": {
|
||||
"MaxTotalSizeMegabytes": 40,
|
||||
"SearchPattern": "*.pdf",
|
||||
"RestrictToCvStyleFileNamesOnly": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using JobScheduler.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JobScheduler.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Loads <c>Jobs:Tasks</c> and runs each enabled task on its own interval.
|
||||
/// </summary>
|
||||
public sealed class JobSchedulerHostedService : BackgroundService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IReadOnlyDictionary<string, IJobTask> _tasksByType;
|
||||
private readonly ILogger<JobSchedulerHostedService> _logger;
|
||||
|
||||
public JobSchedulerHostedService(
|
||||
IConfiguration configuration,
|
||||
IEnumerable<IJobTask> tasks,
|
||||
ILogger<JobSchedulerHostedService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_tasksByType = tasks.ToDictionary(t => t.TaskType, t => t, StringComparer.OrdinalIgnoreCase);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var section = _configuration.GetSection("Jobs:Tasks");
|
||||
var children = section.GetChildren().ToList();
|
||||
|
||||
if (children.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No Jobs:Tasks configured; scheduler idle.");
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var loops = new List<Task>();
|
||||
foreach (var taskSection in children)
|
||||
{
|
||||
if (!taskSection.GetValue("Enabled", true))
|
||||
{
|
||||
_logger.LogInformation("Job task disabled: {Section}", taskSection.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
var taskType = taskSection["TaskType"];
|
||||
if (string.IsNullOrWhiteSpace(taskType))
|
||||
{
|
||||
_logger.LogWarning("Skipping task without TaskType at {Section}", taskSection.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_tasksByType.TryGetValue(taskType, out var task))
|
||||
{
|
||||
_logger.LogError("No IJobTask registered for TaskType '{TaskType}'.", taskType);
|
||||
continue;
|
||||
}
|
||||
|
||||
var interval = ParseInterval(taskSection["Interval"]);
|
||||
var parameters = taskSection.GetSection("Parameters");
|
||||
|
||||
loops.Add(RunTaskLoopAsync(taskType, task, parameters, interval, stoppingToken));
|
||||
}
|
||||
|
||||
if (loops.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No enabled job tasks to run.");
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.WhenAll(loops);
|
||||
}
|
||||
|
||||
private async Task RunTaskLoopAsync(
|
||||
string taskType,
|
||||
IJobTask task,
|
||||
IConfiguration parameters,
|
||||
TimeSpan interval,
|
||||
CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Starting job loop for {TaskType} every {Interval}.",
|
||||
taskType,
|
||||
interval);
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task.ExecuteAsync(parameters, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Job task {TaskType} failed.", taskType);
|
||||
}
|
||||
|
||||
if (interval <= TimeSpan.Zero)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Job task {TaskType} has non-positive Interval; sleeping 1 hour.",
|
||||
taskType);
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Job loop for {TaskType} cancelled.", taskType);
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan ParseInterval(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return TimeSpan.FromHours(1);
|
||||
}
|
||||
|
||||
return TimeSpan.TryParse(value, out var ts) ? ts : TimeSpan.FromHours(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace JobScheduler.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// A named unit of work invoked by <see cref="JobScheduler.Scheduling.JobSchedulerHostedService"/>.
|
||||
/// </summary>
|
||||
public interface IJobTask
|
||||
{
|
||||
/// <summary>Matches <c>Jobs:Tasks:*:TaskType</c> in configuration.</summary>
|
||||
string TaskType { get; }
|
||||
|
||||
Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>JobScheduler</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user