using CvCleanupJob.Models; using JobScheduler.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Models.Settings; namespace CvCleanupJob.Tasks; /// /// Deletes oldest cached CV files until the total size of remaining files is at or below the configured budget. /// public sealed class CvStorageCleanupJobTask : IJobTask { private readonly FileStorageSettings _fileStorage; private readonly ILogger _logger; public CvStorageCleanupJobTask(IOptions fileStorage, ILogger logger) { _fileStorage = fileStorage.Value; _logger = logger; } public string TaskType => "CvStorageCleanup"; public Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var parameters = parametersSection.Get() ?? 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; } /// Matches API behavior for cached CV paths (alphanumeric stem). 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)); } }