From 2af17ec870aaf0ee9ccb22f1299196415961a60b Mon Sep 17 00:00:00 2001 From: TekH Date: Sat, 11 Jul 2026 12:38:42 +0200 Subject: [PATCH] feat: implement DEX job triggering commands with CQRS pattern TriggeringDEXJobBatchCommand: - Orchestrates batch job execution for a profile - Creates unique 20-character timestamp-based batch ID - Executes all SQL jobs sequentially for given profile ID - Uses MediatR ISender to trigger individual job commands TriggeringDEXJobCommand: - Executes single DEX job with three-stage pipeline: 1. Main Query: executes primary SQL with batch ID placeholder replacement 2. Check Query: validates execution with return value check (> 0) 3. ReC Request: invokes ReC API with batch ID reference - Configurable error handling per stage via DexJobOptions - Regex-based placeholder replacement for dynamic batch ID injection - Returns Unit for void-like MediatR command pattern Both commands include: - Comprehensive XML documentation - Primary constructor injection (modern C# syntax) - Proper async/await with CancellationToken support - Integration with ISQLExecutor and ReCClient abstractions --- .../DEXJob/TriggeringDEXJobBatchCommand.cs | 73 ++++++++++ .../DEXJob/TriggeringDEXJobCommand.cs | 131 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 ECMJobRunner.Application/DEXJob/TriggeringDEXJobBatchCommand.cs create mode 100644 ECMJobRunner.Application/DEXJob/TriggeringDEXJobCommand.cs diff --git a/ECMJobRunner.Application/DEXJob/TriggeringDEXJobBatchCommand.cs b/ECMJobRunner.Application/DEXJob/TriggeringDEXJobBatchCommand.cs new file mode 100644 index 0000000..35c82c3 --- /dev/null +++ b/ECMJobRunner.Application/DEXJob/TriggeringDEXJobBatchCommand.cs @@ -0,0 +1,73 @@ +using ECMJobRunner.Domain.Interfaces; +using MediatR; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ECMJobRunner.Application.DEXJob +{ + /// + /// Command to trigger DEX job batch for a profile + /// Executes all SQL jobs associated with a profile ID + /// + public class TriggeringDEXJobBatchCommand : IRequest + { + /// + /// Profile ID to trigger all associated SQL jobs + /// + public int ProfileId { get; set; } + } + + /// + /// Handler for TriggeringDEXJobBatchCommand + /// Retrieves all SQL jobs for a profile and executes them sequentially + /// + public class TriggeringDEXJobBatchCommandHandler(IProfileSqlJobRepository jobRepo, ISender sender) + : IRequestHandler + { + /// + /// Handles the TriggeringDEXJobBatchCommand + /// Creates a unique batch ID and triggers individual job commands + /// + /// The command containing the profile ID + /// Cancellation token + /// Unit value indicating completion + public async Task Handle(TriggeringDEXJobBatchCommand request, CancellationToken cancellationToken) + { + var jobs = await jobRepo.FindAsync(j => j.ProfileId == request.ProfileId, cancellationToken); + + var batchId = CreateBatchId(); + + foreach (var job in jobs) + { + await sender.Send(new TriggeringDEXJobCommand + { + Job = job, + BatchId = batchId + }, cancellationToken); + } + + return Unit.Value; + } + + /// + /// Creates a unique batch ID based on current timestamp + /// + /// 20-character timestamp string (yyyyMMddHHmmssfffffff truncated to 20 chars) + public static string CreateBatchId() + { + // Format: "yyyy" : Year (4 digits) + // "MM" : Month (2 digits) + // "dd" : Day (2 digits) + // "HH" : Hour (24-hour format, 2 digits) + // "mm" : Minute (2 digits) + // "ss" : Second (2 digits) + // "fffffff": Fractions of a second / 100-nanoseconds (7 digits) + + string fullBatchId = DateTime.Now.ToString("yyyyMMddHHmmssfffffff"); + + // Limit to 20 characters as per specification + return fullBatchId.Substring(0, 20); + } + } +} diff --git a/ECMJobRunner.Application/DEXJob/TriggeringDEXJobCommand.cs b/ECMJobRunner.Application/DEXJob/TriggeringDEXJobCommand.cs new file mode 100644 index 0000000..6ba5c1e --- /dev/null +++ b/ECMJobRunner.Application/DEXJob/TriggeringDEXJobCommand.cs @@ -0,0 +1,131 @@ +using ECMJobRunner.Application.Common.Constants; +using ECMJobRunner.Application.Common.Dtos; +using ECMJobRunner.Application.Common.Exceptions; +using ECMJobRunner.Application.Common.Interfaces; +using ECMJobRunner.Application.Common.Options; +using ECMJobRunner.Domain.Entities; +using MediatR; +using Microsoft.Extensions.Options; +using ReC.Client; +using ReC.Client.Api; +using System; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace ECMJobRunner.Application.DEXJob +{ + /// + /// Command to trigger a single DEX job execution + /// Executes main query, check query, and ReC request in sequence + /// + public class TriggeringDEXJobCommand : IRequest + { + /// + /// The SQL job to execute + /// + public ProfileSqlJob Job { get; set; } = null!; + + /// + /// Unique batch identifier for this execution + /// + public string BatchId { get; set; } = null!; + } + + /// + /// Handler for TriggeringDEXJobCommand + /// Orchestrates execution of main query, check query, and ReC HTTP request + /// + public class TriggeringDEXJobCommandHandler( + ISQLExecutor executor, + IOptions options, + ReCClient reCClient) + : IRequestHandler + { + private readonly DexJobOptions _options = options.Value; + + /// + /// Handles the TriggeringDEXJobCommand + /// Executes main query, check query, and ReC request according to configuration + /// + /// The command containing job and batch ID + /// Cancellation token + /// Unit value indicating completion + public async Task Handle(TriggeringDEXJobCommand request, CancellationToken cancel) + { + #region Main Query + if (!string.IsNullOrWhiteSpace(request.Job.SqlMainQuery)) + { + var sqlMainQuery = Regex.Replace( + request.Job.SqlMainQuery!, + _options.Placeholders.BatchId.Pattern, + request.BatchId, + _options.Placeholders.BatchId.RegexOptions); + + try + { + var result = await executor.ExecuteQueryAsync(sqlMainQuery, cancel); + if (_options.Error.MainQuery.OnUnexpectedResult == ErrorAction.Stop) + { + if (result is null) + throw new DEXJobException("SQL Main Query", request.BatchId, sqlMainQuery, "Main Query returned nothing."); + else if (result.ReturnValue is not null) + throw new DEXJobException("SQL Main Query", request.BatchId, sqlMainQuery, $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was null."); + } + } + catch (Exception ex) + { + if (_options.Error.MainQuery.OnExecution == ErrorAction.Stop) + throw new DEXJobException("SQL Main Query", request.BatchId, sqlMainQuery, ex); + } + } + else if (_options.Error.MainQuery.IfNullOrWhiteSpace == ErrorAction.Stop) + { + throw new DEXJobException("SQL Main Query", request.BatchId, null, "SQL Main Query is null or empty"); + } + #endregion + + #region Check Query + if (!string.IsNullOrWhiteSpace(request.Job.SqlCheckQuery)) + { + var sqlCheckQuery = Regex.Replace( + request.Job.SqlCheckQuery!, + _options.Placeholders.BatchId.Pattern, + request.BatchId, + _options.Placeholders.BatchId.RegexOptions); + + try + { + var result = await executor.ExecuteQueryAsync(sqlCheckQuery, cancel); + if (_options.Error.CheckQuery.OnUnexpectedResult == ErrorAction.Stop) + { + if (result is null) + throw new DEXJobException("SQL Check Query", request.BatchId, sqlCheckQuery, "Check Query returned nothing."); + else if (result.ReturnValue <= 0) + throw new DEXJobException("SQL Check Query", request.BatchId, sqlCheckQuery, $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was any value greater than 0."); + } + } + catch (Exception ex) + { + if (_options.Error.CheckQuery.OnExecution == ErrorAction.Stop) + throw new DEXJobException("SQL Check Query", request.BatchId, sqlCheckQuery, ex); + } + } + else if (_options.Error.CheckQuery.IfNullOrWhiteSpace == ErrorAction.Stop) + { + throw new DEXJobException("SQL Check Query", request.BatchId, null, "SQL Check Query is null or empty"); + } + #endregion + + #region ReC Request + long profileId = request.Job.ProfileId; + await reCClient.RecActions.InvokeAsync(profileId, new InvokeReferences() + { + BatchId = request.BatchId, // Fixed: Use request.BatchId instead of profileId.ToString() + }, cancel); + #endregion + + return Unit.Value; // Fixed: Added missing return statement + } + } +}