Add unit tests for Router configuration and transport layers
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

- Implemented tests for RouterConfig, RoutingOptions, StaticInstanceConfig, and RouterConfigOptions to ensure default values are set correctly.
- Added tests for RouterConfigProvider to validate configurations and ensure defaults are returned when no file is specified.
- Created tests for ConfigValidationResult to check success and error scenarios.
- Developed tests for ServiceCollectionExtensions to verify service registration for RouterConfig.
- Introduced UdpTransportTests to validate serialization, connection, request-response, and error handling in UDP transport.
- Added scripts for signing authority gaps and hashing DevPortal SDK snippets.
This commit is contained in:
StellaOps Bot
2025-12-05 08:01:47 +02:00
parent 635c70e828
commit 6a299d231f
294 changed files with 28434 additions and 1329 deletions

View File

@@ -0,0 +1,71 @@
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>
/// Interface for discovering endpoints with YAML configuration support.
/// </summary>
public interface IEndpointDiscoveryService
{
/// <summary>
/// Discovers all endpoints, applying any YAML configuration overrides.
/// </summary>
/// <returns>The discovered endpoints with overrides applied.</returns>
IReadOnlyList<EndpointDescriptor> DiscoverEndpoints();
}
/// <summary>
/// Service that discovers endpoints and applies YAML configuration overrides.
/// </summary>
public sealed class EndpointDiscoveryService : IEndpointDiscoveryService
{
private readonly IEndpointDiscoveryProvider _discoveryProvider;
private readonly IMicroserviceYamlLoader _yamlLoader;
private readonly IEndpointOverrideMerger _merger;
private readonly ILogger<EndpointDiscoveryService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EndpointDiscoveryService"/> class.
/// </summary>
public EndpointDiscoveryService(
IEndpointDiscoveryProvider discoveryProvider,
IMicroserviceYamlLoader yamlLoader,
IEndpointOverrideMerger merger,
ILogger<EndpointDiscoveryService> logger)
{
_discoveryProvider = discoveryProvider;
_yamlLoader = yamlLoader;
_merger = merger;
_logger = logger;
}
/// <inheritdoc />
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints()
{
// 1. Discover endpoints from code (via reflection or source gen)
var codeEndpoints = _discoveryProvider.DiscoverEndpoints();
_logger.LogDebug("Discovered {Count} endpoints from code", codeEndpoints.Count);
// 2. Load YAML overrides
MicroserviceYamlConfig? yamlConfig = null;
try
{
yamlConfig = _yamlLoader.Load();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load YAML configuration, using code defaults only");
}
// 3. Merge code endpoints with YAML overrides
var mergedEndpoints = _merger.Merge(codeEndpoints, yamlConfig);
_logger.LogInformation(
"Endpoint discovery complete: {Count} endpoints (YAML overrides: {HasYaml})",
mergedEndpoints.Count,
yamlConfig != null);
return mergedEndpoints;
}
}

View File

@@ -0,0 +1,115 @@
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>
/// Interface for merging endpoint overrides from YAML configuration.
/// </summary>
public interface IEndpointOverrideMerger
{
/// <summary>
/// Merges YAML overrides with code-defined endpoints.
/// </summary>
/// <param name="codeEndpoints">The endpoints discovered from code.</param>
/// <param name="yamlConfig">The YAML configuration, if any.</param>
/// <returns>The merged endpoints.</returns>
IReadOnlyList<EndpointDescriptor> Merge(
IReadOnlyList<EndpointDescriptor> codeEndpoints,
MicroserviceYamlConfig? yamlConfig);
}
/// <summary>
/// Merges endpoint overrides from YAML configuration with code defaults.
/// </summary>
public sealed class EndpointOverrideMerger : IEndpointOverrideMerger
{
private readonly ILogger<EndpointOverrideMerger> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EndpointOverrideMerger"/> class.
/// </summary>
public EndpointOverrideMerger(ILogger<EndpointOverrideMerger> logger)
{
_logger = logger;
}
/// <inheritdoc />
public IReadOnlyList<EndpointDescriptor> Merge(
IReadOnlyList<EndpointDescriptor> codeEndpoints,
MicroserviceYamlConfig? yamlConfig)
{
if (yamlConfig == null || yamlConfig.Endpoints.Count == 0)
{
return codeEndpoints;
}
WarnUnmatchedOverrides(codeEndpoints, yamlConfig);
return codeEndpoints.Select(ep =>
{
var yamlOverride = FindMatchingOverride(ep, yamlConfig);
return yamlOverride == null ? ep : MergeEndpoint(ep, yamlOverride);
}).ToList();
}
private static EndpointOverrideConfig? FindMatchingOverride(
EndpointDescriptor endpoint,
MicroserviceYamlConfig yamlConfig)
{
return yamlConfig.Endpoints.FirstOrDefault(y =>
string.Equals(y.Method, endpoint.Method, StringComparison.OrdinalIgnoreCase) &&
string.Equals(y.Path, endpoint.Path, StringComparison.OrdinalIgnoreCase));
}
private EndpointDescriptor MergeEndpoint(
EndpointDescriptor codeDefault,
EndpointOverrideConfig yamlOverride)
{
var merged = codeDefault with
{
DefaultTimeout = yamlOverride.GetDefaultTimeoutAsTimeSpan() ?? codeDefault.DefaultTimeout,
SupportsStreaming = yamlOverride.SupportsStreaming ?? codeDefault.SupportsStreaming,
RequiringClaims = yamlOverride.RequiringClaims?.Count > 0
? yamlOverride.RequiringClaims.Select(c => c.ToClaimRequirement()).ToList()
: codeDefault.RequiringClaims
};
if (yamlOverride.GetDefaultTimeoutAsTimeSpan().HasValue ||
yamlOverride.SupportsStreaming.HasValue ||
yamlOverride.RequiringClaims?.Count > 0)
{
_logger.LogDebug(
"Applied YAML overrides to endpoint {Method} {Path}: Timeout={Timeout}, Streaming={Streaming}, Claims={Claims}",
merged.Method,
merged.Path,
merged.DefaultTimeout,
merged.SupportsStreaming,
merged.RequiringClaims?.Count ?? 0);
}
return merged;
}
private void WarnUnmatchedOverrides(
IReadOnlyList<EndpointDescriptor> codeEndpoints,
MicroserviceYamlConfig yamlConfig)
{
var codeKeys = codeEndpoints
.Select(e => (Method: e.Method.ToUpperInvariant(), Path: e.Path.ToLowerInvariant()))
.ToHashSet();
foreach (var yamlEntry in yamlConfig.Endpoints)
{
var key = (Method: yamlEntry.Method.ToUpperInvariant(), Path: yamlEntry.Path.ToLowerInvariant());
if (!codeKeys.Contains(key))
{
_logger.LogWarning(
"YAML override for {Method} {Path} does not match any code endpoint. " +
"YAML cannot create endpoints, only modify existing ones.",
yamlEntry.Method,
yamlEntry.Path);
}
}
}
}

View File

@@ -1,3 +1,5 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>

View File

@@ -0,0 +1,110 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>
/// Discovers endpoints using source-generated provider, falling back to reflection.
/// </summary>
public sealed class GeneratedEndpointDiscoveryProvider : IEndpointDiscoveryProvider
{
private readonly StellaMicroserviceOptions _options;
private readonly ILogger<GeneratedEndpointDiscoveryProvider> _logger;
private readonly ReflectionEndpointDiscoveryProvider _reflectionFallback;
private const string GeneratedProviderTypeName = "StellaOps.Microservice.Generated.GeneratedEndpointProvider";
/// <summary>
/// Initializes a new instance of the <see cref="GeneratedEndpointDiscoveryProvider"/> class.
/// </summary>
public GeneratedEndpointDiscoveryProvider(
StellaMicroserviceOptions options,
ILogger<GeneratedEndpointDiscoveryProvider> logger)
{
_options = options;
_logger = logger;
_reflectionFallback = new ReflectionEndpointDiscoveryProvider(options);
}
/// <inheritdoc />
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints()
{
// Try to find the generated provider
var generatedProvider = TryGetGeneratedProvider();
if (generatedProvider != null)
{
_logger.LogDebug("Using source-generated endpoint discovery");
var endpoints = generatedProvider.GetEndpoints();
// Apply service name and version from options
var result = new List<EndpointDescriptor>();
foreach (var endpoint in endpoints)
{
result.Add(endpoint with
{
ServiceName = _options.ServiceName,
Version = _options.Version
});
}
_logger.LogInformation(
"Discovered {Count} endpoints via source generation",
result.Count);
return result;
}
// Fall back to reflection
_logger.LogDebug("Source-generated provider not found, falling back to reflection");
return _reflectionFallback.DiscoverEndpoints();
}
private IGeneratedEndpointProvider? TryGetGeneratedProvider()
{
try
{
// Look in the entry assembly first
var entryAssembly = Assembly.GetEntryAssembly();
var providerType = entryAssembly?.GetType(GeneratedProviderTypeName);
if (providerType != null)
{
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
}
// Also check the calling assembly
var callingAssembly = Assembly.GetCallingAssembly();
providerType = callingAssembly.GetType(GeneratedProviderTypeName);
if (providerType != null)
{
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
}
// Check all loaded assemblies
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
providerType = assembly.GetType(GeneratedProviderTypeName);
if (providerType != null)
{
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
}
}
catch
{
// Ignore assembly loading errors
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load generated endpoint provider");
}
return null;
}
}

View File

@@ -1,3 +1,5 @@
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
/// <summary>
/// Interface implemented by the source-generated endpoint provider.
/// </summary>
public interface IGeneratedEndpointProvider
{
/// <summary>
/// Gets all discovered endpoint descriptors.
/// </summary>
IReadOnlyList<EndpointDescriptor> GetEndpoints();
/// <summary>
/// Registers all endpoint handlers with the service collection.
/// </summary>
void RegisterHandlers(IServiceCollection services);
/// <summary>
/// Gets all handler types for endpoint discovery.
/// </summary>
IReadOnlyList<Type> GetHandlerTypes();
}

View File

@@ -0,0 +1,145 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace StellaOps.Microservice;
/// <summary>
/// Tracks in-flight requests and manages their cancellation tokens.
/// </summary>
public sealed class InflightRequestTracker : IDisposable
{
private readonly ConcurrentDictionary<Guid, InflightRequest> _inflight = new();
private readonly ILogger<InflightRequestTracker> _logger;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="InflightRequestTracker"/> class.
/// </summary>
public InflightRequestTracker(ILogger<InflightRequestTracker> logger)
{
_logger = logger;
}
/// <summary>
/// Gets the count of in-flight requests.
/// </summary>
public int Count => _inflight.Count;
/// <summary>
/// Starts tracking a request and returns a cancellation token for it.
/// </summary>
/// <param name="correlationId">The correlation ID of the request.</param>
/// <returns>A cancellation token that will be triggered if the request is cancelled.</returns>
public CancellationToken Track(Guid correlationId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var cts = new CancellationTokenSource();
var request = new InflightRequest(cts);
if (!_inflight.TryAdd(correlationId, request))
{
cts.Dispose();
throw new InvalidOperationException($"Request {correlationId} is already being tracked");
}
_logger.LogDebug("Started tracking request {CorrelationId}", correlationId);
return cts.Token;
}
/// <summary>
/// Cancels a specific request.
/// </summary>
/// <param name="correlationId">The correlation ID of the request to cancel.</param>
/// <param name="reason">The reason for cancellation.</param>
/// <returns>True if the request was found and cancelled; otherwise false.</returns>
public bool Cancel(Guid correlationId, string? reason)
{
if (_inflight.TryGetValue(correlationId, out var request))
{
try
{
request.Cts.Cancel();
_logger.LogInformation(
"Cancelled request {CorrelationId}: {Reason}",
correlationId,
reason ?? "Unknown");
return true;
}
catch (ObjectDisposedException)
{
// CTS was already disposed, request completed
return false;
}
}
_logger.LogDebug(
"Cannot cancel request {CorrelationId}: not found (may have already completed)",
correlationId);
return false;
}
/// <summary>
/// Marks a request as completed and removes it from tracking.
/// </summary>
/// <param name="correlationId">The correlation ID of the completed request.</param>
public void Complete(Guid correlationId)
{
if (_inflight.TryRemove(correlationId, out var request))
{
request.Cts.Dispose();
_logger.LogDebug("Completed request {CorrelationId}", correlationId);
}
}
/// <summary>
/// Cancels all in-flight requests.
/// </summary>
/// <param name="reason">The reason for cancellation.</param>
public void CancelAll(string reason)
{
var count = 0;
foreach (var kvp in _inflight)
{
try
{
kvp.Value.Cts.Cancel();
count++;
}
catch (ObjectDisposedException)
{
// Already disposed
}
}
_logger.LogInformation("Cancelled {Count} in-flight requests: {Reason}", count, reason);
// Clear and dispose all
foreach (var kvp in _inflight)
{
if (_inflight.TryRemove(kvp.Key, out var request))
{
request.Cts.Dispose();
}
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
CancelAll("Disposing tracker");
}
private sealed class InflightRequest
{
public CancellationTokenSource Cts { get; }
public InflightRequest(CancellationTokenSource cts)
{
Cts = cts;
}
}
}

View File

@@ -0,0 +1,113 @@
using StellaOps.Router.Common.Models;
using YamlDotNet.Serialization;
namespace StellaOps.Microservice;
/// <summary>
/// Root configuration for microservice endpoint overrides loaded from YAML.
/// </summary>
public sealed class MicroserviceYamlConfig
{
/// <summary>
/// Gets or sets the endpoint override configurations.
/// </summary>
[YamlMember(Alias = "endpoints")]
public List<EndpointOverrideConfig> Endpoints { get; set; } = [];
}
/// <summary>
/// Configuration for overriding an endpoint's properties.
/// </summary>
public sealed class EndpointOverrideConfig
{
/// <summary>
/// Gets or sets the HTTP method to match.
/// </summary>
[YamlMember(Alias = "method")]
public string Method { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the path to match.
/// </summary>
[YamlMember(Alias = "path")]
public string Path { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the default timeout override.
/// </summary>
[YamlMember(Alias = "defaultTimeout")]
public string? DefaultTimeout { get; set; }
/// <summary>
/// Gets or sets whether streaming is supported.
/// </summary>
[YamlMember(Alias = "supportsStreaming")]
public bool? SupportsStreaming { get; set; }
/// <summary>
/// Gets or sets the claim requirements.
/// </summary>
[YamlMember(Alias = "requiringClaims")]
public List<ClaimRequirementConfig>? RequiringClaims { get; set; }
/// <summary>
/// Parses the DefaultTimeout string to a TimeSpan.
/// </summary>
public TimeSpan? GetDefaultTimeoutAsTimeSpan()
{
if (string.IsNullOrWhiteSpace(DefaultTimeout))
return null;
// Handle formats like "30s", "5m", "1h", or "00:00:30"
var value = DefaultTimeout.Trim();
if (value.EndsWith("s", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value[..^1], out var seconds))
return TimeSpan.FromSeconds(seconds);
}
else if (value.EndsWith("m", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value[..^1], out var minutes))
return TimeSpan.FromMinutes(minutes);
}
else if (value.EndsWith("h", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value[..^1], out var hours))
return TimeSpan.FromHours(hours);
}
else if (TimeSpan.TryParse(value, out var timespan))
{
return timespan;
}
return null;
}
}
/// <summary>
/// Configuration for a claim requirement.
/// </summary>
public sealed class ClaimRequirementConfig
{
/// <summary>
/// Gets or sets the claim type.
/// </summary>
[YamlMember(Alias = "type")]
public string Type { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the claim value.
/// </summary>
[YamlMember(Alias = "value")]
public string? Value { get; set; }
/// <summary>
/// Converts to a ClaimRequirement model.
/// </summary>
public ClaimRequirement ToClaimRequirement() => new()
{
Type = Type,
Value = Value
};
}

View File

@@ -0,0 +1,78 @@
using Microsoft.Extensions.Logging;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Microservice;
/// <summary>
/// Interface for loading microservice YAML configuration.
/// </summary>
public interface IMicroserviceYamlLoader
{
/// <summary>
/// Loads the microservice configuration from YAML.
/// </summary>
/// <returns>The configuration, or null if no file is configured or file doesn't exist.</returns>
MicroserviceYamlConfig? Load();
}
/// <summary>
/// Loads microservice configuration from a YAML file.
/// </summary>
public sealed class MicroserviceYamlLoader : IMicroserviceYamlLoader
{
private readonly StellaMicroserviceOptions _options;
private readonly ILogger<MicroserviceYamlLoader> _logger;
private readonly IDeserializer _deserializer;
/// <summary>
/// Initializes a new instance of the <see cref="MicroserviceYamlLoader"/> class.
/// </summary>
public MicroserviceYamlLoader(
StellaMicroserviceOptions options,
ILogger<MicroserviceYamlLoader> logger)
{
_options = options;
_logger = logger;
_deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
}
/// <inheritdoc />
public MicroserviceYamlConfig? Load()
{
if (string.IsNullOrWhiteSpace(_options.ConfigFilePath))
{
_logger.LogDebug("No ConfigFilePath specified, skipping YAML configuration");
return null;
}
var fullPath = Path.GetFullPath(_options.ConfigFilePath);
if (!File.Exists(fullPath))
{
_logger.LogDebug("Configuration file {Path} does not exist, skipping", fullPath);
return null;
}
try
{
var yaml = File.ReadAllText(fullPath);
var config = _deserializer.Deserialize<MicroserviceYamlConfig>(yaml);
_logger.LogInformation(
"Loaded microservice configuration from {Path} with {Count} endpoint overrides",
fullPath,
config?.Endpoints?.Count ?? 0);
return config;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load microservice configuration from {Path}", fullPath);
throw;
}
}
}

View File

@@ -1,85 +1,2 @@
using System.Text.RegularExpressions;
namespace StellaOps.Microservice;
/// <summary>
/// Matches request paths against route templates.
/// </summary>
public sealed partial class PathMatcher
{
private readonly string _template;
private readonly Regex _regex;
private readonly string[] _parameterNames;
private readonly bool _caseInsensitive;
/// <summary>
/// Gets the route template.
/// </summary>
public string Template => _template;
/// <summary>
/// Initializes a new instance of the <see cref="PathMatcher"/> class.
/// </summary>
/// <param name="template">The route template (e.g., "/api/users/{id}").</param>
/// <param name="caseInsensitive">Whether matching should be case-insensitive.</param>
public PathMatcher(string template, bool caseInsensitive = true)
{
_template = template;
_caseInsensitive = caseInsensitive;
// Extract parameter names and build regex
var paramNames = new List<string>();
var pattern = "^" + ParameterRegex().Replace(template, match =>
{
paramNames.Add(match.Groups[1].Value);
return "([^/]+)";
}) + "/?$";
var options = caseInsensitive ? RegexOptions.IgnoreCase : RegexOptions.None;
_regex = new Regex(pattern, options | RegexOptions.Compiled);
_parameterNames = [.. paramNames];
}
/// <summary>
/// Tries to match a path against the template.
/// </summary>
/// <param name="path">The request path.</param>
/// <param name="parameters">The extracted path parameters if matched.</param>
/// <returns>True if the path matches.</returns>
public bool TryMatch(string path, out Dictionary<string, string> parameters)
{
parameters = [];
// Normalize path
path = path.TrimEnd('/');
if (!path.StartsWith('/'))
path = "/" + path;
var match = _regex.Match(path);
if (!match.Success)
return false;
for (int i = 0; i < _parameterNames.Length; i++)
{
parameters[_parameterNames[i]] = match.Groups[i + 1].Value;
}
return true;
}
/// <summary>
/// Checks if a path matches the template.
/// </summary>
/// <param name="path">The request path.</param>
/// <returns>True if the path matches.</returns>
public bool IsMatch(string path)
{
path = path.TrimEnd('/');
if (!path.StartsWith('/'))
path = "/" + path;
return _regex.IsMatch(path);
}
[GeneratedRegex(@"\{([^}:]+)(?::[^}]+)?\}")]
private static partial Regex ParameterRegex();
}
// Re-export PathMatcher from Router.Common for backwards compatibility
global using PathMatcher = StellaOps.Router.Common.PathMatcher;

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
@@ -116,6 +117,13 @@ public sealed class RequestDispatcher
RawRequestContext context,
CancellationToken cancellationToken)
{
// Ensure handler type is set
if (endpoint.HandlerType is null)
{
_logger.LogError("Endpoint {Method} {Path} has no handler type", endpoint.Method, endpoint.Path);
return RawResponse.InternalError("No handler configured");
}
// Get handler instance from DI
var handler = scopedProvider.GetService(endpoint.HandlerType);
if (handler is null)

View File

@@ -14,13 +14,16 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
{
private readonly StellaMicroserviceOptions _options;
private readonly IEndpointDiscoveryProvider _endpointDiscovery;
private readonly ITransportClient _transportClient;
private readonly IMicroserviceTransport? _microserviceTransport;
private readonly ILogger<RouterConnectionManager> _logger;
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new();
private readonly CancellationTokenSource _cts = new();
private IReadOnlyList<EndpointDescriptor>? _endpoints;
private Task? _heartbeatTask;
private bool _disposed;
private volatile InstanceHealthStatus _currentStatus = InstanceHealthStatus.Healthy;
private int _inFlightRequestCount;
private double _errorRate;
/// <inheritdoc />
public IReadOnlyList<ConnectionState> Connections => [.. _connections.Values];
@@ -31,15 +34,42 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
public RouterConnectionManager(
IOptions<StellaMicroserviceOptions> options,
IEndpointDiscoveryProvider endpointDiscovery,
ITransportClient transportClient,
IMicroserviceTransport? microserviceTransport,
ILogger<RouterConnectionManager> logger)
{
_options = options.Value;
_endpointDiscovery = endpointDiscovery;
_transportClient = transportClient;
_microserviceTransport = microserviceTransport;
_logger = logger;
}
/// <summary>
/// Gets or sets the current health status reported by this instance.
/// </summary>
public InstanceHealthStatus CurrentStatus
{
get => _currentStatus;
set => _currentStatus = value;
}
/// <summary>
/// Gets or sets the count of in-flight requests.
/// </summary>
public int InFlightRequestCount
{
get => _inFlightRequestCount;
set => _inFlightRequestCount = value;
}
/// <summary>
/// Gets or sets the error rate (0.0 to 1.0).
/// </summary>
public double ErrorRate
{
get => _errorRate;
set => _errorRate = value;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
@@ -168,32 +198,40 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
{
await Task.Delay(_options.HeartbeatInterval, cancellationToken);
foreach (var connection in _connections.Values)
// Build heartbeat payload with current status and metrics
var heartbeat = new HeartbeatPayload
{
InstanceId = _options.InstanceId,
Status = _currentStatus,
InFlightRequestCount = _inFlightRequestCount,
ErrorRate = _errorRate,
TimestampUtc = DateTime.UtcNow
};
// Send heartbeat via transport
if (_microserviceTransport is not null)
{
try
{
// Build heartbeat payload
var heartbeat = new HeartbeatPayload
{
InstanceId = _options.InstanceId,
Status = connection.Status,
TimestampUtc = DateTime.UtcNow
};
// Update last heartbeat time
connection.LastHeartbeatUtc = DateTime.UtcNow;
await _microserviceTransport.SendHeartbeatAsync(heartbeat, cancellationToken);
_logger.LogDebug(
"Sent heartbeat for connection {ConnectionId}",
connection.ConnectionId);
"Sent heartbeat: status={Status}, inflight={InFlight}, errorRate={ErrorRate:P1}",
heartbeat.Status,
heartbeat.InFlightRequestCount,
heartbeat.ErrorRate);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to send heartbeat for connection {ConnectionId}",
connection.ConnectionId);
_logger.LogWarning(ex, "Failed to send heartbeat");
}
}
// Update connection state local heartbeat times
foreach (var connection in _connections.Values)
{
connection.LastHeartbeatUtc = DateTime.UtcNow;
}
}
catch (OperationCanceledException)
{

View File

@@ -22,17 +22,34 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
// Configure options
// Configure and register options as singleton
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
configure(options);
services.AddSingleton(options);
services.Configure(configure);
// Register endpoint discovery
services.TryAddSingleton<IEndpointDiscoveryProvider>(sp =>
// Register YAML loader and merger
services.TryAddSingleton<IMicroserviceYamlLoader, MicroserviceYamlLoader>();
services.TryAddSingleton<IEndpointOverrideMerger, EndpointOverrideMerger>();
// Register endpoint discovery provider (prefers generated over reflection)
services.TryAddSingleton<IEndpointDiscoveryProvider, GeneratedEndpointDiscoveryProvider>();
// Register endpoint discovery service (with YAML integration)
services.TryAddSingleton<IEndpointDiscoveryService, EndpointDiscoveryService>();
// Register endpoint registry (using discovery service)
services.TryAddSingleton<IEndpointRegistry>(sp =>
{
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
configure(options);
return new ReflectionEndpointDiscoveryProvider(options);
var discoveryService = sp.GetRequiredService<IEndpointDiscoveryService>();
var registry = new EndpointRegistry();
registry.RegisterAll(discoveryService.DiscoverEndpoints());
return registry;
});
// Register request dispatcher
services.TryAddSingleton<RequestDispatcher>();
// Register connection manager
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
@@ -57,12 +74,34 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
// Configure options
// Configure and register options as singleton
var options = new StellaMicroserviceOptions { ServiceName = "", Version = "1.0.0", Region = "" };
configure(options);
services.AddSingleton(options);
services.Configure(configure);
// Register YAML loader and merger
services.TryAddSingleton<IMicroserviceYamlLoader, MicroserviceYamlLoader>();
services.TryAddSingleton<IEndpointOverrideMerger, EndpointOverrideMerger>();
// Register custom endpoint discovery
services.TryAddSingleton<IEndpointDiscoveryProvider, TDiscovery>();
// Register endpoint discovery service (with YAML integration)
services.TryAddSingleton<IEndpointDiscoveryService, EndpointDiscoveryService>();
// Register endpoint registry (using discovery service)
services.TryAddSingleton<IEndpointRegistry>(sp =>
{
var discoveryService = sp.GetRequiredService<IEndpointDiscoveryService>();
var registry = new EndpointRegistry();
registry.RegisterAll(discoveryService.DiscoverEndpoints());
return registry;
});
// Register request dispatcher
services.TryAddSingleton<RequestDispatcher>();
// Register connection manager
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
@@ -71,4 +110,17 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Registers an endpoint handler type for dependency injection.
/// </summary>
/// <typeparam name="THandler">The endpoint handler type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddStellaEndpoint<THandler>(this IServiceCollection services)
where THandler : class, IStellaEndpoint
{
services.AddScoped<THandler>();
return services;
}
}

View File

@@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />

View File

@@ -0,0 +1,164 @@
using System.Threading.Channels;
namespace StellaOps.Microservice.Streaming;
/// <summary>
/// A read-only stream that reads from a channel of data chunks.
/// Used to expose streaming request body to handlers.
/// </summary>
public sealed class StreamingRequestBodyStream : Stream
{
private readonly ChannelReader<StreamChunk> _reader;
private readonly CancellationToken _cancellationToken;
private byte[] _currentBuffer = [];
private int _currentBufferPosition;
private bool _endOfStream;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="StreamingRequestBodyStream"/> class.
/// </summary>
/// <param name="reader">The channel reader for incoming chunks.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public StreamingRequestBodyStream(
ChannelReader<StreamChunk> reader,
CancellationToken cancellationToken)
{
_reader = reader;
_cancellationToken = cancellationToken;
}
/// <inheritdoc />
public override bool CanRead => true;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => false;
/// <inheritdoc />
public override long Length => throw new NotSupportedException("Streaming body length unknown.");
/// <inheritdoc />
public override long Position
{
get => throw new NotSupportedException("Streaming body position not supported.");
set => throw new NotSupportedException("Streaming body position not supported.");
}
/// <inheritdoc />
public override void Flush() { }
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count)
{
return ReadAsync(buffer, offset, count, CancellationToken.None)
.GetAwaiter().GetResult();
}
/// <inheritdoc />
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
return await ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
}
/// <inheritdoc />
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_endOfStream)
{
return 0;
}
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken);
// Try to use remaining data from current buffer first
if (_currentBufferPosition < _currentBuffer.Length)
{
var bytesToCopy = Math.Min(buffer.Length, _currentBuffer.Length - _currentBufferPosition);
_currentBuffer.AsSpan(_currentBufferPosition, bytesToCopy).CopyTo(buffer.Span);
_currentBufferPosition += bytesToCopy;
return bytesToCopy;
}
// Need to read next chunk from channel
if (!await _reader.WaitToReadAsync(linkedCts.Token))
{
_endOfStream = true;
return 0;
}
if (!_reader.TryRead(out var chunk))
{
_endOfStream = true;
return 0;
}
if (chunk.EndOfStream)
{
_endOfStream = true;
// Still process any data in the final chunk
if (chunk.Data.Length == 0)
{
return 0;
}
}
_currentBuffer = chunk.Data;
_currentBufferPosition = 0;
var bytesToReturn = Math.Min(buffer.Length, _currentBuffer.Length);
_currentBuffer.AsSpan(0, bytesToReturn).CopyTo(buffer.Span);
_currentBufferPosition = bytesToReturn;
return bytesToReturn;
}
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException("Seeking not supported on streaming body.");
}
/// <inheritdoc />
public override void SetLength(long value)
{
throw new NotSupportedException("Setting length not supported on streaming body.");
}
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException("Write not supported on streaming body.");
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
_disposed = true;
base.Dispose(disposing);
}
}
/// <summary>
/// Represents a chunk of streaming data.
/// </summary>
public sealed record StreamChunk
{
/// <summary>
/// Gets the chunk data.
/// </summary>
public byte[] Data { get; init; } = [];
/// <summary>
/// Gets a value indicating whether this is the final chunk.
/// </summary>
public bool EndOfStream { get; init; }
/// <summary>
/// Gets the sequence number.
/// </summary>
public int SequenceNumber { get; init; }
}

View File

@@ -0,0 +1,191 @@
using System.Threading.Channels;
namespace StellaOps.Microservice.Streaming;
/// <summary>
/// A write-only stream that writes chunks to a channel.
/// Used to enable streaming response body from handlers.
/// </summary>
public sealed class StreamingResponseBodyStream : Stream
{
private readonly ChannelWriter<StreamChunk> _writer;
private readonly int _chunkSize;
private readonly CancellationToken _cancellationToken;
private byte[] _buffer;
private int _bufferPosition;
private int _sequenceNumber;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="StreamingResponseBodyStream"/> class.
/// </summary>
/// <param name="writer">The channel writer for outgoing chunks.</param>
/// <param name="chunkSize">The chunk size for buffered writes.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public StreamingResponseBodyStream(
ChannelWriter<StreamChunk> writer,
int chunkSize,
CancellationToken cancellationToken)
{
_writer = writer;
_chunkSize = chunkSize;
_cancellationToken = cancellationToken;
_buffer = new byte[chunkSize];
}
/// <inheritdoc />
public override bool CanRead => false;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => true;
/// <inheritdoc />
public override long Length => throw new NotSupportedException();
/// <inheritdoc />
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
/// <inheritdoc />
public override void Flush()
{
FlushAsync(CancellationToken.None).GetAwaiter().GetResult();
}
/// <inheritdoc />
public override async Task FlushAsync(CancellationToken cancellationToken)
{
if (_bufferPosition > 0)
{
var chunk = new StreamChunk
{
Data = _buffer[.._bufferPosition],
SequenceNumber = _sequenceNumber++,
EndOfStream = false
};
await _writer.WriteAsync(chunk, cancellationToken);
_buffer = new byte[_chunkSize];
_bufferPosition = 0;
}
}
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count)
{
throw new NotSupportedException("Read not supported on streaming response body.");
}
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException("Seeking not supported on streaming response body.");
}
/// <inheritdoc />
public override void SetLength(long value)
{
throw new NotSupportedException("Setting length not supported on streaming response body.");
}
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count)
{
WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult();
}
/// <inheritdoc />
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
await WriteAsync(buffer.AsMemory(offset, count), cancellationToken);
}
/// <inheritdoc />
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken);
var bytesWritten = 0;
while (bytesWritten < buffer.Length)
{
var spaceInBuffer = _chunkSize - _bufferPosition;
var bytesToWrite = Math.Min(spaceInBuffer, buffer.Length - bytesWritten);
buffer.Slice(bytesWritten, bytesToWrite).Span.CopyTo(_buffer.AsSpan(_bufferPosition));
_bufferPosition += bytesToWrite;
bytesWritten += bytesToWrite;
if (_bufferPosition >= _chunkSize)
{
await FlushAsync(linkedCts.Token);
}
}
}
/// <summary>
/// Completes the stream by flushing remaining data and sending end-of-stream signal.
/// </summary>
public async Task CompleteAsync(CancellationToken cancellationToken = default)
{
// Flush any remaining buffered data
await FlushAsync(cancellationToken);
// Send end-of-stream marker
var endChunk = new StreamChunk
{
Data = [],
SequenceNumber = _sequenceNumber++,
EndOfStream = true
};
await _writer.WriteAsync(endChunk, cancellationToken);
_writer.Complete();
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
// Try to complete the stream if not already completed
try
{
_writer.TryComplete();
}
catch
{
// Ignore errors during disposal
}
}
_disposed = true;
base.Dispose(disposing);
}
/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
if (!_disposed)
{
try
{
await CompleteAsync(CancellationToken.None);
}
catch
{
// Ignore errors during disposal
}
}
_disposed = true;
await base.DisposeAsync();
}
}