feat(sitecall-audit): add SourceNode to SiteCallOperational + SiteCall entity

This commit is contained in:
Joseph Doherty
2026-05-23 15:53:44 -04:00
parent 354f8792bf
commit 990eb02fe0
10 changed files with 123 additions and 0 deletions

View File

@@ -162,6 +162,9 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
Channel: context.Channel, Channel: context.Channel,
Target: context.Target, Target: context.Target,
SourceSite: context.SourceSite, SourceSite: context.SourceSite,
// SourceNode: stamped by Task 14 once the bridge gets an
// INodeIdentityProvider; null until then.
SourceNode: null,
Status: operationalStatus, Status: operationalStatus,
RetryCount: context.RetryCount, RetryCount: context.RetryCount,
LastError: lastError, LastError: lastError,

View File

@@ -30,6 +30,15 @@ public sealed record SiteCall
/// <summary>Site id that submitted the cached call.</summary> /// <summary>Site id that submitted the cached call.</summary>
public required string SourceSite { get; init; } public required string SourceSite { get; init; }
/// <summary>
/// The cluster node on which the cached call was emitted — <c>node-a</c> /
/// <c>node-b</c> for site rows (qualified by <see cref="SourceSite"/>),
/// <c>central-a</c> / <c>central-b</c> for central-originated rows. Stamped
/// by the emitting node from <c>INodeIdentityProvider</c>; nullable so
/// reconciled rows from a node that has since been retired don't block ingest.
/// </summary>
public string? SourceNode { get; init; }
/// <summary> /// <summary>
/// Lifecycle status — string form of /// Lifecycle status — string form of
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>. Monotonic: later rank /// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>. Monotonic: later rank

View File

