feat(comms): site-side PullAuditEvents handler (#23 M6)
This commit is contained in:
@@ -5,6 +5,7 @@ using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
@@ -36,6 +37,13 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
// 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);
|
||||
// Audit Log (#23 M6): site-local queue handed in by AkkaHostedService on
|
||||
// site roles so the central reconciliation puller's PullAuditEvents RPC
|
||||
// can read Pending/Forwarded rows. Null when not wired (e.g. central-only
|
||||
// host or test composing the server in isolation) — the handler treats
|
||||
// the missing queue as "nothing to ship" and returns an empty response so
|
||||
// central retries on its next reconciliation cycle.
|
||||
private ISiteAuditQueue? _siteAuditQueue;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only constructor — kept <c>internal</c> so the DI container sees a
|
||||
@@ -102,6 +110,20 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
_auditIngestActor = proxy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands the site-local <see cref="ISiteAuditQueue"/> (the same
|
||||
/// <c>SqliteAuditWriter</c> singleton that backs <see cref="IAuditWriter"/>
|
||||
/// on the script thread) to the gRPC server so the M6
|
||||
/// <see cref="PullAuditEvents"/> RPC can serve central's reconciliation
|
||||
/// pulls. Mirrors <see cref="SetAuditIngestActor"/>: wired post-construction
|
||||
/// because the queue and the gRPC server are both DI singletons brought up
|
||||
/// in independent orders on site startup.
|
||||
/// </summary>
|
||||
public void SetSiteAuditQueue(ISiteAuditQueue queue)
|
||||
{
|
||||
_siteAuditQueue = queue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of currently active streaming subscriptions. Exposed for diagnostics.
|
||||
/// </summary>
|
||||
@@ -361,6 +383,144 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
return ack;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M6 reconciliation pull RPC. Central asks the site for any
|
||||
/// AuditLog rows whose <c>OccurredAtUtc >= since_utc</c> and whose
|
||||
/// <c>ForwardState</c> is still <c>Pending</c> or <c>Forwarded</c> (i.e. not
|
||||
/// yet confirmed reconciled), bounded by <c>batch_size</c>. The site responds
|
||||
/// with the rows AND flips them to
|
||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Reconciled"/>
|
||||
/// AFTER serializing the response. The flip is best-effort — if it fails
|
||||
/// (e.g. SQLite disposed mid-call), rows stay Pending/Forwarded and central
|
||||
/// pulls them again on the next reconciliation cycle. Idempotent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see cref="_siteAuditQueue"/> is not wired (central-only host or a
|
||||
/// composition-root test exercising the server in isolation) the RPC returns
|
||||
/// an empty response — central treats that as "nothing to ship" and retries
|
||||
/// on its next cycle, which is the same self-healing semantics as the
|
||||
/// SetAuditIngestActor wiring race window.
|
||||
/// </remarks>
|
||||
public override async Task<PullAuditEventsResponse> PullAuditEvents(
|
||||
PullAuditEventsRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var queue = _siteAuditQueue;
|
||||
if (queue is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"PullAuditEvents invoked before SetSiteAuditQueue was called; returning empty response.");
|
||||
return new PullAuditEventsResponse();
|
||||
}
|
||||
|
||||
if (request.BatchSize <= 0)
|
||||
{
|
||||
// Mirrors the SubscribeInstance guard: reject malformed requests
|
||||
// cleanly with InvalidArgument so the caller doesn't see a generic
|
||||
// RpcException from the underlying SQLite parameter validation.
|
||||
throw new RpcException(new GrpcStatus(
|
||||
StatusCode.InvalidArgument, "batch_size must be > 0"));
|
||||
}
|
||||
|
||||
// sinceUtc defaults to DateTime.MinValue when the wrapper is absent —
|
||||
// i.e. "pull from the beginning of recorded history", which is the
|
||||
// intended behaviour for the very first reconciliation cycle.
|
||||
var since = request.SinceUtc?.ToDateTime().ToUniversalTime() ?? DateTime.MinValue;
|
||||
|
||||
IReadOnlyList<AuditEvent> events;
|
||||
try
|
||||
{
|
||||
events = await queue.ReadPendingSinceAsync(
|
||||
since, request.BatchSize, context.CancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"ReadPendingSinceAsync failed for since={Since} batch={Batch}; returning empty response.",
|
||||
since, request.BatchSize);
|
||||
return new PullAuditEventsResponse();
|
||||
}
|
||||
|
||||
var response = new PullAuditEventsResponse
|
||||
{
|
||||
// batch_size saturated → tell central to issue a follow-up pull
|
||||
// with an advanced cursor. The site doesn't compute the cursor —
|
||||
// central walks it forward from the last returned OccurredAtUtc.
|
||||
MoreAvailable = events.Count >= request.BatchSize,
|
||||
};
|
||||
foreach (var evt in events)
|
||||
{
|
||||
response.Events.Add(AuditEventToDto(evt));
|
||||
}
|
||||
|
||||
// Flip to Reconciled AFTER projecting the response so a fault below the
|
||||
// try/catch (mid-response, mid-flip) leaves the rows in Pending/Forwarded
|
||||
// and central pulls them again next cycle. The flip itself is
|
||||
// best-effort — its failure is a warning, not a fault, because central
|
||||
// will dedup on EventId on the next pull.
|
||||
var ids = new List<Guid>(events.Count);
|
||||
foreach (var evt in events)
|
||||
{
|
||||
ids.Add(evt.EventId);
|
||||
}
|
||||
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await queue.MarkReconciledAsync(ids, context.CancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"MarkReconciledAsync failed after PullAuditEvents response of {Count} rows; rows stay Pending for retry.",
|
||||
ids.Count);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inlined audit-event entity→DTO translation. Keep in sync with
|
||||
/// <c>AuditEventMapper.ToDto</c> in <c>ScadaLink.AuditLog.Telemetry</c> —
|
||||
/// the project-reference cycle (AuditLog → Communication) prevents calling
|
||||
/// the AuditLog mapper directly. The shape mirrors the FromDto pair above.
|
||||
/// </summary>
|
||||
private static AuditEventDto AuditEventToDto(AuditEvent evt)
|
||||
{
|
||||
var dto = new AuditEventDto
|
||||
{
|
||||
EventId = evt.EventId.ToString(),
|
||||
OccurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)),
|
||||
Channel = evt.Channel.ToString(),
|
||||
Kind = evt.Kind.ToString(),
|
||||
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
|
||||
SourceSiteId = evt.SourceSiteId ?? string.Empty,
|
||||
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
|
||||
SourceScript = evt.SourceScript ?? string.Empty,
|
||||
Actor = evt.Actor ?? string.Empty,
|
||||
Target = evt.Target ?? string.Empty,
|
||||
Status = evt.Status.ToString(),
|
||||
ErrorMessage = evt.ErrorMessage ?? string.Empty,
|
||||
ErrorDetail = evt.ErrorDetail ?? string.Empty,
|
||||
RequestSummary = evt.RequestSummary ?? string.Empty,
|
||||
ResponseSummary = evt.ResponseSummary ?? string.Empty,
|
||||
PayloadTruncated = evt.PayloadTruncated,
|
||||
Extra = evt.Extra ?? string.Empty,
|
||||
};
|
||||
|
||||
if (evt.HttpStatus.HasValue) dto.HttpStatus = evt.HttpStatus.Value;
|
||||
if (evt.DurationMs.HasValue) dto.DurationMs = evt.DurationMs.Value;
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private static DateTime EnsureUtc(DateTime value) =>
|
||||
value.Kind == DateTimeKind.Utc
|
||||
? value
|
||||
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
|
||||
|
||||
private static string? NullIfEmpty(string? value) =>
|
||||
string.IsNullOrEmpty(value) ? null : value;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user