feat(notif-outbox): add SourceNode to Notification entity + NotificationSubmit

This commit is contained in:
Joseph Doherty
2026-05-23 15:46:30 -04:00
parent ad625eb36d
commit 354f8792bf
4 changed files with 60 additions and 1 deletions

View File

@@ -25,6 +25,15 @@ public class Notification
/// <summary>Resolved delivery targets snapshotted at delivery time, for audit.</summary>
public string? ResolvedTargets { get; set; }
public string SourceSiteId { get; set; }
/// <summary>
/// The cluster node on which the notification was emitted — `node-a` / `node-b`
/// for site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
/// for central-originated rows. Carried from the site on the
/// <see cref="Commons.Messages.Notification.NotificationSubmit"/> and persisted at
/// central; nullable so rows submitted before the column existed don't block ingest.
/// </summary>
public string? SourceNode { get; set; }
public string? SourceInstanceId { get; set; }
public string? SourceScript { get; set; }

View File

@@ -18,6 +18,15 @@ namespace ScadaLink.Commons.Messages.Notification;
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
/// before the field existed, or for non-routed runs.
/// </param>
/// <param name="SourceNode">
/// 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 <c>INodeIdentityProvider</c> and carried, inside the serialized
/// payload, through the site store-and-forward buffer so the central dispatcher can
/// persist it on the <c>Notifications</c> row and echo it onto the <c>NotifyDeliver</c>
/// audit rows. Additive trailing member — null for messages built before the field
/// existed.
/// </param>
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);
/// <summary>
/// Central -> Site: ack sent after the notification row is persisted.

View File

@@ -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()
{

View File

@@ -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<NotificationSubmit>(json);
Assert.NotNull(roundTripped);
Assert.Equal("node-a", roundTripped!.SourceNode);
}
[Fact]
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
{