feat(siteruntime): site SQLite native_alarm_state store

This commit is contained in:
Joseph Doherty
2026-05-31 01:44:40 -04:00
parent b44a844152
commit 24fd7bee53
2 changed files with 213 additions and 0 deletions
@@ -111,6 +111,15 @@ public class SiteStorageService
oauth_config TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS native_alarm_state (
instance_unique_name TEXT NOT NULL,
source_canonical_name TEXT NOT NULL,
source_reference TEXT NOT NULL,
condition_json TEXT NOT NULL,
last_transition_at TEXT NOT NULL,
PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference)
);
";
await command.ExecuteNonQueryAsync();
@@ -346,6 +355,104 @@ public class SiteStorageService
_logger.LogDebug("Cleared static overrides for {Instance}", instanceName);
}
// ── Task 14: Native Alarm State store (read-only mirror of source A&C conditions) ──
/// <summary>
/// Inserts or updates a single mirrored native alarm condition, keyed by
/// (instance, source canonical name, source reference). Newer transitions overwrite older ones.
/// </summary>
public async Task UpsertNativeAlarmAsync(
string instanceName, string sourceCanonicalName, string sourceReference,
string conditionJson, DateTimeOffset lastTransitionAt)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
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)
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";
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"));
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Removes a single mirrored native alarm condition (e.g. a return-to-normal that drops out of retention).
/// </summary>
public async Task DeleteNativeAlarmAsync(string instanceName, string sourceCanonicalName, string sourceReference)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
DELETE FROM native_alarm_state
WHERE instance_unique_name = @name
AND source_canonical_name = @source
AND source_reference = @ref";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@source", sourceCanonicalName);
command.Parameters.AddWithValue("@ref", sourceReference);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Returns all mirrored native alarm conditions for an instance's source binding,
/// used to rehydrate a NativeAlarmActor on (re)start.
/// </summary>
public async Task<IReadOnlyList<NativeAlarmRow>> GetNativeAlarmsAsync(string instanceName, string sourceCanonicalName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT source_reference, condition_json, last_transition_at
FROM native_alarm_state
WHERE instance_unique_name = @name AND source_canonical_name = @source";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@source", sourceCanonicalName);
var results = new List<NativeAlarmRow>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(new NativeAlarmRow(
reader.GetString(0),
reader.GetString(1),
DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind)));
}
return results;
}
/// <summary>
/// Clears all mirrored native alarm conditions for an instance. Called on redeployment / stop.
/// </summary>
public async Task ClearNativeAlarmsForInstanceAsync(string instanceName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name";
command.Parameters.AddWithValue("@name", instanceName);
await command.ExecuteNonQueryAsync();
_logger.LogDebug("Cleared native alarm state for {Instance}", instanceName);
}
// ── WP-33: Shared Script CRUD ──
/// <summary>
@@ -699,3 +806,8 @@ public class StoredDataConnectionDefinition
/// </summary>
public int FailoverRetryCount { get; init; } = 3;
}
/// <summary>
/// A single mirrored native alarm condition row from the site-local <c>native_alarm_state</c> table (Task 14).
/// </summary>
public record NativeAlarmRow(string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt);
@@ -0,0 +1,101 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
/// <summary>
/// Task 14: site-local SQLite <c>native_alarm_state</c> store — mirrored native alarm
/// condition snapshots keyed by (instance, source canonical name, source reference).
/// </summary>
public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable
{
private readonly string _dbFile;
private SiteStorageService _storage = null!;
public NativeAlarmStateStoreTests()
{
_dbFile = Path.Combine(Path.GetTempPath(), $"nas-{Guid.NewGuid():N}.db");
}
public async Task InitializeAsync()
{
_storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger<SiteStorageService>.Instance);
await _storage.InitializeAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Upsert_Then_Get_RoundTrips()
{
await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{\"Active\":true}", DateTimeOffset.UnixEpoch);
var rows = await _storage.GetNativeAlarmsAsync("inst", "Src");
Assert.Single(rows);
Assert.Equal("Tank01.Hi", rows[0].SourceReference);
Assert.Equal("{\"Active\":true}", rows[0].ConditionJson);
Assert.Equal(DateTimeOffset.UnixEpoch, rows[0].LastTransitionAt);
}
[Fact]
public async Task Upsert_SameKey_ReplacesConditionAndTimestamp()
{
await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{\"Active\":true}", DateTimeOffset.UnixEpoch);
await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{\"Active\":false}", DateTimeOffset.UnixEpoch.AddMinutes(5));
var rows = await _storage.GetNativeAlarmsAsync("inst", "Src");
Assert.Single(rows);
Assert.Equal("{\"Active\":false}", rows[0].ConditionJson);
Assert.Equal(DateTimeOffset.UnixEpoch.AddMinutes(5), rows[0].LastTransitionAt);
}
[Fact]
public async Task Get_ScopesToInstanceAndSourceCanonicalName()
{
await _storage.UpsertNativeAlarmAsync("inst", "SrcA", "Tank01.Hi", "{}", DateTimeOffset.UnixEpoch);
await _storage.UpsertNativeAlarmAsync("inst", "SrcB", "Tank02.Hi", "{}", DateTimeOffset.UnixEpoch);
await _storage.UpsertNativeAlarmAsync("other", "SrcA", "Tank09.Hi", "{}", DateTimeOffset.UnixEpoch);
var rows = await _storage.GetNativeAlarmsAsync("inst", "SrcA");
Assert.Single(rows);
Assert.Equal("Tank01.Hi", rows[0].SourceReference);
}
[Fact]
public async Task Delete_RemovesSingleRow()
{
await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{}", DateTimeOffset.UnixEpoch);
await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Lo", "{}", DateTimeOffset.UnixEpoch);
await _storage.DeleteNativeAlarmAsync("inst", "Src", "Tank01.Hi");
var rows = await _storage.GetNativeAlarmsAsync("inst", "Src");
Assert.Single(rows);
Assert.Equal("Tank01.Lo", rows[0].SourceReference);
}
[Fact]
public async Task ClearForInstance_RemovesAllSourcesForInstanceOnly()
{
await _storage.UpsertNativeAlarmAsync("inst", "SrcA", "Tank01.Hi", "{}", DateTimeOffset.UnixEpoch);
await _storage.UpsertNativeAlarmAsync("inst", "SrcB", "Tank02.Hi", "{}", DateTimeOffset.UnixEpoch);
await _storage.UpsertNativeAlarmAsync("other", "SrcA", "Tank09.Hi", "{}", DateTimeOffset.UnixEpoch);
await _storage.ClearNativeAlarmsForInstanceAsync("inst");
Assert.Empty(await _storage.GetNativeAlarmsAsync("inst", "SrcA"));
Assert.Empty(await _storage.GetNativeAlarmsAsync("inst", "SrcB"));
Assert.Single(await _storage.GetNativeAlarmsAsync("other", "SrcA"));
}
public void Dispose()
{
if (File.Exists(_dbFile))
{
File.Delete(_dbFile);
}
}
}