From 0abb10bb00f743b296895aab90a82adf992d7665 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 20 Nov 2025 09:04:45 -0500 Subject: [PATCH] [FA-9] Need to add persistence layer --- .../Dockerfile | 23 +++++ ...ionArchive.Service.SchedulerService.csproj | 26 +++++ .../GraphQL/Mutation.cs | 35 +++++++ .../GraphQL/Query.cs | 15 +++ .../Models/JobTemplates/EventJobTemplate.cs | 35 +++++++ .../Models/SchedulerJob.cs | 12 +++ .../Program.cs | 52 ++++++++++ .../Properties/launchSettings.json | 39 ++++++++ .../Services/JobManagerService.cs | 99 +++++++++++++++++++ .../appsettings.Development.json | 8 ++ .../appsettings.json | 13 +++ .../Extensions/GraphQLExtensions.cs | 1 + .../Services/EventBus/IEventBus.cs | 1 + .../Implementations/RabbitMQEventBus.cs | 11 ++- .../Services/GraphQL/LoggingErrorFilter.cs | 23 +++++ FictionArchive.sln | 6 ++ 16 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 FictionArchive.Service.SchedulerService/Dockerfile create mode 100644 FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj create mode 100644 FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs create mode 100644 FictionArchive.Service.SchedulerService/GraphQL/Query.cs create mode 100644 FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs create mode 100644 FictionArchive.Service.SchedulerService/Models/SchedulerJob.cs create mode 100644 FictionArchive.Service.SchedulerService/Program.cs create mode 100644 FictionArchive.Service.SchedulerService/Properties/launchSettings.json create mode 100644 FictionArchive.Service.SchedulerService/Services/JobManagerService.cs create mode 100644 FictionArchive.Service.SchedulerService/appsettings.Development.json create mode 100644 FictionArchive.Service.SchedulerService/appsettings.json create mode 100644 FictionArchive.Service.Shared/Services/GraphQL/LoggingErrorFilter.cs diff --git a/FictionArchive.Service.SchedulerService/Dockerfile b/FictionArchive.Service.SchedulerService/Dockerfile new file mode 100644 index 0000000..fd21bd5 --- /dev/null +++ b/FictionArchive.Service.SchedulerService/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj", "FictionArchive.Service.SchedulerService/"] +RUN dotnet restore "FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj" +COPY . . +WORKDIR "/src/FictionArchive.Service.SchedulerService" +RUN dotnet build "./FictionArchive.Service.SchedulerService.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./FictionArchive.Service.SchedulerService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FictionArchive.Service.SchedulerService.dll"] diff --git a/FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj b/FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj new file mode 100644 index 0000000..ccdea73 --- /dev/null +++ b/FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + + + + + + diff --git a/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs b/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs new file mode 100644 index 0000000..e5118b5 --- /dev/null +++ b/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs @@ -0,0 +1,35 @@ +using System.Data; +using FictionArchive.Service.SchedulerService.Models; +using FictionArchive.Service.SchedulerService.Services; +using HotChocolate.Types; +using Quartz; + +namespace FictionArchive.Service.SchedulerService.GraphQL; + +public class Mutation +{ + [Error] + [Error] + public async Task ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager) + { + return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule); + } + + [Error] + public async Task RunJob(string jobKey, JobManagerService jobManager) + { + return await jobManager.TriggerJob(jobKey); + } + + [Error] + public async Task DeleteJob(string jobKey, JobManagerService jobManager) + { + bool deleted = await jobManager.DeleteJob(jobKey); + if (!deleted) + { + throw new KeyNotFoundException($"Job with key {jobKey} was not found"); + } + + return true; + } +} \ No newline at end of file diff --git a/FictionArchive.Service.SchedulerService/GraphQL/Query.cs b/FictionArchive.Service.SchedulerService/GraphQL/Query.cs new file mode 100644 index 0000000..2594569 --- /dev/null +++ b/FictionArchive.Service.SchedulerService/GraphQL/Query.cs @@ -0,0 +1,15 @@ +using FictionArchive.Service.SchedulerService.Models; +using FictionArchive.Service.SchedulerService.Services; +using HotChocolate; +using Quartz; +using Quartz.Impl.Matchers; + +namespace FictionArchive.Service.SchedulerService.GraphQL; + +public class Query +{ + public async Task> GetJobs(JobManagerService jobManager) + { + return await jobManager.GetScheduledJobs(); + } +} \ No newline at end of file diff --git a/FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs b/FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs new file mode 100644 index 0000000..fa4e8fd --- /dev/null +++ b/FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs @@ -0,0 +1,35 @@ +using FictionArchive.Service.Shared.Services.EventBus; +using Newtonsoft.Json; +using Quartz; + +namespace FictionArchive.Service.SchedulerService.Models.JobTemplates; + +public class EventJobTemplate : IJob +{ + private readonly IEventBus _eventBus; + private readonly ILogger _logger; + + public const string EventTypeParameter = "RoutingKey"; + public const string EventDataParameter = "MessageData"; + + public EventJobTemplate(IEventBus eventBus, ILogger logger) + { + _eventBus = eventBus; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + try + { + var eventData = context.MergedJobDataMap.GetString(EventDataParameter); + var eventType = context.MergedJobDataMap.GetString(EventTypeParameter); + var eventObject = JsonConvert.DeserializeObject(eventData); + await _eventBus.Publish(eventObject, eventType); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while running an event job."); + } + } +} \ No newline at end of file diff --git a/FictionArchive.Service.SchedulerService/Models/SchedulerJob.cs b/FictionArchive.Service.SchedulerService/Models/SchedulerJob.cs new file mode 100644 index 0000000..2921ee4 --- /dev/null +++ b/FictionArchive.Service.SchedulerService/Models/SchedulerJob.cs @@ -0,0 +1,12 @@ +using Quartz; + +namespace FictionArchive.Service.SchedulerService.Models; + +public class SchedulerJob +{ + public JobKey JobKey { get; set; } + public string Description { get; set; } + public string JobTypeName { get; set; } + public List CronSchedule { get; set; } + public Dictionary JobData { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.SchedulerService/Program.cs b/FictionArchive.Service.SchedulerService/Program.cs new file mode 100644 index 0000000..69406f4 --- /dev/null +++ b/FictionArchive.Service.SchedulerService/Program.cs @@ -0,0 +1,52 @@ +using FictionArchive.Service.SchedulerService.GraphQL; +using FictionArchive.Service.SchedulerService.Services; +using FictionArchive.Service.Shared.Extensions; +using FictionArchive.Service.Shared.Services.EventBus.Implementations; +using Quartz; + +namespace FictionArchive.Service.SchedulerService; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Services + builder.Services.AddDefaultGraphQl(); + builder.Services.AddHealthChecks(); + builder.Services.AddTransient(); + + #region Event Bus + + builder.Services.AddRabbitMQ(opt => + { + builder.Configuration.GetSection("RabbitMQ").Bind(opt); + }); + + #endregion + + #region Quartz + + builder.Services.AddQuartz(opt => + { + opt.UseMicrosoftDependencyInjectionJobFactory(); + }); + builder.Services.AddQuartzHostedService(opt => + { + opt.WaitForJobsToComplete = true; + }); + + #endregion + + var app = builder.Build(); + + app.UseHttpsRedirection(); + + app.MapHealthChecks("/healthz"); + + app.MapGraphQL(); + + app.Run(); + } +} \ No newline at end of file diff --git a/FictionArchive.Service.SchedulerService/Properties/launchSettings.json b/FictionArchive.Service.SchedulerService/Properties/launchSettings.json new file mode 100644 index 0000000..b430b15 --- /dev/null +++ b/FictionArchive.Service.SchedulerService/Properties/launchSettings.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:61312", + "sslPort": 44365 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5213", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "graphql", + "applicationUrl": "https://localhost:7145;http://localhost:5213", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/FictionArchive.Service.SchedulerService/Services/JobManagerService.cs b/FictionArchive.Service.SchedulerService/Services/JobManagerService.cs new file mode 100644 index 0000000..1e37f75 --- /dev/null +++ b/FictionArchive.Service.SchedulerService/Services/JobManagerService.cs @@ -0,0 +1,99 @@ +using System.Data; +using FictionArchive.Service.SchedulerService.Models; +using FictionArchive.Service.SchedulerService.Models.JobTemplates; +using FictionArchive.Service.Shared.Services.EventBus; +using Quartz; +using Quartz.Impl.Matchers; + +namespace FictionArchive.Service.SchedulerService.Services; + +public class JobManagerService +{ + private readonly ILogger _logger; + private readonly ISchedulerFactory _schedulerFactory; + + public JobManagerService(ILogger logger, ISchedulerFactory schedulerFactory) + { + _logger = logger; + _schedulerFactory = schedulerFactory; + } + + public async Task> GetScheduledJobs() + { + var scheduler = await _schedulerFactory.GetScheduler(); + var groups = await scheduler.GetJobGroupNames(); + var result = new List<(IJobDetail Job, IReadOnlyCollection Triggers)>(); + + foreach (var group in groups) + { + var jobKeys = await scheduler.GetJobKeys(GroupMatcher.GroupEquals(group)); + foreach (var jobKey in jobKeys) + { + var jobDetail = await scheduler.GetJobDetail(jobKey); + var triggers = await scheduler.GetTriggersOfJob(jobKey); + + result.Add((jobDetail, triggers)); + } + } + + return result.Select(tuple => new SchedulerJob() + { + JobKey = tuple.Job.Key, + Description = tuple.Job.Description, + JobTypeName = tuple.Job.JobType.FullName, + JobData = tuple.Job.JobDataMap.ToDictionary(kv => kv.Key, kv => kv.Value.ToString()), + CronSchedule = tuple.Triggers.Where(trigger => trigger is ICronTrigger).Select(trigger => (trigger as ICronTrigger).CronExpressionString).ToList() + }).ToList(); + } + + public async Task ScheduleEventJob(string? jobKey, string? description, string eventType, string eventData, string cronSchedule) + { + var scheduler = await _schedulerFactory.GetScheduler(); + + if (await scheduler.GetJobDetail(new JobKey(jobKey)) != null) + { + throw new DuplicateNameException("A job with the same key already exists."); + } + + jobKey ??= Guid.NewGuid().ToString(); + var jobData = new JobDataMap + { + { EventJobTemplate.EventTypeParameter, eventType }, + { EventJobTemplate.EventDataParameter, eventData } + }; + var job = JobBuilder.Create() + .WithIdentity(jobKey) + .WithDescription(description ?? $"Fires off an event on a set schedule") + .SetJobData(jobData) + .Build(); + var trigger = TriggerBuilder.Create() + .WithIdentity(jobKey) + .WithCronSchedule(cronSchedule) + .StartNow() + .Build(); + + await scheduler.ScheduleJob(job, trigger); + + return new SchedulerJob() + { + CronSchedule = new List { cronSchedule }, + Description = description, + JobKey = new JobKey(jobKey), + JobTypeName = typeof(EventJobTemplate).FullName, + JobData = jobData.ToDictionary(kv => kv.Key, kv => kv.Value.ToString()) + }; + } + + public async Task TriggerJob(string jobKey) + { + var scheduler = await _schedulerFactory.GetScheduler(); + await scheduler.TriggerJob(new JobKey(jobKey)); + return true; + } + + public async Task DeleteJob(string jobKey) + { + var scheduler = await _schedulerFactory.GetScheduler(); + return await scheduler.DeleteJob(new JobKey(jobKey)); + } +} \ No newline at end of file diff --git a/FictionArchive.Service.SchedulerService/appsettings.Development.json b/FictionArchive.Service.SchedulerService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/FictionArchive.Service.SchedulerService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/FictionArchive.Service.SchedulerService/appsettings.json b/FictionArchive.Service.SchedulerService/appsettings.json new file mode 100644 index 0000000..250543c --- /dev/null +++ b/FictionArchive.Service.SchedulerService/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "RabbitMQ": { + "ConnectionString": "amqp://localhost", + "ClientIdentifier": "SchedulerService" + }, + "AllowedHosts": "*" +} diff --git a/FictionArchive.Service.Shared/Extensions/GraphQLExtensions.cs b/FictionArchive.Service.Shared/Extensions/GraphQLExtensions.cs index 3ab030c..b51551a 100644 --- a/FictionArchive.Service.Shared/Extensions/GraphQLExtensions.cs +++ b/FictionArchive.Service.Shared/Extensions/GraphQLExtensions.cs @@ -13,6 +13,7 @@ public static class GraphQLExtensions .AddQueryType() .AddMutationType() .AddDiagnosticEventListener() + .AddErrorFilter() .AddType() .AddType() .AddMutationConventions(applyToAllMutations: true) diff --git a/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs b/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs index d3433c1..46c7e75 100644 --- a/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs +++ b/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs @@ -3,4 +3,5 @@ namespace FictionArchive.Service.Shared.Services.EventBus; public interface IEventBus { Task Publish(TEvent integrationEvent) where TEvent : IntegrationEvent; + Task Publish(object integrationEvent, string eventType); } \ No newline at end of file diff --git a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs index cdf83d8..6650e18 100644 --- a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs +++ b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs @@ -36,15 +36,20 @@ public class RabbitMQEventBus : IEventBus, IHostedService public async Task Publish(TEvent integrationEvent) where TEvent : IntegrationEvent { var routingKey = typeof(TEvent).Name; - var channel = await _connectionProvider.GetDefaultChannelAsync(); // Set integration event values integrationEvent.CreatedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); integrationEvent.EventId = Guid.NewGuid(); + await Publish(integrationEvent, routingKey); + } + + public async Task Publish(object integrationEvent, string eventType) + { + var channel = await _connectionProvider.GetDefaultChannelAsync(); var body = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(integrationEvent)); - await channel.BasicPublishAsync(ExchangeName, routingKey, true, body); - _logger.LogInformation("Published event {EventName}", routingKey); + await channel.BasicPublishAsync(ExchangeName, eventType, true, body); + _logger.LogInformation("Published event {EventName}", eventType); } public async Task StartAsync(CancellationToken cancellationToken) diff --git a/FictionArchive.Service.Shared/Services/GraphQL/LoggingErrorFilter.cs b/FictionArchive.Service.Shared/Services/GraphQL/LoggingErrorFilter.cs new file mode 100644 index 0000000..aa03776 --- /dev/null +++ b/FictionArchive.Service.Shared/Services/GraphQL/LoggingErrorFilter.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; + +namespace FictionArchive.Service.Shared.Services.GraphQL; + +public class LoggingErrorFilter : IErrorFilter +{ + private readonly ILogger _logger; + + public LoggingErrorFilter(ILogger logger) + { + _logger = logger; + } + + public IError OnError(IError error) + { + if (error.Exception != null) + { + _logger.LogError(error.Exception, "Unexpected GraphQL error occurred"); + } + + return error; + } +} \ No newline at end of file diff --git a/FictionArchive.sln b/FictionArchive.sln index a22fd12..aa199ef 100644 --- a/FictionArchive.sln +++ b/FictionArchive.sln @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Tran EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Shared", "FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj", "{82638874-304C-43E6-8EFA-8AD4C41C4435}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.SchedulerService", "FictionArchive.Service.SchedulerService\FictionArchive.Service.SchedulerService.csproj", "{6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +38,9 @@ Global {82638874-304C-43E6-8EFA-8AD4C41C4435}.Debug|Any CPU.Build.0 = Debug|Any CPU {82638874-304C-43E6-8EFA-8AD4C41C4435}.Release|Any CPU.ActiveCfg = Release|Any CPU {82638874-304C-43E6-8EFA-8AD4C41C4435}.Release|Any CPU.Build.0 = Release|Any CPU + {6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal