From e416b21dade4503ee0a73e8c2a48b6ffd2c95b84 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 13:58:57 -0400 Subject: [PATCH] feat(commons): CachedCallTelemetry combined operational+audit packet (#23 M3) --- .../Integration/CachedCallTelemetry.cs | 34 +++ .../Types/SiteCallOperational.cs | 46 ++++ .../Integration/CachedCallTelemetryTests.cs | 203 ++++++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs create mode 100644 src/ScadaLink.Commons/Types/SiteCallOperational.cs create mode 100644 tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs diff --git a/src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs b/src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs new file mode 100644 index 0000000..37532aa --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs @@ -0,0 +1,34 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types; + +namespace ScadaLink.Commons.Messages.Integration; + +/// +/// Combined audit + operational telemetry packet for cached outbound calls +/// (Audit Log #23 / M3). The site emits one packet per lifecycle event +/// — Submit (Audit kind CachedSubmit), per-attempt +/// ApiCallCached/DbWriteCached, terminal CachedResolve — +/// and central writes the row plus the +/// 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. +/// +/// +/// +/// Both inner records carry the same TrackedOperationId — the +/// idempotency key end-to-end. The +/// 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. +/// +/// +/// Additive-only per Commons REQ-COM-5a (M2 reviewer note) — this is a new +/// message, not a rename of any existing M2 envelope. +/// +/// +/// The Audit Log #23 row to insert at central. +/// The SiteCalls upsert mirroring this lifecycle event. +public sealed record CachedCallTelemetry( + AuditEvent Audit, + SiteCallOperational Operational); diff --git a/src/ScadaLink.Commons/Types/SiteCallOperational.cs b/src/ScadaLink.Commons/Types/SiteCallOperational.cs new file mode 100644 index 0000000..25d9414 --- /dev/null +++ b/src/ScadaLink.Commons/Types/SiteCallOperational.cs @@ -0,0 +1,46 @@ +namespace ScadaLink.Commons.Types; + +/// +/// Operational state of one cached call as seen by the site, carried on the +/// combined CachedCallTelemetry packet (Audit Log #23 / M3) and persisted +/// at central as the SiteCalls row mirroring the call's status. +/// +/// +/// +/// One row per 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. +/// +/// +/// Idempotency key shared with the audit row. +/// +/// Trust-boundary channel — "ApiOutbound" or "DbOutbound". String form +/// (not the enum) so the +/// record serialises identically across SQL / gRPC / JSON boundaries. +/// +/// Human-readable target (e.g. "ERP.GetOrder"). +/// Site id that submitted the cached call. +/// +/// Lifecycle status — string form of : +/// Submitted, Retrying, Attempted, Delivered, +/// Failed, Parked, Discarded. +/// +/// Number of dispatch attempts so far; 0 prior to first attempt. +/// Most recent error message; null when no failures have occurred. +/// Most recent HTTP status code (API calls only); null otherwise. +/// UTC timestamp the cached call was first submitted. +/// UTC timestamp of the latest status mutation. +/// UTC timestamp the row reached a terminal status; null while still active. +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); diff --git a/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs b/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs new file mode 100644 index 0000000..03980d8 --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs @@ -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; + +/// +/// Audit Log #23 (M3 Bundle A — Task A4) — tests for the combined +/// audit + operational telemetry packet emitted per cached-call lifecycle event +/// (Submit → per-attempt ApiCallCached / DbWriteCached → +/// terminal Resolve). The site emits one packet per event; central writes +/// AuditLog + SiteCalls in one MS SQL transaction. +/// +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); + } +}