feat(site-runtime): persist native-alarm display metadata so rehydrated conditions render fully (UA4)
This commit is contained in:
@@ -191,13 +191,26 @@ public class NativeAlarmActor : ReceiveActor
|
||||
continue;
|
||||
}
|
||||
|
||||
// Metadata (type/category/message) is not persisted — rehydrate the
|
||||
// condition + key so the DebugView reflects last-known state until the
|
||||
// first source snapshot replaces it.
|
||||
// Restore the persisted display metadata (UA4) so the rehydrated condition renders
|
||||
// fully (type/category/message/values) until the 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(
|
||||
row.SourceReference, string.Empty, string.Empty, AlarmTransitionKind.Snapshot,
|
||||
condition, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty,
|
||||
null, row.LastTransitionAt, string.Empty, string.Empty);
|
||||
row.SourceReference, string.Empty, meta.AlarmTypeName, AlarmTransitionKind.Snapshot,
|
||||
condition, meta.Category, string.Empty, meta.Message, string.Empty, string.Empty,
|
||||
null, row.LastTransitionAt, meta.CurrentValue, meta.LimitValue);
|
||||
_alarms[row.SourceReference] = t;
|
||||
// Rehydration replays last-known state on (re)start — surface it
|
||||
// upward for the DebugView but do NOT re-log it as a fresh operational
|
||||
@@ -479,7 +492,12 @@ public class NativeAlarmActor : ReceiveActor
|
||||
return;
|
||||
|
||||
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();
|
||||
_dirtyUpserts.Clear();
|
||||
|
||||
@@ -519,4 +537,16 @@ public class NativeAlarmActor : ReceiveActor
|
||||
public static readonly FlushPersistence Instance = new();
|
||||
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,
|
||||
condition_json TEXT NOT NULL,
|
||||
last_transition_at TEXT NOT NULL,
|
||||
metadata_json TEXT,
|
||||
PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference)
|
||||
);
|
||||
";
|
||||
@@ -169,6 +170,10 @@ public class SiteStorageService
|
||||
// (added in primary/backup data connections feature)
|
||||
await TryAddColumnAsync(connection, "data_connection_definitions", "backup_configuration", "TEXT");
|
||||
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)
|
||||
@@ -483,7 +488,7 @@ public class SiteStorageService
|
||||
/// <returns>A task that completes when the alarm condition has been inserted or updated.</returns>
|
||||
public async Task UpsertNativeAlarmAsync(
|
||||
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 connection.OpenAsync();
|
||||
@@ -491,17 +496,19 @@ public class SiteStorageService
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO native_alarm_state
|
||||
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at)
|
||||
VALUES (@name, @source, @ref, @json, @at)
|
||||
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
|
||||
VALUES (@name, @source, @ref, @json, @at, @meta)
|
||||
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
|
||||
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("@source", sourceCanonicalName);
|
||||
command.Parameters.AddWithValue("@ref", sourceReference);
|
||||
command.Parameters.AddWithValue("@json", conditionJson);
|
||||
command.Parameters.AddWithValue("@at", lastTransitionAt.ToString("O"));
|
||||
command.Parameters.AddWithValue("@meta", (object?)metadataJson ?? DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
@@ -518,7 +525,7 @@ public class SiteStorageService
|
||||
/// <returns>A task that completes when all rows have been committed.</returns>
|
||||
public async Task UpsertNativeAlarmsAsync(
|
||||
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)
|
||||
return;
|
||||
@@ -531,17 +538,19 @@ public class SiteStorageService
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = @"
|
||||
INSERT INTO native_alarm_state
|
||||
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at)
|
||||
VALUES (@name, @source, @ref, @json, @at)
|
||||
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
|
||||
VALUES (@name, @source, @ref, @json, @at, @meta)
|
||||
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
|
||||
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 pSource = command.Parameters.Add("@source", SqliteType.Text);
|
||||
var pRef = command.Parameters.Add("@ref", SqliteType.Text);
|
||||
var pJson = command.Parameters.Add("@json", SqliteType.Text);
|
||||
var pAt = command.Parameters.Add("@at", SqliteType.Text);
|
||||
var pMeta = command.Parameters.Add("@meta", SqliteType.Text);
|
||||
pName.Value = instanceName;
|
||||
pSource.Value = sourceCanonicalName;
|
||||
|
||||
@@ -550,6 +559,7 @@ public class SiteStorageService
|
||||
pRef.Value = row.SourceReference;
|
||||
pJson.Value = row.ConditionJson;
|
||||
pAt.Value = row.LastTransitionAt.ToString("O");
|
||||
pMeta.Value = (object?)row.MetadataJson ?? DBNull.Value;
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
@@ -595,7 +605,7 @@ public class SiteStorageService
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT source_reference, condition_json, last_transition_at
|
||||
SELECT source_reference, condition_json, last_transition_at, metadata_json
|
||||
FROM native_alarm_state
|
||||
WHERE instance_unique_name = @name AND source_canonical_name = @source";
|
||||
command.Parameters.AddWithValue("@name", instanceName);
|
||||
@@ -608,7 +618,8 @@ public class SiteStorageService
|
||||
results.Add(new NativeAlarmRow(
|
||||
reader.GetString(0),
|
||||
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;
|
||||
@@ -1020,5 +1031,8 @@ public class StoredDataConnectionDefinition
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
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"));
|
||||
}
|
||||
|
||||
// ── 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 ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user