203 lines
8.4 KiB
C#
203 lines
8.4 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Messages.Integration;
|
|
using ScadaLink.Commons.Types;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
|
|
namespace ScadaLink.AuditLog.Site.Telemetry;
|
|
|
|
/// <summary>
|
|
/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): translates per-attempt
|
|
/// notifications from the store-and-forward retry loop into one (or two)
|
|
/// <see cref="CachedCallTelemetry"/> packets and pushes them through
|
|
/// <see cref="ICachedCallTelemetryForwarder"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The S&F loop's <see cref="ICachedCallLifecycleObserver"/> reports a
|
|
/// single coarse outcome per attempt; the audit pipeline however models the
|
|
/// lifecycle as TWO rows on terminal outcomes — an <c>Attempted</c>
|
|
/// (<see cref="AuditKind.ApiCallCached"/> / <see cref="AuditKind.DbWriteCached"/>)
|
|
/// row capturing the per-attempt mechanics, plus a <see cref="AuditKind.CachedResolve"/>
|
|
/// row marking the terminal state for downstream consumers. The bridge fans
|
|
/// out per outcome:
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item><description><c>TransientFailure</c> -> one Attempted(Failed) row.</description></item>
|
|
/// <item><description><c>Delivered</c> -> Attempted(Delivered) + CachedResolve(Delivered).</description></item>
|
|
/// <item><description><c>PermanentFailure</c> -> Attempted(Failed) + CachedResolve(Parked).</description></item>
|
|
/// <item><description><c>ParkedMaxRetries</c> -> Attempted(Failed) + CachedResolve(Parked).</description></item>
|
|
/// </list>
|
|
/// <para>
|
|
/// <b>Best-effort emission (alog.md §7):</b> the bridge itself never throws;
|
|
/// the underlying forwarder swallows + logs its own failures.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
|
|
{
|
|
private readonly ICachedCallTelemetryForwarder _forwarder;
|
|
private readonly ILogger<CachedCallLifecycleBridge> _logger;
|
|
|
|
public CachedCallLifecycleBridge(
|
|
ICachedCallTelemetryForwarder forwarder,
|
|
ILogger<CachedCallLifecycleBridge> logger)
|
|
{
|
|
_forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task OnAttemptCompletedAsync(
|
|
CachedCallAttemptContext context, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
|
|
try
|
|
{
|
|
await EmitAttemptedAsync(context, ct).ConfigureAwait(false);
|
|
|
|
if (IsTerminal(context.Outcome))
|
|
{
|
|
await EmitResolveAsync(context, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Defensive — both EmitX paths call the forwarder which is itself
|
|
// best-effort. A throw here is unexpected, but the alog.md §7
|
|
// contract requires we never propagate.
|
|
_logger.LogWarning(ex,
|
|
"CachedCallLifecycleBridge: unexpected throw for {TrackedOperationId} (Outcome {Outcome})",
|
|
context.TrackedOperationId, context.Outcome);
|
|
}
|
|
}
|
|
|
|
private async Task EmitAttemptedAsync(CachedCallAttemptContext context, CancellationToken ct)
|
|
{
|
|
// Per-attempt row: kind discriminates channel; status is always
|
|
// Attempted regardless of outcome (success vs. failure is captured
|
|
// by the companion HttpStatus / ErrorMessage fields, NOT by flipping
|
|
// the status — CachedResolve carries the terminal Status). Per the
|
|
// M3 brief and alog.md §4.
|
|
var kind = ChannelToAttemptKind(context.Channel);
|
|
var status = AuditStatus.Attempted;
|
|
|
|
var packet = BuildPacket(
|
|
context,
|
|
kind: kind,
|
|
status: status,
|
|
// Operational status mirror — for the per-attempt row the
|
|
// operational state is the running status; the bridge always
|
|
// writes "Attempted" so reconciliation can't roll back.
|
|
operationalStatus: "Attempted",
|
|
terminalAtUtc: null,
|
|
lastError: context.LastError,
|
|
httpStatus: context.HttpStatus);
|
|
|
|
await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task EmitResolveAsync(CachedCallAttemptContext context, CancellationToken ct)
|
|
{
|
|
var (auditStatus, operationalStatus) = TerminalOutcomeToStatuses(context.Outcome);
|
|
|
|
var packet = BuildPacket(
|
|
context,
|
|
kind: AuditKind.CachedResolve,
|
|
status: auditStatus,
|
|
operationalStatus: operationalStatus,
|
|
terminalAtUtc: context.OccurredAtUtc,
|
|
lastError: context.LastError,
|
|
httpStatus: context.HttpStatus);
|
|
|
|
await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private static CachedCallTelemetry BuildPacket(
|
|
CachedCallAttemptContext context,
|
|
AuditKind kind,
|
|
AuditStatus status,
|
|
string operationalStatus,
|
|
DateTime? terminalAtUtc,
|
|
string? lastError,
|
|
int? httpStatus)
|
|
{
|
|
var channel = ChannelStringToEnum(context.Channel);
|
|
|
|
return new CachedCallTelemetry(
|
|
Audit: new AuditEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
|
|
Channel = channel,
|
|
Kind = kind,
|
|
CorrelationId = context.TrackedOperationId.Value,
|
|
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
|
SourceInstanceId = context.SourceInstanceId,
|
|
SourceScript = null, // Not threaded through S&F; left null on retry-loop rows.
|
|
Target = context.Target,
|
|
Status = status,
|
|
HttpStatus = httpStatus,
|
|
DurationMs = context.DurationMs,
|
|
ErrorMessage = lastError,
|
|
ForwardState = AuditForwardState.Pending,
|
|
},
|
|
Operational: new SiteCallOperational(
|
|
TrackedOperationId: context.TrackedOperationId,
|
|
Channel: context.Channel,
|
|
Target: context.Target,
|
|
SourceSite: context.SourceSite,
|
|
Status: operationalStatus,
|
|
RetryCount: context.RetryCount,
|
|
LastError: lastError,
|
|
HttpStatus: httpStatus,
|
|
CreatedAtUtc: DateTime.SpecifyKind(context.CreatedAtUtc, DateTimeKind.Utc),
|
|
UpdatedAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
|
|
TerminalAtUtc: terminalAtUtc is null
|
|
? null
|
|
: DateTime.SpecifyKind(terminalAtUtc.Value, DateTimeKind.Utc)));
|
|
}
|
|
|
|
private static AuditKind ChannelToAttemptKind(string channel) => channel switch
|
|
{
|
|
"ApiOutbound" => AuditKind.ApiCallCached,
|
|
"DbOutbound" => AuditKind.DbWriteCached,
|
|
// Defensive default — the S&F observer is filtered to cached-call
|
|
// categories so this branch shouldn't fire in practice.
|
|
_ => AuditKind.ApiCallCached,
|
|
};
|
|
|
|
private static AuditChannel ChannelStringToEnum(string channel) => channel switch
|
|
{
|
|
"ApiOutbound" => AuditChannel.ApiOutbound,
|
|
"DbOutbound" => AuditChannel.DbOutbound,
|
|
_ => AuditChannel.ApiOutbound,
|
|
};
|
|
|
|
private static (AuditStatus auditStatus, string operationalStatus) TerminalOutcomeToStatuses(
|
|
CachedCallAttemptOutcome outcome) => outcome switch
|
|
{
|
|
CachedCallAttemptOutcome.Delivered =>
|
|
(AuditStatus.Delivered, "Delivered"),
|
|
CachedCallAttemptOutcome.PermanentFailure =>
|
|
(AuditStatus.Parked, "Parked"),
|
|
CachedCallAttemptOutcome.ParkedMaxRetries =>
|
|
(AuditStatus.Parked, "Parked"),
|
|
// TransientFailure isn't terminal — see IsTerminal — but the switch
|
|
// is exhaustive so we route it through Failed for safety.
|
|
CachedCallAttemptOutcome.TransientFailure =>
|
|
(AuditStatus.Failed, "Failed"),
|
|
_ => (AuditStatus.Failed, "Failed"),
|
|
};
|
|
|
|
private static bool IsTerminal(CachedCallAttemptOutcome outcome) => outcome switch
|
|
{
|
|
CachedCallAttemptOutcome.Delivered => true,
|
|
CachedCallAttemptOutcome.PermanentFailure => true,
|
|
CachedCallAttemptOutcome.ParkedMaxRetries => true,
|
|
CachedCallAttemptOutcome.TransientFailure => false,
|
|
_ => false,
|
|
};
|
|
}
|