From 990eb02fe0b8b20179757e34536105b337117f71 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 15:53:44 -0400 Subject: [PATCH] feat(sitecall-audit): add SourceNode to SiteCallOperational + SiteCall entity --- .../Telemetry/CachedCallLifecycleBridge.cs | 3 ++ .../Entities/Audit/SiteCall.cs | 9 ++++ .../Types/SiteCallOperational.cs | 8 ++++ .../Scripts/ScriptRuntimeContext.cs | 12 ++++++ .../CachedCallCombinedTelemetryTests.cs | 1 + .../CachedWriteCombinedTelemetryTests.cs | 1 + .../CachedCallTelemetryForwarderTests.cs | 3 ++ .../Entities/Audit/SiteCallTests.cs | 40 ++++++++++++++++++ .../Integration/CachedCallTelemetryTests.cs | 4 ++ .../Types/SiteCallOperationalTests.cs | 42 +++++++++++++++++++ 10 files changed, 123 insertions(+) create mode 100644 tests/ScadaLink.Commons.Tests/Entities/Audit/SiteCallTests.cs create mode 100644 tests/ScadaLink.Commons.Tests/Types/SiteCallOperationalTests.cs diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs index 121370f..8ca8ba4 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs @@ -162,6 +162,9 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver Channel: context.Channel, Target: context.Target, SourceSite: context.SourceSite, + // SourceNode: stamped by Task 14 once the bridge gets an + // INodeIdentityProvider; null until then. + SourceNode: null, Status: operationalStatus, RetryCount: context.RetryCount, LastError: lastError, diff --git a/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs b/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs index f83f3ab..96c807e 100644 --- a/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs +++ b/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs @@ -30,6 +30,15 @@ public sealed record SiteCall /// Site id that submitted the cached call. public required string SourceSite { get; init; } + /// + /// The cluster node on which the cached call was emitted — node-a / + /// node-b for site rows (qualified by ), + /// central-a / central-b for central-originated rows. Stamped + /// by the emitting node from INodeIdentityProvider; nullable so + /// reconciled rows from a node that has since been retired don't block ingest. + /// + public string? SourceNode { get; init; } + /// /// Lifecycle status — string form of /// . Monotonic: later rank diff --git a/src/ScadaLink.Commons/Types/SiteCallOperational.cs b/src/ScadaLink.Commons/Types/SiteCallOperational.cs index 25d9414..40acf6a 100644 --- a/src/ScadaLink.Commons/Types/SiteCallOperational.cs +++ b/src/ScadaLink.Commons/Types/SiteCallOperational.cs @@ -21,6 +21,13 @@ namespace ScadaLink.Commons.Types; /// /// Human-readable target (e.g. "ERP.GetOrder"). /// Site id that submitted the cached call. +/// +/// The cluster node on which the cached call was emitted — node-a / node-b +/// for site rows (qualified by ), central-a / +/// central-b for central-originated rows. Stamped by the emitting node from +/// INodeIdentityProvider; nullable so reconciled rows from a node that has since +/// been retired don't block ingest. +/// /// /// Lifecycle status — string form of : /// Submitted, Retrying, Attempted, Delivered, @@ -37,6 +44,7 @@ public sealed record SiteCallOperational( string Channel, string Target, string SourceSite, + string? SourceNode, string Status, int RetryCount, string? LastError, diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index a8c68fb..8f8314a 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -648,6 +648,9 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, + // SourceNode: stamped by Task 14 once the script context + // gets an INodeIdentityProvider; null until then. + SourceNode: null, Status: "Submitted", RetryCount: 0, LastError: null, @@ -766,6 +769,9 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, + // SourceNode: stamped by Task 14 once the script context + // gets an INodeIdentityProvider; null until then. + SourceNode: null, Status: "Attempted", // RetryCount stays 0 — the operation never reached the // S&F retry sweep, so no retries were performed. @@ -833,6 +839,9 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, + // SourceNode: stamped by Task 14 once the script context + // gets an INodeIdentityProvider; null until then. + SourceNode: null, Status: operationalTerminalStatus, RetryCount: 0, LastError: result.Success ? null : result.ErrorMessage, @@ -1243,6 +1252,9 @@ public class ScriptRuntimeContext Channel: "DbOutbound", Target: target, SourceSite: _siteId, + // SourceNode: stamped by Task 14 once the script context + // gets an INodeIdentityProvider; null until then. + SourceNode: null, Status: "Submitted", RetryCount: 0, LastError: null, diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs index 342b028..58e1ce0 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs @@ -72,6 +72,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture +/// Verifies the central operational entity carries the +/// SourceNode column (additive, nullable) through init-only construction and +/// with expressions. Sibling to . +/// +public class SiteCallTests +{ + private static SiteCall MinimalRow() => new() + { + TrackedOperationId = TrackedOperationId.New(), + Channel = "ApiOutbound", + Target = "ERP.GetOrder", + SourceSite = "site-01", + Status = "Submitted", + RetryCount = 0, + CreatedAtUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc), + UpdatedAtUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc), + IngestedAtUtc = new DateTime(2026, 5, 23, 12, 0, 1, DateTimeKind.Utc), + }; + + [Fact] + public void SiteCall_carries_SourceNode() + { + // SourceNode identifies the cluster node that emitted the cached call + // (site node-a/node-b or central-a/central-b). Additive nullable init + // property — defaults to null on rows ingested before the column + // existed, and round-trips its value via `with` expressions. + var row = MinimalRow(); + Assert.Null(row.SourceNode); + + var stamped = row with { SourceNode = "node-b" }; + Assert.Equal("node-b", stamped.SourceNode); + Assert.Null(row.SourceNode); + } +} diff --git a/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs b/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs index 03980d8..5c1bca7 100644 --- a/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs +++ b/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs @@ -60,6 +60,10 @@ public class CachedCallTelemetryTests Channel: nameof(AuditChannel.ApiOutbound), Target: "ERP.GetOrder", SourceSite: SiteId, + // SourceNode: actual stamping arrives with Task 14; for now the + // packet builder leaves the column null so existing assertions on + // the packet's other fields stay intact. + SourceNode: null, Status: status.ToString(), RetryCount: retryCount, LastError: lastError, diff --git a/tests/ScadaLink.Commons.Tests/Types/SiteCallOperationalTests.cs b/tests/ScadaLink.Commons.Tests/Types/SiteCallOperationalTests.cs new file mode 100644 index 0000000..29d9eb4 --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Types/SiteCallOperationalTests.cs @@ -0,0 +1,42 @@ +using ScadaLink.Commons.Types; + +namespace ScadaLink.Commons.Tests.Types; + +/// +/// Verifies — the positional record carried on +/// the combined CachedCallTelemetry packet — round-trips the SourceNode +/// field through positional construction (where the parameter sits between +/// SourceSite and Status, mirroring the central SiteCalls +/// table column order). +/// +public class SiteCallOperationalTests +{ + [Fact] + public void SiteCallOperational_carries_SourceNode() + { + // SourceNode identifies the cluster node that emitted the cached call + // (site node-a/node-b or central-a/central-b). Nullable — callsites + // pass null until INodeIdentityProvider stamping arrives in Task 14. + var trackedId = TrackedOperationId.New(); + var nowUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc); + + var defaulted = new SiteCallOperational( + TrackedOperationId: trackedId, + Channel: "ApiOutbound", + Target: "ERP.GetOrder", + SourceSite: "site-01", + SourceNode: null, + Status: "Submitted", + RetryCount: 0, + LastError: null, + HttpStatus: null, + CreatedAtUtc: nowUtc, + UpdatedAtUtc: nowUtc, + TerminalAtUtc: null); + Assert.Null(defaulted.SourceNode); + + var stamped = defaulted with { SourceNode = "node-a" }; + Assert.Equal("node-a", stamped.SourceNode); + Assert.Null(defaulted.SourceNode); + } +}