diff --git a/src/TickerQ.Dashboard/DependencyInjection/AspNetCoreExtensions.cs b/src/TickerQ.Dashboard/DependencyInjection/AspNetCoreExtensions.cs deleted file mode 100644 index 838295c1..00000000 --- a/src/TickerQ.Dashboard/DependencyInjection/AspNetCoreExtensions.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using TickerQ.Utilities.Enums; -using System; -using Microsoft.Extensions.DependencyInjection; -using TickerQ.DependencyInjection; -using TickerQ.Utilities; - -namespace TickerQ.Dashboard.DependencyInjection -{ - /// - /// ASP.NET Core specific extensions for TickerQ with Dashboard support - /// - public static class AspNetCoreExtensions - { - /// - /// Initializes TickerQ for ASP.NET Core minimal hosting (WebApplication) with Dashboard support - /// - public static WebApplication UseTickerQ(this WebApplication app, TickerQStartMode qStartMode = TickerQStartMode.Immediate) - { - UseTickerQ((IApplicationBuilder)app, qStartMode); - return app; - } - - /// - /// Initializes TickerQ for ASP.NET Core applications with Dashboard support - /// - public static IApplicationBuilder UseTickerQ(this IApplicationBuilder app, TickerQStartMode qStartMode = TickerQStartMode.Immediate) - { - var serviceProvider = app.ApplicationServices; - - // Initialize core TickerQ functionality using the base extension from TickerQ package - serviceProvider.UseTickerQ(qStartMode); - - // Handle Dashboard-specific initialization if configured - var tickerExecutionContext = serviceProvider.GetService(); - if (tickerExecutionContext?.DashboardApplicationAction != null) - { - // Cast object back to IApplicationBuilder for Dashboard middleware - tickerExecutionContext.DashboardApplicationAction(app); - tickerExecutionContext.DashboardApplicationAction = null; - } - - return app; - } - } -} diff --git a/src/TickerQ.Dashboard/DependencyInjection/ServiceExtensions.cs b/src/TickerQ.Dashboard/DependencyInjection/ServiceExtensions.cs index f6f97336..8c940fc0 100644 --- a/src/TickerQ.Dashboard/DependencyInjection/ServiceExtensions.cs +++ b/src/TickerQ.Dashboard/DependencyInjection/ServiceExtensions.cs @@ -72,7 +72,14 @@ private static void UseDashboardDelegate(this TickerOp { tickerConfiguration.UseDashboardApplication((appObj) => { - var app = (IApplicationBuilder)appObj; + if (appObj is not IApplicationBuilder app) + throw new InvalidOperationException( + "TickerQ Dashboard can only be used in ASP.NET Core applications. " + + "The current host does not provide an HTTP application pipeline " + + "(IApplicationBuilder is not available). " + + "If you are running a Worker Service, Console app, or background node, " + + "remove the dashboard configuration or move it to a WebApplication." + ); // Configure static files and middleware with endpoints app.UseDashboardWithEndpoints(dashboardConfig); }); diff --git a/src/TickerQ.Dashboard/Infrastructure/Dashboard/TickerDashboardRepository.cs b/src/TickerQ.Dashboard/Infrastructure/Dashboard/TickerDashboardRepository.cs index 1e46c266..0794defa 100644 --- a/src/TickerQ.Dashboard/Infrastructure/Dashboard/TickerDashboardRepository.cs +++ b/src/TickerQ.Dashboard/Infrastructure/Dashboard/TickerDashboardRepository.cs @@ -629,11 +629,20 @@ public async Task DeleteCronTickerOccurrenceByIdAsync(Guid id, CancellationToken foreach (var tickerFunction in TickerFunctionProvider.TickerFunctions.Select(x => new { x.Key, x.Value.Priority })) { if (TickerFunctionProvider.TickerFunctionRequestTypes.TryGetValue(tickerFunction.Key, - out var functionTypeContext)) + out var functionTypeContext) && + functionTypeContext.Item2 != null) { JsonExampleGenerator.TryGenerateExampleJson(functionTypeContext.Item2, out var exampleJson); yield return (tickerFunction.Key, (functionTypeContext.Item1, exampleJson, tickerFunction.Priority)); } + else if (TickerFunctionProvider.TickerFunctionRequestInfos.TryGetValue(tickerFunction.Key, + out var requestInfo)) + { + var exampleJson = string.IsNullOrWhiteSpace(requestInfo.RequestExampleJson) + ? null + : requestInfo.RequestExampleJson; + yield return (tickerFunction.Key, (requestInfo.RequestType ?? string.Empty, exampleJson, tickerFunction.Priority)); + } else { yield return (tickerFunction.Key, (string.Empty, null, tickerFunction.Priority)); diff --git a/src/TickerQ.RemoteExecutor/Models/RegisteredFunctionsResponse.cs b/src/TickerQ.RemoteExecutor/Models/RegisteredFunctionsResponse.cs index fbb447b1..a269a047 100644 --- a/src/TickerQ.RemoteExecutor/Models/RegisteredFunctionsResponse.cs +++ b/src/TickerQ.RemoteExecutor/Models/RegisteredFunctionsResponse.cs @@ -26,6 +26,7 @@ public sealed class Function public string Id { get; set; } = string.Empty; public string FunctionName { get; set; } = string.Empty; public string RequestType { get; set; } = string.Empty; + public string? RequestExampleJson { get; set; } public string? CronExpression { get; set; } public int TaskPriority { get; set; } public DateTime? AppliedAt { get; set; } diff --git a/src/TickerQ.RemoteExecutor/Models/RemoteTickerFunctionDescriptor.cs b/src/TickerQ.RemoteExecutor/Models/RemoteTickerFunctionDescriptor.cs index a7b135eb..257bf623 100644 --- a/src/TickerQ.RemoteExecutor/Models/RemoteTickerFunctionDescriptor.cs +++ b/src/TickerQ.RemoteExecutor/Models/RemoteTickerFunctionDescriptor.cs @@ -7,6 +7,8 @@ public sealed class RemoteTickerFunctionDescriptor public string Name { get; set; } = string.Empty; public string CronExpression { get; set; } = string.Empty; public string Callback { get; set; } = string.Empty; + public string RequestType { get; set; } = string.Empty; + public string RequestExampleJson { get; set; } = string.Empty; public TickerTaskPriority Priority { get; set; } public bool IsActive { get; set; } = true; } diff --git a/src/TickerQ.RemoteExecutor/RemoteExecutionDelegateFactory.cs b/src/TickerQ.RemoteExecutor/RemoteExecutionDelegateFactory.cs new file mode 100644 index 00000000..00b3fcd6 --- /dev/null +++ b/src/TickerQ.RemoteExecutor/RemoteExecutionDelegateFactory.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using TickerQ.Utilities; + +namespace TickerQ.RemoteExecutor; + +internal static class RemoteExecutionDelegateFactory +{ + public static TickerFunctionDelegate Create( + string callbackUrl, + Func secretProvider, + bool allowEmptySecret) + { + if (string.IsNullOrWhiteSpace(callbackUrl)) + throw new ArgumentException("Callback URL is required.", nameof(callbackUrl)); + + return async (ct, serviceProvider, context) => + { + var httpClientFactory = serviceProvider.GetRequiredService(); + var httpClient = httpClientFactory.CreateClient("tickerq-callback"); + + var payload = new + { + context.Id, + context.FunctionName, + context.Type, + context.RetryCount, + context.ScheduledFor + }; + + var json = JsonSerializer.Serialize(payload); + var bodyBytes = Encoding.UTF8.GetBytes(json); + + var uri = new Uri($"{callbackUrl}/execute"); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + var secret = secretProvider(serviceProvider); + var signature = ComputeSignature(secret, HttpMethod.Post.Method, uri.PathAndQuery, timestamp, bodyBytes, allowEmptySecret); + + using var request = new HttpRequestMessage(HttpMethod.Post, uri); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + request.Headers.Add("X-TickerQ-Signature", signature); + request.Headers.Add("X-Timestamp", timestamp); + + using var response = await httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + }; + } + + private static string ComputeSignature( + string? secret, + string method, + string pathAndQuery, + string timestamp, + byte[] bodyBytes, + bool allowEmptySecret) + { + if (allowEmptySecret && string.IsNullOrWhiteSpace(secret)) + return string.Empty; + + var safeSecret = secret ?? string.Empty; + var header = $"{method}\n{pathAndQuery}\n{timestamp}\n"; + var headerBytes = Encoding.UTF8.GetBytes(header); + var payload = new byte[headerBytes.Length + bodyBytes.Length]; + Buffer.BlockCopy(headerBytes, 0, payload, 0, headerBytes.Length); + Buffer.BlockCopy(bodyBytes, 0, payload, headerBytes.Length, bodyBytes.Length); + + var secretKey = Encoding.UTF8.GetBytes(safeSecret); + var signatureBytes = HMACSHA256.HashData(secretKey, payload); + return Convert.ToBase64String(signatureBytes); + } +} diff --git a/src/TickerQ.RemoteExecutor/RemoteExecutionEndpoints.cs b/src/TickerQ.RemoteExecutor/RemoteExecutionEndpoints.cs index 3ddf9fe8..43f7f834 100644 --- a/src/TickerQ.RemoteExecutor/RemoteExecutionEndpoints.cs +++ b/src/TickerQ.RemoteExecutor/RemoteExecutionEndpoints.cs @@ -1,11 +1,6 @@ using System.Security.Cryptography; using System.Text; -using System.Text.Json; using System.Globalization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; using TickerQ.RemoteExecutor.Models; using TickerQ.Utilities; using TickerQ.Utilities.Entities; @@ -108,62 +103,45 @@ private static void MapFunctionRegistration(IEndpointRouteBuilder group) .ToArray(); var functionDict = TickerFunctionProvider.TickerFunctions.ToDictionary(); + var requestInfoDict = TickerFunctionProvider.TickerFunctionRequestInfos?.ToDictionary() + ?? new Dictionary(); foreach (var newFunction in newFunctions) { // Handle inactive functions by removing them if (!newFunction.IsActive) { - functionDict.Remove(newFunction.Name); + if (RemoteFunctionRegistry.IsRemote(newFunction.Name) && + functionDict.Remove(newFunction.Name)) + { + RemoteFunctionRegistry.Remove(newFunction.Name); + requestInfoDict.Remove(newFunction.Name); + } continue; } // Capture callback URL to avoid closure issues var callbackUrl = newFunction.Callback.TrimEnd('/'); - var newFunctionDelegate = new TickerFunctionDelegate(async (ct, serviceProvider, context) => - { - var httpClientFactory = serviceProvider.GetRequiredService(); - var remoteOptions = serviceProvider.GetRequiredService(); - var httpClient = httpClientFactory.CreateClient("tickerq-callback"); + var newFunctionDelegate = RemoteExecutionDelegateFactory.Create( + callbackUrl, + sp => sp.GetRequiredService().WebHookSignature, + allowEmptySecret: true); - // Build a minimal payload describing the execution - var payload = new - { - context.Id, - context.FunctionName, - context.Type, - context.RetryCount, - context.ScheduledFor - }; - - // Serialize payload for signature computation - var json = JsonSerializer.Serialize(payload); - var bodyBytes = Encoding.UTF8.GetBytes(json); - - // Build request with HMAC signature - var uri = new Uri($"{callbackUrl}/execute"); - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); - var signature = ComputeCallbackSignature( - remoteOptions.WebHookSignature, - HttpMethod.Post.Method, - uri.PathAndQuery, - timestamp, - bodyBytes); - - using var request = new HttpRequestMessage(HttpMethod.Post, uri); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - request.Headers.Add("X-TickerQ-Signature", signature); - request.Headers.Add("X-Timestamp", timestamp); - - using var response = await httpClient.SendAsync(request, ct); - response.EnsureSuccessStatusCode(); - }); - - functionDict.TryAdd(newFunction.Name, (newFunction.CronExpression, newFunction.Priority, newFunctionDelegate)); + if (functionDict.TryAdd(newFunction.Name, (newFunction.CronExpression, newFunction.Priority, newFunctionDelegate))) + { + RemoteFunctionRegistry.MarkRemote(newFunction.Name); + requestInfoDict[newFunction.Name] = (newFunction.RequestType, newFunction.RequestExampleJson); + } } TickerFunctionProvider.RegisterFunctions(functionDict); + + var existingRequestTypes = TickerFunctionProvider.TickerFunctionRequestTypes; + if (existingRequestTypes != null && existingRequestTypes.Count > 0) + TickerFunctionProvider.RegisterRequestType(existingRequestTypes.ToDictionary()); + + TickerFunctionProvider.RegisterRequestInfo(requestInfoDict); TickerFunctionProvider.Build(); if (cronPairs.Length > 0) @@ -471,11 +449,26 @@ private static async Task ReadBodyBytesAsync(HttpRequest request, Cancel private static bool RemoveFunctionByName(string functionName) { + if (!RemoteFunctionRegistry.IsRemote(functionName)) + return false; + var functionDict = TickerFunctionProvider.TickerFunctions.ToDictionary(); if (!functionDict.Remove(functionName)) return false; + RemoteFunctionRegistry.Remove(functionName); + var requestInfoDict = TickerFunctionProvider.TickerFunctionRequestInfos?.ToDictionary() + ?? new Dictionary(); + requestInfoDict.Remove(functionName); TickerFunctionProvider.RegisterFunctions(functionDict); + var existingRequestTypes = TickerFunctionProvider.TickerFunctionRequestTypes; + if (existingRequestTypes != null && existingRequestTypes.Count > 0) + { + var requestTypesDict = existingRequestTypes.ToDictionary(); + requestTypesDict.Remove(functionName); + TickerFunctionProvider.RegisterRequestType(requestTypesDict); + } + TickerFunctionProvider.RegisterRequestInfo(requestInfoDict); TickerFunctionProvider.Build(); return true; } @@ -491,24 +484,4 @@ private sealed class RemoveFunctionPayload public string FunctionName { get; set; } = string.Empty; } - private static string ComputeCallbackSignature( - string secret, - string method, - string pathAndQuery, - string timestamp, - byte[] bodyBytes) - { - if (string.IsNullOrWhiteSpace(secret)) - return string.Empty; - - var header = $"{method}\n{pathAndQuery}\n{timestamp}\n"; - var headerBytes = Encoding.UTF8.GetBytes(header); - var payload = new byte[headerBytes.Length + bodyBytes.Length]; - Buffer.BlockCopy(headerBytes, 0, payload, 0, headerBytes.Length); - Buffer.BlockCopy(bodyBytes, 0, payload, headerBytes.Length, bodyBytes.Length); - - var secretKey = Encoding.UTF8.GetBytes(secret); - var signatureBytes = HMACSHA256.HashData(secretKey, payload); - return Convert.ToBase64String(signatureBytes); - } } diff --git a/src/TickerQ.RemoteExecutor/RemoteExecutionServiceExtension.cs b/src/TickerQ.RemoteExecutor/RemoteExecutionServiceExtension.cs index ede161a5..e3c1e92a 100644 --- a/src/TickerQ.RemoteExecutor/RemoteExecutionServiceExtension.cs +++ b/src/TickerQ.RemoteExecutor/RemoteExecutionServiceExtension.cs @@ -27,7 +27,8 @@ public static TickerOptionsBuilder AddTickerRemoteExec cfg.DefaultRequestHeaders.Add("X-Api-Secret", tickerqRemoteExecutionOptions.ApiSecret); }); services.AddHttpClient("tickerq-callback"); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Register options as singleton so background service can access it services.AddSingleton(tickerqRemoteExecutionOptions); diff --git a/src/TickerQ.RemoteExecutor/RemoteFunctionRegistry.cs b/src/TickerQ.RemoteExecutor/RemoteFunctionRegistry.cs new file mode 100644 index 00000000..86d2090b --- /dev/null +++ b/src/TickerQ.RemoteExecutor/RemoteFunctionRegistry.cs @@ -0,0 +1,27 @@ +using System.Collections.Concurrent; + +namespace TickerQ.RemoteExecutor; + +internal static class RemoteFunctionRegistry +{ + private static readonly ConcurrentDictionary RemoteFunctions = new(); + + public static void MarkRemote(string functionName) + { + if (string.IsNullOrWhiteSpace(functionName)) + return; + + RemoteFunctions[functionName] = 0; + } + + public static void Remove(string functionName) + { + if (string.IsNullOrWhiteSpace(functionName)) + return; + + RemoteFunctions.TryRemove(functionName, out _); + } + + public static bool IsRemote(string functionName) + => !string.IsNullOrWhiteSpace(functionName) && RemoteFunctions.ContainsKey(functionName); +} diff --git a/src/TickerQ.RemoteExecutor/RemoteFunctionsSyncService.cs b/src/TickerQ.RemoteExecutor/RemoteFunctionsSyncService.cs index b1fa22ae..9f87a49b 100644 --- a/src/TickerQ.RemoteExecutor/RemoteFunctionsSyncService.cs +++ b/src/TickerQ.RemoteExecutor/RemoteFunctionsSyncService.cs @@ -1,8 +1,4 @@ -using System.Net.Http.Json; -using System.Text; using System.Text.Json; -using System.Globalization; -using System.Security.Cryptography; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -137,6 +133,7 @@ private async Task RegisterFunctionsFromResponse(RegisteredFunctionsResponse res var functionDict = TickerFunctionProvider.TickerFunctions.ToDictionary(); var cronPairs = new List<(string Name, string CronExpression)>(); + var requestInfoDict = new Dictionary(); foreach (var node in response.Nodes) { @@ -162,55 +159,27 @@ private async Task RegisterFunctionsFromResponse(RegisteredFunctionsResponse res if (!function.IsActive) { - if (functionDict.Remove(function.FunctionName)) - _logger?.LogDebug("Removed inactive function {FunctionName}", function.FunctionName); + if (RemoteFunctionRegistry.IsRemote(function.FunctionName) && + functionDict.Remove(function.FunctionName)) + { + requestInfoDict.Remove(function.FunctionName); + RemoteFunctionRegistry.Remove(function.FunctionName); + _logger?.LogDebug("Removed inactive remote function {FunctionName}", function.FunctionName); + } else + { _logger?.LogDebug("Skipping inactive function {FunctionName}", function.FunctionName); + } continue; } // Capture callbackUrl in local variable to avoid closure issues var callbackUrl = node.CallbackUrl.TrimEnd('/'); - // Create function delegate similar to MapFunctionRegistration - var functionDelegate = new TickerFunctionDelegate(async (ct, serviceProvider, context) => - { - var httpClientFactory = serviceProvider.GetRequiredService(); - var httpClient = httpClientFactory.CreateClient("tickerq-callback"); - - var payload = new - { - context.Id, - context.FunctionName, - context.Type, - context.RetryCount, - context.ScheduledFor - }; - - // 1. Serialize ONCE - var json = JsonSerializer.Serialize(payload); - var bodyBytes = Encoding.UTF8.GetBytes(json); - - // 2. Build request + signature - var uri = new Uri($"{callbackUrl}/execute"); - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); - var signature = ComputeSignature( - response.WebhookSignature, - HttpMethod.Post.Method, - uri.PathAndQuery, - timestamp, - bodyBytes); - - // 3. Build request manually - using var request = new HttpRequestMessage(HttpMethod.Post, uri); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - - request.Headers.Add("X-TickerQ-Signature", signature); - request.Headers.Add("X-Timestamp", timestamp); - - using var responseCallback = await httpClient.SendAsync(request, ct); - responseCallback.EnsureSuccessStatusCode(); - }); + var functionDelegate = RemoteExecutionDelegateFactory.Create( + callbackUrl, + _ => response.WebhookSignature, + allowEmptySecret: false); // Convert int priority to TickerTaskPriority enum var priority = (TickerTaskPriority)function.TaskPriority; @@ -219,6 +188,10 @@ private async Task RegisterFunctionsFromResponse(RegisteredFunctionsResponse res var cronExpression = function.CronExpression ?? string.Empty; functionDict[function.FunctionName] = (cronExpression, priority, functionDelegate); + RemoteFunctionRegistry.MarkRemote(function.FunctionName); + requestInfoDict[function.FunctionName] = ( + function.RequestType, + function.RequestExampleJson ?? string.Empty); if (!string.IsNullOrWhiteSpace(cronExpression)) { @@ -233,9 +206,16 @@ private async Task RegisterFunctionsFromResponse(RegisteredFunctionsResponse res if (functionDict.Count > 0) TickerFunctionProvider.RegisterFunctions(functionDict); + var existingRequestTypes = TickerFunctionProvider.TickerFunctionRequestTypes; + if (existingRequestTypes != null && existingRequestTypes.Count > 0) + TickerFunctionProvider.RegisterRequestType(existingRequestTypes.ToDictionary()); + + if (requestInfoDict.Count > 0) + TickerFunctionProvider.RegisterRequestInfo(requestInfoDict); + TickerFunctionProvider.Build(); _logger?.LogInformation("Registered {Count} functions", functionDict.Count); - + // Migrate cron tickers if we have cron expressions and the manager is available if (cronPairs.Count > 0 && _internalTickerManager != null) { @@ -248,21 +228,4 @@ await _internalTickerManager.MigrateDefinedCronTickers( } } - private static string ComputeSignature( - string secret, - string method, - string pathAndQuery, - string timestamp, - byte[] bodyBytes) - { - var header = $"{method}\n{pathAndQuery}\n{timestamp}\n"; - var headerBytes = Encoding.UTF8.GetBytes(header); - var payload = new byte[headerBytes.Length + bodyBytes.Length]; - Buffer.BlockCopy(headerBytes, 0, payload, 0, headerBytes.Length); - Buffer.BlockCopy(bodyBytes, 0, payload, headerBytes.Length, bodyBytes.Length); - - var secretKey = Encoding.UTF8.GetBytes(secret); - var signatureBytes = HMACSHA256.HashData(secretKey, payload); - return Convert.ToBase64String(signatureBytes); - } } diff --git a/src/TickerQ.RemoteExecutor/TickerExecutionTaskHandlerRouter.cs b/src/TickerQ.RemoteExecutor/TickerExecutionTaskHandlerRouter.cs new file mode 100644 index 00000000..6c575afa --- /dev/null +++ b/src/TickerQ.RemoteExecutor/TickerExecutionTaskHandlerRouter.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.DependencyInjection; +using TickerQ.Utilities.Interfaces; +using TickerQ.Utilities.Models; + +namespace TickerQ.RemoteExecutor; + +internal sealed class TickerExecutionTaskHandlerRouter : ITickerExecutionTaskHandler +{ + private readonly IServiceProvider _serviceProvider; + private readonly TickerRemoteExecutionTaskHandler _remoteHandler; + private ITickerExecutionTaskHandler? _localHandler; + + public TickerExecutionTaskHandlerRouter( + IServiceProvider serviceProvider, + TickerRemoteExecutionTaskHandler remoteHandler) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _remoteHandler = remoteHandler ?? throw new ArgumentNullException(nameof(remoteHandler)); + } + + public Task ExecuteTaskAsync( + InternalFunctionContext context, + bool isDue, + CancellationToken cancellationToken = default) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + if (RemoteFunctionRegistry.IsRemote(context.FunctionName)) + { + return _remoteHandler.ExecuteTaskAsync(context, isDue, cancellationToken); + } + + var localHandler = ResolveLocalHandler(); + if (localHandler != null) + { + return localHandler.ExecuteTaskAsync(context, isDue, cancellationToken); + } + + // Fallback to remote handler if no local handler is available. + return _remoteHandler.ExecuteTaskAsync(context, isDue, cancellationToken); + } + + private ITickerExecutionTaskHandler? ResolveLocalHandler() + { + if (_localHandler != null) + return _localHandler; + + ITickerExecutionTaskHandler? candidate = null; + foreach (var handler in _serviceProvider.GetServices()) + { + if (ReferenceEquals(handler, this)) + continue; + if (handler is TickerRemoteExecutionTaskHandler) + continue; + + candidate = handler; + } + + _localHandler = candidate; + return _localHandler; + } +} diff --git a/src/TickerQ.RemoteExecutor/TickerRemoteExecutionTaskHandler.cs b/src/TickerQ.RemoteExecutor/TickerRemoteExecutionTaskHandler.cs index f3ffb51f..d2759abb 100644 --- a/src/TickerQ.RemoteExecutor/TickerRemoteExecutionTaskHandler.cs +++ b/src/TickerQ.RemoteExecutor/TickerRemoteExecutionTaskHandler.cs @@ -32,7 +32,8 @@ public async Task ExecuteTaskAsync(InternalFunctionContext context, bool isDue, FunctionName = context.FunctionName, RetryCount = context.RetryCount, IsDue = isDue, - ScheduledFor = context.ExecutionTime + ScheduledFor = context.ExecutionTime, + ServiceScope = scope }; await function.Delegate(cancellationTokenSource.Token, scope.ServiceProvider, tickerFunctionContext); } diff --git a/src/TickerQ.Utilities/TickerFunctionProvider.cs b/src/TickerQ.Utilities/TickerFunctionProvider.cs index bcd69c76..82f1dccc 100644 --- a/src/TickerQ.Utilities/TickerFunctionProvider.cs +++ b/src/TickerQ.Utilities/TickerFunctionProvider.cs @@ -22,10 +22,12 @@ public static class TickerFunctionProvider { // Callback actions to collect registrations private static Action> _requestTypeRegistrations; + private static Action> _requestInfoRegistrations; private static Action> _functionRegistrations; // Final frozen dictionaries public static FrozenDictionary TickerFunctionRequestTypes; + public static FrozenDictionary TickerFunctionRequestInfos; public static FrozenDictionary TickerFunctions; /// @@ -100,6 +102,28 @@ public static void RegisterRequestType(IDictionary reque RegisterRequestType(requestTypes); } + /// + /// Registers request type metadata (string type + example JSON) for functions. + /// + /// The request info entries to register. Cannot be null. + /// Thrown when requestInfos parameter is null. + public static void RegisterRequestInfo(IDictionary requestInfos) + { + if (requestInfos == null) + throw new ArgumentNullException(nameof(requestInfos)); + + if (requestInfos.Count == 0) + return; + + _requestInfoRegistrations += dict => + { + foreach (var (key, value) in requestInfos) + { + dict.TryAdd(key, value); + } + }; + } + /// /// Updates cron expressions for registered functions by adding to the callback chain. /// This method should only be called during application startup before Build() is called. @@ -145,8 +169,11 @@ public static void Build() } else { - TickerFunctions = new Dictionary() - .ToFrozenDictionary(); + if (TickerFunctions == null) + { + TickerFunctions = new Dictionary() + .ToFrozenDictionary(); + } } // Build request types dictionary @@ -160,8 +187,28 @@ public static void Build() } else { - TickerFunctionRequestTypes = new Dictionary() - .ToFrozenDictionary(); + if (TickerFunctionRequestTypes == null) + { + TickerFunctionRequestTypes = new Dictionary() + .ToFrozenDictionary(); + } + } + + // Build request info dictionary (string type + example JSON) + if (_requestInfoRegistrations != null) + { + var requestInfoDict = new Dictionary(); + _requestInfoRegistrations(requestInfoDict); + TickerFunctionRequestInfos = requestInfoDict.ToFrozenDictionary(); + _requestInfoRegistrations = null; + } + else + { + if (TickerFunctionRequestInfos == null) + { + TickerFunctionRequestInfos = new Dictionary() + .ToFrozenDictionary(); + } } } } @@ -185,4 +232,4 @@ public static async Task GetRequestAsync(TickerFunctionContext context, Ca return default; } } -} \ No newline at end of file +} diff --git a/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs b/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs index 275dc7af..5e39828d 100644 --- a/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs +++ b/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs @@ -95,21 +95,13 @@ public static IServiceCollection AddTickerQ(this IServ /// public static IHost UseTickerQ(this IHost host, TickerQStartMode qStartMode = TickerQStartMode.Immediate) { - InitializeTickerQ(host.Services, qStartMode); + InitializeTickerQ(host, qStartMode); return host; } - /// - /// Initializes TickerQ with a service provider directly - /// - public static IServiceProvider UseTickerQ(this IServiceProvider serviceProvider, TickerQStartMode qStartMode = TickerQStartMode.Immediate) - { - InitializeTickerQ(serviceProvider, qStartMode); - return serviceProvider; - } - - private static void InitializeTickerQ(IServiceProvider serviceProvider, TickerQStartMode qStartMode) + private static void InitializeTickerQ(IHost host, TickerQStartMode qStartMode) { + var serviceProvider = host.Services; var tickerExecutionContext = serviceProvider.GetService(); var configuration = serviceProvider.GetService(); var notificationHubSender = serviceProvider.GetService(); @@ -138,6 +130,13 @@ private static void InitializeTickerQ(IServiceProvider serviceProvider, TickerQS // If background services are not registered (due to DisableBackgroundServices()), // silently skip background service configuration. This is expected behavior. + if (tickerExecutionContext?.DashboardApplicationAction != null) + { + // Cast object back to IApplicationBuilder for Dashboard middleware + tickerExecutionContext.DashboardApplicationAction(host); + tickerExecutionContext.DashboardApplicationAction = null; + } + TickerFunctionProvider.UpdateCronExpressionsFromIConfiguration(configuration); TickerFunctionProvider.Build(); @@ -165,9 +164,6 @@ private static void InitializeTickerQ(IServiceProvider serviceProvider, TickerQS tickerExecutionContext.ExternalProviderApplicationAction(serviceProvider); tickerExecutionContext.ExternalProviderApplicationAction = null; } - - // Dashboard integration is handled by TickerQ.Dashboard package via DashboardApplicationAction - // It will be invoked when UseTickerQ is called from ASP.NET Core specific extension } private static async Task SeedDefinedCronTickers(IServiceProvider serviceProvider)