diff --git a/Manager.App/Components/Pages/Services.razor b/Manager.App/Components/Pages/Services.razor index e9ca48e..99b9942 100644 --- a/Manager.App/Components/Pages/Services.razor +++ b/Manager.App/Components/Pages/Services.razor @@ -7,7 +7,7 @@ Services - + Services @@ -20,16 +20,19 @@ - + - - Pause - - Resume - - + + @foreach (var action in context.Item?.Actions ?? []) + { + + + @action.Id + + + } + diff --git a/Manager.App/Services/ExtendedBackgroundService.cs b/Manager.App/Services/ExtendedBackgroundService.cs index 6f03f8a..93cf8f0 100644 --- a/Manager.App/Services/ExtendedBackgroundService.cs +++ b/Manager.App/Services/ExtendedBackgroundService.cs @@ -7,52 +7,91 @@ public abstract class ExtendedBackgroundService(string name, string description, : BackgroundService { private TaskCompletionSource _resumeSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly List _actions = []; + private TaskCompletionSource? _manualContinue; + public ServiceState State { get; private set; } = ServiceState.Stopped; public CircularBuffer ProgressEvents { get; } = new(500); public string Name { get; } = name; - public string Description { get; set; } = description; - public TimeSpan ExecuteInterval { get; set; } = executeInterval ?? TimeSpan.FromMinutes(1); + public string Description { get; } = description; + public TimeSpan ExecuteInterval { get; } = executeInterval ?? TimeSpan.FromSeconds(5); + + public IReadOnlyList Actions => _actions; + + protected void AddActions(IEnumerable actions) + { + _actions.AddRange(actions); + } protected sealed override async Task ExecuteAsync(CancellationToken stoppingToken) { State = ServiceState.Running; logger.LogInformation("Initializing background service: {ServiceName}", Name); + + _actions.AddRange( + [ + new ServiceAction("Start", "Start the service (after the service is stopped of faulted.)", Start, () => State is ServiceState.Stopped or ServiceState.Faulted), + new ServiceAction("Pause", "Pause the service", Pause, () => State != ServiceState.Paused), + new ServiceAction("Resume", "Resume the service", Resume, () => State != ServiceState.Running) + ]); + await InitializeAsync(stoppingToken); - try + while (!stoppingToken.IsCancellationRequested) { - logger.LogInformation("Running background service: {ServiceName}", Name); - while (!stoppingToken.IsCancellationRequested) + if (State == ServiceState.Running) { - if (State == ServiceState.Paused) + try { - _resumeSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - await _resumeSignal.Task.WaitAsync(stoppingToken); - } + logger.LogInformation("Started running background service: {ServiceName}", Name); + while (!stoppingToken.IsCancellationRequested) + { + if (State == ServiceState.Paused) + { + _resumeSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await _resumeSignal.Task.WaitAsync(stoppingToken); + } - await ExecuteServiceAsync(stoppingToken); - - await Task.Delay(ExecuteInterval, stoppingToken); + await ExecuteServiceAsync(stoppingToken); + + await Task.Delay(ExecuteInterval, stoppingToken); + } + } + catch (OperationCanceledException e) + { + logger.LogInformation(e, "Service {ServiceName} received cancellation", Name); + } + catch (Exception e) + { + State = ServiceState.Faulted; + logger.LogError(e, "Background service {ServiceName} faulted!", Name); + LogEvent("Error executing background service.", LogSeverity.Error); + } + finally + { + State = ServiceState.Stopped; + } } - } - catch (Exception e) - { - if (e is not OperationCanceledException) - { - State = ServiceState.Faulted; - logger.LogError(e,"Background service {ServiceName} faulted!", Name); - throw; - } - logger.LogInformation(e,"Service {ServiceName} received cancellation", Name); - } - finally - { - State = ServiceState.Stopped; + + _manualContinue = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var delayTask = Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + await Task.WhenAny(delayTask, _manualContinue.Task); + _manualContinue = null; } } protected void LogEvent(string message, LogSeverity severity = LogSeverity.Info) => ProgressEvents.Add(new ServiceEvent(string.Intern(Name), message, DateTime.UtcNow, severity)); + public void Start() + { + if (State is ServiceState.Stopped or ServiceState.Faulted) + { + State = ServiceState.Running; + _manualContinue?.TrySetResult(); + LogEvent("Started service."); + } + } + public void Pause() { if (State == ServiceState.Running) @@ -94,4 +133,6 @@ public enum ServiceState Paused } -public record struct ServiceEvent(string Source, string Message, DateTime DateUtc, LogSeverity Severity); \ No newline at end of file +public record struct ServiceEvent(string Source, string Message, DateTime DateUtc, LogSeverity Severity); + +public record ServiceAction(string Id, string Description, Action Action, Func IsEnabled); \ No newline at end of file diff --git a/Manager.App/Services/System/CacheService.cs b/Manager.App/Services/System/CacheService.cs index 956dfb8..44a3433 100644 --- a/Manager.App/Services/System/CacheService.cs +++ b/Manager.App/Services/System/CacheService.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using DotBased.Logging; using DotBased.Monads; +using DotBased.Utilities; using Manager.Data.Contexts; using Manager.Data.Entities.Cache; using Manager.YouTube; @@ -10,23 +11,45 @@ using Microsoft.EntityFrameworkCore.Infrastructure; namespace Manager.App.Services.System; -public class CacheService(ILogger logger, IHostEnvironment environment) : ExtendedBackgroundService(nameof(CacheService), "Manages caching.", logger, TimeSpan.FromHours(5)) +public class CacheService(ILogger logger, IHostEnvironment environment) + : ExtendedBackgroundService(nameof(CacheService), "Manages caching.", logger, TimeSpan.FromHours(5)) { private DirectoryInfo? _cacheDirectory; private PooledDbContextFactory? _dbContextFactory; private const string DataSubDir = "data"; - + private const int CacheMaxAgeDays = 1; + private readonly SemaphoreSlim _cacheSemaphoreSlim = new(1, 1); + protected override Task InitializeAsync(CancellationToken stoppingToken) { _cacheDirectory = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "cache")); _cacheDirectory.Create(); Directory.CreateDirectory(Path.Combine(_cacheDirectory.FullName, DataSubDir)); LogEvent($"Cache directory: {_cacheDirectory.FullName}"); - + + AddActions([ + new ServiceAction("Clear cache", "Manually clear cache", () => + { + LogEvent("Manual cache clear requested."); + _ = Task.Run(async () => + { + try + { + await ClearCacheAsync(stoppingToken); + } + catch (Exception e) + { + logger.LogError(e, "Error clearing cache manually!"); + LogEvent("Error manually clearing cache.", LogSeverity.Error); + } + }, stoppingToken); + }, () => true) + ]); + var dbContextOptionsBuilder = new DbContextOptionsBuilder(); dbContextOptionsBuilder.UseSqlite($"Data Source={Path.Combine(_cacheDirectory.FullName, "cache_index.db")}"); _dbContextFactory = new PooledDbContextFactory(dbContextOptionsBuilder.Options); - + return Task.CompletedTask; } @@ -72,20 +95,24 @@ public class CacheService(ILogger logger, IHostEnvironment environ } await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - + var urlKeyBytes = SHA1.HashData(Encoding.UTF8.GetBytes(url)); var urlKey = Convert.ToHexString(urlKeyBytes); - var cacheEntity = await context.Cache.FirstOrDefaultAsync(c => c.Id == urlKey, cancellationToken: cancellationToken); + var cacheEntity = + await context.Cache.FirstOrDefaultAsync(c => c.Id == urlKey, cancellationToken: cancellationToken); if (cacheEntity == null) { - var downloadResult = await NetworkService.DownloadBytesAsync(new HttpRequestMessage(HttpMethod.Get, url)); + var downloadResult = + await NetworkService.DownloadBytesAsync(new HttpRequestMessage(HttpMethod.Get, url)); if (!downloadResult.IsSuccess) { LogEvent($"Failed to download from url: {url}"); return ResultError.Fail("Download failed."); } + var download = downloadResult.Value; - await using var downloadFile = File.Create(Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{urlKey}.cache")); + await using var downloadFile = + File.Create(Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{urlKey}.cache")); await downloadFile.WriteAsync(download.Data.AsMemory(0, download.Data.Length), cancellationToken); cacheEntity = new CacheEntity @@ -96,7 +123,7 @@ public class CacheService(ILogger logger, IHostEnvironment environ ContentType = download.ContentType, OriginalFileName = download.FileName, }; - + context.Cache.Add(cacheEntity); var saved = await context.SaveChangesAsync(cancellationToken); if (saved <= 0) @@ -126,58 +153,71 @@ public class CacheService(ILogger logger, IHostEnvironment environ private async Task ClearCacheAsync(CancellationToken cancellationToken) { - if (_dbContextFactory == null) + if (!await _cacheSemaphoreSlim.WaitAsync(0, cancellationToken)) { - throw new InvalidOperationException("No DbContext factory configured."); - } - - if (_cacheDirectory == null) - { - throw new InvalidOperationException("No cache directory configured."); - } - - await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var toRemove = dbContext.Cache.Where(c => c.CachedAtUtc < DateTime.UtcNow.AddDays(-1)); - if (!toRemove.Any()) - { - LogEvent("No items found to purge from cache."); + LogEvent("The cache cleaning task is already running. Skipping this call.", LogSeverity.Warning); return; } - var totalToRemove = toRemove.Count(); - LogEvent($"Found {totalToRemove} cache items that are older than 1 day(s)"); - - var deleted = new List(); - foreach (var entity in toRemove) + try { - var pathToFile = Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{entity.Id}.cache"); - if (!File.Exists(pathToFile)) + if (_dbContextFactory == null) + throw new InvalidOperationException("No DbContext factory configured."); + + if (_cacheDirectory == null) + throw new InvalidOperationException("No cache directory configured."); + + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var toRemove = dbContext.Cache.Where(c => c.CachedAtUtc < DateTime.UtcNow.AddDays(-CacheMaxAgeDays)); + if (!toRemove.Any()) { + LogEvent($"No items older than {CacheMaxAgeDays} day(s) found to clear from cache."); + return; + } + + var totalToRemove = toRemove.Count(); + LogEvent($"Found {totalToRemove} cache items that are older than 1 day(s)"); + + var deleted = new List(); + long totalBytesRemoved = 0; + foreach (var entity in toRemove) + { + var pathToFile = Path.Combine(_cacheDirectory.FullName, DataSubDir, $"{entity.Id}.cache"); + if (!File.Exists(pathToFile)) + { + deleted.Add(entity); + totalBytesRemoved += entity.ContentLength; + continue; + } + + try + { + File.Delete(pathToFile); + } + catch (Exception e) + { + logger.LogError(e, "Failed to delete cache entity with id: {EntityId}. Skipping cache entity...", + entity.Id); + continue; + } + + totalBytesRemoved += entity.ContentLength; deleted.Add(entity); - continue; } - try - { - File.Delete(pathToFile); - } - catch (Exception e) - { - logger.LogError(e, "Failed to delete cache entity with id: {EntityId}. Skipping cache entity...", entity.Id); - continue; - } - deleted.Add(entity); + dbContext.RemoveRange(deleted); + var dbDeleted = await dbContext.SaveChangesAsync(cancellationToken); + + if (dbDeleted < deleted.Count) + LogEvent("Could not delete all files from cache.", LogSeverity.Warning); + + LogEvent($"Removed {dbDeleted}/{totalToRemove} items from cache. Total of {Suffix.BytesToSizeSuffix(totalBytesRemoved)} removed from disk."); } - - dbContext.RemoveRange(deleted); - var dbDeleted = await dbContext.SaveChangesAsync(cancellationToken); - if (dbDeleted < deleted.Count) + finally { - LogEvent("Could not delete all files from cache.", LogSeverity.Warning); + _cacheSemaphoreSlim.Release(); } - - LogEvent($"Removed {dbDeleted}/{totalToRemove} items"); } } diff --git a/Manager.App/Services/System/SettingsService.cs b/Manager.App/Services/System/SettingsService.cs new file mode 100644 index 0000000..19b45d7 --- /dev/null +++ b/Manager.App/Services/System/SettingsService.cs @@ -0,0 +1,16 @@ +namespace Manager.App.Services.System; + +public class SettingsService(ILogger logger) : ExtendedBackgroundService(nameof(SettingsService), "Service for handling application settings.", logger, TimeSpan.FromMinutes(10)) +{ + protected override async Task InitializeAsync(CancellationToken stoppingToken) + { + AddActions([ + new ServiceAction("Save settings", "Save the application settings to the database", () => { }, () => true) + ]); + } + + protected override async Task ExecuteServiceAsync(CancellationToken stoppingToken) + { + + } +} \ No newline at end of file diff --git a/Manager.Data/Contexts/ApplicationContext.cs b/Manager.Data/Contexts/ApplicationContext.cs new file mode 100644 index 0000000..ce2f7b3 --- /dev/null +++ b/Manager.Data/Contexts/ApplicationContext.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; + +namespace Manager.Data.Contexts; + +public sealed class ApplicationContext : DbContext +{ + public ApplicationContext(DbContextOptions options) : base(options) + { + ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + ChangeTracker.LazyLoadingEnabled = false; + Database.EnsureCreated(); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.AddInterceptors(new DateInterceptor()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + + base.OnModelCreating(modelBuilder); + } +} \ No newline at end of file diff --git a/Manager.Data/Entities/ApplicationContext/WorkItemEntity.cs b/Manager.Data/Entities/ApplicationContext/WorkItemEntity.cs new file mode 100644 index 0000000..67879e4 --- /dev/null +++ b/Manager.Data/Entities/ApplicationContext/WorkItemEntity.cs @@ -0,0 +1,10 @@ +namespace Manager.Data.Entities.ApplicationContext; + +public class WorkItemEntity : DateTimeBase +{ + public required Guid Id { get; set; } + public required string Name { get; set; } + public required string Description { get; set; } + public WorkStatus Status { get; set; } = WorkStatus.Pending; + public required string ClientId { get; set; } +} \ No newline at end of file diff --git a/Manager.Data/Entities/ApplicationContext/WorkStatus.cs b/Manager.Data/Entities/ApplicationContext/WorkStatus.cs new file mode 100644 index 0000000..be23d9a --- /dev/null +++ b/Manager.Data/Entities/ApplicationContext/WorkStatus.cs @@ -0,0 +1,10 @@ +namespace Manager.Data.Entities.ApplicationContext; + +public enum WorkStatus +{ + Pending = 0, + InProgress = 1, + Paused = 2, + Completed = 3, + Faulted = 4, +} \ No newline at end of file