249 lines
11 KiB
C#
249 lines
11 KiB
C#
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. 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).
|
|
/// </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 />
|
|
/// <remarks>
|
|
/// <b>Concurrency assumption</b>: saves for a given <c>alarmId</c> are serialized by the
|
|
/// owning host actor (one actor owns the engine per equipment). The check-then-insert
|
|
/// pattern is therefore safe under that guarantee — two concurrent inserts for the same
|
|
/// alarm cannot occur in the live
|
|
/// runtime. The <see cref="DbUpdateConcurrencyException"/> catch handles the edge case of a
|
|
/// racing concurrent restart during crash recovery.
|
|
/// </remarks>
|
|
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)
|
|
{
|
|
// Required members are set here to satisfy the compiler; ApplyState is the single
|
|
// source of truth for all columns and immediately overwrites these values below.
|
|
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, // unknown string → Enabled (safe default)
|
|
// 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, // unknown string → Unacknowledged (safe default)
|
|
Confirmed: string.Equals(row.ConfirmedState, "Confirmed", StringComparison.Ordinal)
|
|
? AlarmConfirmedState.Confirmed
|
|
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
|
|
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, // unknown string → Unshelved (safe default)
|
|
};
|
|
|
|
private static string SerializeComments(ImmutableList<AlarmComment> comments)
|
|
{
|
|
if (comments.IsEmpty) return "[]";
|
|
var dtos = comments.Select(c => new CommentDto
|
|
{
|
|
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip;
|
|
// the engine always creates AlarmComment instances with Utc kind.
|
|
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; }
|
|
}
|
|
}
|