This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -0,0 +1,557 @@
using System.Buffers;
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.Messaging.Options;
using StellaOps.Router.Transport.Messaging.Protocol;
namespace StellaOps.Router.Transport.Messaging;
/// <summary>
/// Transport client that communicates with the gateway via StellaOps.Messaging.
/// Implements both ITransportClient (for sending to microservices) and
/// IMicroserviceTransport (for microservices connecting to gateway).
/// </summary>
public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTransport, IDisposable
{
private readonly IMessageQueueFactory _queueFactory;
private readonly MessagingTransportOptions _options;
private readonly ILogger<MessagingTransportClient> _logger;
private readonly CorrelationTracker _correlationTracker;
private readonly JsonSerializerOptions _jsonOptions;
private readonly ConcurrentDictionary<string, CancellationTokenSource> _inflightHandlers = new();
private readonly CancellationTokenSource _clientCts = new();
private IMessageQueue<RpcRequestMessage>? _requestQueue;
private IMessageQueue<RpcResponseMessage>? _responseQueue;
private IMessageQueue<RpcResponseMessage>? _serviceIncomingQueue;
private Task? _receiveTask;
private string? _connectionId;
private InstanceDescriptor? _instance;
private bool _disposed;
/// <inheritdoc />
public event Func<Frame, CancellationToken, Task<Frame>>? OnRequestReceived;
/// <inheritdoc />
public event Func<Guid, string?, Task>? OnCancelReceived;
/// <summary>
/// Initializes a new instance of the <see cref="MessagingTransportClient"/> class.
/// </summary>
public MessagingTransportClient(
IMessageQueueFactory queueFactory,
IOptions<MessagingTransportOptions> options,
ILogger<MessagingTransportClient> logger)
{
_queueFactory = queueFactory;
_options = options.Value;
_logger = logger;
_correlationTracker = new CorrelationTracker();
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
/// <inheritdoc />
public async Task ConnectAsync(
InstanceDescriptor instance,
IReadOnlyList<EndpointDescriptor> endpoints,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_connectionId = Guid.NewGuid().ToString("N");
_instance = instance;
// Create request queue (for sending to gateway)
_requestQueue = _queueFactory.Create<RpcRequestMessage>(new MessageQueueOptions
{
QueueName = _options.GetRequestQueueName("gateway"),
ConsumerName = $"{instance.ServiceName}-{instance.InstanceId}"
});
// Create response queue (for receiving gateway responses)
_responseQueue = _queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
{
QueueName = _options.ResponseQueueName,
ConsumerName = $"{instance.ServiceName}-{instance.InstanceId}"
});
// Create service-specific incoming queue (for receiving requests from gateway)
_serviceIncomingQueue = _queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
{
QueueName = _options.GetRequestQueueName(instance.ServiceName),
ConsumerName = $"{instance.ServiceName}-{instance.InstanceId}",
DefaultLeaseDuration = _options.LeaseDuration
});
// Send HELLO frame
var helloPayload = new HelloPayload
{
Instance = instance,
Endpoints = endpoints
};
var helloMessage = new RpcRequestMessage
{
CorrelationId = Guid.NewGuid().ToString("N"),
ConnectionId = _connectionId,
TargetService = "gateway",
FrameType = FrameType.Hello,
PayloadBase64 = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(helloPayload, _jsonOptions)),
SenderInstanceId = instance.InstanceId
};
await _requestQueue.EnqueueAsync(helloMessage, cancellationToken: cancellationToken);
_logger.LogInformation(
"Connected as {ServiceName}/{Version} instance {InstanceId} with {EndpointCount} endpoints via messaging",
instance.ServiceName,
instance.Version,
instance.InstanceId,
endpoints.Count);
// Start receiving responses and requests
_receiveTask = Task.Run(() => ReceiveLoopAsync(_clientCts.Token), CancellationToken.None);
}
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
{
// Run two loops concurrently: one for responses, one for incoming requests
var responseTask = ProcessResponsesAsync(cancellationToken);
var incomingTask = ProcessIncomingRequestsAsync(cancellationToken);
await Task.WhenAll(responseTask, incomingTask);
}
private async Task ProcessResponsesAsync(CancellationToken cancellationToken)
{
if (_responseQueue is null) return;
while (!cancellationToken.IsCancellationRequested)
{
try
{
var leases = await _responseQueue.LeaseAsync(
new LeaseRequest { BatchSize = _options.BatchSize },
cancellationToken);
foreach (var lease in leases)
{
try
{
// Check if this response is for us (our connection)
if (lease.Message.ConnectionId == _connectionId ||
string.IsNullOrEmpty(lease.Message.ConnectionId))
{
var frame = DecodeFrame(
lease.Message.FrameType,
lease.Message.CorrelationId,
lease.Message.PayloadBase64);
_correlationTracker.TryCompleteRequest(lease.Message.CorrelationId, frame);
}
await lease.AcknowledgeAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing response {MessageId}", lease.MessageId);
await lease.ReleaseAsync(ReleaseDisposition.Retry, cancellationToken);
}
}
if (leases.Count == 0)
{
await Task.Delay(100, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in response processing loop");
await Task.Delay(1000, cancellationToken);
}
}
}
private async Task ProcessIncomingRequestsAsync(CancellationToken cancellationToken)
{
if (_serviceIncomingQueue is null) return;
while (!cancellationToken.IsCancellationRequested)
{
try
{
var leases = await _serviceIncomingQueue.LeaseAsync(
new LeaseRequest { BatchSize = _options.BatchSize },
cancellationToken);
foreach (var lease in leases)
{
try
{
await HandleIncomingRequestAsync(lease, cancellationToken);
await lease.AcknowledgeAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling request {MessageId}", lease.MessageId);
await lease.ReleaseAsync(ReleaseDisposition.Retry, cancellationToken);
}
}
if (leases.Count == 0)
{
await Task.Delay(100, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in incoming request processing loop");
await Task.Delay(1000, cancellationToken);
}
}
}
private async Task HandleIncomingRequestAsync(
IMessageLease<RpcResponseMessage> lease,
CancellationToken cancellationToken)
{
var message = lease.Message;
if (message.FrameType == FrameType.Cancel)
{
HandleCancelMessage(message);
return;
}
if (message.FrameType is not (FrameType.Request or FrameType.RequestStreamData))
{
return;
}
if (OnRequestReceived is null)
{
_logger.LogWarning("No request handler registered, discarding request {CorrelationId}",
message.CorrelationId);
return;
}
using var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_inflightHandlers[message.CorrelationId] = handlerCts;
try
{
var requestFrame = DecodeFrame(message.FrameType, message.CorrelationId, message.PayloadBase64);
var responseFrame = await OnRequestReceived(requestFrame, handlerCts.Token);
// Send response back to gateway
if (!handlerCts.Token.IsCancellationRequested && _requestQueue is not null)
{
var responseMessage = new RpcRequestMessage
{
CorrelationId = message.CorrelationId,
ConnectionId = _connectionId!,
TargetService = "gateway",
FrameType = FrameType.Response,
PayloadBase64 = Convert.ToBase64String(responseFrame.Payload.Span),
SenderInstanceId = _instance?.InstanceId
};
await _requestQueue.EnqueueAsync(responseMessage, cancellationToken: cancellationToken);
}
}
catch (OperationCanceledException)
{
_logger.LogDebug("Request {CorrelationId} was cancelled", message.CorrelationId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling request {CorrelationId}", message.CorrelationId);
// Send error response if not cancelled
if (!handlerCts.Token.IsCancellationRequested && _requestQueue is not null)
{
var errorMessage = new RpcRequestMessage
{
CorrelationId = message.CorrelationId,
ConnectionId = _connectionId!,
TargetService = "gateway",
FrameType = FrameType.Response,
PayloadBase64 = Convert.ToBase64String(Array.Empty<byte>()),
SenderInstanceId = _instance?.InstanceId
};
await _requestQueue.EnqueueAsync(errorMessage, cancellationToken: cancellationToken);
}
}
finally
{
_inflightHandlers.TryRemove(message.CorrelationId, out _);
}
}
private void HandleCancelMessage(RpcResponseMessage message)
{
_logger.LogDebug("Received CANCEL for correlation {CorrelationId}", message.CorrelationId);
if (_inflightHandlers.TryGetValue(message.CorrelationId, out var handlerCts))
{
try
{
handlerCts.Cancel();
_logger.LogInformation("Cancelled handler for request {CorrelationId}", message.CorrelationId);
}
catch (ObjectDisposedException) { }
}
_correlationTracker.TryCancelRequest(message.CorrelationId);
if (OnCancelReceived is not null && Guid.TryParse(message.CorrelationId, out var correlationGuid))
{
_ = OnCancelReceived(correlationGuid, message.ErrorMessage);
}
}
/// <inheritdoc />
public async Task<Frame> SendRequestAsync(
ConnectionState connection,
Frame requestFrame,
TimeSpan timeout,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_requestQueue is null)
{
throw new InvalidOperationException("Not connected");
}
var correlationId = requestFrame.CorrelationId ?? Guid.NewGuid().ToString("N");
// Register for response before sending
var responseTask = _correlationTracker.RegisterRequestAsync(correlationId, timeout, cancellationToken);
var message = new RpcRequestMessage
{
CorrelationId = correlationId,
ConnectionId = connection.ConnectionId,
TargetService = connection.Instance.ServiceName,
FrameType = requestFrame.Type,
PayloadBase64 = Convert.ToBase64String(requestFrame.Payload.Span),
Timeout = timeout,
ReplyToQueue = _options.ResponseQueueName,
SenderInstanceId = _instance?.InstanceId
};
await _requestQueue.EnqueueAsync(message, cancellationToken: cancellationToken);
return await responseTask;
}
/// <inheritdoc />
public async Task SendCancelAsync(
ConnectionState connection,
Guid correlationId,
string? reason = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_requestQueue is null)
{
throw new InvalidOperationException("Not connected");
}
var message = new RpcRequestMessage
{
CorrelationId = correlationId.ToString("N"),
ConnectionId = connection.ConnectionId,
TargetService = connection.Instance.ServiceName,
FrameType = FrameType.Cancel,
PayloadBase64 = string.Empty,
SenderInstanceId = _instance?.InstanceId
};
await _requestQueue.EnqueueAsync(message);
_logger.LogDebug("Sent CANCEL for correlation {CorrelationId}", correlationId);
}
/// <inheritdoc />
public async Task SendStreamingAsync(
ConnectionState connection,
Frame requestHeader,
Stream requestBody,
Func<Stream, Task> readResponseBody,
PayloadLimits limits,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_requestQueue is null)
{
throw new InvalidOperationException("Not connected");
}
var correlationId = requestHeader.CorrelationId ?? Guid.NewGuid().ToString("N");
// Send header frame
var headerMessage = new RpcRequestMessage
{
CorrelationId = correlationId,
ConnectionId = connection.ConnectionId,
TargetService = connection.Instance.ServiceName,
FrameType = FrameType.Request,
PayloadBase64 = Convert.ToBase64String(requestHeader.Payload.Span),
SenderInstanceId = _instance?.InstanceId
};
await _requestQueue.EnqueueAsync(headerMessage, cancellationToken: cancellationToken);
// Stream request body in chunks
var buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
long totalBytesRead = 0;
int bytesRead;
while ((bytesRead = await requestBody.ReadAsync(buffer, cancellationToken)) > 0)
{
totalBytesRead += bytesRead;
if (totalBytesRead > limits.MaxRequestBytesPerCall)
{
throw new InvalidOperationException(
$"Request body exceeds limit of {limits.MaxRequestBytesPerCall} bytes");
}
var dataMessage = new RpcRequestMessage
{
CorrelationId = correlationId,
ConnectionId = connection.ConnectionId,
TargetService = connection.Instance.ServiceName,
FrameType = FrameType.RequestStreamData,
PayloadBase64 = Convert.ToBase64String(buffer, 0, bytesRead),
SenderInstanceId = _instance?.InstanceId
};
await _requestQueue.EnqueueAsync(dataMessage, cancellationToken: cancellationToken);
}
// Signal end of stream
var endMessage = new RpcRequestMessage
{
CorrelationId = correlationId,
ConnectionId = connection.ConnectionId,
TargetService = connection.Instance.ServiceName,
FrameType = FrameType.RequestStreamData,
PayloadBase64 = string.Empty,
SenderInstanceId = _instance?.InstanceId
};
await _requestQueue.EnqueueAsync(endMessage, cancellationToken: cancellationToken);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
// Read streaming response
using var responseStream = new MemoryStream();
await readResponseBody(responseStream);
}
/// <inheritdoc />
public async Task SendHeartbeatAsync(HeartbeatPayload heartbeat, CancellationToken cancellationToken)
{
if (_requestQueue is null || _connectionId is null) return;
var message = new RpcRequestMessage
{
CorrelationId = Guid.NewGuid().ToString("N"),
ConnectionId = _connectionId,
TargetService = "gateway",
FrameType = FrameType.Heartbeat,
PayloadBase64 = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(heartbeat, _jsonOptions)),
SenderInstanceId = _instance?.InstanceId
};
await _requestQueue.EnqueueAsync(message, cancellationToken: cancellationToken);
}
/// <inheritdoc />
public async Task DisconnectAsync()
{
if (_connectionId is null) return;
// Cancel all inflight handlers
foreach (var kvp in _inflightHandlers)
{
try { kvp.Value.Cancel(); }
catch (ObjectDisposedException) { }
}
_inflightHandlers.Clear();
await _clientCts.CancelAsync();
if (_receiveTask is not null)
{
try { await _receiveTask; }
catch (OperationCanceledException) { }
}
_connectionId = null;
_instance = null;
_logger.LogInformation("Disconnected from messaging transport");
}
private static Frame DecodeFrame(FrameType frameType, string? correlationId, string payloadBase64)
{
return new Frame
{
Type = frameType,
CorrelationId = correlationId,
Payload = string.IsNullOrEmpty(payloadBase64)
? ReadOnlyMemory<byte>.Empty
: Convert.FromBase64String(payloadBase64)
};
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var kvp in _inflightHandlers)
{
try { kvp.Value.Cancel(); }
catch (ObjectDisposedException) { }
}
_inflightHandlers.Clear();
_clientCts.Cancel();
_clientCts.Dispose();
_correlationTracker.Dispose();
}
}

