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)