Preserve publish queue timestamp ordering - #4016
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a deterministic regression test to prevent a previously fixed SessionPublishQueue “lost wake-up” race from reappearing, without changing production code.
Changes:
- Adds a new concurrency/regression test that forces the critical publish/subscription interleaving.
- Introduces private-lock access helpers (via reflection) to orchestrate specific execution ordering.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #4016 +/- ##
===========================================
+ Coverage 36.98% 79.92% +42.94%
===========================================
Files 898 1515 +617
Lines 138122 209992 +71870
Branches 25039 36220 +11181
===========================================
+ Hits 51079 167833 +116754
+ Misses 80638 29561 -51077
- Partials 6405 12598 +6193
🚀 New features to boost your workflow:
|
mrsuciu
left a comment
There was a problem hiding this comment.
There is still an inconsistency since PublishTimerExpired() bypasses the subscription-selection policy used by PublishAsync(). PublishTimerExpired enumerates through m_queuedSubscriptions (which is a ConcurrentDictionary and doesn't offer order guarrantee) and calls "assign subscription to request if one is available" AssignSubscriptionToRequest(subscription); which servers it to the first waiting publish request. No Priority taken into account in this case. Am I missing something
Addresses review feedback on #4016: PublishTimerExpired() bypassed the subscription selection policy used by PublishAsync(). It iterated m_queuedSubscriptions - a ConcurrentDictionary with no ordering guarantee - and handed every newly notifying Subscription straight to the first waiting Publish request, so Priority and Timestamp were ignored whenever several Subscriptions became ready in the same timer tick. - PublishTimerExpired() now flags all notifying Subscriptions as available first and then drains the waiting requests through GetSubscriptionToPublish(), so requests are served highest priority and longest waiting first. - PublishCompleted() uses the same path when more notifications are pending instead of assigning the Subscription directly. - AssignSubscriptionToRequest() is replaced by AssignSubscriptionsToRequests()/TryAssignSubscriptionToRequest(), which also skips a request whose task completed (cancelled or timed out) between the check and the result being set instead of losing the Subscription. - Adds PublishTimerAssignsWaitingRequestToHighestPrioritySubscriptionAsync, which derives the Priority from the observed timer iteration order so the assertion does not depend on dictionary ordering. It fails without the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 181ba7ea-c72f-42d7-a737-00a3faadaa17
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
tests/Opc.Ua.Server.Tests/SessionPublishQueueTests.cs:182
- This test asserts a specific
timerOrderacross twoPublishTimerExpired()calls, but the subscription enumeration order is not guaranteed (and can vary between enumerations), which can make the test flaky. Consider asserting set membership (both subscriptions were visited) and keep the determinism focused on the publish-selection outcome (i.e., thatPublishAsync()returnsolderSubscription), rather than assuming a stable callback order.
Assert.That(timerOrder, Has.Count.EqualTo(2));
Assert.That(timerOrder[0], Is.SameAs(newerSubscription));
Assert.That(timerOrder[1], Is.SameAs(olderSubscription));
src/Opc.Ua.Server/Subscription/SessionPublishQueue.cs:524
AssignSubscriptionsToRequests()no longer takesm_lockinternally (the oldAssignSubscriptionToRequest()did). This makes thread-safety depend on all call sites holdingm_lock, which is easy to violate during future edits. To avoid accidental race conditions, consider reintroducing the internallock (m_lock)inAssignSubscriptionsToRequests()(and keepTryAssignSubscriptionToRequest()private to the locked context), or add an explicit invariant (e.g., a debug assertion / naming convention likeAssignSubscriptionsToRequestsLocked) to enforce correct usage.
private void AssignSubscriptionsToRequests()
{
while (m_queuedRequests.Count > 0)
{
QueuedSubscription? subscriptionToPublish = GetSubscriptionToPublish();
if (subscriptionToPublish == null)
{
break;
}
if (!TryAssignSubscriptionToRequest(subscriptionToPublish))
{
// no usable request left, keep the subscription available.
subscriptionToPublish.Publishing = false;
break;
}
}
}
src/Opc.Ua.Server/Subscription/SessionPublishQueue.cs:490
- Within a single timer tick, assigning
Timestamp = DateTime.UtcNowinside the loop can introduce per-subscription timestamp differences that depend onnotifyingSubscriptionsorder (which ultimately stems from an unordered dictionary enumeration). If the intent is that all notifications discovered in the same timer tick are equivalently 'ready now', consider capturingDateTime.UtcNowonce before the loop and applying the same value to all newly-ready subscriptions to reduce ordering artifacts.
foreach (QueuedSubscription subscription in notifyingSubscriptions)
{
if (subscription.Publishing || subscription.ReadyToPublish)
{
continue;
}
subscription.ReadyToPublish = true;
subscription.Timestamp = DateTime.UtcNow;
}
# Conflicts: # src/Opc.Ua.Server/Subscription/SessionPublishQueue.cs
Description
Fixes publish request assignment ordering in
SessionPublishQueue, following up on the review of the proposed #3997 follow-up.Two related problems were identified during review:
PublishTimerExpired()reset itsTimestampwhen no Publish request was available, which broke oldest-first selection among equal-priority Subscriptions. Master already uses a single lock for the ready check and the Publish-request enqueue, so the original lost-wakeup window does not require removing theReadyToPublishguard.PublishTimerExpired()bypassed the selection policy used byPublishAsync(). It iteratedm_queuedSubscriptions— aConcurrentDictionarywith no ordering guarantee — and handed each newly notifying Subscription straight to the first waiting request, soPriorityandTimestampwere ignored whenever several Subscriptions became ready in the same timer tick.This PR now:
ReadyToPublishtimer early exit so already-ready Subscriptions retain their timestamps.GetSubscriptionToPublish(), so requests are served highest priority and longest waiting first regardless of dictionary iteration order.PublishCompleted(..., moreNotifications: true)through the same path instead of assigning the Subscription directly.AssignSubscriptionToRequest()withAssignSubscriptionsToRequests()/TryAssignSubscriptionToRequest(). The latter also skips a request whose task completed (cancelled or timed out) between theIsCompletedcheck andTrySetResult, instead of losing the Subscription.Related Issues
master378correction in [master378] Restore ReadyToPublish guard in SessionPublishQueue.PublishTimerExpired to preserve Subscription timestamp ordering #4119Testing
SessionPublishQueueTestspass on net10.0 and net48.PublishTimerAssignsWaitingRequestToHighestPrioritySubscriptionAsyncandPublishTimerPreservesReadySubscriptionTimestampOrderAsyncboth fail without the corresponding source change.Opc.Ua.Server.Testsproject shows no new failures on net10.0; the singleServerFluentApiHostingTests.ConfigureApplicationBuildsSharedClientAndServerConfigurationAsyncfailure reproduces unchanged on the branch without these edits.