up
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user