refactor(scripted-alarms): review-fix polish for T5/T7/T8 (observer isolation, warning hoist, doc)
This commit is contained in:
+12
-1
@@ -32,6 +32,8 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
|
||||
// AreInputsReady gate tests exactly this bit — so an "unknown path" snapshot uses it too.
|
||||
private const uint StatusBad = 0x80000000u;
|
||||
|
||||
// Intentionally never pruned: cold-start semantics depend on retained last values so
|
||||
// ReadTag can answer synchronously for any path that has ever been pushed.
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _cache
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ImmutableList<Action<string, DataValueSnapshot>>> _observers
|
||||
@@ -72,7 +74,10 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
|
||||
if (_observers.TryGetValue(path, out var observers))
|
||||
{
|
||||
foreach (var observer in observers)
|
||||
observer(path, snapshot);
|
||||
{
|
||||
try { observer(path, snapshot); }
|
||||
catch { /* one misbehaving observer must not silence the rest */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +96,12 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// <b>No-replay contract</b>: a new subscriber does NOT receive a synthetic initial
|
||||
/// notification for the current cached value. Callers that need the current value must
|
||||
/// call <see cref="ReadTag"/> immediately after subscribing. The engine's cold-start path
|
||||
/// (startup-recovery + read-cache-refill) already does this.
|
||||
/// </remarks>
|
||||
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
@@ -76,6 +76,14 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore
|
||||
}
|
||||
|
||||
/// <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), mirroring
|
||||
/// <c>EfAlarmActorStateStore</c>. 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);
|
||||
@@ -85,6 +93,8 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore
|
||||
|
||||
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,
|
||||
@@ -151,15 +161,15 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore
|
||||
AlarmId: row.ScriptedAlarmId,
|
||||
Enabled: string.Equals(row.EnabledState, "Disabled", StringComparison.Ordinal)
|
||||
? AlarmEnabledState.Disabled
|
||||
: AlarmEnabledState.Enabled,
|
||||
: 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,
|
||||
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
|
||||
Confirmed: string.Equals(row.ConfirmedState, "Confirmed", StringComparison.Ordinal)
|
||||
? AlarmConfirmedState.Confirmed
|
||||
: AlarmConfirmedState.Unconfirmed,
|
||||
: 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,
|
||||
@@ -194,7 +204,7 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore
|
||||
{
|
||||
"OneShotShelved" => ShelvingKind.OneShot,
|
||||
"TimedShelved" => ShelvingKind.Timed,
|
||||
_ => ShelvingKind.Unshelved,
|
||||
_ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default)
|
||||
};
|
||||
|
||||
private static string SerializeComments(ImmutableList<AlarmComment> comments)
|
||||
@@ -202,6 +212,8 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user