From f33893b7c41cae9abfd355721bfa6e9838df29b0 Mon Sep 17 00:00:00 2001 From: Roman Ettlinger Date: Sun, 18 Oct 2026 16:33:41 +0200 Subject: [PATCH] Server: advance a subscription's publish deadline by one interval per expiry With the fixed-cadence sweep running at the publishing interval, each subscription's deadline keeps one phase relative to the sweep for the whole session. When that phase falls inside the ~16 ms Windows timer jitter, a late sweep followed by an on-time one finds the subscription a whole interval behind, and the advance loop jumped past two deadlines: a publishing cycle was dropped, and with queue size 1 the item values of that cycle were overwritten. Runs became bimodal (100 % or 56-85 % delivered) depending on the phase drawn at setup. The deadline now moves by one interval per expiry, so the next sweep processes the expiry that was due; only a subscription still behind by a whole interval after that (a stalled sweep) skips the missed expiries as before. 2 sessions x 1 subscription x 100 items, write = publish = 100 ms, security none, 10 s window, paired and idle-gated: - before: 6 of 24 runs at 56-85 %, the rest 96-101 % - after: 24 of 24 runs at 97-101 % - master: steady 88-98 % With write 150 ms: 98.5-100 % (before: one run at 89.6 %). The 50 x 15 x 600 LoadTest shape is unchanged (98.8-100.7 %). Co-Authored-By: Claude Opus 5.5 --- .../Subscription/Subscription.cs | 16 +++++- ...SubscriptionStateMachineRegressionTests.cs | 57 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/Opc.Ua.Server/Subscription/Subscription.cs b/src/Opc.Ua.Server/Subscription/Subscription.cs index 2153507289..87b431e6eb 100644 --- a/src/Opc.Ua.Server/Subscription/Subscription.cs +++ b/src/Opc.Ua.Server/Subscription/Subscription.cs @@ -781,10 +781,20 @@ namespace Opc.Ua.Server return PublishingState.Idle; } - // set next expiry time, strictly after the current time. - while (m_publishTimerExpiry <= currentTime) + // set next expiry time. The publish timer sweep runs on its own cadence, and + // when it runs at the publishing interval a sweep that wakes a little late + // can find the subscription a whole interval behind. Advancing by one + // interval keeps that expiry due for the next sweep instead of dropping a + // publishing cycle; only a subscription that is still behind by a whole + // interval after that (a stalled sweep) skips the missed expiries. + long publishingInterval = (long)m_publishingInterval; + m_publishTimerExpiry += publishingInterval; + if (m_publishTimerExpiry <= currentTime - publishingInterval) { - m_publishTimerExpiry += (long)m_publishingInterval; + while (m_publishTimerExpiry <= currentTime) + { + m_publishTimerExpiry += publishingInterval; + } } // OPC 10000-4 ยง5.14.1.2 row 27: the LifetimeCounter is decremented when the diff --git a/tests/Opc.Ua.Server.Tests/SubscriptionStateMachineRegressionTests.cs b/tests/Opc.Ua.Server.Tests/SubscriptionStateMachineRegressionTests.cs index ac22198e2f..90ff700eb5 100644 --- a/tests/Opc.Ua.Server.Tests/SubscriptionStateMachineRegressionTests.cs +++ b/tests/Opc.Ua.Server.Tests/SubscriptionStateMachineRegressionTests.cs @@ -156,6 +156,63 @@ namespace Opc.Ua.Server.Tests Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo((uint)steps.Length)); } + /// + /// A sweep that runs at the publishing interval and wakes a little early or late + /// around the deadline does not drop publishing cycles. The deadline moves by one + /// interval per expiry, so a sweep that finds the subscription a whole interval + /// behind because of timer jitter keeps the next expiry due for the next sweep. + /// + [Test] + public void SweepJitterAroundTheDeadlineDoesNotDropCycles() + { + var clock = new FakeTimeProvider(); + using Subscription subscription = CreateSubscription( + clock, + maxLifetimeCount: 1000, + maxKeepAliveCount: 300); + + // deadlines at 100, 200, 300, ...; sweeps at 95, 205, 295, 405, 495, 605, 695, 805. + long[] steps = [95, 110, 90, 110, 90, 110, 90, 110]; + foreach (long step in steps) + { + clock.Advance(TimeSpan.FromMilliseconds(step)); + subscription.PublishTimerExpired(); + } + + // the sweep at 95 is early; every later sweep processes one expiry. + Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo(7u)); + } + + /// + /// A subscription that fell behind by more than a publishing interval (a stalled + /// sweep) skips the missed expiries instead of catching up with a burst. + /// + [Test] + public void StalledSweepSkipsMissedExpiries() + { + var clock = new FakeTimeProvider(); + using Subscription subscription = CreateSubscription( + clock, + maxLifetimeCount: 1000, + maxKeepAliveCount: 300); + + clock.Advance(TimeSpan.FromMilliseconds(101)); + subscription.PublishTimerExpired(); + + // the sweep stalls for ten intervals; the late sweep processes one expiry. + clock.Advance(TimeSpan.FromMilliseconds(1000)); + subscription.PublishTimerExpired(); + Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo(2u)); + + // the next expiry is the first deadline after the late sweep (1200 ms). + clock.Advance(TimeSpan.FromMilliseconds(50)); + subscription.PublishTimerExpired(); + Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo(2u)); + clock.Advance(TimeSpan.FromMilliseconds(50)); + subscription.PublishTimerExpired(); + Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo(3u)); + } + /// /// M1-1: a client that keeps answering keep-alives never lets the lifetime expire. ///