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 <noreply@anthropic.com>
This commit is contained in:
Roman Ettlinger
2026-10-18 16:33:41 +02:00
co-authored by Claude Opus 5.5
parent f0b1d0348b
commit f33893b7c4
2 changed files with 70 additions and 3 deletions
+13 -3
View File
@@ -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
@@ -156,6 +156,63 @@ namespace Opc.Ua.Server.Tests
Assert.That(subscription.Diagnostics.CurrentLifetimeCount, Is.EqualTo((uint)steps.Length));
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// M1-1: a client that keeps answering keep-alives never lets the lifetime expire.
/// </summary>