feat(site-runtime): persist native-alarm display metadata so rehydrated conditions render fully (UA4)

This commit is contained in:
Joseph Doherty
2026-07-09 01:09:13 -04:00
parent 326d945d85
commit 2efe1a4202
3 changed files with 98 additions and 18 deletions
@@ -191,13 +191,26 @@ public class NativeAlarmActor : ReceiveActor
continue; continue;
} }
// Metadata (type/category/message) is not persisted — rehydrate the // Restore the persisted display metadata (UA4) so the rehydrated condition renders
// condition + key so the DebugView reflects last-known state until the // fully (type/category/message/values) until the first source snapshot replaces it.
// first source snapshot replaces it. // Null/malformed metadata falls back to empty strings (pre-UA4 rows behave as before).
NativeAlarmMetadata meta = EmptyMetadata;
if (!string.IsNullOrEmpty(row.MetadataJson))
{
try
{
meta = JsonSerializer.Deserialize<NativeAlarmMetadata>(row.MetadataJson) ?? EmptyMetadata;
}
catch (JsonException)
{
meta = EmptyMetadata;
}
}
var t = new NativeAlarmTransition( var t = new NativeAlarmTransition(
row.SourceReference, string.Empty, string.Empty, AlarmTransitionKind.Snapshot, row.SourceReference, string.Empty, meta.AlarmTypeName, AlarmTransitionKind.Snapshot,
condition, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, condition, meta.Category, string.Empty, meta.Message, string.Empty, string.Empty,
null, row.LastTransitionAt, string.Empty, string.Empty); null, row.LastTransitionAt, meta.CurrentValue, meta.LimitValue);
_alarms[row.SourceReference] = t; _alarms[row.SourceReference] = t;
// Rehydration replays last-known state on (re)start — surface it // Rehydration replays last-known state on (re)start — surface it
// upward for the DebugView but do NOT re-log it as a fresh operational // upward for the DebugView but do NOT re-log it as a fresh operational
@@ -479,7 +492,12 @@ public class NativeAlarmActor : ReceiveActor
return; return;
var batch = _dirtyUpserts.Values var batch = _dirtyUpserts.Values
.Select(t => (t.SourceReference, JsonSerializer.Serialize(t.Condition), t.TransitionTime)) .Select(t => (
t.SourceReference,
JsonSerializer.Serialize(t.Condition),
(string?)JsonSerializer.Serialize(new NativeAlarmMetadata(
t.AlarmTypeName, t.Category, t.Message, t.CurrentValue, t.LimitValue)),
t.TransitionTime))
.ToList(); .ToList();
_dirtyUpserts.Clear(); _dirtyUpserts.Clear();
@@ -519,4 +537,16 @@ public class NativeAlarmActor : ReceiveActor
public static readonly FlushPersistence Instance = new(); public static readonly FlushPersistence Instance = new();
private FlushPersistence() { } private FlushPersistence() { }
} }
/// <summary>Empty metadata fallback for pre-UA4 rows or malformed <c>metadata_json</c>.</summary>
private static readonly NativeAlarmMetadata EmptyMetadata =
new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
/// <summary>
/// Persisted display metadata for a native alarm condition (UA4). Serialized into the
/// <c>metadata_json</c> column so a rehydrated condition renders fully (type/category/message/
/// current+limit values) before the first source snapshot re-supplies it.
/// </summary>
private sealed record NativeAlarmMetadata(
string AlarmTypeName, string Category, string Message, string CurrentValue, string LimitValue);
} }
@@ -152,6 +152,7 @@ public class SiteStorageService
source_reference TEXT NOT NULL, source_reference TEXT NOT NULL,
condition_json TEXT NOT NULL, condition_json TEXT NOT NULL,
last_transition_at TEXT NOT NULL, last_transition_at TEXT NOT NULL,
metadata_json TEXT,
PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference) PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference)
); );
"; ";
@@ -169,6 +170,10 @@ public class SiteStorageService
// (added in primary/backup data connections feature) // (added in primary/backup data connections feature)
await TryAddColumnAsync(connection, "data_connection_definitions", "backup_configuration", "TEXT"); await TryAddColumnAsync(connection, "data_connection_definitions", "backup_configuration", "TEXT");
await TryAddColumnAsync(connection, "data_connection_definitions", "failover_retry_count", "INTEGER NOT NULL DEFAULT 3"); await TryAddColumnAsync(connection, "data_connection_definitions", "failover_retry_count", "INTEGER NOT NULL DEFAULT 3");
// Native-alarm display metadata (UA4) — restored on rehydration so a persisted condition
// renders fully (type/category/message/values) before the first source snapshot arrives.
await TryAddColumnAsync(connection, "native_alarm_state", "metadata_json", "TEXT");
} }
private async Task TryAddColumnAsync(SqliteConnection connection, string table, string column, string type) private async Task TryAddColumnAsync(SqliteConnection connection, string table, string column, string type)
@@ -483,7 +488,7 @@ public class SiteStorageService
/// <returns>A task that completes when the alarm condition has been inserted or updated.</returns> /// <returns>A task that completes when the alarm condition has been inserted or updated.</returns>
public async Task UpsertNativeAlarmAsync( public async Task UpsertNativeAlarmAsync(
string instanceName, string sourceCanonicalName, string sourceReference, string instanceName, string sourceCanonicalName, string sourceReference,
string conditionJson, DateTimeOffset lastTransitionAt) string conditionJson, DateTimeOffset lastTransitionAt, string? metadataJson = null)
{ {
await using var connection = new SqliteConnection(_connectionString); await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync(); await connection.OpenAsync();
@@ -491,17 +496,19 @@ public class SiteStorageService
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
command.CommandText = @" command.CommandText = @"
INSERT INTO native_alarm_state INSERT INTO native_alarm_state
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at) (instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
VALUES (@name, @source, @ref, @json, @at) VALUES (@name, @source, @ref, @json, @at, @meta)
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
condition_json = excluded.condition_json, condition_json = excluded.condition_json,
last_transition_at = excluded.last_transition_at"; last_transition_at = excluded.last_transition_at,
metadata_json = excluded.metadata_json";
command.Parameters.AddWithValue("@name", instanceName); command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@source", sourceCanonicalName); command.Parameters.AddWithValue("@source", sourceCanonicalName);
command.Parameters.AddWithValue("@ref", sourceReference); command.Parameters.AddWithValue("@ref", sourceReference);
command.Parameters.AddWithValue("@json", conditionJson); command.Parameters.AddWithValue("@json", conditionJson);
command.Parameters.AddWithValue("@at", lastTransitionAt.ToString("O")); command.Parameters.AddWithValue("@at", lastTransitionAt.ToString("O"));
command.Parameters.AddWithValue("@meta", (object?)metadataJson ?? DBNull.Value);
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
@@ -518,7 +525,7 @@ public class SiteStorageService
/// <returns>A task that completes when all rows have been committed.</returns> /// <returns>A task that completes when all rows have been committed.</returns>
public async Task UpsertNativeAlarmsAsync( public async Task UpsertNativeAlarmsAsync(
string instanceName, string sourceCanonicalName, string instanceName, string sourceCanonicalName,
IReadOnlyList<(string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt)> rows) IReadOnlyList<(string SourceReference, string ConditionJson, string? MetadataJson, DateTimeOffset LastTransitionAt)> rows)
{ {
if (rows.Count == 0) if (rows.Count == 0)
return; return;
@@ -531,17 +538,19 @@ public class SiteStorageService
command.Transaction = transaction; command.Transaction = transaction;
command.CommandText = @" command.CommandText = @"
INSERT INTO native_alarm_state INSERT INTO native_alarm_state
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at) (instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
VALUES (@name, @source, @ref, @json, @at) VALUES (@name, @source, @ref, @json, @at, @meta)
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
condition_json = excluded.condition_json, condition_json = excluded.condition_json,
last_transition_at = excluded.last_transition_at"; last_transition_at = excluded.last_transition_at,
metadata_json = excluded.metadata_json";
var pName = command.Parameters.Add("@name", SqliteType.Text); var pName = command.Parameters.Add("@name", SqliteType.Text);
var pSource = command.Parameters.Add("@source", SqliteType.Text); var pSource = command.Parameters.Add("@source", SqliteType.Text);
var pRef = command.Parameters.Add("@ref", SqliteType.Text); var pRef = command.Parameters.Add("@ref", SqliteType.Text);
var pJson = command.Parameters.Add("@json", SqliteType.Text); var pJson = command.Parameters.Add("@json", SqliteType.Text);
var pAt = command.Parameters.Add("@at", SqliteType.Text); var pAt = command.Parameters.Add("@at", SqliteType.Text);
var pMeta = command.Parameters.Add("@meta", SqliteType.Text);
pName.Value = instanceName; pName.Value = instanceName;
pSource.Value = sourceCanonicalName; pSource.Value = sourceCanonicalName;
@@ -550,6 +559,7 @@ public class SiteStorageService
pRef.Value = row.SourceReference; pRef.Value = row.SourceReference;
pJson.Value = row.ConditionJson; pJson.Value = row.ConditionJson;
pAt.Value = row.LastTransitionAt.ToString("O"); pAt.Value = row.LastTransitionAt.ToString("O");
pMeta.Value = (object?)row.MetadataJson ?? DBNull.Value;
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
@@ -595,7 +605,7 @@ public class SiteStorageService
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
command.CommandText = @" command.CommandText = @"
SELECT source_reference, condition_json, last_transition_at SELECT source_reference, condition_json, last_transition_at, metadata_json
FROM native_alarm_state FROM native_alarm_state
WHERE instance_unique_name = @name AND source_canonical_name = @source"; WHERE instance_unique_name = @name AND source_canonical_name = @source";
command.Parameters.AddWithValue("@name", instanceName); command.Parameters.AddWithValue("@name", instanceName);
@@ -608,7 +618,8 @@ public class SiteStorageService
results.Add(new NativeAlarmRow( results.Add(new NativeAlarmRow(
reader.GetString(0), reader.GetString(0),
reader.GetString(1), reader.GetString(1),
DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind))); DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind),
reader.IsDBNull(3) ? null : reader.GetString(3)));
} }
return results; return results;
@@ -1020,5 +1031,8 @@ public class StoredDataConnectionDefinition
/// <summary> /// <summary>
/// A single mirrored native alarm condition row from the site-local <c>native_alarm_state</c> table. /// A single mirrored native alarm condition row from the site-local <c>native_alarm_state</c> table.
/// <paramref name="MetadataJson"/> carries the serialized display metadata (type/category/message/
/// values) restored on rehydration (UA4); null on rows written before the column existed.
/// </summary> /// </summary>
public record NativeAlarmRow(string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt); public record NativeAlarmRow(
string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt, string? MetadataJson = null);
@@ -184,6 +184,42 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Contains(rows, r => r.ConditionJson.Contains("49")); Assert.Contains(rows, r => r.ConditionJson.Contains("49"));
} }
// ── PLAN-03 Task 21 (UA4): rehydration restores display metadata ────────
[Fact]
public async Task Rehydration_RestoresDisplayMetadata()
{
var instance1 = CreateTestProbe();
var dcl1 = CreateTestProbe();
var actor1 = SpawnWithFlush(instance1.Ref, dcl1.Ref, TimeSpan.FromMilliseconds(100));
dcl1.ExpectMsg<SubscribeAlarmsRequest>();
// A live raise carrying full display metadata.
actor1.Tell(new NativeAlarmTransitionUpdate("Opc", new NativeAlarmTransition(
"ref-1", "T01", "HighLevelAlarm", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800),
"Process", "desc", "Tank overflow", "", "", null, DateTimeOffset.UtcNow, "92", "90")));
instance1.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// Wait for the coalesced flush to land the metadata in SQLite.
await AwaitAssertAsync(async () =>
{
var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName);
Assert.Contains(rows, r => r.MetadataJson != null && r.MetadataJson.Contains("HighLevelAlarm"));
}, TimeSpan.FromSeconds(3));
// A fresh actor on the SAME storage rehydrates in PreStart and re-emits the condition
// with the restored metadata instead of empty strings.
var instance2 = CreateTestProbe();
var dcl2 = CreateTestProbe();
SpawnWithFlush(instance2.Ref, dcl2.Ref, TimeSpan.FromMilliseconds(100));
var emitted = instance2.FishForMessage<AlarmStateChanged>(m => m.SourceReference == "ref-1", TimeSpan.FromSeconds(5));
Assert.Equal("HighLevelAlarm", emitted.AlarmTypeName);
Assert.Equal("Process", emitted.Category);
Assert.Equal("Tank overflow", emitted.Message);
}
// ── M1.5: site event log `alarm` category ────────────────────────────── // ── M1.5: site event log `alarm` category ──────────────────────────────
[Fact] [Fact]