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:
@@ -86,6 +86,7 @@ public class NativeAlarmActor : ReceiveActor
|
||||
/// a logging failure never affects the mirror.</param>
|
||||
/// <param name="persistFlushInterval">How long native-alarm transitions coalesce before a
|
||||
/// batched SQLite persist flush (P4). Defaults to 100 ms.</param>
|
||||
/// <param name="rehydrationRead">Optional prestarted persisted-state read; null reads from storage.</param>
|
||||
public NativeAlarmActor(
|
||||
ResolvedNativeAlarmSource source,
|
||||
string instanceName,
|
||||
@@ -96,8 +97,10 @@ public class NativeAlarmActor : ReceiveActor
|
||||
ILogger logger,
|
||||
AlarmKind nativeKind = AlarmKind.NativeOpcUa,
|
||||
IServiceProvider? serviceProvider = null,
|
||||
TimeSpan? persistFlushInterval = null)
|
||||
TimeSpan? persistFlushInterval = null,
|
||||
Task<IReadOnlyList<NativeAlarmRow>>? rehydrationRead = null)
|
||||
{
|
||||
_rehydrationRead = rehydrationRead;
|
||||
_source = source;
|
||||
_instanceName = instanceName;
|
||||
_instanceActor = instanceActor;
|
||||
@@ -124,7 +127,7 @@ public class NativeAlarmActor : ReceiveActor
|
||||
|
||||
// Rehydrate last-known state from SQLite (non-blocking), then subscribe.
|
||||
// A fresh source snapshot will reconcile/replace this once it arrives.
|
||||
_storage.GetNativeAlarmsAsync(_instanceName, _source.CanonicalName)
|
||||
(_rehydrationRead ?? _storage.GetNativeAlarmsAsync(_instanceName, _source.CanonicalName))
|
||||
.PipeTo(Self, Self, rows => new RehydrationCompleted(rows));
|
||||
|
||||
SendSubscribe();
|
||||
@@ -149,32 +152,48 @@ public class NativeAlarmActor : ReceiveActor
|
||||
base.PostStop();
|
||||
}
|
||||
|
||||
private readonly Task<IReadOnlyList<NativeAlarmRow>>? _rehydrationRead;
|
||||
|
||||
private string? _subscribeCorrelation;
|
||||
private DateTimeOffset _subscribeDeadline;
|
||||
private int _subscribeDelayMs;
|
||||
private bool _subscribeSucceeded;
|
||||
|
||||
private void SendSubscribe()
|
||||
{
|
||||
_dclManager.Tell(new SubscribeAlarmsRequest(
|
||||
CorrelationId: Guid.NewGuid().ToString("N"),
|
||||
InstanceUniqueName: _instanceName,
|
||||
ConnectionName: _source.ConnectionName,
|
||||
SourceReference: _source.SourceReference,
|
||||
ConditionFilter: _source.ConditionFilter,
|
||||
Timestamp: DateTimeOffset.UtcNow));
|
||||
|
||||
// Arm a response-timeout retry so a LOST subscribe response is re-sent, not
|
||||
// only a failure reply (S4). Cancelled on a Success reply; a failure reply
|
||||
// re-arms it at 1× (overriding this 2× response-timeout arm). 2× interval so
|
||||
// a slow-but-arriving response is not pre-empted by a redundant re-send.
|
||||
if (_subscribeSucceeded) return;
|
||||
if (_subscribeCorrelation is not null && DateTimeOffset.UtcNow < _subscribeDeadline)
|
||||
{
|
||||
_retryTimer?.Cancel();
|
||||
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
|
||||
TimeSpan.FromMilliseconds(Math.Max(1, (_subscribeDeadline - DateTimeOffset.UtcNow).TotalMilliseconds)),
|
||||
Self, RetrySubscribe.Instance, Self);
|
||||
return;
|
||||
}
|
||||
_subscribeDelayMs = _subscribeDelayMs == 0
|
||||
? Math.Clamp(_options.NativeAlarmRetryIntervalMs, 1, 60000) : Math.Min(60000, _subscribeDelayMs * 2);
|
||||
_subscribeCorrelation = Guid.NewGuid().ToString("N");
|
||||
var responseTimeout = TimeSpan.FromMilliseconds(Math.Min(120000, _subscribeDelayMs * 2));
|
||||
_subscribeDeadline = DateTimeOffset.UtcNow + responseTimeout;
|
||||
_dclManager.Tell(new SubscribeAlarmsRequest(_subscribeCorrelation, _instanceName,
|
||||
_source.ConnectionName, _source.SourceReference, _source.ConditionFilter, DateTimeOffset.UtcNow));
|
||||
_retryTimer?.Cancel();
|
||||
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
|
||||
TimeSpan.FromMilliseconds(_options.NativeAlarmRetryIntervalMs * 2),
|
||||
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(responseTimeout,
|
||||
Self, RetrySubscribe.Instance, Self);
|
||||
}
|
||||
|
||||
private readonly HashSet<string> _liveTouched = new();
|
||||
private bool _authoritativeSnapshotReceived;
|
||||
private bool _rehydrationCompleted;
|
||||
|
||||
private void HandleRehydration(RehydrationCompleted msg)
|
||||
{
|
||||
if (_authoritativeSnapshotReceived || _rehydrationCompleted) return;
|
||||
_rehydrationCompleted = true;
|
||||
foreach (var row in msg.Rows)
|
||||
{
|
||||
// A live transition that arrived before the read completed wins.
|
||||
if (_alarms.ContainsKey(row.SourceReference))
|
||||
if (_alarms.ContainsKey(row.SourceReference) || _liveTouched.Contains(row.SourceReference))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -227,8 +246,12 @@ public class NativeAlarmActor : ReceiveActor
|
||||
// event (it is not a live transition).
|
||||
Emit(t, t.Condition, logSiteEvent: false);
|
||||
}
|
||||
_liveTouched.Clear();
|
||||
}
|
||||
|
||||
private bool _snapshotInProgress;
|
||||
private readonly Dictionary<string, NativeAlarmTransition> _liveDuringSnapshot = new();
|
||||
|
||||
private void HandleTransition(NativeAlarmTransitionUpdate update)
|
||||
{
|
||||
var t = update.Transition;
|
||||
@@ -237,15 +260,18 @@ public class NativeAlarmActor : ReceiveActor
|
||||
switch (t.Kind)
|
||||
{
|
||||
case AlarmTransitionKind.SnapshotStart:
|
||||
if (t.SnapshotId is null || t.SnapshotId == _lastCompletedSnapshotId || t.SnapshotId == _snapshotId)
|
||||
if (t.SnapshotId is not null && (t.SnapshotId == _lastCompletedSnapshotId || t.SnapshotId == _snapshotId))
|
||||
return;
|
||||
_snapshotId = t.SnapshotId;
|
||||
_snapshotInProgress = true;
|
||||
_snapshotBuffer.Clear();
|
||||
_liveDuringSnapshot.Clear();
|
||||
break;
|
||||
|
||||
case AlarmTransitionKind.Snapshot:
|
||||
if (t.SnapshotId != _snapshotId)
|
||||
return;
|
||||
_snapshotInProgress = true;
|
||||
_snapshotBuffer[t.SourceReference] = t;
|
||||
break;
|
||||
|
||||
@@ -270,6 +296,16 @@ public class NativeAlarmActor : ReceiveActor
|
||||
/// </summary>
|
||||
private void ApplySnapshotSwap()
|
||||
{
|
||||
foreach (var (source, live) in _liveDuringSnapshot)
|
||||
{
|
||||
if (_snapshotBuffer.TryGetValue(source, out var snapshot) && snapshot.TransitionTime > live.TransitionTime) continue;
|
||||
if (!live.Condition.Active && live.Condition.Acknowledged) _snapshotBuffer.Remove(source);
|
||||
else _snapshotBuffer[source] = live;
|
||||
}
|
||||
_liveDuringSnapshot.Clear();
|
||||
_snapshotInProgress = false;
|
||||
_authoritativeSnapshotReceived = true;
|
||||
_liveTouched.Clear();
|
||||
foreach (var (sourceRef, prior) in _alarms)
|
||||
{
|
||||
if (!_snapshotBuffer.ContainsKey(sourceRef))
|
||||
@@ -304,12 +340,18 @@ public class NativeAlarmActor : ReceiveActor
|
||||
|
||||
private void ApplyLiveTransition(NativeAlarmTransition t)
|
||||
{
|
||||
if (!_authoritativeSnapshotReceived && !_rehydrationCompleted) _liveTouched.Add(t.SourceReference);
|
||||
// Retain timestamp ordering for a clear removed from the visible mirror
|
||||
// while a refresh is still assembling.
|
||||
if (_liveDuringSnapshot.TryGetValue(t.SourceReference, out var recent) && t.TransitionTime < recent.TransitionTime)
|
||||
return;
|
||||
// Ignore stale (out-of-order) transitions for a condition we already hold.
|
||||
if (_alarms.TryGetValue(t.SourceReference, out var existing) && t.TransitionTime < existing.TransitionTime)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_snapshotInProgress) _liveDuringSnapshot[t.SourceReference] = t;
|
||||
_alarms[t.SourceReference] = t;
|
||||
MarkDirtyUpsert(t);
|
||||
Emit(t, t.Condition);
|
||||
@@ -333,6 +375,8 @@ public class NativeAlarmActor : ReceiveActor
|
||||
// An interrupted refresh is not a snapshot. Never merge its partial rows
|
||||
// into the next connection's replay.
|
||||
_snapshotBuffer.Clear();
|
||||
_liveDuringSnapshot.Clear();
|
||||
_snapshotInProgress = false;
|
||||
_snapshotId = null;
|
||||
// Keep last-known conditions (uncertain) rather than clearing them; the
|
||||
// reconnect snapshot reconciles state. No emit — avoids a flap.
|
||||
@@ -343,8 +387,12 @@ public class NativeAlarmActor : ReceiveActor
|
||||
|
||||
private void HandleSubscribeResponse(SubscribeAlarmsResponse resp)
|
||||
{
|
||||
if (resp.CorrelationId != _subscribeCorrelation) return;
|
||||
_subscribeCorrelation = null;
|
||||
if (resp.Success)
|
||||
{
|
||||
_subscribeSucceeded = true;
|
||||
_subscribeDelayMs = 0;
|
||||
// Success — cancel the response-timeout retry armed in SendSubscribe.
|
||||
_retryTimer?.Cancel();
|
||||
_logger.LogInformation("Native alarm subscription established for {Source} on {Instance}",
|
||||
@@ -358,7 +406,7 @@ public class NativeAlarmActor : ReceiveActor
|
||||
|
||||
_retryTimer?.Cancel();
|
||||
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
|
||||
TimeSpan.FromMilliseconds(_options.NativeAlarmRetryIntervalMs),
|
||||
TimeSpan.FromMilliseconds(_subscribeDelayMs),
|
||||
Self, RetrySubscribe.Instance, Self);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user