fix(opcua): #855 review 1 — re-decide frozen replay entries; a failed delivery is not a deferral
- QueueFrozenReplay re-asks each item's verdict right before each entry's gate hold (Server-object and folder cut paths): a grant revoked between the pre-Lock verdicts and the replay no longer lets the replay through. A denied entry is also removed from the cut's re-sent set. - ReportEventWithoutBlocking logs and returns on a delivery that faulted inline instead of booking it as a deferral episode (which ran the drain and ClearStaleRefreshCuts under the event-report gate while Lock was held). - OnConditionReportEvent isolates failures per Server-object item. - Tests: revocation between cut and replay (folder + Server object), throwing hop not booked as a deferral; production-overload sampling inside Lock. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
@@ -1529,6 +1529,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
if (delivery.IsCompletedSuccessfully) return;
|
||||
|
||||
// #855 review: a delivery that FAILED inline is over, not suspended. Booking it as a deferral used to open and close
|
||||
// a deferral episode synchronously (moving the episode a concurrent plain refresh checks, and clearing cuts under
|
||||
// the event-report gate while Lock was held). Only an incomplete delivery is deferred.
|
||||
if (delivery.IsCompleted)
|
||||
{
|
||||
_logger.LogError(delivery.AsTask().Exception?.GetBaseException(),
|
||||
"OtOpcUaNodeManager: failed to report Part 9 condition event for {NodeId}", condition.NodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
var queue = new DeferredConditionReports();
|
||||
@@ -1901,6 +1911,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// </summary>
|
||||
private long _deferralEpisode;
|
||||
|
||||
/// <summary>Deferral episodes ended since start (test observation, #855 review).</summary>
|
||||
internal long DeferralEpisodes => Interlocked.Read(ref _deferralEpisode);
|
||||
|
||||
/// <summary>What a Server-object item's latest ConditionRefresh conveyed (#854).</summary>
|
||||
/// <param name="Watermark">Every condition event with a sequence at or below this was reported before the replay was read.</param>
|
||||
/// <param name="Episode">The deferral episode the cut was taken in; once it ends the cut is stale and inert.</param>
|
||||
@@ -5782,17 +5795,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
foreach (var item in _allEventsItems.Keys)
|
||||
{
|
||||
if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
if (reportingSession is { IsNull: false } reportingId
|
||||
&& item.Session?.Id is { } own
|
||||
&& !own.Equals(reportingId))
|
||||
try
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
if (reportingSession is { IsNull: false } reportingId
|
||||
&& item.Session?.Id is { } own
|
||||
&& !own.Equals(reportingId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QueueConditionEventToItem(item, e, deferred, DeferredServerObjectDeliveryProbe);
|
||||
QueueConditionEventToItem(item, e, deferred, DeferredServerObjectDeliveryProbe);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// #855 review: one item's failure (an item disposed under us, the SDK's operation guard at shutdown) must
|
||||
// not cost the other Server-object items, nor abort the walk before the folder hops.
|
||||
_logger.LogWarning(ex, "OtOpcUaNodeManager: failed to deliver a condition event to Server-object item {ItemId}", item.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6671,7 +6693,24 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
}
|
||||
|
||||
ServerObjectRefreshProbe?.Invoke(true, Monitor.IsEntered(Lock));
|
||||
QueueFrozenReplay(items, states, perItem.Select(entries => entries.Select(x => (x.Event, x.Condition.NodeId)).ToList()).ToArray(), generation);
|
||||
// #855 review: the verdicts above were taken before Lock; a deploy may have revoked a grant since. Each replay entry
|
||||
// is re-decided right before it is queued (scopes resolved once per condition).
|
||||
var scopeCache = new Dictionary<NodeId, List<NodeId>>();
|
||||
QueueFrozenReplay(
|
||||
items, states, perItem.Select(entries => entries.Select(x => (x.Event, x.Condition.NodeId)).ToList()).ToArray(),
|
||||
generation,
|
||||
(item, e) =>
|
||||
{
|
||||
if (enforced)
|
||||
{
|
||||
if (ConditionOf(e) is not { } condition) return false;
|
||||
if (!scopeCache.TryGetValue(condition.NodeId, out var scopes))
|
||||
scopeCache[condition.NodeId] = scopes = ConditionVisibilityScopes(condition);
|
||||
if (!IsConditionVisibleTo(item, scopes)) return false;
|
||||
}
|
||||
|
||||
return !ServiceResult.IsBad(ValidateEventRolePermissions(item, e));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -6685,8 +6724,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// replay of its condition, so that replay entry is dropped rather than queued after it (the newer event is already
|
||||
/// inside RefreshStart..RefreshEnd). A later refresh of the item supersedes this one.
|
||||
/// </summary>
|
||||
/// <param name="allowed">
|
||||
/// The item's verdict for one entry, asked right before the entry's gate hold (never under it: it takes <c>Lock</c>),
|
||||
/// against the CURRENT generation — the verdicts the cut was built on were taken earlier (#855 review). An entry
|
||||
/// denied here is dropped AND removed from the cut's re-sent set, so the condition counts as "omitted for the
|
||||
/// grants" and its later events are left to the ACL rather than withheld as superseded.
|
||||
/// </param>
|
||||
private static void QueueFrozenReplay(
|
||||
List<IEventMonitoredItem> items, List<EventItemState> states, List<(IFilterTarget Event, NodeId ConditionId)>[] perItem, int generation)
|
||||
List<IEventMonitoredItem> items, List<EventItemState> states, List<(IFilterTarget Event, NodeId ConditionId)>[] perItem,
|
||||
int generation, Func<IEventMonitoredItem, IFilterTarget, bool> allowed)
|
||||
{
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
@@ -6694,9 +6740,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
var state = states[i];
|
||||
foreach (var (e, conditionId) in perItem[i])
|
||||
{
|
||||
if (state.PendingReplay != generation) break;
|
||||
var permitted = allowed(item, e);
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (state.PendingReplay != generation) break;
|
||||
if (!permitted)
|
||||
{
|
||||
state.Cut?.Refreshed.Remove(conditionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state.NewerSinceCut.Contains(conditionId)) continue;
|
||||
item.QueueEvent(e);
|
||||
}
|
||||
@@ -6926,7 +6980,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
}
|
||||
|
||||
DirectRefreshProbe?.Invoke(true, Monitor.IsEntered(Lock));
|
||||
QueueFrozenReplay(items, states, perItem, generation);
|
||||
// #855 review: re-decided per entry right before queueing — a deploy may have revoked since the verdicts.
|
||||
QueueFrozenReplay(items, states, perItem, generation, (item, e) => IsDirectRefreshEventAllowed(item, e, enforced));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user