@@ -21,6 +21,13 @@ namespace ScadaLink.Commons.Types;
/// </param> /// </param>
/// <param name="Target">Human-readable target (e.g. <c>"ERP.GetOrder"</c>).</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="SourceSite">Site id that submitted the cached call.</param>
/// <param name="SourceNode">
/// The cluster node on which the cached call was emitted — <c>node-a</c> / <c>node-b</c>
/// for site rows (qualified by <paramref name="SourceSite"/>), <c>central-a</c> /
/// <c>central-b</c> for central-originated rows. Stamped by the emitting node from
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that has since
/// been retired don't block ingest.
/// </param>
/// <param name="Status"> /// <param name="Status">
/// Lifecycle status — string form of <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>: /// 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>Submitted</c>, <c>Retrying</c>, <c>Attempted</c>, <c>Delivered</c>,
@@ -37,6 +44,7 @@ public sealed record SiteCallOperational(
string Channel, string Channel,
string Target, string Target,
string SourceSite, string SourceSite,
string? SourceNode,
string Status, string Status,
int RetryCount, int RetryCount,
string? LastError, string? LastError,

View File

@@ -648,6 +648,9 @@ public class ScriptRuntimeContext
Channel: "ApiOutbound", Channel: "ApiOutbound",
Target: target, Target: target,
SourceSite: _siteId, SourceSite: _siteId,
// SourceNode: stamped by Task 14 once the script context
// gets an INodeIdentityProvider; null until then.
SourceNode: null,
Status: "Submitted", Status: "Submitted",
RetryCount: 0, RetryCount: 0,
LastError: null, LastError: null,
@@ -766,6 +769,9 @@ public class ScriptRuntimeContext
Channel: "ApiOutbound", Channel: "ApiOutbound",
Target: target, Target: target,
SourceSite: _siteId, SourceSite: _siteId,
// SourceNode: stamped by Task 14 once the script context
// gets an INodeIdentityProvider; null until then.
SourceNode: null,
Status: "Attempted", Status: "Attempted",
// RetryCount stays 0 — the operation never reached the // RetryCount stays 0 — the operation never reached the
// S&F retry sweep, so no retries were performed. // S&F retry sweep, so no retries were performed.
@@ -833,6 +839,9 @@ public class ScriptRuntimeContext
Channel: "ApiOutbound", Channel: "ApiOutbound",
Target: target, Target: target,
SourceSite: _siteId, SourceSite: _siteId,
// SourceNode: stamped by Task 14 once the script context
// gets an INodeIdentityProvider; null until then.
SourceNode: null,
Status: operationalTerminalStatus, Status: operationalTerminalStatus,
RetryCount: 0, RetryCount: 0,
LastError: result.Success ? null : result.ErrorMessage, LastError: result.Success ? null : result.ErrorMessage,
@@ -1243,6 +1252,9 @@ public class ScriptRuntimeContext
Channel: "DbOutbound", Channel: "DbOutbound",
Target: target, Target: target,
SourceSite: _siteId, SourceSite: _siteId,
// SourceNode: stamped by Task 14 once the script context
// gets an INodeIdentityProvider; null until then.
SourceNode: null,
Status: "Submitted", Status: "Submitted",
RetryCount: 0, RetryCount: 0,
LastError: null, LastError: null,

View File

@@ -72,6 +72,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
Channel: "ApiOutbound", Channel: "ApiOutbound",
Target: target, Target: target,
SourceSite: siteId, SourceSite: siteId,
SourceNode: null,
Status: "Submitted", Status: "Submitted",
RetryCount: 0, RetryCount: 0,
LastError: null, LastError: null,

View File

@@ -61,6 +61,7 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
Channel: "DbOutbound", Channel: "DbOutbound",
Target: target, Target: target,
SourceSite: siteId, SourceSite: siteId,
SourceNode: null,
Status: "Submitted", Status: "Submitted",
RetryCount: 0, RetryCount: 0,
LastError: null, LastError: null,

View File

@@ -51,6 +51,7 @@ public class CachedCallTelemetryForwarderTests
Channel: "ApiOutbound", Channel: "ApiOutbound",
Target: "ERP.GetOrder", Target: "ERP.GetOrder",
SourceSite: "site-1", SourceSite: "site-1",
SourceNode: null,
Status: "Submitted", Status: "Submitted",
RetryCount: 0, RetryCount: 0,
LastError: null, LastError: null,
@@ -80,6 +81,7 @@ public class CachedCallTelemetryForwarderTests
Channel: "ApiOutbound", Channel: "ApiOutbound",
Target: "ERP.GetOrder", Target: "ERP.GetOrder",
SourceSite: "site-1", SourceSite: "site-1",
SourceNode: null,
Status: "Attempted", Status: "Attempted",
RetryCount: retryCount, RetryCount: retryCount,
LastError: lastError, LastError: lastError,
@@ -107,6 +109,7 @@ public class CachedCallTelemetryForwarderTests
Channel: "ApiOutbound", Channel: "ApiOutbound",
Target: "ERP.GetOrder", Target: "ERP.GetOrder",
SourceSite: "site-1", SourceSite: "site-1",
SourceNode: null,
Status: status, Status: status,
RetryCount: 2, RetryCount: 2,
LastError: null, LastError: null,

View File

@@ -0,0 +1,40 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Tests.Entities.Audit;
/// <summary>
/// Verifies the <see cref="SiteCall"/> central operational entity carries the
/// SourceNode column (additive, nullable) through init-only construction and
/// <c>with</c> expressions. Sibling to <see cref="AuditEventTests"/>.
/// </summary>
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);
}
}

View File

@@ -60,6 +60,10 @@ public class CachedCallTelemetryTests
Channel: nameof(AuditChannel.ApiOutbound), Channel: nameof(AuditChannel.ApiOutbound),
Target: "ERP.GetOrder", Target: "ERP.GetOrder",
SourceSite: SiteId, 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(), Status: status.ToString(),
RetryCount: retryCount, RetryCount: retryCount,
LastError: lastError, LastError: lastError,

View File

@@ -0,0 +1,42 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Tests.Types;
/// <summary>
/// Verifies <see cref="SiteCallOperational"/> — the positional record carried on
/// the combined <c>CachedCallTelemetry</c> packet — round-trips the SourceNode
/// field through positional construction (where the parameter sits between
/// <c>SourceSite</c> and <c>Status</c>, mirroring the central <c>SiteCalls</c>
/// table column order).
/// </summary>
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);
}
}