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;
|
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]
|
||||||
|
|||||||
Reference in New Issue
Block a user