From 354f8792bf4ba68702062a7e7c2f30e362389e7d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 15:46:30 -0400 Subject: [PATCH] feat(notif-outbox): add SourceNode to Notification entity + NotificationSubmit --- .../Entities/Notifications/Notification.cs | 9 +++++++ .../Notification/NotificationMessages.cs | 12 ++++++++- .../Entities/NotificationEntityTests.cs | 14 ++++++++++ .../Messages/NotificationMessagesTests.cs | 26 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs index f9305d0..7113008 100644 --- a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs +++ b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs @@ -25,6 +25,15 @@ public class Notification /// Resolved delivery targets snapshotted at delivery time, for audit. public string? ResolvedTargets { get; set; } public string SourceSiteId { get; set; } + + /// + /// The cluster node on which the notification was emitted — `node-a` / `node-b` + /// for site rows (qualified by ), `central-a` / `central-b` + /// for central-originated rows. Carried from the site on the + /// and persisted at + /// central; nullable so rows submitted before the column existed don't block ingest. + /// + public string? SourceNode { get; set; } public string? SourceInstanceId { get; set; } public string? SourceScript { get; set; } diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs index 1c5a868..2b2486c 100644 --- a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs @@ -18,6 +18,15 @@ namespace ScadaLink.Commons.Messages.Notification; /// NotifyDeliver audit rows. Additive trailing member — null for messages built /// before the field existed, or for non-routed runs. /// +/// +/// The cluster node on which the notification was emitted — `node-a` / `node-b` for site +/// submissions, `central-a` / `central-b` for central-originated rows. Stamped by the +/// emitting node from INodeIdentityProvider and carried, inside the serialized +/// payload, through the site store-and-forward buffer so the central dispatcher can +/// persist it on the Notifications row and echo it onto the NotifyDeliver +/// audit rows. Additive trailing member — null for messages built before the field +/// existed. +/// public record NotificationSubmit( string NotificationId, string ListName, @@ -28,7 +37,8 @@ public record NotificationSubmit( string? SourceScript, DateTimeOffset SiteEnqueuedAt, Guid? OriginExecutionId = null, - Guid? OriginParentExecutionId = null); + Guid? OriginParentExecutionId = null, + string? SourceNode = null); /// /// Central -> Site: ack sent after the notification row is persisted. diff --git a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs index bb872cd..518958f 100644 --- a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs +++ b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs @@ -51,6 +51,20 @@ public class NotificationEntityTests Assert.Equal(parentExecutionId, n.OriginParentExecutionId); } + [Fact] + public void SourceNode_DefaultsToNull_AndIsSettable() + { + // SourceNode identifies the cluster node that emitted the notification + // (site node-a/node-b or central-a/central-b). Additive nullable + // property — defaults to null on rows submitted before the column + // existed, and round-trips its value when set. + var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA"); + Assert.Null(n.SourceNode); + + n.SourceNode = "node-a"; + Assert.Equal("node-a", n.SourceNode); + } + [Fact] public void Constructor_NullArguments_Throw() { diff --git a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs index 20c090a..2b6569c 100644 --- a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs +++ b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs @@ -126,6 +126,32 @@ public class NotificationMessagesTests Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId); } + [Fact] + public void NotificationSubmit_carries_SourceNode() + { + // SourceNode is an additive trailing member — old call sites and old + // serialized payloads leave it null. When supplied it round-trips + // through both construction and JSON (the buffered S&F payload IS a + // serialized NotificationSubmit). + var defaulted = new NotificationSubmit( + "notif-9", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow); + Assert.Null(defaulted.SourceNode); + + var stamped = new NotificationSubmit( + "notif-10", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, + OriginExecutionId: null, + OriginParentExecutionId: null, + SourceNode: "node-a"); + Assert.Equal("node-a", stamped.SourceNode); + + var json = System.Text.Json.JsonSerializer.Serialize(stamped); + var roundTripped = System.Text.Json.JsonSerializer.Deserialize(json); + Assert.NotNull(roundTripped); + Assert.Equal("node-a", roundTripped!.SourceNode); + } + [Fact] public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch() {