fix(historian): preserve stable alarm delivery identities
v2-ci / build (pull_request) Successful in 5m52s
v2-ci / unit-tests (pull_request) Failing after 1h23m55s

This commit is contained in:
Joseph Doherty
2026-10-13 07:10:58 -04:00
parent 0ccfbbee5c
commit 109163621c
13 changed files with 341 additions and 27 deletions
@@ -0,0 +1,25 @@
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>Stable delivery GUIDs for persisted alarm queue rows.</summary>
public static class AlarmDeliveryIds
{
/// <summary>
/// Converts an existing durable row key into a nonzero version-8 GUID. Version 1 of this encoding hashes
/// UTF-8 "OtOpcUa.AlarmDelivery/v1\0" followed by the exact persisted key with SHA-256, takes the first
/// 16 bytes, sets the UUID version/variant bits, and interprets the bytes in network order.
/// This encoding must remain stable across retries, upgrades and replicated failover.
/// </summary>
/// <param name="queueId">The persisted row key, never a hash recomputed from the current payload.</param>
/// <returns>The row's stable delivery identity.</returns>
public static Guid FromQueueId(string queueId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(queueId);
var digest = SHA256.HashData(Encoding.UTF8.GetBytes("OtOpcUa.AlarmDelivery/v1\0" + queueId));
digest[6] = (byte)((digest[6] & 0x0F) | 0x80);
digest[8] = (byte)((digest[8] & 0x3F) | 0x80);
return new Guid(digest.AsSpan(0, 16), bigEndian: true);
}
}
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
@@ -34,4 +35,31 @@ public sealed record AlarmHistorianEvent(
string Message,
string User,
string? Comment,
DateTime TimestampUtc);
DateTime TimestampUtc)
{
/// <summary>
/// Delivery identity, minted once for direct sends and replaced by the durable queue's identity when drained.
/// Excluded from payload serialization and business equality so redundant enqueues still converge.
/// A record copy preserves this identity; an explicitly empty identity is invalid for delivery.
/// </summary>
[JsonIgnore]
public Guid DeliveryId { get; init; } = Guid.NewGuid();
/// <summary>Compares the ten business fields, excluding transport delivery identity.</summary>
/// <param name="other">The event to compare.</param>
/// <returns>Whether the business fields are equal.</returns>
public bool Equals(AlarmHistorianEvent? other) => ReferenceEquals(this, other) || other is not null
&& AlarmId == other.AlarmId && EquipmentPath == other.EquipmentPath && AlarmName == other.AlarmName
&& AlarmTypeName == other.AlarmTypeName && Severity == other.Severity && EventKind == other.EventKind
&& Message == other.Message && User == other.User && Comment == other.Comment && TimestampUtc == other.TimestampUtc;
/// <summary>Hashes the same business fields used by equality, excluding delivery identity.</summary>
/// <returns>The business-field hash code.</returns>
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(AlarmId); hash.Add(EquipmentPath); hash.Add(AlarmName); hash.Add(AlarmTypeName); hash.Add(Severity);
hash.Add(EventKind); hash.Add(Message); hash.Add(User); hash.Add(Comment); hash.Add(TimestampUtc);
return hash.ToHashCode();
}
}
@@ -603,6 +603,9 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
try
{
evt = JsonSerializer.Deserialize<AlarmHistorianEvent>(payload);
// The durable key survives retry, payload edits, legacy migration and replicated failover.
// Never derive delivery identity from a reserialized payload or an individual RPC attempt.
if (evt is not null) evt = evt with { DeliveryId = AlarmDeliveryIds.FromQueueId(id) };
}
catch (JsonException)
{
@@ -35,16 +35,16 @@ internal static class AlarmEventMapper
/// <returns>The gateway wire event ready for <c>SendEvent</c>.</returns>
public static HistorianEvent ToHistorianEvent(AlarmHistorianEvent alarm)
{
if (alarm.DeliveryId == Guid.Empty)
throw new ArgumentException("Alarm delivery identity must be nonzero.", nameof(alarm));
// Timestamp.FromDateTime requires a Utc-kind DateTime; coerce defensively (TimestampUtc is
// already Utc by contract, but a caller could pass Unspecified).
var eventTime = Timestamp.FromDateTime(DateTime.SpecifyKind(alarm.TimestampUtc, DateTimeKind.Utc));
var historianEvent = new HistorianEvent
{
// Deliberately DO NOT set HistorianEvent.Id: the gateway's SendEvent path rejects a
// client-supplied event id (the server handler throws and the call fails permanently —
// confirmed live). The historian assigns event identity server-side; the alarm's own id
// is preserved below as a property for read-back correlation/traceability.
// Identity belongs to the event/durable queue, never to an individual RPC attempt.
Id = alarm.DeliveryId.ToString("D"),
SourceName = alarm.EquipmentPath,
Type = alarm.AlarmTypeName,
EventTime = eventTime,