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:
Joseph Doherty
2026-11-11 12:31:02 -05:00
parent d9b23e0b95
commit d2b1545659
3 changed files with 182 additions and 15 deletions
@@ -358,6 +358,110 @@ public sealed class FolderConditionRefreshWireTests
keyEvents.Has(keyA, "after-revoke").ShouldBeFalse();
}
/// <summary>
/// #855 review: the cut path decides its replay before taking <c>Lock</c>; a grant revoked between that decision
/// and the queueing of the frozen replay must still withhold the replay (it is re-decided right before each entry).
/// The revocation lands from the refresh probe, which runs after the cut is installed and before the replay is queued.
/// </summary>
/// <param name="serverObject">Refresh a Server-object item instead of a folder item.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task A_grant_revoked_between_the_cut_and_its_replay_withholds_the_replay(bool serverObject)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Refresh855RevokeCut", encrypted: false, ct);
rig.KeyRoles = [];
rig.MaterialiseNative();
var anonGrant = ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly);
var state = rig.Enforce(true, [ServerEventRig.Key()], KeyEquipmentGrant(EqA), anonGrant);
// An anonymous subscriber keeps EqA a notifier fan-out the stall applies to (and stays granted throughout).
using var anonymous = await rig.ConnectAsync(anonymous: true, ct);
var anonEvents = new RecordedEvents();
var anonSub = await RecordedEvents.SubscribeAsync(anonymous, anonEvents, ct);
RecordedEvents.AddEventItem(anonSub, ServerEventRig.UnsNodeId(anonymous, EqA));
await anonSub.ApplyChangesAsync(ct);
using var keyed = await rig.ConnectAsync(anonymous: false, ct);
var keyEvents = new RecordedEvents();
var keySub = await RecordedEvents.SubscribeAsync(keyed, keyEvents, ct);
var item = RecordedEvents.AddEventItem(keySub, serverObject ? ObjectIds.Server : ServerEventRig.UnsNodeId(keyed, EqA));
await keySub.ApplyChangesAsync(ct);
item.Status.Error.ShouldBeNull();
rig.Project(native: true, Active("seed"));
await RecordedEvents.WaitUntilAsync(() => keyEvents.Has(item, "seed"));
var revoked = 0;
void Revoke(bool cut, bool _)
{
if (!cut || Interlocked.Exchange(ref revoked, 1) != 0) return;
state.Apply(
ServerEventRig.Cluster, "rev-2", [ServerEventRig.Key()], [anonGrant], ServerEventRig.ScopePaths(),
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(),
ServerEventRig.TagReferences());
}
if (serverObject) rig.Nm.ServerObjectRefreshProbe = Revoke;
else rig.Nm.DirectRefreshProbe = Revoke;
using (var stall = NotifierStall.Hold(rig.Nm, EqA))
{
for (var i = 0; i < FloodSize; i++) rig.Project(native: true, Active($"f{i}"));
rig.Nm.DeferredEventReports.ShouldBeGreaterThan(0);
await keyed.CallAsync(ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, keySub.Id);
await RecordedEvents.WaitUntilAsync(() => keyEvents.HasType(item, ObjectTypeIds.RefreshEndEventType));
stall.Release();
}
rig.Nm.ServerObjectRefreshProbe = null;
rig.Nm.DirectRefreshProbe = null;
Volatile.Read(ref revoked).ShouldBe(1, "the refresh took the cut path");
await RecordedEvents.WaitUntilAsync(() => rig.Nm.DeferredEventReports == 0);
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
var (replayed, after) = Window(keyEvents.Of(item));
replayed.ShouldBeEmpty("the grant was revoked before the replay was queued");
after.ShouldBeEmpty("and nothing reaches the item after RefreshEnd either");
}
/// <summary>
/// #855 review: a delivery that fails inline (here: a hop that throws) is over, not suspended — it must not be booked
/// as a deferral episode (which moved the episode a concurrent plain refresh checks, and cleared cuts under the
/// event-report gate while <c>Lock</c> was held). The Server-object item, served before the failing hop, still gets
/// the event, and the next event is delivered normally.
/// </summary>
[Fact]
public async Task A_delivery_that_throws_is_not_booked_as_a_deferral()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Refresh855Fault", encrypted: false, ct);
rig.KeyRoles = [];
rig.MaterialiseNative();
rig.Enforce(false, [ServerEventRig.Key()], KeyEquipmentGrant(EqA));
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA));
var onServer = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
var episodes = rig.Nm.DeferralEpisodes;
rig.Nm.NotifierDeliveryStall = node =>
node.NodeId.IdentifierAsString == EqA ? throw new InvalidOperationException("hop fault (test)") : null;
rig.Project(native: true, Active("faulted"));
rig.Nm.NotifierDeliveryStall = null;
rig.Nm.DeferredEventReports.ShouldBe(0);
rig.Nm.DeferralEpisodes.ShouldBe(episodes, "a failed delivery is not a deferral episode");
rig.Project(native: true, Active("next"));
await RecordedEvents.WaitUntilAsync(() =>
events.Has(onServer, "faulted") && events.Has(onServer, "next") && events.Has(onA, "next"));
events.Has(onA, "faulted").ShouldBeFalse("the faulted hop delivered nothing");
}
/// <summary>
/// Production shape, no stall: bursts reported under one <c>Lock</c> hold (an apply pass) and single reports, against
/// folder items that refresh repeatedly while their client is slow to publish. Folder items are served per item, so
@@ -398,10 +502,14 @@ public sealed class FolderConditionRefreshWireTests
{
lock (rig.Nm.Lock)
{
for (var i = 0; i < 1000; i++) rig.Project(native: true, Active($"s{Interlocked.Increment(ref counter)}"));
for (var i = 0; i < 1000; i++)
{
rig.Project(native: true, Active($"s{Interlocked.Increment(ref counter)}"));
// Sampled inside Lock, after each report: a deferral cannot open and drain unseen (#855 review).
peakDeferred = Math.Max(peakDeferred, rig.Nm.DeferredEventReports);
}
}
peakDeferred = Math.Max(peakDeferred, rig.Nm.DeferredEventReports);
rig.Project(native: true, Active($"s{Interlocked.Increment(ref counter)}"));
Thread.Sleep(2);
}