Add gateway registration resync and slim HELLO transport frames
Introduce GatewayRegistrationResyncService to recover stale registrations, extract IGatewayTransportClient interface, add EndpointsUpdate and RegistrationResyncRequest frame types, and expand transport test coverage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -107,13 +107,16 @@ RegisterGatewayTransportIfEnabled("tls", bootstrapOptions.Transports.Tls.Enabled
|
|||||||
RegisterGatewayTransportIfEnabled("messaging", bootstrapOptions.Transports.Messaging.Enabled, "Gateway:Transports:Messaging");
|
RegisterGatewayTransportIfEnabled("messaging", bootstrapOptions.Transports.Messaging.Enabled, "Gateway:Transports:Messaging");
|
||||||
|
|
||||||
builder.Services.AddSingleton<GatewayTransportClient>();
|
builder.Services.AddSingleton<GatewayTransportClient>();
|
||||||
|
builder.Services.AddSingleton<IGatewayTransportClient>(sp => sp.GetRequiredService<GatewayTransportClient>());
|
||||||
builder.Services.AddSingleton<ITransportClient>(sp => sp.GetRequiredService<GatewayTransportClient>());
|
builder.Services.AddSingleton<ITransportClient>(sp => sp.GetRequiredService<GatewayTransportClient>());
|
||||||
|
builder.Services.AddSingleton<GatewayRegistrationResyncService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton(new GatewayRouteCatalog(bootstrapOptions.Routes));
|
builder.Services.AddSingleton(new GatewayRouteCatalog(bootstrapOptions.Routes));
|
||||||
builder.Services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
|
builder.Services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
|
||||||
builder.Services.AddSingleton<IRouterOpenApiDocumentCache, RouterOpenApiDocumentCache>();
|
builder.Services.AddSingleton<IRouterOpenApiDocumentCache, RouterOpenApiDocumentCache>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<GatewayHostedService>();
|
builder.Services.AddSingleton<GatewayHostedService>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<GatewayHostedService>());
|
||||||
builder.Services.AddHostedService<GatewayHealthMonitorService>();
|
builder.Services.AddHostedService<GatewayHealthMonitorService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<IDpopReplayCache, InMemoryDpopReplayCache>();
|
builder.Services.AddSingleton<IDpopReplayCache, InMemoryDpopReplayCache>();
|
||||||
@@ -215,6 +218,26 @@ if (bootstrapOptions.OpenApi.Enabled)
|
|||||||
app.MapRouterOpenApi();
|
app.MapRouterOpenApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.MapPost(
|
||||||
|
"/api/v1/gateway/administration/router/resync",
|
||||||
|
async (
|
||||||
|
GatewayRegistrationResyncRequest? request,
|
||||||
|
GatewayRegistrationResyncService resyncService,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var result = await resyncService.RequestResyncAsync(
|
||||||
|
request?.ConnectionId,
|
||||||
|
string.IsNullOrWhiteSpace(request?.Reason)
|
||||||
|
? "administration-request"
|
||||||
|
: request!.Reason!,
|
||||||
|
force: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return result.MatchedCount == 0
|
||||||
|
? Results.NotFound(result)
|
||||||
|
: Results.Ok(result);
|
||||||
|
});
|
||||||
|
|
||||||
app.UseWhen(
|
app.UseWhen(
|
||||||
context => !GatewayRoutes.IsSystemPath(context.Request.Path),
|
context => !GatewayRoutes.IsSystemPath(context.Request.Path),
|
||||||
branch =>
|
branch =>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public sealed class GatewayHostedService : IHostedService
|
|||||||
private readonly TlsTransportServer? _tlsServer;
|
private readonly TlsTransportServer? _tlsServer;
|
||||||
private readonly MessagingTransportServer? _messagingServer;
|
private readonly MessagingTransportServer? _messagingServer;
|
||||||
private readonly IGlobalRoutingState _routingState;
|
private readonly IGlobalRoutingState _routingState;
|
||||||
private readonly GatewayTransportClient _transportClient;
|
private readonly IGatewayTransportClient _transportClient;
|
||||||
|
private readonly GatewayRegistrationResyncService _registrationResyncService;
|
||||||
private readonly IEffectiveClaimsStore _claimsStore;
|
private readonly IEffectiveClaimsStore _claimsStore;
|
||||||
private readonly IRouterOpenApiDocumentCache? _openApiCache;
|
private readonly IRouterOpenApiDocumentCache? _openApiCache;
|
||||||
private readonly IOptions<GatewayOptions> _options;
|
private readonly IOptions<GatewayOptions> _options;
|
||||||
@@ -33,7 +34,8 @@ public sealed class GatewayHostedService : IHostedService
|
|||||||
|
|
||||||
public GatewayHostedService(
|
public GatewayHostedService(
|
||||||
IGlobalRoutingState routingState,
|
IGlobalRoutingState routingState,
|
||||||
GatewayTransportClient transportClient,
|
IGatewayTransportClient transportClient,
|
||||||
|
GatewayRegistrationResyncService registrationResyncService,
|
||||||
IEffectiveClaimsStore claimsStore,
|
IEffectiveClaimsStore claimsStore,
|
||||||
IOptions<GatewayOptions> options,
|
IOptions<GatewayOptions> options,
|
||||||
GatewayServiceStatus status,
|
GatewayServiceStatus status,
|
||||||
@@ -48,6 +50,7 @@ public sealed class GatewayHostedService : IHostedService
|
|||||||
_messagingServer = messagingServer;
|
_messagingServer = messagingServer;
|
||||||
_routingState = routingState;
|
_routingState = routingState;
|
||||||
_transportClient = transportClient;
|
_transportClient = transportClient;
|
||||||
|
_registrationResyncService = registrationResyncService;
|
||||||
_claimsStore = claimsStore;
|
_claimsStore = claimsStore;
|
||||||
_options = options;
|
_options = options;
|
||||||
_status = status;
|
_status = status;
|
||||||
@@ -110,6 +113,7 @@ public sealed class GatewayHostedService : IHostedService
|
|||||||
{
|
{
|
||||||
_messagingServer.OnHelloReceived += HandleMessagingHello;
|
_messagingServer.OnHelloReceived += HandleMessagingHello;
|
||||||
_messagingServer.OnHeartbeatReceived += HandleMessagingHeartbeat;
|
_messagingServer.OnHeartbeatReceived += HandleMessagingHeartbeat;
|
||||||
|
_messagingServer.OnEndpointsUpdated += HandleMessagingEndpointsUpdated;
|
||||||
_messagingServer.OnResponseReceived += HandleMessagingResponse;
|
_messagingServer.OnResponseReceived += HandleMessagingResponse;
|
||||||
_messagingServer.OnConnectionClosed += HandleMessagingDisconnection;
|
_messagingServer.OnConnectionClosed += HandleMessagingDisconnection;
|
||||||
await _messagingServer.StartAsync(cancellationToken);
|
await _messagingServer.StartAsync(cancellationToken);
|
||||||
@@ -149,6 +153,7 @@ public sealed class GatewayHostedService : IHostedService
|
|||||||
await _messagingServer.StopAsync(cancellationToken);
|
await _messagingServer.StopAsync(cancellationToken);
|
||||||
_messagingServer.OnHelloReceived -= HandleMessagingHello;
|
_messagingServer.OnHelloReceived -= HandleMessagingHello;
|
||||||
_messagingServer.OnHeartbeatReceived -= HandleMessagingHeartbeat;
|
_messagingServer.OnHeartbeatReceived -= HandleMessagingHeartbeat;
|
||||||
|
_messagingServer.OnEndpointsUpdated -= HandleMessagingEndpointsUpdated;
|
||||||
_messagingServer.OnResponseReceived -= HandleMessagingResponse;
|
_messagingServer.OnResponseReceived -= HandleMessagingResponse;
|
||||||
_messagingServer.OnConnectionClosed -= HandleMessagingDisconnection;
|
_messagingServer.OnConnectionClosed -= HandleMessagingDisconnection;
|
||||||
}
|
}
|
||||||
@@ -487,32 +492,50 @@ public sealed class GatewayHostedService : IHostedService
|
|||||||
|
|
||||||
#region Messaging Transport Event Handlers
|
#region Messaging Transport Event Handlers
|
||||||
|
|
||||||
private Task HandleMessagingHello(ConnectionState state, HelloPayload payload)
|
private async Task HandleMessagingHello(ConnectionState state, HelloPayload payload)
|
||||||
{
|
{
|
||||||
// The MessagingTransportServer already built the ConnectionState with TransportType.Messaging
|
|
||||||
// We need to add it to the routing state and update the claims store
|
|
||||||
_routingState.AddConnection(state);
|
_routingState.AddConnection(state);
|
||||||
_claimsStore.UpdateFromMicroservice(payload.Instance.ServiceName, payload.Endpoints);
|
if (payload.Endpoints.Count > 0)
|
||||||
_openApiCache?.Invalidate();
|
{
|
||||||
|
_claimsStore.UpdateFromMicroservice(payload.Instance.ServiceName, payload.Endpoints);
|
||||||
|
_openApiCache?.Invalidate();
|
||||||
|
_registrationResyncService.ClearPending(state.ConnectionId);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Messaging connection registered: {ConnectionId} service={ServiceName} version={Version}",
|
"Messaging connection registered: {ConnectionId} service={ServiceName} version={Version}",
|
||||||
state.ConnectionId,
|
state.ConnectionId,
|
||||||
state.Instance.ServiceName,
|
state.Instance.ServiceName,
|
||||||
state.Instance.Version);
|
state.Instance.Version);
|
||||||
|
}
|
||||||
return Task.CompletedTask;
|
else
|
||||||
|
{
|
||||||
|
await _registrationResyncService.RequestResyncAsync(
|
||||||
|
state.ConnectionId,
|
||||||
|
"service-startup",
|
||||||
|
force: true,
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task HandleMessagingHeartbeat(ConnectionState state, HeartbeatPayload payload)
|
private async Task HandleMessagingHeartbeat(ConnectionState state, HeartbeatPayload payload)
|
||||||
{
|
{
|
||||||
|
var knownConnection = _routingState.GetConnection(state.ConnectionId);
|
||||||
|
if (knownConnection is null)
|
||||||
|
{
|
||||||
|
_routingState.AddConnection(state);
|
||||||
|
await _registrationResyncService.RequestResyncAsync(
|
||||||
|
state.ConnectionId,
|
||||||
|
"gateway-state-miss",
|
||||||
|
force: false,
|
||||||
|
CancellationToken.None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_routingState.UpdateConnection(state.ConnectionId, conn =>
|
_routingState.UpdateConnection(state.ConnectionId, conn =>
|
||||||
{
|
{
|
||||||
conn.LastHeartbeatUtc = DateTime.UtcNow;
|
conn.LastHeartbeatUtc = DateTime.UtcNow;
|
||||||
conn.Status = payload.Status;
|
conn.Status = payload.Status;
|
||||||
});
|
});
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task HandleMessagingResponse(ConnectionState state, Frame frame)
|
private Task HandleMessagingResponse(ConnectionState state, Frame frame)
|
||||||
@@ -521,8 +544,18 @@ public sealed class GatewayHostedService : IHostedService
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task HandleMessagingEndpointsUpdated(ConnectionState state, EndpointsUpdatePayload payload)
|
||||||
|
{
|
||||||
|
_routingState.AddConnection(state);
|
||||||
|
_claimsStore.UpdateFromMicroservice(state.Instance.ServiceName, payload.Endpoints);
|
||||||
|
_openApiCache?.Invalidate();
|
||||||
|
_registrationResyncService.ClearPending(state.ConnectionId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private Task HandleMessagingDisconnection(string connectionId)
|
private Task HandleMessagingDisconnection(string connectionId)
|
||||||
{
|
{
|
||||||
|
_registrationResyncService.ClearPending(connectionId);
|
||||||
HandleDisconnect(connectionId);
|
HandleDisconnect(connectionId);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace StellaOps.Gateway.WebService.Services;
|
||||||
|
|
||||||
|
public sealed record GatewayRegistrationResyncRequest(string? ConnectionId, string? Reason);
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using StellaOps.Router.Common.Abstractions;
|
||||||
|
using StellaOps.Router.Common.Enums;
|
||||||
|
using StellaOps.Router.Common.Models;
|
||||||
|
|
||||||
|
namespace StellaOps.Gateway.WebService.Services;
|
||||||
|
|
||||||
|
public sealed class GatewayRegistrationResyncService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(15);
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = false
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly IGlobalRoutingState _routingState;
|
||||||
|
private readonly IGatewayTransportClient _transportClient;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<GatewayRegistrationResyncService> _logger;
|
||||||
|
private readonly ConcurrentDictionary<string, DateTimeOffset> _lastRequestUtc = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public GatewayRegistrationResyncService(
|
||||||
|
IGlobalRoutingState routingState,
|
||||||
|
IGatewayTransportClient transportClient,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
ILogger<GatewayRegistrationResyncService> logger)
|
||||||
|
{
|
||||||
|
_routingState = routingState;
|
||||||
|
_transportClient = transportClient;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GatewayRegistrationResyncResult> RequestResyncAsync(
|
||||||
|
string? connectionId,
|
||||||
|
string reason,
|
||||||
|
bool force,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var connections = string.IsNullOrWhiteSpace(connectionId)
|
||||||
|
? _routingState.GetAllConnections()
|
||||||
|
: _routingState.GetAllConnections()
|
||||||
|
.Where(connection => string.Equals(connection.ConnectionId, connectionId, StringComparison.Ordinal))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var requestedCount = 0;
|
||||||
|
var skippedCount = 0;
|
||||||
|
|
||||||
|
foreach (var connection in connections)
|
||||||
|
{
|
||||||
|
if (await TryRequestResyncAsync(connection, reason, force, cancellationToken))
|
||||||
|
{
|
||||||
|
requestedCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GatewayRegistrationResyncResult
|
||||||
|
{
|
||||||
|
ConnectionId = connectionId,
|
||||||
|
Reason = reason,
|
||||||
|
MatchedCount = connections.Count,
|
||||||
|
RequestedCount = requestedCount,
|
||||||
|
SkippedCount = skippedCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearPending(string connectionId)
|
||||||
|
{
|
||||||
|
_lastRequestUtc.TryRemove(connectionId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryRequestResyncAsync(
|
||||||
|
ConnectionState connection,
|
||||||
|
string reason,
|
||||||
|
bool force,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var nowUtc = _timeProvider.GetUtcNow();
|
||||||
|
if (!force &&
|
||||||
|
_lastRequestUtc.TryGetValue(connection.ConnectionId, out var lastRequestUtc) &&
|
||||||
|
nowUtc - lastRequestUtc < RequestCooldown)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = new RegistrationResyncRequestPayload
|
||||||
|
{
|
||||||
|
Reason = reason
|
||||||
|
};
|
||||||
|
|
||||||
|
var frame = new Frame
|
||||||
|
{
|
||||||
|
Type = FrameType.ResyncRequest,
|
||||||
|
CorrelationId = null,
|
||||||
|
Payload = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions)
|
||||||
|
};
|
||||||
|
|
||||||
|
await _transportClient.SendFrameAsync(connection, frame, cancellationToken);
|
||||||
|
_lastRequestUtc[connection.ConnectionId] = nowUtc;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Requested endpoint metadata replay for {ConnectionId} ({ServiceName}/{Version}): {Reason}",
|
||||||
|
connection.ConnectionId,
|
||||||
|
connection.Instance.ServiceName,
|
||||||
|
connection.Instance.Version,
|
||||||
|
reason);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record GatewayRegistrationResyncResult
|
||||||
|
{
|
||||||
|
public string? ConnectionId { get; init; }
|
||||||
|
|
||||||
|
public required string Reason { get; init; }
|
||||||
|
|
||||||
|
public int MatchedCount { get; init; }
|
||||||
|
|
||||||
|
public int RequestedCount { get; init; }
|
||||||
|
|
||||||
|
public int SkippedCount { get; init; }
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ using System.Threading.Channels;
|
|||||||
|
|
||||||
namespace StellaOps.Gateway.WebService.Services;
|
namespace StellaOps.Gateway.WebService.Services;
|
||||||
|
|
||||||
public sealed class GatewayTransportClient : ITransportClient
|
public sealed class GatewayTransportClient : IGatewayTransportClient
|
||||||
{
|
{
|
||||||
private readonly TcpTransportServer? _tcpServer;
|
private readonly TcpTransportServer? _tcpServer;
|
||||||
private readonly TlsTransportServer? _tlsServer;
|
private readonly TlsTransportServer? _tlsServer;
|
||||||
@@ -49,7 +49,7 @@ public sealed class GatewayTransportClient : ITransportClient
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SendFrameAsync(connection, frame, cancellationToken);
|
await DispatchFrameAsync(connection, frame, cancellationToken);
|
||||||
|
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
timeoutCts.CancelAfter(timeout);
|
timeoutCts.CancelAfter(timeout);
|
||||||
@@ -71,7 +71,7 @@ public sealed class GatewayTransportClient : ITransportClient
|
|||||||
Payload = ReadOnlyMemory<byte>.Empty
|
Payload = ReadOnlyMemory<byte>.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
await SendFrameAsync(connection, frame, CancellationToken.None);
|
await DispatchFrameAsync(connection, frame, CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendStreamingAsync(
|
public async Task SendStreamingAsync(
|
||||||
@@ -102,7 +102,7 @@ public sealed class GatewayTransportClient : ITransportClient
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SendFrameAsync(connection, headerFrame, cancellationToken);
|
await DispatchFrameAsync(connection, headerFrame, cancellationToken);
|
||||||
await StreamRequestBodyAsync(connection, correlationId, requestBody, limits, cancellationToken);
|
await StreamRequestBodyAsync(connection, correlationId, requestBody, limits, cancellationToken);
|
||||||
|
|
||||||
using var responseStream = new MemoryStream();
|
using var responseStream = new MemoryStream();
|
||||||
@@ -142,7 +142,12 @@ public sealed class GatewayTransportClient : ITransportClient
|
|||||||
_logger.LogDebug("No pending request for correlation ID {CorrelationId}", frame.CorrelationId);
|
_logger.LogDebug("No pending request for correlation ID {CorrelationId}", frame.CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendFrameAsync(ConnectionState connection, Frame frame, CancellationToken cancellationToken)
|
public Task SendFrameAsync(ConnectionState connection, Frame frame, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return DispatchFrameAsync(connection, frame, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DispatchFrameAsync(ConnectionState connection, Frame frame, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
switch (connection.TransportType)
|
switch (connection.TransportType)
|
||||||
{
|
{
|
||||||
@@ -211,7 +216,7 @@ public sealed class GatewayTransportClient : ITransportClient
|
|||||||
CorrelationId = correlationId,
|
CorrelationId = correlationId,
|
||||||
Payload = new ReadOnlyMemory<byte>(buffer, 0, bytesRead)
|
Payload = new ReadOnlyMemory<byte>(buffer, 0, bytesRead)
|
||||||
};
|
};
|
||||||
await SendFrameAsync(connection, dataFrame, cancellationToken);
|
await DispatchFrameAsync(connection, dataFrame, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
var endFrame = new Frame
|
var endFrame = new Frame
|
||||||
@@ -220,7 +225,7 @@ public sealed class GatewayTransportClient : ITransportClient
|
|||||||
CorrelationId = correlationId,
|
CorrelationId = correlationId,
|
||||||
Payload = ReadOnlyMemory<byte>.Empty
|
Payload = ReadOnlyMemory<byte>.Empty
|
||||||
};
|
};
|
||||||
await SendFrameAsync(connection, endFrame, cancellationToken);
|
await DispatchFrameAsync(connection, endFrame, cancellationToken);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using StellaOps.Router.Common.Abstractions;
|
||||||
|
using StellaOps.Router.Common.Models;
|
||||||
|
|
||||||
|
namespace StellaOps.Gateway.WebService.Services;
|
||||||
|
|
||||||
|
public interface IGatewayTransportClient : ITransportClient
|
||||||
|
{
|
||||||
|
Task SendFrameAsync(ConnectionState connection, Frame frame, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
void HandleResponseFrame(Frame frame);
|
||||||
|
}
|
||||||
@@ -101,6 +101,7 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
|||||||
if (_microserviceTransport is not null)
|
if (_microserviceTransport is not null)
|
||||||
{
|
{
|
||||||
_microserviceTransport.OnRequestReceived += HandleRequestReceivedAsync;
|
_microserviceTransport.OnRequestReceived += HandleRequestReceivedAsync;
|
||||||
|
_microserviceTransport.OnResyncRequested += HandleResyncRequestedAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get schema definitions from generated provider and runtime discovery provider.
|
// Get schema definitions from generated provider and runtime discovery provider.
|
||||||
@@ -128,7 +129,12 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
|||||||
{
|
{
|
||||||
// Listen for transport death to trigger automatic reconnection
|
// Listen for transport death to trigger automatic reconnection
|
||||||
_microserviceTransport.TransportDied += OnTransportDied;
|
_microserviceTransport.TransportDied += OnTransportDied;
|
||||||
await SendRegistrationRefreshAsync(cancellationToken);
|
await _microserviceTransport.ConnectAsync(
|
||||||
|
CreateInstanceDescriptor(),
|
||||||
|
_endpoints,
|
||||||
|
_schemas,
|
||||||
|
_openApiInfo,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -159,6 +165,7 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_microserviceTransport.OnRequestReceived -= HandleRequestReceivedAsync;
|
_microserviceTransport.OnRequestReceived -= HandleRequestReceivedAsync;
|
||||||
|
_microserviceTransport.OnResyncRequested -= HandleResyncRequestedAsync;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +315,12 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
|||||||
|
|
||||||
if (_microserviceTransport is null || _endpoints is null) return;
|
if (_microserviceTransport is null || _endpoints is null) return;
|
||||||
|
|
||||||
await SendRegistrationRefreshAsync(_cts.Token);
|
await _microserviceTransport.ConnectAsync(
|
||||||
|
CreateInstanceDescriptor(),
|
||||||
|
_endpoints,
|
||||||
|
_schemas,
|
||||||
|
_openApiInfo,
|
||||||
|
_cts.Token);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Messaging transport reconnected for {ServiceName}/{Version}",
|
"Messaging transport reconnected for {ServiceName}/{Version}",
|
||||||
@@ -327,44 +339,24 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
|||||||
private async Task HeartbeatLoopAsync(CancellationToken cancellationToken)
|
private async Task HeartbeatLoopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var nextHeartbeatDueUtc = DateTime.UtcNow + _options.HeartbeatInterval;
|
var nextHeartbeatDueUtc = DateTime.UtcNow + _options.HeartbeatInterval;
|
||||||
var nextRegistrationRefreshDueUtc = DateTime.UtcNow + _options.RegistrationRefreshInterval;
|
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var nowUtc = DateTime.UtcNow;
|
var nowUtc = DateTime.UtcNow;
|
||||||
var delay = Min(nextHeartbeatDueUtc, nextRegistrationRefreshDueUtc) - nowUtc;
|
var delay = nextHeartbeatDueUtc - nowUtc;
|
||||||
if (delay > TimeSpan.Zero)
|
if (delay > TimeSpan.Zero)
|
||||||
{
|
{
|
||||||
await Task.Delay(delay, cancellationToken);
|
await Task.Delay(delay, cancellationToken);
|
||||||
nowUtc = DateTime.UtcNow;
|
nowUtc = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_microserviceTransport is not null &&
|
|
||||||
_endpoints is not null &&
|
|
||||||
nowUtc >= nextRegistrationRefreshDueUtc)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await SendRegistrationRefreshAsync(cancellationToken);
|
|
||||||
_logger.LogDebug(
|
|
||||||
"Sent periodic HELLO refresh for {ServiceName}/{Version}",
|
|
||||||
_options.ServiceName,
|
|
||||||
_options.Version);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to send periodic HELLO refresh");
|
|
||||||
}
|
|
||||||
|
|
||||||
nextRegistrationRefreshDueUtc = nowUtc + _options.RegistrationRefreshInterval;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_microserviceTransport is not null && nowUtc >= nextHeartbeatDueUtc)
|
if (_microserviceTransport is not null && nowUtc >= nextHeartbeatDueUtc)
|
||||||
{
|
{
|
||||||
var heartbeat = new HeartbeatPayload
|
var heartbeat = new HeartbeatPayload
|
||||||
{
|
{
|
||||||
|
Instance = CreateInstanceDescriptor(),
|
||||||
InstanceId = _options.InstanceId,
|
InstanceId = _options.InstanceId,
|
||||||
Status = _currentStatus,
|
Status = _currentStatus,
|
||||||
InFlightRequestCount = _inFlightRequestCount,
|
InFlightRequestCount = _inFlightRequestCount,
|
||||||
@@ -407,15 +399,22 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendRegistrationRefreshAsync(CancellationToken cancellationToken)
|
private async Task HandleResyncRequestedAsync(
|
||||||
|
RegistrationResyncRequestPayload request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_microserviceTransport is null || _endpoints is null)
|
if (_microserviceTransport is null || _endpoints is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _microserviceTransport.ConnectAsync(
|
_logger.LogInformation(
|
||||||
CreateInstanceDescriptor(),
|
"Router requested endpoint metadata replay for {ServiceName}/{Version}: {Reason}",
|
||||||
|
_options.ServiceName,
|
||||||
|
_options.Version,
|
||||||
|
request.Reason);
|
||||||
|
|
||||||
|
await _microserviceTransport.SendEndpointsUpdateAsync(
|
||||||
_endpoints,
|
_endpoints,
|
||||||
_schemas,
|
_schemas,
|
||||||
_openApiInfo,
|
_openApiInfo,
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ public interface IMicroserviceTransport
|
|||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
Task SendHeartbeatAsync(HeartbeatPayload heartbeat, CancellationToken cancellationToken);
|
Task SendHeartbeatAsync(HeartbeatPayload heartbeat, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the current endpoint/schema/OpenAPI metadata after the router explicitly requests a replay.
|
||||||
|
/// </summary>
|
||||||
|
Task SendEndpointsUpdateAsync(
|
||||||
|
IReadOnlyList<EndpointDescriptor> endpoints,
|
||||||
|
IReadOnlyDictionary<string, SchemaDefinition>? schemas,
|
||||||
|
ServiceOpenApiInfo? openApiInfo,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Event raised when a REQUEST frame is received from the gateway.
|
/// Event raised when a REQUEST frame is received from the gateway.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -59,6 +71,11 @@ public interface IMicroserviceTransport
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
event Func<Guid, string?, Task>? OnCancelReceived;
|
event Func<Guid, string?, Task>? OnCancelReceived;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event raised when the router explicitly requests a fresh endpoint metadata replay.
|
||||||
|
/// </summary>
|
||||||
|
event Func<RegistrationResyncRequestPayload, CancellationToken, Task>? OnResyncRequested { add { } remove { } }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised when the transport connection is permanently lost and cannot recover.
|
/// Raised when the transport connection is permanently lost and cannot recover.
|
||||||
/// Consumers should reconnect by calling <see cref="ConnectAsync"/> again.
|
/// Consumers should reconnect by calling <see cref="ConnectAsync"/> again.
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ public enum FrameType
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Cancel,
|
Cancel,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gateway-to-service control frame requesting a fresh endpoint metadata replay.
|
||||||
|
/// </summary>
|
||||||
|
ResyncRequest,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional frame for updating endpoint metadata at runtime.
|
/// Optional frame for updating endpoint metadata at runtime.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace StellaOps.Router.Common.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload for the EndpointsUpdate frame sent by a microservice after the router requests a metadata replay.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EndpointsUpdatePayload
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the endpoints registered by this instance.
|
||||||
|
/// </summary>
|
||||||
|
public required IReadOnlyList<EndpointDescriptor> Endpoints { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the schema definitions for request/response validation.
|
||||||
|
/// Keys are schema IDs referenced by EndpointDescriptor.SchemaInfo.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, SchemaDefinition> Schemas { get; init; } = new Dictionary<string, SchemaDefinition>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the OpenAPI metadata for this service.
|
||||||
|
/// </summary>
|
||||||
|
public ServiceOpenApiInfo? OpenApiInfo { get; init; }
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ namespace StellaOps.Router.Common.Models;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record HeartbeatPayload
|
public sealed record HeartbeatPayload
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the instance descriptor.
|
||||||
|
/// </summary>
|
||||||
|
public InstanceDescriptor? Instance { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the instance ID.
|
/// Gets the instance ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace StellaOps.Router.Common.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload for the ResyncRequest frame sent by the router when it needs a fresh endpoint metadata replay.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RegistrationResyncRequestPayload
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the reason for the replay request.
|
||||||
|
/// </summary>
|
||||||
|
public string Reason { get; init; } = "router-requested-resync";
|
||||||
|
}
|
||||||
@@ -51,6 +51,9 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public event Func<Guid, string?, Task>? OnCancelReceived;
|
public event Func<Guid, string?, Task>? OnCancelReceived;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event Func<RegistrationResyncRequestPayload, CancellationToken, Task>? OnResyncRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="MessagingTransportClient"/> class.
|
/// Initializes a new instance of the <see cref="MessagingTransportClient"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -100,9 +103,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
|
|||||||
var helloPayload = new HelloPayload
|
var helloPayload = new HelloPayload
|
||||||
{
|
{
|
||||||
Instance = instance,
|
Instance = instance,
|
||||||
Endpoints = endpoints,
|
Endpoints = []
|
||||||
Schemas = schemas ?? new Dictionary<string, SchemaDefinition>(),
|
|
||||||
OpenApiInfo = openApiInfo
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var helloMessage = new RpcRequestMessage
|
var helloMessage = new RpcRequestMessage
|
||||||
@@ -129,7 +130,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Refreshed messaging registration for {ServiceName}/{Version} instance {InstanceId} on connection {ConnectionId}",
|
"Replayed slim messaging HELLO for {ServiceName}/{Version} instance {InstanceId} on connection {ConnectionId}",
|
||||||
instance.ServiceName,
|
instance.ServiceName,
|
||||||
instance.Version,
|
instance.Version,
|
||||||
instance.InstanceId,
|
instance.InstanceId,
|
||||||
@@ -235,32 +236,20 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[DIAG] Consumer loop: calling LeaseAsync (batch={BatchSize})", _options.BatchSize);
|
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
||||||
|
|
||||||
var leases = await _serviceIncomingQueue.LeaseAsync(
|
var leases = await _serviceIncomingQueue.LeaseAsync(
|
||||||
new LeaseRequest { BatchSize = _options.BatchSize },
|
new LeaseRequest { BatchSize = _options.BatchSize },
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
sw.Stop();
|
|
||||||
consecutiveFailures = 0;
|
consecutiveFailures = 0;
|
||||||
|
|
||||||
if (leases.Count > 0)
|
if (leases.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[DIAG] Consumer loop: leased {Count} messages in {ElapsedMs}ms, processing concurrently",
|
|
||||||
leases.Count, sw.ElapsedMilliseconds);
|
|
||||||
|
|
||||||
await Task.WhenAll(leases.Select(lease =>
|
await Task.WhenAll(leases.Select(lease =>
|
||||||
ProcessLeaseWithGuardAsync(lease, HandleIncomingRequestAsync, cancellationToken)));
|
ProcessLeaseWithGuardAsync(lease, HandleIncomingRequestAsync, cancellationToken)));
|
||||||
|
|
||||||
_logger.LogWarning("[DIAG] Consumer loop: finished processing {Count} messages", leases.Count);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[DIAG] Consumer loop: no messages, waiting for notification (lease took {ElapsedMs}ms)",
|
|
||||||
sw.ElapsedMilliseconds);
|
|
||||||
await _serviceIncomingQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
|
await _serviceIncomingQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
|
||||||
_logger.LogWarning("[DIAG] Consumer loop: notification received or timeout, resuming");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
@@ -299,6 +288,12 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.FrameType == FrameType.ResyncRequest)
|
||||||
|
{
|
||||||
|
await HandleResyncRequestAsync(message, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.FrameType is not (FrameType.Request or FrameType.RequestStreamData))
|
if (message.FrameType is not (FrameType.Request or FrameType.RequestStreamData))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -387,6 +382,27 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleResyncRequestAsync(
|
||||||
|
RpcResponseMessage message,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (OnResyncRequested is null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Ignoring router resync request for {ConnectionId}; no replay handler is registered",
|
||||||
|
message.ConnectionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = string.IsNullOrEmpty(message.PayloadBase64)
|
||||||
|
? new RegistrationResyncRequestPayload()
|
||||||
|
: JsonSerializer.Deserialize<RegistrationResyncRequestPayload>(
|
||||||
|
Convert.FromBase64String(message.PayloadBase64),
|
||||||
|
_jsonOptions) ?? new RegistrationResyncRequestPayload();
|
||||||
|
|
||||||
|
await OnResyncRequested(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<Frame> SendRequestAsync(
|
public async Task<Frame> SendRequestAsync(
|
||||||
ConnectionState connection,
|
ConnectionState connection,
|
||||||
@@ -553,6 +569,38 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
|
|||||||
await _requestQueue.EnqueueAsync(message, cancellationToken: cancellationToken);
|
await _requestQueue.EnqueueAsync(message, cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SendEndpointsUpdateAsync(
|
||||||
|
IReadOnlyList<EndpointDescriptor> endpoints,
|
||||||
|
IReadOnlyDictionary<string, SchemaDefinition>? schemas,
|
||||||
|
ServiceOpenApiInfo? openApiInfo,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_requestQueue is null || _connectionId is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = new EndpointsUpdatePayload
|
||||||
|
{
|
||||||
|
Endpoints = endpoints,
|
||||||
|
Schemas = schemas ?? new Dictionary<string, SchemaDefinition>(),
|
||||||
|
OpenApiInfo = openApiInfo
|
||||||
|
};
|
||||||
|
|
||||||
|
var message = new RpcRequestMessage
|
||||||
|
{
|
||||||
|
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||||
|
ConnectionId = _connectionId,
|
||||||
|
TargetService = "gateway",
|
||||||
|
FrameType = FrameType.EndpointsUpdate,
|
||||||
|
PayloadBase64 = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(payload, _jsonOptions)),
|
||||||
|
SenderInstanceId = _instance?.InstanceId
|
||||||
|
};
|
||||||
|
|
||||||
|
await _requestQueue.EnqueueAsync(message, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task DisconnectAsync()
|
public async Task DisconnectAsync()
|
||||||
{
|
{
|
||||||
@@ -707,33 +755,29 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
where TMessage : class
|
where TMessage : class
|
||||||
{
|
{
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(55));
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(55));
|
||||||
|
|
||||||
_logger.LogWarning("[DIAG] Guard: processing message {MessageId}", lease.MessageId);
|
|
||||||
// WaitAsync abandons handlers that ignore CancellationToken (e.g.,
|
// WaitAsync abandons handlers that ignore CancellationToken (e.g.,
|
||||||
// StackExchange.Redis commands with their own internal timeout).
|
// StackExchange.Redis commands with their own internal timeout).
|
||||||
// The handler continues in background but the consumer loop is unblocked.
|
// The handler continues in background but the consumer loop is unblocked.
|
||||||
await handler(lease, timeoutCts.Token).WaitAsync(timeoutCts.Token);
|
await handler(lease, timeoutCts.Token).WaitAsync(timeoutCts.Token);
|
||||||
sw.Stop();
|
|
||||||
_logger.LogWarning("[DIAG] Guard: message {MessageId} processed in {ElapsedMs}ms", lease.MessageId, sw.ElapsedMilliseconds);
|
|
||||||
await lease.AcknowledgeAsync(cancellationToken);
|
await lease.AcknowledgeAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[DIAG] Guard: message {MessageId} cancelled (shutdown) after {ElapsedMs}ms", lease.MessageId, sw.ElapsedMilliseconds);
|
// Graceful shutdown.
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Guard: message {MessageId} TIMED OUT after {ElapsedMs}ms (55s limit)", lease.MessageId, sw.ElapsedMilliseconds);
|
_logger.LogWarning("Guard: message {MessageId} timed out after 55s", lease.MessageId);
|
||||||
await TryReleaseAsync(lease, ReleaseDisposition.Retry, cancellationToken);
|
await TryReleaseAsync(lease, ReleaseDisposition.Retry, cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Guard: message {MessageId} FAILED after {ElapsedMs}ms", lease.MessageId, sw.ElapsedMilliseconds);
|
_logger.LogError(ex, "Guard: message {MessageId} failed", lease.MessageId);
|
||||||
await TryReleaseAsync(lease, ReleaseDisposition.Retry, cancellationToken);
|
await TryReleaseAsync(lease, ReleaseDisposition.Retry, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Func<ConnectionState, HeartbeatPayload, Task>? OnHeartbeatReceived;
|
public event Func<ConnectionState, HeartbeatPayload, Task>? OnHeartbeatReceived;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event raised when a microservice replays endpoint metadata after a router resync request.
|
||||||
|
/// </summary>
|
||||||
|
public event Func<ConnectionState, EndpointsUpdatePayload, Task>? OnEndpointsUpdated;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Event raised when a RESPONSE frame is received.
|
/// Event raised when a RESPONSE frame is received.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -229,6 +234,10 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
|
|||||||
await HandleHeartbeatMessageAsync(message, cancellationToken);
|
await HandleHeartbeatMessageAsync(message, cancellationToken);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case FrameType.EndpointsUpdate:
|
||||||
|
await HandleEndpointsUpdateMessageAsync(message, cancellationToken);
|
||||||
|
break;
|
||||||
|
|
||||||
case FrameType.Response:
|
case FrameType.Response:
|
||||||
case FrameType.ResponseStreamData:
|
case FrameType.ResponseStreamData:
|
||||||
// Response from microservice to gateway - route to pending request
|
// Response from microservice to gateway - route to pending request
|
||||||
@@ -302,17 +311,37 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
|
|||||||
|
|
||||||
private async Task HandleHeartbeatMessageAsync(RpcRequestMessage message, CancellationToken cancellationToken)
|
private async Task HandleHeartbeatMessageAsync(RpcRequestMessage message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var payload = JsonSerializer.Deserialize<HeartbeatPayload>(
|
||||||
|
Convert.FromBase64String(message.PayloadBase64), _jsonOptions);
|
||||||
|
|
||||||
if (!_connections.TryGetValue(message.ConnectionId, out var state))
|
if (!_connections.TryGetValue(message.ConnectionId, out var state))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Heartbeat from unknown connection {ConnectionId}", message.ConnectionId);
|
if (payload?.Instance is null)
|
||||||
return;
|
{
|
||||||
|
_logger.LogWarning("Heartbeat from unknown connection {ConnectionId}", message.ConnectionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = new ConnectionState
|
||||||
|
{
|
||||||
|
ConnectionId = message.ConnectionId,
|
||||||
|
Instance = payload.Instance,
|
||||||
|
Status = payload.Status,
|
||||||
|
LastHeartbeatUtc = DateTime.UtcNow,
|
||||||
|
TransportType = TransportType.Messaging
|
||||||
|
};
|
||||||
|
|
||||||
|
_connections[message.ConnectionId] = state;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Heartbeat discovered unknown messaging connection {ConnectionId} for {ServiceName}/{Version}; awaiting endpoint replay",
|
||||||
|
message.ConnectionId,
|
||||||
|
state.Instance.ServiceName,
|
||||||
|
state.Instance.Version);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.LastHeartbeatUtc = DateTime.UtcNow;
|
state.LastHeartbeatUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
var payload = JsonSerializer.Deserialize<HeartbeatPayload>(
|
|
||||||
Convert.FromBase64String(message.PayloadBase64), _jsonOptions);
|
|
||||||
|
|
||||||
if (payload is not null)
|
if (payload is not null)
|
||||||
{
|
{
|
||||||
state.Status = payload.Status;
|
state.Status = payload.Status;
|
||||||
@@ -326,6 +355,58 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleEndpointsUpdateMessageAsync(
|
||||||
|
RpcRequestMessage message,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connections.TryGetValue(message.ConnectionId, out var existing))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Endpoint metadata replay arrived for unknown connection {ConnectionId}",
|
||||||
|
message.ConnectionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = JsonSerializer.Deserialize<EndpointsUpdatePayload>(
|
||||||
|
Convert.FromBase64String(message.PayloadBase64),
|
||||||
|
_jsonOptions);
|
||||||
|
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid EndpointsUpdate payload from {ConnectionId}", message.ConnectionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = new ConnectionState
|
||||||
|
{
|
||||||
|
ConnectionId = existing.ConnectionId,
|
||||||
|
Instance = existing.Instance,
|
||||||
|
Status = existing.Status,
|
||||||
|
LastHeartbeatUtc = DateTime.UtcNow,
|
||||||
|
TransportType = existing.TransportType,
|
||||||
|
Schemas = payload.Schemas,
|
||||||
|
OpenApiInfo = payload.OpenApiInfo
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var endpoint in payload.Endpoints)
|
||||||
|
{
|
||||||
|
state.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
_connections[message.ConnectionId] = state;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Endpoint metadata replay received from {ServiceName}/{Version} instance {InstanceId} via messaging",
|
||||||
|
state.Instance.ServiceName,
|
||||||
|
state.Instance.Version,
|
||||||
|
state.Instance.InstanceId);
|
||||||
|
|
||||||
|
if (OnEndpointsUpdated is not null)
|
||||||
|
{
|
||||||
|
await OnEndpointsUpdated(state, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a request frame to a microservice via messaging.
|
/// Sends a request frame to a microservice via messaging.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
|||||||
| Task ID | Status | Notes |
|
| Task ID | Status | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| RTR-MSG-002 | DONE | `docs-archived/implplan/SPRINT_20260307_008_Router_integrations_messaging_reregistration_stability.md` - periodic HELLO re-registration now reuses the existing messaging connection/receive loop instead of minting duplicate logical connections. |
|
| RTR-MSG-002 | DONE | `docs-archived/implplan/SPRINT_20260307_008_Router_integrations_messaging_reregistration_stability.md` - periodic HELLO re-registration now reuses the existing messaging connection/receive loop instead of minting duplicate logical connections. |
|
||||||
| RVM-06 | DONE | Updated messaging microservice HELLO payload to include schemas/OpenAPI metadata via the new schema-aware `IMicroserviceTransport.ConnectAsync(...)` overload. |
|
| RVM-06 | DONE | Messaging HELLO now stays slim and identity-only; the router requests full endpoint/schema/OpenAPI replay explicitly via `ResyncRequest` and `EndpointsUpdate` frames when startup or administration needs metadata. |
|
||||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/StellaOps.Router.Transport.Messaging.md. |
|
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Libraries/StellaOps.Router.Transport.Messaging/StellaOps.Router.Transport.Messaging.md. |
|
||||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||||
|
|||||||
@@ -60,11 +60,17 @@ public sealed class MessagingTransportIntegrationTests
|
|||||||
tcpServer,
|
tcpServer,
|
||||||
tlsServer,
|
tlsServer,
|
||||||
messagingServer);
|
messagingServer);
|
||||||
|
var resyncService = new GatewayRegistrationResyncService(
|
||||||
|
routingState.Object,
|
||||||
|
transportClient,
|
||||||
|
TimeProvider.System,
|
||||||
|
NullLogger<GatewayRegistrationResyncService>.Instance);
|
||||||
|
|
||||||
// Act & Assert - construction should succeed with messaging server
|
// Act & Assert - construction should succeed with messaging server
|
||||||
var hostedService = new GatewayHostedService(
|
var hostedService = new GatewayHostedService(
|
||||||
routingState.Object,
|
routingState.Object,
|
||||||
transportClient,
|
transportClient,
|
||||||
|
resyncService,
|
||||||
claimsStore.Object,
|
claimsStore.Object,
|
||||||
gatewayOptions,
|
gatewayOptions,
|
||||||
new GatewayServiceStatus(),
|
new GatewayServiceStatus(),
|
||||||
@@ -96,11 +102,17 @@ public sealed class MessagingTransportIntegrationTests
|
|||||||
tcpServer,
|
tcpServer,
|
||||||
tlsServer,
|
tlsServer,
|
||||||
messagingServer: null);
|
messagingServer: null);
|
||||||
|
var resyncService = new GatewayRegistrationResyncService(
|
||||||
|
routingState.Object,
|
||||||
|
transportClient,
|
||||||
|
TimeProvider.System,
|
||||||
|
NullLogger<GatewayRegistrationResyncService>.Instance);
|
||||||
|
|
||||||
// Act & Assert - construction should succeed without messaging server
|
// Act & Assert - construction should succeed without messaging server
|
||||||
var hostedService = new GatewayHostedService(
|
var hostedService = new GatewayHostedService(
|
||||||
routingState.Object,
|
routingState.Object,
|
||||||
transportClient,
|
transportClient,
|
||||||
|
resyncService,
|
||||||
claimsStore.Object,
|
claimsStore.Object,
|
||||||
gatewayOptions,
|
gatewayOptions,
|
||||||
new GatewayServiceStatus(),
|
new GatewayServiceStatus(),
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using StellaOps.Gateway.WebService.Services;
|
||||||
|
using StellaOps.Router.Common.Abstractions;
|
||||||
|
using StellaOps.Router.Common.Enums;
|
||||||
|
using StellaOps.Router.Common.Models;
|
||||||
|
|
||||||
|
namespace StellaOps.Gateway.WebService.Tests.Services;
|
||||||
|
|
||||||
|
public sealed class GatewayRegistrationResyncServiceTests
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestResyncAsync_TargetConnection_SendsResyncFrame()
|
||||||
|
{
|
||||||
|
var connection = CreateConnectionState("conn-1");
|
||||||
|
var routingState = new Mock<IGlobalRoutingState>();
|
||||||
|
routingState.Setup(state => state.GetAllConnections()).Returns([connection]);
|
||||||
|
|
||||||
|
var transportClient = new Mock<IGatewayTransportClient>();
|
||||||
|
Frame? capturedFrame = null;
|
||||||
|
transportClient
|
||||||
|
.Setup(client => client.SendFrameAsync(connection, It.IsAny<Frame>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<ConnectionState, Frame, CancellationToken>((_, frame, _) => capturedFrame = frame)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var service = new GatewayRegistrationResyncService(
|
||||||
|
routingState.Object,
|
||||||
|
transportClient.Object,
|
||||||
|
TimeProvider.System,
|
||||||
|
NullLogger<GatewayRegistrationResyncService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.RequestResyncAsync("conn-1", "unit-test", force: true, CancellationToken.None);
|
||||||
|
|
||||||
|
result.MatchedCount.Should().Be(1);
|
||||||
|
result.RequestedCount.Should().Be(1);
|
||||||
|
capturedFrame.Should().NotBeNull();
|
||||||
|
capturedFrame!.Type.Should().Be(FrameType.ResyncRequest);
|
||||||
|
|
||||||
|
var payload = JsonSerializer.Deserialize<RegistrationResyncRequestPayload>(capturedFrame.Payload.Span, JsonOptions);
|
||||||
|
payload.Should().NotBeNull();
|
||||||
|
payload!.Reason.Should().Be("unit-test");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestResyncAsync_UsesCooldownUnlessForced()
|
||||||
|
{
|
||||||
|
var connection = CreateConnectionState("conn-1");
|
||||||
|
var routingState = new Mock<IGlobalRoutingState>();
|
||||||
|
routingState.Setup(state => state.GetAllConnections()).Returns([connection]);
|
||||||
|
|
||||||
|
var transportClient = new Mock<IGatewayTransportClient>();
|
||||||
|
transportClient
|
||||||
|
.Setup(client => client.SendFrameAsync(connection, It.IsAny<Frame>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var service = new GatewayRegistrationResyncService(
|
||||||
|
routingState.Object,
|
||||||
|
transportClient.Object,
|
||||||
|
TimeProvider.System,
|
||||||
|
NullLogger<GatewayRegistrationResyncService>.Instance);
|
||||||
|
|
||||||
|
var first = await service.RequestResyncAsync("conn-1", "first", force: false, CancellationToken.None);
|
||||||
|
var second = await service.RequestResyncAsync("conn-1", "second", force: false, CancellationToken.None);
|
||||||
|
var third = await service.RequestResyncAsync("conn-1", "forced", force: true, CancellationToken.None);
|
||||||
|
|
||||||
|
first.RequestedCount.Should().Be(1);
|
||||||
|
second.RequestedCount.Should().Be(0);
|
||||||
|
third.RequestedCount.Should().Be(1);
|
||||||
|
|
||||||
|
transportClient.Verify(
|
||||||
|
client => client.SendFrameAsync(connection, It.IsAny<Frame>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Exactly(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearPending_AllowsImmediateRetry()
|
||||||
|
{
|
||||||
|
var connection = CreateConnectionState("conn-1");
|
||||||
|
var routingState = new Mock<IGlobalRoutingState>();
|
||||||
|
routingState.Setup(state => state.GetAllConnections()).Returns([connection]);
|
||||||
|
|
||||||
|
var transportClient = new Mock<IGatewayTransportClient>();
|
||||||
|
transportClient
|
||||||
|
.Setup(client => client.SendFrameAsync(connection, It.IsAny<Frame>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var service = new GatewayRegistrationResyncService(
|
||||||
|
routingState.Object,
|
||||||
|
transportClient.Object,
|
||||||
|
TimeProvider.System,
|
||||||
|
NullLogger<GatewayRegistrationResyncService>.Instance);
|
||||||
|
|
||||||
|
await service.RequestResyncAsync("conn-1", "first", force: false, CancellationToken.None);
|
||||||
|
service.ClearPending("conn-1");
|
||||||
|
var result = await service.RequestResyncAsync("conn-1", "second", force: false, CancellationToken.None);
|
||||||
|
|
||||||
|
result.RequestedCount.Should().Be(1);
|
||||||
|
transportClient.Verify(
|
||||||
|
client => client.SendFrameAsync(connection, It.IsAny<Frame>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Exactly(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConnectionState CreateConnectionState(string connectionId)
|
||||||
|
{
|
||||||
|
return new ConnectionState
|
||||||
|
{
|
||||||
|
ConnectionId = connectionId,
|
||||||
|
Instance = new InstanceDescriptor
|
||||||
|
{
|
||||||
|
InstanceId = "svc-1",
|
||||||
|
ServiceName = "timelineindexer",
|
||||||
|
Version = "1.0.0",
|
||||||
|
Region = "local"
|
||||||
|
},
|
||||||
|
TransportType = TransportType.Messaging
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -388,7 +388,7 @@ public sealed class RouterConnectionManagerTests : IDisposable
|
|||||||
|
|
||||||
[Trait("Category", TestCategories.Unit)]
|
[Trait("Category", TestCategories.Unit)]
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_ReplaysHelloWithinRegistrationRefreshInterval()
|
public async Task StartAsync_DoesNotReplayHelloWithinRegistrationRefreshInterval()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
_options.Routers.Add(new RouterEndpointConfig
|
_options.Routers.Add(new RouterEndpointConfig
|
||||||
@@ -398,7 +398,6 @@ public sealed class RouterConnectionManagerTests : IDisposable
|
|||||||
TransportType = TransportType.InMemory
|
TransportType = TransportType.InMemory
|
||||||
});
|
});
|
||||||
|
|
||||||
var registrationReplayObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
var connectCount = 0;
|
var connectCount = 0;
|
||||||
_transportMock
|
_transportMock
|
||||||
.Setup(t => t.ConnectAsync(
|
.Setup(t => t.ConnectAsync(
|
||||||
@@ -409,10 +408,7 @@ public sealed class RouterConnectionManagerTests : IDisposable
|
|||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.Callback(() =>
|
.Callback(() =>
|
||||||
{
|
{
|
||||||
if (Interlocked.Increment(ref connectCount) >= 2)
|
Interlocked.Increment(ref connectCount);
|
||||||
{
|
|
||||||
registrationReplayObserved.TrySetResult();
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
@@ -420,11 +416,54 @@ public sealed class RouterConnectionManagerTests : IDisposable
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
await manager.StartAsync(CancellationToken.None);
|
await manager.StartAsync(CancellationToken.None);
|
||||||
await registrationReplayObserved.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
await Task.Delay(150);
|
||||||
await manager.StopAsync(CancellationToken.None);
|
await manager.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
connectCount.Should().BeGreaterThanOrEqualTo(2);
|
connectCount.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Trait("Category", TestCategories.Unit)]
|
||||||
|
[Fact]
|
||||||
|
public async Task ResyncRequest_ReplaysEndpointMetadataWithoutReconnect()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_options.Routers.Add(new RouterEndpointConfig
|
||||||
|
{
|
||||||
|
Host = "localhost",
|
||||||
|
Port = 5000,
|
||||||
|
TransportType = TransportType.InMemory
|
||||||
|
});
|
||||||
|
|
||||||
|
var endpoints = new List<EndpointDescriptor>
|
||||||
|
{
|
||||||
|
new() { ServiceName = "test-service", Version = "1.0.0", Method = "GET", Path = "/api/users" }
|
||||||
|
};
|
||||||
|
|
||||||
|
_discoveryProviderMock.Setup(d => d.DiscoverEndpoints()).Returns(endpoints);
|
||||||
|
|
||||||
|
var transport = new RecordingMicroserviceTransport();
|
||||||
|
using var manager = new RouterConnectionManager(
|
||||||
|
Options.Create(_options),
|
||||||
|
_discoveryProviderMock.Object,
|
||||||
|
_requestDispatcherMock.Object,
|
||||||
|
transport,
|
||||||
|
NullLogger<RouterConnectionManager>.Instance);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await manager.StartAsync(CancellationToken.None);
|
||||||
|
await transport.RaiseResyncRequestedAsync(new RegistrationResyncRequestPayload
|
||||||
|
{
|
||||||
|
Reason = "unit-test"
|
||||||
|
});
|
||||||
|
await manager.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
transport.ConnectCount.Should().Be(1);
|
||||||
|
transport.EndpointUpdateCount.Should().Be(1);
|
||||||
|
transport.LastEndpointUpdate.Should().NotBeNull();
|
||||||
|
transport.LastEndpointUpdate!.Endpoints.Should().ContainSingle();
|
||||||
|
transport.LastEndpointUpdate.Endpoints[0].Path.Should().Be("/api/users");
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -471,4 +510,69 @@ public sealed class RouterConnectionManagerTests : IDisposable
|
|||||||
|
|
||||||
public IReadOnlyDictionary<string, SchemaDefinition> DiscoverSchemaDefinitions() => _schemas;
|
public IReadOnlyDictionary<string, SchemaDefinition> DiscoverSchemaDefinitions() => _schemas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingMicroserviceTransport : IMicroserviceTransport
|
||||||
|
{
|
||||||
|
public int ConnectCount { get; private set; }
|
||||||
|
|
||||||
|
public int EndpointUpdateCount { get; private set; }
|
||||||
|
|
||||||
|
public EndpointsUpdatePayload? LastEndpointUpdate { get; private set; }
|
||||||
|
|
||||||
|
public event Func<Frame, CancellationToken, Task<Frame>>? OnRequestReceived;
|
||||||
|
|
||||||
|
public event Func<Guid, string?, Task>? OnCancelReceived;
|
||||||
|
|
||||||
|
public event Func<RegistrationResyncRequestPayload, CancellationToken, Task>? OnResyncRequested;
|
||||||
|
|
||||||
|
public event Action? TransportDied;
|
||||||
|
|
||||||
|
public Task ConnectAsync(
|
||||||
|
InstanceDescriptor instance,
|
||||||
|
IReadOnlyList<EndpointDescriptor> endpoints,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ConnectCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ConnectAsync(
|
||||||
|
InstanceDescriptor instance,
|
||||||
|
IReadOnlyList<EndpointDescriptor> endpoints,
|
||||||
|
IReadOnlyDictionary<string, SchemaDefinition>? schemas,
|
||||||
|
ServiceOpenApiInfo? openApiInfo,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ConnectCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisconnectAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SendHeartbeatAsync(HeartbeatPayload heartbeat, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SendEndpointsUpdateAsync(
|
||||||
|
IReadOnlyList<EndpointDescriptor> endpoints,
|
||||||
|
IReadOnlyDictionary<string, SchemaDefinition>? schemas,
|
||||||
|
ServiceOpenApiInfo? openApiInfo,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EndpointUpdateCount++;
|
||||||
|
LastEndpointUpdate = new EndpointsUpdatePayload
|
||||||
|
{
|
||||||
|
Endpoints = endpoints,
|
||||||
|
Schemas = schemas ?? new Dictionary<string, SchemaDefinition>(),
|
||||||
|
OpenApiInfo = openApiInfo
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RaiseResyncRequestedAsync(RegistrationResyncRequestPayload request)
|
||||||
|
{
|
||||||
|
return OnResyncRequested is null
|
||||||
|
? Task.CompletedTask
|
||||||
|
: OnResyncRequested.Invoke(request, CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ public sealed class MessagingTransportQueueOptionsTests
|
|||||||
|
|
||||||
[Trait("Category", TestCategories.Unit)]
|
[Trait("Category", TestCategories.Unit)]
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MessagingTransportClient_ConnectAsync_IncludesSchemasAndOpenApiInfoInHelloPayload()
|
public async Task MessagingTransportClient_ConnectAsync_SendsSlimHelloPayload()
|
||||||
{
|
{
|
||||||
var options = Options.Create(new MessagingTransportOptions
|
var options = Options.Create(new MessagingTransportOptions
|
||||||
{
|
{
|
||||||
@@ -152,7 +152,82 @@ public sealed class MessagingTransportQueueOptionsTests
|
|||||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||||
|
|
||||||
payload.Should().NotBeNull();
|
payload.Should().NotBeNull();
|
||||||
payload!.Schemas.Should().ContainKey(schemaId);
|
payload!.Instance.ServiceName.Should().Be("timelineindexer");
|
||||||
|
payload.Endpoints.Should().BeEmpty();
|
||||||
|
payload.Schemas.Should().BeEmpty();
|
||||||
|
payload.OpenApiInfo.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Trait("Category", TestCategories.Unit)]
|
||||||
|
[Fact]
|
||||||
|
public async Task MessagingTransportClient_SendEndpointsUpdateAsync_IncludesSchemasAndOpenApiInfoInUpdatePayload()
|
||||||
|
{
|
||||||
|
var options = Options.Create(new MessagingTransportOptions
|
||||||
|
{
|
||||||
|
ConsumerGroup = "timelineindexer-test",
|
||||||
|
BatchSize = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
var queueFactory = new RecordingQueueFactory();
|
||||||
|
var client = new MessagingTransportClient(
|
||||||
|
queueFactory,
|
||||||
|
options,
|
||||||
|
NullLogger<MessagingTransportClient>.Instance);
|
||||||
|
|
||||||
|
var instance = new InstanceDescriptor
|
||||||
|
{
|
||||||
|
InstanceId = "timelineindexer-1",
|
||||||
|
ServiceName = "timelineindexer",
|
||||||
|
Version = "1.0.0",
|
||||||
|
Region = "local"
|
||||||
|
};
|
||||||
|
|
||||||
|
var endpoints =
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new EndpointDescriptor
|
||||||
|
{
|
||||||
|
ServiceName = "timelineindexer",
|
||||||
|
Version = "1.0.0",
|
||||||
|
Method = "GET",
|
||||||
|
Path = "/api/v1/timeline"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var schemaId = "TimelineEvent";
|
||||||
|
var schemas = new Dictionary<string, SchemaDefinition>
|
||||||
|
{
|
||||||
|
[schemaId] = new SchemaDefinition
|
||||||
|
{
|
||||||
|
SchemaId = schemaId,
|
||||||
|
SchemaJson = "{\"type\":\"object\"}",
|
||||||
|
ETag = "abc123"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.ConnectAsync(instance, endpoints, CancellationToken.None);
|
||||||
|
await client.SendEndpointsUpdateAsync(
|
||||||
|
endpoints,
|
||||||
|
schemas,
|
||||||
|
new ServiceOpenApiInfo
|
||||||
|
{
|
||||||
|
Title = "timelineindexer",
|
||||||
|
Description = "Timeline service"
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
|
await client.DisconnectAsync();
|
||||||
|
|
||||||
|
var updateMessage = queueFactory.EnqueuedMessages
|
||||||
|
.OfType<RpcRequestMessage>()
|
||||||
|
.First(message => message.FrameType == Common.Enums.FrameType.EndpointsUpdate);
|
||||||
|
|
||||||
|
var payload = JsonSerializer.Deserialize<EndpointsUpdatePayload>(
|
||||||
|
Convert.FromBase64String(updateMessage.PayloadBase64),
|
||||||
|
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||||
|
|
||||||
|
payload.Should().NotBeNull();
|
||||||
|
payload!.Endpoints.Should().ContainSingle();
|
||||||
|
payload.Schemas.Should().ContainKey(schemaId);
|
||||||
payload.Schemas[schemaId].SchemaJson.Should().Be("{\"type\":\"object\"}");
|
payload.Schemas[schemaId].SchemaJson.Should().Be("{\"type\":\"object\"}");
|
||||||
payload.OpenApiInfo.Should().NotBeNull();
|
payload.OpenApiInfo.Should().NotBeNull();
|
||||||
payload.OpenApiInfo!.Title.Should().Be("timelineindexer");
|
payload.OpenApiInfo!.Title.Should().Be("timelineindexer");
|
||||||
|
|||||||
Reference in New Issue
Block a user