fix(alarms): SDK timed-unshelve timer is a no-op; commented re-ack comment is durable (#597)
Review R1 M2: since #597 the SDK timer fires at real expiry, and OnTimedUnshelve routed an operator-style Unshelve by 'system' from both pair nodes, pre-empting the engine's AutoUnshelve. The engine owns expiry, so the callback now routes nothing. Integration test: an engine stand-in that acts on a routed Unshelve and expires late, plus a wrapped OnTimedUnshelve (positive control: the SDK timer fired). Review R1 m1: Part9StateMachine.ApplyAcknowledge records a commented re-ack as AddComment (persisted and replicated) instead of a NoOp; an uncommented re-ack is still a no-op. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+42
-8
@@ -263,14 +263,18 @@ public sealed class ConditionMethodEventTests
|
||||
|
||||
/// <summary>
|
||||
/// A scripted TimedShelve expires: the engine's expiry comes back as an Unshelved re-projection, which
|
||||
/// reports exactly one unshelve event after the one shelve event.
|
||||
/// reports exactly one unshelve event after the one shelve event — and the SDK's own expiry timer routes
|
||||
/// nothing (review R1 M2).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The expiry is the engine's (computed from the command's <c>UnshelveAtUtc</c>). Since Gitea #597 the node
|
||||
/// is shelved for the real duration, so the SDK's own timer fires at the same instant — but it only routes
|
||||
/// an Unshelve back to the engine (ignored by this stand-in, a no-op on an unshelved engine alarm) and
|
||||
/// changes no node state, so it adds no event. The stand-in engine here schedules its expiry from the real
|
||||
/// <c>UnshelveAtUtc</c> the method handler computed.
|
||||
/// is shelved for the real duration, so the SDK's own timer fires at that instant. It used to route an
|
||||
/// operator-style <c>Unshelve</c> by <c>system</c>, which — because the real engine only checks expiry every
|
||||
/// 5 s — pre-empted the engine's <c>AutoUnshelve</c> on every expiry. This stand-in engine therefore
|
||||
/// (a) expires LATE, like the real one, so the SDK timer demonstrably fires first; and (b) ACTS on a routed
|
||||
/// <c>Unshelve</c> as the real engine would, recording who unshelved it. The wrapped
|
||||
/// <c>OnTimedUnshelve</c> is the positive control: the SDK timer did fire, and still nothing was routed and
|
||||
/// the unshelve on record is the engine's own.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event()
|
||||
@@ -283,21 +287,47 @@ public sealed class ConditionMethodEventTests
|
||||
rig.MaterialiseScripted();
|
||||
var current = PreState("TimedShelve", native: false) with { Message = "base" };
|
||||
Task? expiry = null;
|
||||
var routed = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
string? unshelvedBy = null;
|
||||
var engineLag = TimeSpan.FromMilliseconds(1500); // the real engine's 5 s shelving-check granularity
|
||||
rig.Nm.AlarmCommandRouter = cmd =>
|
||||
{
|
||||
routed.Enqueue(cmd.Operation);
|
||||
if (cmd.Operation == "Unshelve")
|
||||
{
|
||||
// What the real engine does with a routed Unshelve: an operator-style unshelve.
|
||||
unshelvedBy ??= $"Unshelve:{cmd.User}";
|
||||
current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null };
|
||||
rig.ProjectLater(native: false, current);
|
||||
return;
|
||||
}
|
||||
if (cmd.Operation != "TimedShelve") return;
|
||||
current = current with { Shelving = AlarmShelvingKind.Timed };
|
||||
// The real engine's projection carries its expiry, so the re-projection keeps the SDK timer armed
|
||||
// for the real remaining time (without it, it would re-arm for MaxTimeShelved and never fire here).
|
||||
current = current with { Shelving = AlarmShelvingKind.Timed, UnshelveAtUtc = cmd.UnshelveAtUtc };
|
||||
rig.ProjectLater(native: false, current);
|
||||
var unshelveAt = cmd.UnshelveAtUtc!.Value;
|
||||
expiry = Task.Run(async () =>
|
||||
{
|
||||
var wait = unshelveAt - DateTime.UtcNow;
|
||||
var wait = unshelveAt + engineLag - DateTime.UtcNow;
|
||||
if (wait > TimeSpan.Zero) await Task.Delay(wait);
|
||||
current = current with { Shelving = AlarmShelvingKind.Unshelved };
|
||||
if (current.Shelving != AlarmShelvingKind.Timed) return; // ApplyShelvingCheck: Timed only
|
||||
unshelvedBy ??= "AutoUnshelve:system";
|
||||
current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null };
|
||||
rig.Project(native: false, current);
|
||||
});
|
||||
};
|
||||
|
||||
// Count the SDK's own expiry-timer firings without changing what the production handler does.
|
||||
var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId)!;
|
||||
var sdkTimerFired = 0;
|
||||
var production = condition.OnTimedUnshelve!;
|
||||
condition.OnTimedUnshelve = (context, c) =>
|
||||
{
|
||||
Interlocked.Increment(ref sdkTimerFired);
|
||||
return production(context, c);
|
||||
};
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
@@ -315,6 +345,10 @@ public sealed class ConditionMethodEventTests
|
||||
rig.Project(false, current = current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
|
||||
Volatile.Read(ref sdkTimerFired).ShouldBeGreaterThanOrEqualTo(1, "the SDK's timed-unshelve timer fired");
|
||||
routed.ShouldNotContain("Unshelve", "the SDK timer must not route an operator-style Unshelve");
|
||||
unshelvedBy.ShouldBe("AutoUnshelve:system", "the engine's own expiry owns the unshelve");
|
||||
|
||||
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList();
|
||||
shelving.ShouldBe(
|
||||
[ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved,
|
||||
|
||||
@@ -268,19 +268,15 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnTimedUnshelve fires with the SDK's system context (no session, no user identity) —
|
||||
/// the real SDK path when a TimedShelve duration expires. The gate must NOT veto: the result must be
|
||||
/// Good, the router must be invoked exactly once with Operation == "Unshelve", and no UnshelveAtUtc is
|
||||
/// carried.
|
||||
/// <para>
|
||||
/// Regression (OpcUaServer-007): the routed command's <see cref="AlarmCommand.User"/> must be a
|
||||
/// NON-EMPTY system user (<c>"system"</c>). The engine's <c>Part9StateMachine.ApplyUnshelve</c> rejects
|
||||
/// a null/whitespace user with <c>ArgumentException("User required.")</c>; that exception is swallowed
|
||||
/// downstream, so an empty user made the timed auto-unshelve SILENTLY no-op — a TimedShelve would never
|
||||
/// auto-expire. Asserting a non-empty user here pins the value the state machine requires.
|
||||
/// </para></summary>
|
||||
/// <summary>OnTimedUnshelve fires with the SDK's system context (no session, no user identity) when the
|
||||
/// SDK's own TimedShelve timer expires. It must return Good and route NOTHING (review R1 M2, Gitea #597):
|
||||
/// the scripted engine owns timed-shelve expiry (its shelving check records an "AutoUnshelve" by
|
||||
/// "system"), and since #597 arms the SDK timer for the real remaining time, an "Unshelve" routed from
|
||||
/// here pre-empted that check on every expiry, from both pair nodes, as an operator-style unshelve.
|
||||
/// <para>Positive control: on the same condition and router, a client Unshelve (OnShelve with
|
||||
/// <c>shelving:false</c>) DOES route — so the empty capture is not a disconnected router.</para></summary>
|
||||
[Fact]
|
||||
public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_unshelve()
|
||||
public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_nothing()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
@@ -299,14 +295,13 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
var result = condition.OnTimedUnshelve!(ctx, condition);
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
var cmd = captured[0];
|
||||
cmd.AlarmId.ShouldBe("alm-tu"); // == ScriptedAlarmId / condition NodeId identifier
|
||||
cmd.Operation.ShouldBe("Unshelve");
|
||||
// System-initiated (no client principal), but the user MUST be non-empty so ApplyUnshelve accepts it.
|
||||
cmd.User.ShouldNotBeNullOrWhiteSpace();
|
||||
cmd.User.ShouldBe("system");
|
||||
cmd.UnshelveAtUtc.ShouldBeNull();
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
// Positive control: the router is live for a genuine operator Unshelve on the same condition.
|
||||
var operatorCtx = SessionContext(server, "grace", OpcUaDataPlaneRoles.AlarmAck);
|
||||
condition.OnShelve!(operatorCtx, condition, shelving: false, oneShot: false, shelvingTime: 0.0)
|
||||
.ShouldBe(ServiceResult.Good);
|
||||
captured.Select(c => c.Operation).ShouldBe(["Unshelve"]);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user