View File

@@ -0,0 +1,400 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.Messaging.Options;
using StellaOps.Router.Transport.Messaging.Protocol;
namespace StellaOps.Router.Transport.Messaging;
/// <summary>
/// Transport server that receives requests from microservices via StellaOps.Messaging.
/// Used by the Gateway to handle incoming RPC calls.
/// </summary>
public sealed class MessagingTransportServer : ITransportServer, IDisposable
{
private readonly IMessageQueueFactory _queueFactory;
private readonly MessagingTransportOptions _options;
private readonly ILogger<MessagingTransportServer> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new();
private readonly ConcurrentDictionary<string, IMessageQueue<RpcResponseMessage>> _serviceQueues = new();
private readonly CancellationTokenSource _serverCts = new();
private IMessageQueue<RpcRequestMessage>? _requestQueue;
private IMessageQueue<RpcResponseMessage>? _responseQueue;
private Task? _requestProcessingTask;
private Task? _responseProcessingTask;
private bool _running;
private bool _disposed;
/// <summary>
/// Event raised when a HELLO frame is received.
/// </summary>
public event Func<ConnectionState, HelloPayload, Task>? OnHelloReceived;
/// <summary>
/// Event raised when a HEARTBEAT frame is received.
/// </summary>
public event Func<ConnectionState, HeartbeatPayload, Task>? OnHeartbeatReceived;
/// <summary>
/// Event raised when a RESPONSE frame is received.
/// </summary>
public event Func<ConnectionState, Frame, Task>? OnResponseReceived;
/// <summary>
/// Event raised when a connection is closed.
/// </summary>
public event Func<string, Task>? OnConnectionClosed;
/// <summary>
/// Initializes a new instance of the <see cref="MessagingTransportServer"/> class.
/// </summary>
public MessagingTransportServer(
IMessageQueueFactory queueFactory,
IOptions<MessagingTransportOptions> options,
ILogger<MessagingTransportServer> logger)
{
_queueFactory = queueFactory;
_options = options.Value;
_logger = logger;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_running)
{
_logger.LogWarning("Messaging transport server is already running");
return Task.CompletedTask;
}
// Create the gateway request queue (receives requests from all services)
_requestQueue = _queueFactory.Create<RpcRequestMessage>(new MessageQueueOptions
{
QueueName = _options.GetRequestQueueName("gateway"),
ConsumerName = _options.ConsumerGroup,
DeadLetterQueue = _options.GetRequestQueueName("gateway") + _options.DeadLetterSuffix,
DefaultLeaseDuration = _options.LeaseDuration
});
// Create the gateway response queue
_responseQueue = _queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
{
QueueName = _options.ResponseQueueName,
ConsumerName = _options.ConsumerGroup,
DefaultLeaseDuration = _options.LeaseDuration
});
_running = true;
_requestProcessingTask = Task.Run(() => ProcessRequestsAsync(_serverCts.Token), CancellationToken.None);
_responseProcessingTask = Task.Run(() => ProcessResponsesAsync(_serverCts.Token), CancellationToken.None);
_logger.LogInformation("Messaging transport server started");
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
if (!_running) return;
_logger.LogInformation("Messaging transport server stopping");
_running = false;
await _serverCts.CancelAsync();
if (_requestProcessingTask is not null)
{
try
{
await _requestProcessingTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException) { }
}
if (_responseProcessingTask is not null)
{
try
{
await _responseProcessingTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException) { }
}
_logger.LogInformation("Messaging transport server stopped");
}
private async Task ProcessRequestsAsync(CancellationToken cancellationToken)
{
if (_requestQueue is null) return;
while (!cancellationToken.IsCancellationRequested)
{
try
{
var leases = await _requestQueue.LeaseAsync(
new LeaseRequest { BatchSize = _options.BatchSize, LeaseDuration = _options.LeaseDuration },
cancellationToken);
foreach (var lease in leases)
{
try
{
await ProcessRequestMessageAsync(lease, cancellationToken);
await lease.AcknowledgeAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing request {MessageId}", lease.MessageId);
await lease.ReleaseAsync(ReleaseDisposition.Retry, cancellationToken);
}
}
// Small delay if no messages
if (leases.Count == 0)
{
await Task.Delay(100, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in request processing loop");
await Task.Delay(1000, cancellationToken);
}
}
}
private async Task ProcessResponsesAsync(CancellationToken cancellationToken)
{
if (_responseQueue is null) return;
while (!cancellationToken.IsCancellationRequested)
{
try
{
var leases = await _responseQueue.LeaseAsync(
new LeaseRequest { BatchSize = _options.BatchSize, LeaseDuration = _options.LeaseDuration },
cancellationToken);
foreach (var lease in leases)
{
try
{
await ProcessResponseMessageAsync(lease, cancellationToken);
await lease.AcknowledgeAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing response {MessageId}", lease.MessageId);
await lease.ReleaseAsync(ReleaseDisposition.Retry, cancellationToken);
}
}
if (leases.Count == 0)
{
await Task.Delay(100, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in response processing loop");
await Task.Delay(1000, cancellationToken);
}
}
}
private async Task ProcessRequestMessageAsync(IMessageLease<RpcRequestMessage> lease, CancellationToken cancellationToken)
{
var message = lease.Message;
switch (message.FrameType)
{
case FrameType.Hello:
await HandleHelloMessageAsync(message, cancellationToken);
break;
case FrameType.Heartbeat:
await HandleHeartbeatMessageAsync(message, cancellationToken);
break;
case FrameType.Response:
case FrameType.ResponseStreamData:
// Response from microservice to gateway - route to pending request
if (_connections.TryGetValue(message.ConnectionId, out var state) && OnResponseReceived is not null)
{
var frame = DecodeFrame(message.FrameType, message.CorrelationId, message.PayloadBase64);
await OnResponseReceived(state, frame);
}
break;
default:
_logger.LogWarning("Unexpected frame type {FrameType} in request queue", message.FrameType);
break;
}
}
private async Task ProcessResponseMessageAsync(IMessageLease<RpcResponseMessage> lease, CancellationToken cancellationToken)
{
var message = lease.Message;
if (_connections.TryGetValue(message.ConnectionId, out var state) && OnResponseReceived is not null)
{
var frame = DecodeFrame(message.FrameType, message.CorrelationId, message.PayloadBase64);
await OnResponseReceived(state, frame);
}
}
private async Task HandleHelloMessageAsync(RpcRequestMessage message, CancellationToken cancellationToken)
{
// Parse HelloPayload from the message
var payload = JsonSerializer.Deserialize<HelloPayload>(
Convert.FromBase64String(message.PayloadBase64), _jsonOptions);
if (payload is null)
{
_logger.LogWarning("Invalid HELLO payload from {ConnectionId}", message.ConnectionId);
return;
}
// Create ConnectionState
var state = new ConnectionState
{
ConnectionId = message.ConnectionId,
Instance = payload.Instance,
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = TransportType.Messaging,
Schemas = payload.Schemas,
OpenApiInfo = payload.OpenApiInfo
};
// Register endpoints
foreach (var endpoint in payload.Endpoints)
{
state.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
}
_connections[message.ConnectionId] = state;
_logger.LogInformation(
"HELLO received from {ServiceName}/{Version} instance {InstanceId} via messaging",
payload.Instance.ServiceName,
payload.Instance.Version,
payload.Instance.InstanceId);
if (OnHelloReceived is not null)
{
await OnHelloReceived(state, payload);
}
}
private async Task HandleHeartbeatMessageAsync(RpcRequestMessage message, CancellationToken cancellationToken)
{
if (!_connections.TryGetValue(message.ConnectionId, out var state))
{
_logger.LogWarning("Heartbeat from unknown connection {ConnectionId}", message.ConnectionId);
return;
}
state.LastHeartbeatUtc = DateTime.UtcNow;
var payload = JsonSerializer.Deserialize<HeartbeatPayload>(
Convert.FromBase64String(message.PayloadBase64), _jsonOptions);
if (payload is not null)
{
state.Status = payload.Status;
_logger.LogDebug("Heartbeat received from {ConnectionId}", message.ConnectionId);
if (OnHeartbeatReceived is not null)
{
await OnHeartbeatReceived(state, payload);
}
}
}
/// <summary>
/// Sends a request frame to a microservice via messaging.
/// </summary>
public async ValueTask SendToMicroserviceAsync(
string connectionId,
Frame frame,
CancellationToken cancellationToken)
{
if (!_connections.TryGetValue(connectionId, out var state))
{
throw new InvalidOperationException($"Connection {connectionId} not found");
}
var serviceName = state.Instance.ServiceName;
// Get or create the service-specific request queue
var serviceQueue = _serviceQueues.GetOrAdd(serviceName, svc =>
_queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
{
QueueName = _options.GetRequestQueueName(svc),
ConsumerName = _options.ConsumerGroup
}));
var message = new RpcResponseMessage
{
CorrelationId = frame.CorrelationId ?? Guid.NewGuid().ToString("N"),
ConnectionId = connectionId,
FrameType = frame.Type,
PayloadBase64 = Convert.ToBase64String(frame.Payload.Span)
};
await serviceQueue.EnqueueAsync(message, cancellationToken: cancellationToken);
}
/// <summary>
/// Gets a connection by ID.
/// </summary>
public ConnectionState? GetConnection(string connectionId)
{
_connections.TryGetValue(connectionId, out var state);
return state;
}
private static Frame DecodeFrame(FrameType frameType, string? correlationId, string payloadBase64)
{
return new Frame
{
Type = frameType,
CorrelationId = correlationId,
Payload = Convert.FromBase64String(payloadBase64)
};
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_serverCts.Cancel();
_serverCts.Dispose();
}
}

View File

@@ -0,0 +1,62 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Router.Transport.Messaging.Options;
/// <summary>
/// Configuration options for the messaging-based router transport.
/// </summary>
public class MessagingTransportOptions
{
/// <summary>
/// Gets or sets the queue name template for incoming requests.
/// Use {service} placeholder for service-specific queues.
/// Example: "router:requests:{service}"
/// </summary>
[Required]
public string RequestQueueTemplate { get; set; } = "router:requests:{service}";
/// <summary>
/// Gets or sets the queue name for gateway responses.
/// Example: "router:responses"
/// </summary>
[Required]
public string ResponseQueueName { get; set; } = "router:responses";
/// <summary>
/// Gets or sets the consumer group name for request processing.
/// </summary>
public string ConsumerGroup { get; set; } = "router-gateway";
/// <summary>
/// Gets or sets the timeout for RPC requests.
/// </summary>
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets the lease duration for message processing.
/// </summary>
public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the batch size for leasing messages.
/// </summary>
public int BatchSize { get; set; } = 10;
/// <summary>
/// Gets or sets the heartbeat interval.
/// </summary>
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets or sets the dead letter queue suffix.
/// </summary>
public string DeadLetterSuffix { get; set; } = ":dlq";
/// <summary>
/// Gets the request queue name for a specific service.
/// </summary>
public string GetRequestQueueName(string serviceName)
{
return RequestQueueTemplate.Replace("{service}", serviceName);
}
}

View File

@@ -0,0 +1,131 @@
using System.Collections.Concurrent;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Transport.Messaging.Protocol;
/// <summary>
/// Tracks pending request/response correlations for RPC-style messaging.
/// </summary>
public sealed class CorrelationTracker : IDisposable
{
private readonly ConcurrentDictionary<string, PendingRequest> _pendingRequests = new();
private readonly Timer _cleanupTimer;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="CorrelationTracker"/> class.
/// </summary>
public CorrelationTracker()
{
// Cleanup expired requests every 30 seconds
_cleanupTimer = new Timer(CleanupExpiredRequests, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
}
/// <summary>
/// Registers a pending request and returns a task that completes when the response arrives.
/// </summary>
public Task<Frame> RegisterRequestAsync(
string correlationId,
TimeSpan timeout,
CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<Frame>(TaskCreationOptions.RunContinuationsAsynchronously);
var pending = new PendingRequest(tcs, DateTimeOffset.UtcNow.Add(timeout), cancellationToken);
if (!_pendingRequests.TryAdd(correlationId, pending))
{
throw new InvalidOperationException($"Correlation ID {correlationId} is already in use");
}
// Register cancellation callback
cancellationToken.Register(() =>
{
if (_pendingRequests.TryRemove(correlationId, out var removed))
{
removed.TaskCompletionSource.TrySetCanceled(cancellationToken);
}
});
return tcs.Task;
}
/// <summary>
/// Completes a pending request with the given response.
/// </summary>
public bool TryCompleteRequest(string correlationId, Frame response)
{
if (_pendingRequests.TryRemove(correlationId, out var pending))
{
return pending.TaskCompletionSource.TrySetResult(response);
}
return false;
}
/// <summary>
/// Fails a pending request with an exception.
/// </summary>
public bool TryFailRequest(string correlationId, Exception exception)
{
if (_pendingRequests.TryRemove(correlationId, out var pending))
{
return pending.TaskCompletionSource.TrySetException(exception);
}
return false;
}
/// <summary>
/// Cancels a pending request.
/// </summary>
public bool TryCancelRequest(string correlationId)
{
if (_pendingRequests.TryRemove(correlationId, out var pending))
{
return pending.TaskCompletionSource.TrySetCanceled();
}
return false;
}
/// <summary>
/// Gets the number of pending requests.
/// </summary>
public int PendingCount => _pendingRequests.Count;
private void CleanupExpiredRequests(object? state)
{
var now = DateTimeOffset.UtcNow;
var expiredKeys = _pendingRequests
.Where(kvp => kvp.Value.ExpiresAt < now)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in expiredKeys)
{
if (_pendingRequests.TryRemove(key, out var pending))
{
pending.TaskCompletionSource.TrySetException(
new TimeoutException($"Request {key} timed out"));
}
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cleanupTimer.Dispose();
// Cancel all pending requests
foreach (var kvp in _pendingRequests)
{
kvp.Value.TaskCompletionSource.TrySetCanceled();
}
_pendingRequests.Clear();
}
private sealed record PendingRequest(
TaskCompletionSource<Frame> TaskCompletionSource,
DateTimeOffset ExpiresAt,
CancellationToken CancellationToken);
}

View File

@@ -0,0 +1,54 @@
using StellaOps.Router.Common.Enums;
namespace StellaOps.Router.Transport.Messaging.Protocol;
/// <summary>
/// Represents an RPC request message sent via the messaging transport.
/// </summary>
public sealed record RpcRequestMessage
{
/// <summary>
/// Gets the correlation ID for matching requests to responses.
/// </summary>
public required string CorrelationId { get; init; }
/// <summary>
/// Gets the connection ID of the sender.
/// </summary>
public required string ConnectionId { get; init; }
/// <summary>
/// Gets the target service name.
/// </summary>
public required string TargetService { get; init; }
/// <summary>
/// Gets the frame type.
/// </summary>
public required FrameType FrameType { get; init; }
/// <summary>
/// Gets the frame payload as base64-encoded bytes.
/// </summary>
public required string PayloadBase64 { get; init; }
/// <summary>
/// Gets the timestamp when this request was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Gets the timeout for this request.
/// </summary>
public TimeSpan? Timeout { get; init; }
/// <summary>
/// Gets the reply-to queue name for responses.
/// </summary>
public string? ReplyToQueue { get; init; }
/// <summary>
/// Gets the instance ID of the sender.
/// </summary>
public string? SenderInstanceId { get; init; }
}

View File

@@ -0,0 +1,49 @@
using StellaOps.Router.Common.Enums;
namespace StellaOps.Router.Transport.Messaging.Protocol;
/// <summary>
/// Represents an RPC response message sent via the messaging transport.
/// </summary>
public sealed record RpcResponseMessage
{
/// <summary>
/// Gets the correlation ID matching the original request.
/// </summary>
public required string CorrelationId { get; init; }
/// <summary>
/// Gets the connection ID of the responder.
/// </summary>
public required string ConnectionId { get; init; }
/// <summary>
/// Gets the frame type.
/// </summary>
public required FrameType FrameType { get; init; }
/// <summary>
/// Gets the frame payload as base64-encoded bytes.
/// </summary>
public required string PayloadBase64 { get; init; }
/// <summary>
/// Gets the timestamp when this response was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Gets a value indicating whether the request was successful.
/// </summary>
public bool IsSuccess { get; init; } = true;
/// <summary>
/// Gets the error message if the request failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Gets the instance ID of the responder.
/// </summary>
public string? ResponderInstanceId { get; init; }
}

View File

@@ -0,0 +1,93 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Transport.Messaging.Options;
using StellaOps.Router.Transport.Messaging.Protocol;
namespace StellaOps.Router.Transport.Messaging;
/// <summary>
/// Extension methods for registering messaging transport services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds the messaging transport for both server and client.
/// Requires StellaOps.Messaging services to be registered.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddMessagingTransport(
this IServiceCollection services,
Action<MessagingTransportOptions>? configure = null)
{
services.AddOptions<MessagingTransportOptions>();
if (configure is not null)
{
services.Configure(configure);
}
// Shared correlation tracker
services.TryAddSingleton<CorrelationTracker>();
// Transport implementations
services.TryAddSingleton<MessagingTransportServer>();
services.TryAddSingleton<MessagingTransportClient>();
// Register interfaces
services.TryAddSingleton<ITransportServer>(sp => sp.GetRequiredService<MessagingTransportServer>());
services.TryAddSingleton<ITransportClient>(sp => sp.GetRequiredService<MessagingTransportClient>());
services.TryAddSingleton<IMicroserviceTransport>(sp => sp.GetRequiredService<MessagingTransportClient>());
return services;
}
/// <summary>
/// Adds the messaging transport server only (for Gateway).
/// Requires StellaOps.Messaging services to be registered.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddMessagingTransportServer(
this IServiceCollection services,
Action<MessagingTransportOptions>? configure = null)
{
services.AddOptions<MessagingTransportOptions>();
if (configure is not null)
{
services.Configure(configure);
}
services.TryAddSingleton<MessagingTransportServer>();
services.TryAddSingleton<ITransportServer>(sp => sp.GetRequiredService<MessagingTransportServer>());
return services;
}
/// <summary>
/// Adds the messaging transport client only (for Microservice SDK).
/// Requires StellaOps.Messaging services to be registered.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddMessagingTransportClient(
this IServiceCollection services,
Action<MessagingTransportOptions>? configure = null)
{
services.AddOptions<MessagingTransportOptions>();
if (configure is not null)
{
services.Configure(configure);
}
services.TryAddSingleton<CorrelationTracker>();
services.TryAddSingleton<MessagingTransportClient>();
services.TryAddSingleton<ITransportClient>(sp => sp.GetRequiredService<MessagingTransportClient>());
services.TryAddSingleton<IMicroserviceTransport>(sp => sp.GetRequiredService<MessagingTransportClient>());
return services;
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Router.Transport.Messaging</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>