feat(scripted-alarms): EfAlarmConditionStateStore (T8)
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Production-side <see cref="IAlarmStateStore"/> backed by the
|
||||
/// <see cref="ScriptedAlarmState"/> table in the central config DB. Unlike the narrower
|
||||
/// <see cref="EfAlarmActorStateStore"/> (which only persists AckedState for the actor's
|
||||
/// 3-state enum), this store maps the full Part 9 <see cref="AlarmConditionState"/> —
|
||||
/// Enabled / Acked / Confirmed / Shelving + the ack/confirm audit trail + operator
|
||||
/// comments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>ActiveState is NOT persisted</b> — the entity has no Active column. On
|
||||
/// <see cref="LoadAsync"/> it is restored as <see cref="AlarmActiveState.Inactive"/>;
|
||||
/// the engine re-derives it from the live predicate on startup (Phase 7 decision #14).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>LastTransitionUtc ↔ UpdatedAtUtc</b>: the table has no dedicated transition
|
||||
/// column, so <c>LastTransitionUtc</c> is written into the row-write
|
||||
/// <see cref="ScriptedAlarmState.UpdatedAtUtc"/> on save and read back from it on load.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>LastActiveUtc / LastClearedUtc are transient</b> — they have no columns and
|
||||
/// default to <c>null</c> on load (they re-derive from the predicate alongside Active).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="AlarmConditionState.Comments"/> serializes to/from
|
||||
/// <see cref="ScriptedAlarmState.CommentsJson"/> via System.Text.Json. An empty list
|
||||
/// round-trips as <c>"[]"</c> (matching the entity default + <see cref="EfAlarmActorStateStore"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class EfAlarmConditionStateStore : IAlarmStateStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly ILogger<EfAlarmConditionStateStore> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="EfAlarmConditionStateStore"/>.</summary>
|
||||
/// <param name="dbFactory">The factory for creating config database contexts.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public EfAlarmConditionStateStore(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
ILogger<EfAlarmConditionStateStore> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
return row is null ? null : MapToState(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var rows = await db.ScriptedAlarmStates.AsNoTracking()
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
return rows.Select(MapToState).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == state.AlarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new ScriptedAlarmState
|
||||
{
|
||||
ScriptedAlarmId = state.AlarmId,
|
||||
EnabledState = MapEnabledToColumn(state.Enabled),
|
||||
AckedState = MapAckedToColumn(state.Acked),
|
||||
ConfirmedState = MapConfirmedToColumn(state.Confirmed),
|
||||
ShelvingState = MapShelvingToColumn(state.Shelving.Kind),
|
||||
};
|
||||
ApplyState(row, state);
|
||||
db.ScriptedAlarmStates.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyState(row, state);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException ex)
|
||||
{
|
||||
// Two writers racing to save the same alarm is benign — last writer wins on
|
||||
// UpdatedAtUtc and the next transition writes again. Log + drop so a race never
|
||||
// crashes the engine.
|
||||
_logger.LogDebug(ex,
|
||||
"EfAlarmConditionStateStore: concurrency conflict for {AlarmId}; dropping save",
|
||||
state.AlarmId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (row is null) return;
|
||||
|
||||
db.ScriptedAlarmStates.Remove(row);
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void ApplyState(ScriptedAlarmState row, AlarmConditionState state)
|
||||
{
|
||||
row.EnabledState = MapEnabledToColumn(state.Enabled);
|
||||
row.AckedState = MapAckedToColumn(state.Acked);
|
||||
row.ConfirmedState = MapConfirmedToColumn(state.Confirmed);
|
||||
row.ShelvingState = MapShelvingToColumn(state.Shelving.Kind);
|
||||
row.ShelvingExpiresUtc = state.Shelving.UnshelveAtUtc;
|
||||
row.LastAckUser = state.LastAckUser;
|
||||
row.LastAckComment = state.LastAckComment;
|
||||
row.LastAckUtc = state.LastAckUtc;
|
||||
row.LastConfirmUser = state.LastConfirmUser;
|
||||
row.LastConfirmComment = state.LastConfirmComment;
|
||||
row.LastConfirmUtc = state.LastConfirmUtc;
|
||||
row.CommentsJson = SerializeComments(state.Comments);
|
||||
// No dedicated transition column — persist LastTransitionUtc into UpdatedAtUtc.
|
||||
row.UpdatedAtUtc = state.LastTransitionUtc;
|
||||
}
|
||||
|
||||
private static AlarmConditionState MapToState(ScriptedAlarmState row) => new(
|
||||
AlarmId: row.ScriptedAlarmId,
|
||||
Enabled: string.Equals(row.EnabledState, "Disabled", StringComparison.Ordinal)
|
||||
? AlarmEnabledState.Disabled
|
||||
: AlarmEnabledState.Enabled,
|
||||
// Active is not persisted — the engine re-derives it from the predicate at startup.
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: string.Equals(row.AckedState, "Acknowledged", StringComparison.Ordinal)
|
||||
? AlarmAckedState.Acknowledged
|
||||
: AlarmAckedState.Unacknowledged,
|
||||
Confirmed: string.Equals(row.ConfirmedState, "Confirmed", StringComparison.Ordinal)
|
||||
? AlarmConfirmedState.Confirmed
|
||||
: AlarmConfirmedState.Unconfirmed,
|
||||
Shelving: new ShelvingState(MapShelvingFromColumn(row.ShelvingState), row.ShelvingExpiresUtc),
|
||||
// No transition column — UpdatedAtUtc carries the last transition timestamp.
|
||||
LastTransitionUtc: row.UpdatedAtUtc,
|
||||
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: row.LastAckUtc,
|
||||
LastAckUser: row.LastAckUser,
|
||||
LastAckComment: row.LastAckComment,
|
||||
LastConfirmUtc: row.LastConfirmUtc,
|
||||
LastConfirmUser: row.LastConfirmUser,
|
||||
LastConfirmComment: row.LastConfirmComment,
|
||||
Comments: DeserializeComments(row.CommentsJson));
|
||||
|
||||
private static string MapEnabledToColumn(AlarmEnabledState enabled)
|
||||
=> enabled == AlarmEnabledState.Enabled ? "Enabled" : "Disabled";
|
||||
|
||||
private static string MapAckedToColumn(AlarmAckedState acked)
|
||||
=> acked == AlarmAckedState.Acknowledged ? "Acknowledged" : "Unacknowledged";
|
||||
|
||||
private static string MapConfirmedToColumn(AlarmConfirmedState confirmed)
|
||||
=> confirmed == AlarmConfirmedState.Confirmed ? "Confirmed" : "Unconfirmed";
|
||||
|
||||
private static string MapShelvingToColumn(ShelvingKind kind) => kind switch
|
||||
{
|
||||
ShelvingKind.OneShot => "OneShotShelved",
|
||||
ShelvingKind.Timed => "TimedShelved",
|
||||
_ => "Unshelved",
|
||||
};
|
||||
|
||||
private static ShelvingKind MapShelvingFromColumn(string column) => column switch
|
||||
{
|
||||
"OneShotShelved" => ShelvingKind.OneShot,
|
||||
"TimedShelved" => ShelvingKind.Timed,
|
||||
_ => ShelvingKind.Unshelved,
|
||||
};
|
||||
|
||||
private static string SerializeComments(ImmutableList<AlarmComment> comments)
|
||||
{
|
||||
if (comments.IsEmpty) return "[]";
|
||||
var dtos = comments.Select(c => new CommentDto
|
||||
{
|
||||
TimestampUtc = c.TimestampUtc,
|
||||
User = c.User,
|
||||
Kind = c.Kind,
|
||||
Text = c.Text,
|
||||
});
|
||||
return JsonSerializer.Serialize(dtos, JsonOptions);
|
||||
}
|
||||
|
||||
private static ImmutableList<AlarmComment> DeserializeComments(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return ImmutableList<AlarmComment>.Empty;
|
||||
var dtos = JsonSerializer.Deserialize<List<CommentDto>>(json, JsonOptions);
|
||||
if (dtos is null || dtos.Count == 0) return ImmutableList<AlarmComment>.Empty;
|
||||
return dtos
|
||||
.Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
|
||||
.ToImmutableList();
|
||||
}
|
||||
|
||||
/// <summary>Stable on-disk shape for a persisted <see cref="AlarmComment"/> in <c>CommentsJson</c>.</summary>
|
||||
private sealed class CommentDto
|
||||
{
|
||||
/// <summary>When the comment was recorded (UTC).</summary>
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
|
||||
/// <summary>Identity of the actor that wrote the comment.</summary>
|
||||
public string? User { get; set; }
|
||||
|
||||
/// <summary>Human-readable classification of the comment (Acknowledge, Confirm, …).</summary>
|
||||
public string? Kind { get; set; }
|
||||
|
||||
/// <summary>Operator-supplied or engine-generated comment text.</summary>
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user