Resolve all 33 medium-severity architecture review findings
Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.
Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.
Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.
Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.
Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.
Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.
Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
This commit is contained in:
@@ -2899,6 +2899,87 @@ public class DataConnectionActor : UntypedActor, IWithTimers
|
||||
|
||||
// ── Native alarm subscriptions ──
|
||||
|
||||
private sealed class AlarmReplayState
|
||||
{
|
||||
public Dictionary<string, NativeAlarmTransition> Current { get; set; } = new();
|
||||
public Dictionary<string, NativeAlarmTransition> Snapshot { get; } = new();
|
||||
public Dictionary<string, NativeAlarmTransition> LiveDuringSnapshot { get; } = new();
|
||||
public bool InSnapshot { get; set; }
|
||||
public bool Ready { get; set; }
|
||||
public Guid? Epoch { get; set; }
|
||||
}
|
||||
private readonly Dictionary<string, AlarmReplayState> _alarmReplay = new();
|
||||
|
||||
private void CacheAlarmTransition(string source, NativeAlarmTransition transition)
|
||||
{
|
||||
if (!_alarmReplay.TryGetValue(source, out var state))
|
||||
_alarmReplay[source] = state = new AlarmReplayState();
|
||||
switch (transition.Kind)
|
||||
{
|
||||
case AlarmTransitionKind.SnapshotStart:
|
||||
if (transition.SnapshotId is not null && transition.SnapshotId == state.Epoch) return;
|
||||
state.Snapshot.Clear();
|
||||
state.LiveDuringSnapshot.Clear();
|
||||
state.InSnapshot = true;
|
||||
state.Epoch = transition.SnapshotId;
|
||||
break;
|
||||
case AlarmTransitionKind.Snapshot:
|
||||
if (transition.SnapshotId != state.Epoch || (transition.SnapshotId is not null && !state.InSnapshot)) return;
|
||||
state.InSnapshot = true;
|
||||
state.Snapshot[transition.SourceReference] = transition;
|
||||
break;
|
||||
case AlarmTransitionKind.SnapshotComplete:
|
||||
if (transition.SnapshotId != state.Epoch || (transition.SnapshotId is not null && !state.InSnapshot)) return;
|
||||
state.Current = SnapshotWithLiveChanges(state);
|
||||
state.Snapshot.Clear();
|
||||
state.LiveDuringSnapshot.Clear();
|
||||
state.InSnapshot = false;
|
||||
state.Ready = true;
|
||||
break;
|
||||
default:
|
||||
if (state.LiveDuringSnapshot.TryGetValue(transition.SourceReference, out var recent)
|
||||
&& transition.TransitionTime < recent.TransitionTime) return;
|
||||
if (state.Current.TryGetValue(transition.SourceReference, out var prior)
|
||||
&& transition.TransitionTime < prior.TransitionTime) return;
|
||||
if (state.InSnapshot) state.LiveDuringSnapshot[transition.SourceReference] = transition;
|
||||
if (!transition.Condition.Active && transition.Condition.Acknowledged)
|
||||
state.Current.Remove(transition.SourceReference);
|
||||
else
|
||||
state.Current[transition.SourceReference] = transition;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, NativeAlarmTransition> SnapshotWithLiveChanges(AlarmReplayState state)
|
||||
{
|
||||
var result = new Dictionary<string, NativeAlarmTransition>(state.Snapshot);
|
||||
foreach (var (key, live) in state.LiveDuringSnapshot)
|
||||
{
|
||||
if (result.TryGetValue(key, out var snapshot) && snapshot.TransitionTime > live.TransitionTime) continue;
|
||||
if (!live.Condition.Active && live.Condition.Acknowledged) result.Remove(key);
|
||||
else result[key] = live;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ReplayAlarmsTo(string source, IActorRef subscriber)
|
||||
{
|
||||
if (!_alarmReplay.TryGetValue(source, out var state) || (!state.Ready && !state.InSnapshot)) return;
|
||||
var epoch = state.InSnapshot ? state.Epoch : Guid.NewGuid();
|
||||
var boundary = new NativeAlarmTransition("", source, "", AlarmTransitionKind.SnapshotStart,
|
||||
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
|
||||
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "", SnapshotSourceReference: source, SnapshotId: epoch);
|
||||
// Legacy Mx snapshots have no start or epoch. Mid-frame subscribers get
|
||||
// the prefix already received and then share the remaining live frame.
|
||||
if (epoch is not null)
|
||||
subscriber.Tell(new NativeAlarmTransitionUpdate(_connectionName, boundary));
|
||||
foreach (var transition in (state.InSnapshot ? SnapshotWithLiveChanges(state) : state.Current).Values)
|
||||
subscriber.Tell(new NativeAlarmTransitionUpdate(_connectionName, transition with
|
||||
{ Kind = AlarmTransitionKind.Snapshot, SnapshotSourceReference = source, SnapshotId = epoch }));
|
||||
if (!state.InSnapshot)
|
||||
subscriber.Tell(new NativeAlarmTransitionUpdate(_connectionName, boundary with { Kind = AlarmTransitionKind.SnapshotComplete }));
|
||||
}
|
||||
|
||||
private void HandleSubscribeAlarms(SubscribeAlarmsRequest request)
|
||||
{
|
||||
var subscriber = Sender;
|
||||
@@ -2920,7 +3001,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
|
||||
_alarmSourceSubscribers[request.SourceReference] = subs;
|
||||
IndexAlarmSource(request.SourceReference);
|
||||
}
|
||||
subs.Add(subscriber);
|
||||
var newlyAdded = subs.Add(subscriber);
|
||||
Context.Watch(subscriber);
|
||||
// S10: the adapter feed carries a single shared condition filter per source.
|
||||
// A second subscriber with a different filter silently overwrites the first
|
||||
@@ -2943,6 +3024,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
|
||||
if (_alarmSubscriptionIds.ContainsKey(request.SourceReference) ||
|
||||
_alarmSubscribesInFlight.Contains(request.SourceReference))
|
||||
{
|
||||
if (newlyAdded) ReplayAlarmsTo(request.SourceReference, subscriber);
|
||||
subscriber.Tell(new SubscribeAlarmsResponse(
|
||||
request.CorrelationId, request.InstanceUniqueName, true, null, now));
|
||||
return;
|
||||
@@ -3081,12 +3163,15 @@ public class DataConnectionActor : UntypedActor, IWithTimers
|
||||
var scope = transition.SnapshotSourceReference
|
||||
?? (string.IsNullOrEmpty(transition.SourceObjectReference) ? null : transition.SourceObjectReference);
|
||||
var targets = scope is null
|
||||
? _alarmSourceSubscribers.Values
|
||||
: _alarmSourceSubscribers.Where(kv => kv.Key == scope).Select(kv => kv.Value);
|
||||
foreach (var subs in targets)
|
||||
foreach (var sub in subs)
|
||||
? _alarmSourceSubscribers
|
||||
: _alarmSourceSubscribers.Where(kv => kv.Key == scope);
|
||||
foreach (var (source, subscribers) in targets)
|
||||
{
|
||||
CacheAlarmTransition(source, transition);
|
||||
foreach (var sub in subscribers)
|
||||
if (notified.Add(sub))
|
||||
sub.Tell(new NativeAlarmTransitionUpdate(_connectionName, transition));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3110,6 +3195,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
|
||||
!predicate.IsAllowed(transition))
|
||||
continue;
|
||||
|
||||
CacheAlarmTransition(sourceRef, transition);
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
if (notified.Add(sub))
|
||||
@@ -3215,6 +3301,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
|
||||
|
||||
// No subscribers remain for this source — tear down the adapter feed.
|
||||
_alarmSourceSubscribers.Remove(sourceReference);
|
||||
_alarmReplay.Remove(sourceReference);
|
||||
UnindexAlarmSource(sourceReference);
|
||||
_alarmSourceFilter.Remove(sourceReference);
|
||||
_alarmSourceFilterPredicate.Remove(sourceReference);
|
||||
@@ -3237,6 +3324,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
|
||||
return;
|
||||
|
||||
_alarmSubscriptionIds.Clear();
|
||||
_alarmReplay.Clear();
|
||||
_alarmSubscribesInFlight.Clear();
|
||||
var self = Self;
|
||||
var generation = _adapterGeneration;
|
||||
|
||||
Reference in New Issue
Block a user