fix(communication): drop IsConfiguredPlaceholder rows in StreamRelayActor before gRPC pack

Placeholder AlarmStateChanged rows are a DebugView snapshot-only concept emitted
by InstanceActor.BuildAlarmStatesSnapshot; they are never a real alarm transition.
Their timestamp may be DateTimeOffset.MinValue (the Protobuf Timestamp lower boundary),
which can throw when packed via Timestamp.FromDateTimeOffset.

Added early-return guard at the top of HandleAlarmStateChanged before any timestamp
pack or channel write. Updated the existing NativeBindingLinkage round-trip test to
use a real (non-placeholder) native alarm; added DropsAlarmStateChanged_WhenIsConfiguredPlaceholder
to assert placeholders are silently dropped (15/15 pass).
This commit is contained in:
Joseph Doherty
2026-06-17 15:44:28 -04:00
parent 7f59ae12cb
commit e7660134f2
2 changed files with 48 additions and 6 deletions
@@ -56,6 +56,15 @@ public class StreamRelayActor : ReceiveActor
private void HandleAlarmStateChanged(AlarmStateChanged msg)
{
// Placeholder rows (IsConfiguredPlaceholder) are a Debug View snapshot-only
// concept emitted by InstanceActor.BuildAlarmStatesSnapshot — they are never a
// real alarm transition and must not be relayed to the live gRPC stream (their
// timestamp may be DateTimeOffset.MinValue, the Protobuf Timestamp boundary).
if (msg.IsConfiguredPlaceholder)
{
return;
}
var protoEvent = new SiteStreamEvent
{
CorrelationId = _correlationId,
@@ -116,9 +116,9 @@ public class StreamRelayActorTests : TestKit
[Fact]
public void RelaysAlarmStateChanged_NativeBindingLinkage_SurvivesFullRoundTrip()
{
// DV-1: the native source-binding canonical name and the configured-placeholder
// flag must pack into AlarmStateUpdate (StreamRelayActor) and unpack back out
// (SiteStreamGrpcClient.ConvertToDomainEvent) — the full cross-process round trip.
// DV-1: the native source-binding canonical name must pack into AlarmStateUpdate
// (StreamRelayActor) and unpack back out (SiteStreamGrpcClient.ConvertToDomainEvent)
// — the full cross-process round trip for a real (non-placeholder) native alarm.
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-native-binding", channel.Writer)));
@@ -130,7 +130,7 @@ public class StreamRelayActorTests : TestKit
Kind = AlarmKind.NativeOpcUa,
SourceReference = "Motor1.MotorAlarms.Hi",
NativeSourceCanonicalName = "Motor1.MotorAlarms",
IsConfiguredPlaceholder = true,
IsConfiguredPlaceholder = false,
Condition = new AlarmConditionState(
Active: true, Acknowledged: false, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 900)
@@ -150,13 +150,46 @@ public class StreamRelayActorTests : TestKit
// Packed onto the wire frame by StreamRelayActor.
Assert.Equal("Motor1.MotorAlarms", protoEvent.AlarmChanged.NativeSourceCanonicalName);
Assert.True(protoEvent.AlarmChanged.IsConfiguredPlaceholder);
Assert.False(protoEvent.AlarmChanged.IsConfiguredPlaceholder);
// Unpacked back into the domain record by SiteStreamGrpcClient.
var roundTripped = Assert.IsType<AlarmStateChanged>(
SiteStreamGrpcClient.ConvertToDomainEvent(protoEvent));
Assert.Equal("Motor1.MotorAlarms", roundTripped.NativeSourceCanonicalName);
Assert.True(roundTripped.IsConfiguredPlaceholder);
Assert.False(roundTripped.IsConfiguredPlaceholder);
}
[Fact]
public void DropsAlarmStateChanged_WhenIsConfiguredPlaceholder()
{
// Placeholder rows are a Debug View snapshot-only concept emitted by
// InstanceActor.BuildAlarmStatesSnapshot. They are never a real alarm
// transition; their timestamp may be DateTimeOffset.MinValue (the Protobuf
// Timestamp boundary). The relay must drop them without writing to the channel.
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-placeholder-drop", channel.Writer)));
var domainEvent = new AlarmStateChanged(
"Site1.Tank01", "Tank1.Levels.Hi", AlarmState.Normal, 500,
DateTimeOffset.MinValue)
{
Kind = AlarmKind.NativeOpcUa,
NativeSourceCanonicalName = "Tank1.Levels",
IsConfiguredPlaceholder = true,
Condition = new AlarmConditionState(
Active: false, Acknowledged: false, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 0)
};
actor.Tell(domainEvent);
// Allow time for the actor to process the message.
Thread.Sleep(500);
// Nothing must have been written — placeholder is silently dropped.
Assert.False(channel.Reader.TryRead(out _),
"Placeholder AlarmStateChanged must not be relayed to the gRPC stream");
}
[Fact]