309 lines
13 KiB
C#
309 lines
13 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Threading.Channels;
|
|
using Akka.Actor;
|
|
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Messages.Audit;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using GrpcStatus = Grpc.Core.Status;
|
|
|
|
namespace ScadaLink.Communication.Grpc;
|
|
|
|
/// <summary>
|
|
/// gRPC service that accepts instance stream subscriptions from central nodes.
|
|
/// Creates a StreamRelayActor per subscription to bridge Akka domain events
|
|
/// through a Channel<T> to the gRPC response stream.
|
|
/// </summary>
|
|
public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|
{
|
|
private readonly ISiteStreamSubscriber _streamSubscriber;
|
|
private ActorSystem? _actorSystem;
|
|
private readonly ILogger<SiteStreamGrpcServer> _logger;
|
|
private readonly ConcurrentDictionary<string, StreamEntry> _activeStreams = new();
|
|
private readonly int _maxConcurrentStreams;
|
|
private readonly TimeSpan _maxStreamLifetime;
|
|
private volatile bool _ready;
|
|
private long _actorCounter;
|
|
// Audit Log (#23 M2): central-side ingest actor proxy. Set by the host
|
|
// after the cluster singleton starts (see Bundle E wiring). When null the
|
|
// IngestAuditEvents RPC replies with an empty IngestAck so sites can
|
|
// safely retry — wiring-incomplete is treated as transient, never fatal.
|
|
private IActorRef? _auditIngestActor;
|
|
// Per Bundle D's brief — Ask timeout is 30 s. The ingest actor's repo
|
|
// calls are sub-100 ms in steady state; a generous timeout absorbs a slow
|
|
// MSSQL connection without surfacing as a gRPC failure on a healthy site.
|
|
private static readonly TimeSpan AuditIngestAskTimeout = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>
|
|
/// Test-only constructor — kept <c>internal</c> so the DI container sees a
|
|
/// single public constructor and is not faced with an ambiguous choice.
|
|
/// </summary>
|
|
internal SiteStreamGrpcServer(
|
|
ISiteStreamSubscriber streamSubscriber,
|
|
ILogger<SiteStreamGrpcServer> logger,
|
|
int maxConcurrentStreams = 100)
|
|
: this(streamSubscriber, logger, maxConcurrentStreams, TimeSpan.FromHours(4))
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// DI constructor — binds <see cref="CommunicationOptions.GrpcMaxConcurrentStreams"/>
|
|
/// and <see cref="CommunicationOptions.GrpcMaxStreamLifetime"/> so the documented
|
|
/// concurrency limit and the 4-hour zombie-stream session timeout are honoured
|
|
/// rather than hard-coded.
|
|
/// </summary>
|
|
public SiteStreamGrpcServer(
|
|
ISiteStreamSubscriber streamSubscriber,
|
|
ILogger<SiteStreamGrpcServer> logger,
|
|
IOptions<CommunicationOptions> options)
|
|
: this(streamSubscriber, logger,
|
|
options.Value.GrpcMaxConcurrentStreams,
|
|
options.Value.GrpcMaxStreamLifetime)
|
|
{
|
|
}
|
|
|
|
private SiteStreamGrpcServer(
|
|
ISiteStreamSubscriber streamSubscriber,
|
|
ILogger<SiteStreamGrpcServer> logger,
|
|
int maxConcurrentStreams,
|
|
TimeSpan maxStreamLifetime)
|
|
{
|
|
_streamSubscriber = streamSubscriber;
|
|
_logger = logger;
|
|
_maxConcurrentStreams = maxConcurrentStreams;
|
|
_maxStreamLifetime = maxStreamLifetime;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks the server as ready to accept subscriptions and injects the ActorSystem.
|
|
/// Called after the site runtime actor system is fully initialized.
|
|
/// The ActorSystem is set here rather than via the constructor so that
|
|
/// the gRPC server can be created by DI before the actor system exists.
|
|
/// </summary>
|
|
public void SetReady(ActorSystem actorSystem)
|
|
{
|
|
_actorSystem = actorSystem;
|
|
_ready = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hands the central-side <c>AuditLogIngestActor</c> proxy to the gRPC
|
|
/// server so the <see cref="IngestAuditEvents"/> RPC can route incoming
|
|
/// site batches. Audit Log (#23) M2 wiring point — mirrors the way
|
|
/// <c>CommunicationService.SetNotificationOutbox</c> takes the Notification
|
|
/// Outbox singleton proxy. Bundle E supplies the actor after the cluster
|
|
/// singleton starts.
|
|
/// </summary>
|
|
public void SetAuditIngestActor(IActorRef proxy)
|
|
{
|
|
_auditIngestActor = proxy;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Number of currently active streaming subscriptions. Exposed for diagnostics.
|
|
/// </summary>
|
|
public int ActiveStreamCount => _activeStreams.Count;
|
|
|
|
/// <summary>Effective max concurrent stream limit. Exposed for tests.</summary>
|
|
internal int MaxConcurrentStreams => _maxConcurrentStreams;
|
|
|
|
/// <summary>Effective per-stream session lifetime. Exposed for tests.</summary>
|
|
internal TimeSpan MaxStreamLifetime => _maxStreamLifetime;
|
|
|
|
public override async Task SubscribeInstance(
|
|
InstanceStreamRequest request,
|
|
IServerStreamWriter<SiteStreamEvent> responseStream,
|
|
ServerCallContext context)
|
|
{
|
|
if (!_ready)
|
|
throw new RpcException(new GrpcStatus(StatusCode.Unavailable, "Server not ready"));
|
|
|
|
// Communication-014: correlation_id arrives off the wire on a public gRPC
|
|
// endpoint and is used (below) to compose an Akka actor name. Akka actor names
|
|
// have a restricted character set — a id containing '/', whitespace, or other
|
|
// disallowed characters would make ActorOf throw InvalidActorNameException,
|
|
// escaping as an unhandled RPC fault. Reject unsafe ids cleanly up front.
|
|
if (string.IsNullOrEmpty(request.CorrelationId) ||
|
|
!ActorPath.IsValidPathElement(request.CorrelationId))
|
|
{
|
|
throw new RpcException(new GrpcStatus(
|
|
StatusCode.InvalidArgument, "correlation_id is missing or not a valid identifier"));
|
|
}
|
|
|
|
// Duplicate prevention -- cancel existing stream for this correlationId
|
|
if (_activeStreams.TryRemove(request.CorrelationId, out var existingEntry))
|
|
{
|
|
existingEntry.Cts.Cancel();
|
|
existingEntry.Cts.Dispose();
|
|
}
|
|
|
|
// Check max concurrent streams after duplicate removal
|
|
if (_activeStreams.Count >= _maxConcurrentStreams)
|
|
throw new RpcException(new GrpcStatus(StatusCode.ResourceExhausted, "Max concurrent streams reached"));
|
|
|
|
using var streamCts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
|
|
// Session timeout (design doc "gRPC Connection Keepalive": 4-hour third layer
|
|
// of dead-client detection) — forces a long-lived zombie stream to terminate
|
|
// even if keepalive PINGs never detect the loss.
|
|
if (_maxStreamLifetime > TimeSpan.Zero && _maxStreamLifetime != Timeout.InfiniteTimeSpan)
|
|
streamCts.CancelAfter(_maxStreamLifetime);
|
|
var entry = new StreamEntry(streamCts);
|
|
_activeStreams[request.CorrelationId] = entry;
|
|
|
|
var channel = Channel.CreateBounded<SiteStreamEvent>(
|
|
new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.DropOldest });
|
|
|
|
var actorSeq = Interlocked.Increment(ref _actorCounter);
|
|
var relayActor = _actorSystem!.ActorOf(
|
|
Props.Create(typeof(Actors.StreamRelayActor), request.CorrelationId, channel.Writer),
|
|
$"stream-relay-{request.CorrelationId}-{actorSeq}");
|
|
|
|
var subscriptionId = _streamSubscriber.Subscribe(request.InstanceUniqueName, relayActor);
|
|
|
|
_logger.LogInformation(
|
|
"Stream {CorrelationId} started for {Instance} (subscription {SubscriptionId})",
|
|
request.CorrelationId, request.InstanceUniqueName, subscriptionId);
|
|
|
|
try
|
|
{
|
|
await foreach (var evt in channel.Reader.ReadAllAsync(streamCts.Token))
|
|
{
|
|
await responseStream.WriteAsync(evt, streamCts.Token);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Normal cancellation (client disconnect or duplicate replacement)
|
|
}
|
|
finally
|
|
{
|
|
_streamSubscriber.RemoveSubscriber(relayActor);
|
|
_actorSystem!.Stop(relayActor);
|
|
channel.Writer.TryComplete();
|
|
|
|
// Only remove our own entry -- a replacement stream may have already taken the slot
|
|
_activeStreams.TryRemove(
|
|
new KeyValuePair<string, StreamEntry>(request.CorrelationId, entry));
|
|
|
|
_logger.LogInformation(
|
|
"Stream {CorrelationId} for {Instance} ended",
|
|
request.CorrelationId, request.InstanceUniqueName);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Audit Log (#23) M2 site→central push RPC. Decodes a site batch into
|
|
/// <see cref="AuditEvent"/> rows, Asks the central <c>AuditLogIngestActor</c>
|
|
/// proxy to persist them, and echoes the accepted EventIds back so the site
|
|
/// can flip its local rows to <c>Forwarded</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The DTO→entity conversion is inlined here (rather than calling the
|
|
/// AuditLog mapper) to avoid a project-reference cycle:
|
|
/// <c>ScadaLink.AuditLog</c> already references
|
|
/// <c>ScadaLink.Communication</c>, so the gRPC server cannot reach back
|
|
/// into AuditLog for its mapper. The shape mirrors
|
|
/// <c>AuditEventMapper.FromDto</c> in <c>ScadaLink.AuditLog.Telemetry</c>;
|
|
/// the two must evolve together.
|
|
/// </para>
|
|
/// <para>
|
|
/// When <see cref="_auditIngestActor"/> is not yet wired (host startup
|
|
/// race window), the RPC returns an empty <see cref="IngestAck"/> rather
|
|
/// than failing — the site treats the missing ack as a transient outcome
|
|
/// and retries on the next drain, which is the desired idempotent
|
|
/// behaviour.
|
|
/// </para>
|
|
/// </remarks>
|
|
public override async Task<IngestAck> IngestAuditEvents(
|
|
AuditEventBatch request,
|
|
ServerCallContext context)
|
|
{
|
|
// Empty batch is a no-op; reply immediately so the client moves on.
|
|
if (request.Events.Count == 0)
|
|
{
|
|
return new IngestAck();
|
|
}
|
|
|
|
var actor = _auditIngestActor;
|
|
if (actor is null)
|
|
{
|
|
// Wiring incomplete (host startup race). Sites treat an empty
|
|
// ack as "nothing was acked, leave rows Pending, retry next
|
|
// drain" — exactly the right behaviour during host bring-up.
|
|
_logger.LogWarning(
|
|
"IngestAuditEvents received {Count} events before SetAuditIngestActor was called; returning empty ack.",
|
|
request.Events.Count);
|
|
return new IngestAck();
|
|
}
|
|
|
|
// Inlined FromDto. Keep in sync with AuditEventMapper.FromDto in
|
|
// ScadaLink.AuditLog.Telemetry — there is no shared mapper because
|
|
// doing so would create a project-reference cycle (AuditLog → Communication).
|
|
var entities = new List<AuditEvent>(request.Events.Count);
|
|
foreach (var dto in request.Events)
|
|
{
|
|
entities.Add(new AuditEvent
|
|
{
|
|
EventId = Guid.Parse(dto.EventId),
|
|
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
|
|
IngestedAtUtc = null,
|
|
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
|
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
|
CorrelationId = string.IsNullOrEmpty(dto.CorrelationId) ? null : Guid.Parse(dto.CorrelationId),
|
|
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
|
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
|
SourceScript = NullIfEmpty(dto.SourceScript),
|
|
Actor = NullIfEmpty(dto.Actor),
|
|
Target = NullIfEmpty(dto.Target),
|
|
Status = Enum.Parse<AuditStatus>(dto.Status),
|
|
HttpStatus = dto.HttpStatus,
|
|
DurationMs = dto.DurationMs,
|
|
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
|
|
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
|
|
RequestSummary = NullIfEmpty(dto.RequestSummary),
|
|
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
|
|
PayloadTruncated = dto.PayloadTruncated,
|
|
Extra = NullIfEmpty(dto.Extra),
|
|
ForwardState = null,
|
|
});
|
|
}
|
|
|
|
var cmd = new IngestAuditEventsCommand(entities);
|
|
IngestAuditEventsReply reply;
|
|
try
|
|
{
|
|
reply = await actor.Ask<IngestAuditEventsReply>(
|
|
cmd, AuditIngestAskTimeout, context.CancellationToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Audit ingest is best-effort; failing this RPC at the gRPC layer
|
|
// would surface as a transport error and force the site to retry
|
|
// (which it would do anyway). Logging + an empty ack keeps the
|
|
// semantics consistent with the "wiring incomplete" path above.
|
|
_logger.LogError(ex,
|
|
"AuditLogIngestActor Ask failed for batch of {Count} events; returning empty ack.",
|
|
request.Events.Count);
|
|
return new IngestAck();
|
|
}
|
|
|
|
var ack = new IngestAck();
|
|
foreach (var id in reply.AcceptedEventIds)
|
|
{
|
|
ack.AcceptedEventIds.Add(id.ToString());
|
|
}
|
|
return ack;
|
|
}
|
|
|
|
private static string? NullIfEmpty(string? value) =>
|
|
string.IsNullOrEmpty(value) ? null : value;
|
|
|
|
/// <summary>
|
|
/// Tracks a single active stream so cleanup only removes its own entry.
|
|
/// </summary>
|
|
private sealed record StreamEntry(CancellationTokenSource Cts);
|
|
}
|