feat(commons): CachedCallTelemetry combined operational+audit packet (#23 M3)
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Messages.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Combined audit + operational telemetry packet for cached outbound calls
|
||||||
|
/// (Audit Log #23 / M3). The site emits one packet per lifecycle event
|
||||||
|
/// — <c>Submit</c> (Audit kind <c>CachedSubmit</c>), per-attempt
|
||||||
|
/// <c>ApiCallCached</c>/<c>DbWriteCached</c>, terminal <c>CachedResolve</c> —
|
||||||
|
/// and central writes the <see cref="AuditEvent"/> row plus the
|
||||||
|
/// <see cref="SiteCallOperational"/> upsert in one MS SQL transaction. Two
|
||||||
|
/// payload shapes ride on a single wire message so the same on-the-wire batch
|
||||||
|
/// can carry mixed lifecycle events without the central dual-write needing a
|
||||||
|
/// second RPC for the operational state.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Both inner records carry the same <c>TrackedOperationId</c> — the
|
||||||
|
/// idempotency key end-to-end. The <see cref="AuditEvent.CorrelationId"/>
|
||||||
|
/// pattern (used by Audit Log #23 to thread cached-call rows together) is
|
||||||
|
/// honoured by the site emitter; the packet itself is shape-only and makes no
|
||||||
|
/// independent correlation guarantees.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Additive-only per Commons REQ-COM-5a (M2 reviewer note) — this is a new
|
||||||
|
/// message, not a rename of any existing M2 envelope.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Audit">The Audit Log #23 row to insert at central.</param>
|
||||||
|
/// <param name="Operational">The <c>SiteCalls</c> upsert mirroring this lifecycle event.</param>
|
||||||
|
public sealed record CachedCallTelemetry(
|
||||||
|
AuditEvent Audit,
|
||||||
|
SiteCallOperational Operational);
|
||||||
46
src/ScadaLink.Commons/Types/SiteCallOperational.cs
Normal file
46
src/ScadaLink.Commons/Types/SiteCallOperational.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
namespace ScadaLink.Commons.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operational state of one cached call as seen by the site, carried on the
|
||||||
|
/// combined <c>CachedCallTelemetry</c> packet (Audit Log #23 / M3) and persisted
|
||||||
|
/// at central as the <c>SiteCalls</c> row mirroring the call's status.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One row per <see cref="TrackedOperationId"/> at central; ingest is
|
||||||
|
/// insert-if-not-exists then upsert-on-newer-status (monotonic — never rolls
|
||||||
|
/// back). The site remains the source of truth — this record is the
|
||||||
|
/// "eventually-consistent mirror" the central UI's Site Calls page reads.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="TrackedOperationId">Idempotency key shared with the audit row.</param>
|
||||||
|
/// <param name="Channel">
|
||||||
|
/// Trust-boundary channel — <c>"ApiOutbound"</c> or <c>"DbOutbound"</c>. String form
|
||||||
|
/// (not the <see cref="ScadaLink.Commons.Types.Enums.AuditChannel"/> enum) so the
|
||||||
|
/// record serialises identically across SQL / gRPC / JSON boundaries.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Target">Human-readable target (e.g. <c>"ERP.GetOrder"</c>).</param>
|
||||||
|
/// <param name="SourceSite">Site id that submitted the cached call.</param>
|
||||||
|
/// <param name="Status">
|
||||||
|
/// Lifecycle status — string form of <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>:
|
||||||
|
/// <c>Submitted</c>, <c>Retrying</c>, <c>Attempted</c>, <c>Delivered</c>,
|
||||||
|
/// <c>Failed</c>, <c>Parked</c>, <c>Discarded</c>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="RetryCount">Number of dispatch attempts so far; 0 prior to first attempt.</param>
|
||||||
|
/// <param name="LastError">Most recent error message; null when no failures have occurred.</param>
|
||||||
|
/// <param name="HttpStatus">Most recent HTTP status code (API calls only); null otherwise.</param>
|
||||||
|
/// <param name="CreatedAtUtc">UTC timestamp the cached call was first submitted.</param>
|
||||||
|
/// <param name="UpdatedAtUtc">UTC timestamp of the latest status mutation.</param>
|
||||||
|
/// <param name="TerminalAtUtc">UTC timestamp the row reached a terminal status; null while still active.</param>
|
||||||
|
public sealed record SiteCallOperational(
|
||||||
|
TrackedOperationId TrackedOperationId,
|
||||||
|
string Channel,
|
||||||
|
string Target,
|
||||||
|
string SourceSite,
|
||||||
|
string Status,
|
||||||
|
int RetryCount,
|
||||||
|
string? LastError,
|
||||||
|
int? HttpStatus,
|
||||||
|
DateTime CreatedAtUtc,
|
||||||
|
DateTime UpdatedAtUtc,
|
||||||
|
DateTime? TerminalAtUtc);
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Messages.Integration;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Tests.Messages.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M3 Bundle A — Task A4) — tests for the combined
|
||||||
|
/// audit + operational telemetry packet emitted per cached-call lifecycle event
|
||||||
|
/// (<c>Submit</c> → per-attempt <c>ApiCallCached</c> / <c>DbWriteCached</c> →
|
||||||
|
/// terminal <c>Resolve</c>). The site emits one packet per event; central writes
|
||||||
|
/// <c>AuditLog</c> + <c>SiteCalls</c> in one MS SQL transaction.
|
||||||
|
/// </summary>
|
||||||
|
public class CachedCallTelemetryTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime FixedNowUtc = new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private const string SiteId = "site-77";
|
||||||
|
private const string InstanceName = "Plant.Pump42";
|
||||||
|
private const string SourceScript = "ScriptActor:OnTick";
|
||||||
|
|
||||||
|
private static AuditEvent BuildAuditEvent(
|
||||||
|
TrackedOperationId trackedId,
|
||||||
|
AuditKind kind,
|
||||||
|
AuditStatus status,
|
||||||
|
Guid? correlationId = null,
|
||||||
|
string? errorMessage = null,
|
||||||
|
int? httpStatus = null)
|
||||||
|
{
|
||||||
|
return new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = FixedNowUtc,
|
||||||
|
Channel = AuditChannel.ApiOutbound,
|
||||||
|
Kind = kind,
|
||||||
|
CorrelationId = correlationId ?? trackedId.Value,
|
||||||
|
SourceSiteId = SiteId,
|
||||||
|
SourceInstanceId = InstanceName,
|
||||||
|
SourceScript = SourceScript,
|
||||||
|
Target = "ERP.GetOrder",
|
||||||
|
Status = status,
|
||||||
|
HttpStatus = httpStatus,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
PayloadTruncated = false,
|
||||||
|
ForwardState = AuditForwardState.Pending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteCallOperational BuildOperational(
|
||||||
|
TrackedOperationId trackedId,
|
||||||
|
AuditStatus status,
|
||||||
|
int retryCount,
|
||||||
|
string? lastError = null,
|
||||||
|
int? httpStatus = null,
|
||||||
|
DateTime? terminalAtUtc = null)
|
||||||
|
{
|
||||||
|
return new SiteCallOperational(
|
||||||
|
TrackedOperationId: trackedId,
|
||||||
|
Channel: nameof(AuditChannel.ApiOutbound),
|
||||||
|
Target: "ERP.GetOrder",
|
||||||
|
SourceSite: SiteId,
|
||||||
|
Status: status.ToString(),
|
||||||
|
RetryCount: retryCount,
|
||||||
|
LastError: lastError,
|
||||||
|
HttpStatus: httpStatus,
|
||||||
|
CreatedAtUtc: FixedNowUtc,
|
||||||
|
UpdatedAtUtc: terminalAtUtc ?? FixedNowUtc,
|
||||||
|
TerminalAtUtc: terminalAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmitPacket_AuditCarriesCachedSubmit_AndOperationalRetryCountZero()
|
||||||
|
{
|
||||||
|
var trackedId = TrackedOperationId.New();
|
||||||
|
var audit = BuildAuditEvent(trackedId, AuditKind.CachedSubmit, AuditStatus.Submitted);
|
||||||
|
var operational = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||||
|
|
||||||
|
var packet = new CachedCallTelemetry(audit, operational);
|
||||||
|
|
||||||
|
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
|
||||||
|
Assert.Equal(nameof(AuditStatus.Submitted), packet.Operational.Status);
|
||||||
|
Assert.Equal(0, packet.Operational.RetryCount);
|
||||||
|
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||||
|
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttemptedPacket_AuditCarriesApiCallCached_RetryCountAlignsBetweenAuditAndOperational()
|
||||||
|
{
|
||||||
|
var trackedId = TrackedOperationId.New();
|
||||||
|
var audit = BuildAuditEvent(
|
||||||
|
trackedId,
|
||||||
|
AuditKind.ApiCallCached,
|
||||||
|
AuditStatus.Attempted,
|
||||||
|
errorMessage: "HTTP 503 from ERP",
|
||||||
|
httpStatus: 503);
|
||||||
|
var operational = BuildOperational(
|
||||||
|
trackedId,
|
||||||
|
AuditStatus.Attempted,
|
||||||
|
retryCount: 2,
|
||||||
|
lastError: "HTTP 503 from ERP",
|
||||||
|
httpStatus: 503);
|
||||||
|
|
||||||
|
var packet = new CachedCallTelemetry(audit, operational);
|
||||||
|
|
||||||
|
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
|
||||||
|
Assert.Equal(nameof(AuditStatus.Attempted), packet.Operational.Status);
|
||||||
|
// Retry-count alignment: the operational row carries the canonical N;
|
||||||
|
// the audit row's error/http surface the same attempt's outcome.
|
||||||
|
Assert.Equal(packet.Audit.ErrorMessage, packet.Operational.LastError);
|
||||||
|
Assert.Equal(packet.Audit.HttpStatus, packet.Operational.HttpStatus);
|
||||||
|
Assert.Equal(2, packet.Operational.RetryCount);
|
||||||
|
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttemptedPacket_DbWriteCached_CarriesDbWriteCachedKind()
|
||||||
|
{
|
||||||
|
var trackedId = TrackedOperationId.New();
|
||||||
|
var audit = BuildAuditEvent(
|
||||||
|
trackedId,
|
||||||
|
AuditKind.DbWriteCached,
|
||||||
|
AuditStatus.Attempted,
|
||||||
|
errorMessage: "Timeout",
|
||||||
|
httpStatus: null);
|
||||||
|
var operational = BuildOperational(
|
||||||
|
trackedId,
|
||||||
|
AuditStatus.Attempted,
|
||||||
|
retryCount: 1,
|
||||||
|
lastError: "Timeout");
|
||||||
|
|
||||||
|
var packet = new CachedCallTelemetry(audit, operational);
|
||||||
|
|
||||||
|
Assert.Equal(AuditKind.DbWriteCached, packet.Audit.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
|
||||||
|
Assert.Equal(1, packet.Operational.RetryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AuditStatus.Delivered)]
|
||||||
|
[InlineData(AuditStatus.Failed)]
|
||||||
|
[InlineData(AuditStatus.Parked)]
|
||||||
|
[InlineData(AuditStatus.Discarded)]
|
||||||
|
public void ResolvePacket_AuditCarriesCachedResolve_OperationalTerminalAtUtcSet(AuditStatus terminalStatus)
|
||||||
|
{
|
||||||
|
var trackedId = TrackedOperationId.New();
|
||||||
|
var terminalAt = FixedNowUtc.AddMinutes(5);
|
||||||
|
var audit = BuildAuditEvent(trackedId, AuditKind.CachedResolve, terminalStatus);
|
||||||
|
var operational = BuildOperational(
|
||||||
|
trackedId,
|
||||||
|
terminalStatus,
|
||||||
|
retryCount: 3,
|
||||||
|
terminalAtUtc: terminalAt);
|
||||||
|
|
||||||
|
var packet = new CachedCallTelemetry(audit, operational);
|
||||||
|
|
||||||
|
Assert.Equal(AuditKind.CachedResolve, packet.Audit.Kind);
|
||||||
|
Assert.Equal(terminalStatus, packet.Audit.Status);
|
||||||
|
Assert.Equal(terminalStatus.ToString(), packet.Operational.Status);
|
||||||
|
Assert.NotNull(packet.Operational.TerminalAtUtc);
|
||||||
|
Assert.Equal(terminalAt, packet.Operational.TerminalAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CachedCallTelemetry_RoundTripEquality()
|
||||||
|
{
|
||||||
|
var trackedId = TrackedOperationId.New();
|
||||||
|
var audit = BuildAuditEvent(trackedId, AuditKind.CachedSubmit, AuditStatus.Submitted);
|
||||||
|
var operational = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||||
|
|
||||||
|
var a = new CachedCallTelemetry(audit, operational);
|
||||||
|
var b = new CachedCallTelemetry(audit, operational);
|
||||||
|
|
||||||
|
Assert.Equal(a, b);
|
||||||
|
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||||
|
|
||||||
|
var differentOperational = operational with { RetryCount = 1 };
|
||||||
|
var c = a with { Operational = differentOperational };
|
||||||
|
|
||||||
|
Assert.NotEqual(a, c);
|
||||||
|
Assert.Equal(0, a.Operational.RetryCount);
|
||||||
|
Assert.Equal(1, c.Operational.RetryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteCallOperational_RoundTripEquality_AndWithExpression()
|
||||||
|
{
|
||||||
|
var trackedId = TrackedOperationId.New();
|
||||||
|
var a = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||||
|
var b = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||||
|
|
||||||
|
Assert.Equal(a, b);
|
||||||
|
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||||
|
|
||||||
|
var withDifferentRetry = a with { RetryCount = 5 };
|
||||||
|
Assert.NotEqual(a, withDifferentRetry);
|
||||||
|
Assert.Equal(0, a.RetryCount);
|
||||||
|
Assert.Equal(5, withDifferentRetry.RetryCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user