Files
Marc Schier 636912bd4c Harden subscription retirement after #4114 and refresh the client namespace table on model change (#4115)
# Description

Two things, both surfaced by the live NodeManager registration work in
#4094.

> **Rebased on #4114.** Upstream landed #4114 (`cc4667d0b`) while this
PR was open; it fixes the
> same #4113 root cause. `origin/master` is merged in and the conflicts
were resolved **in favour of
> the upstream API shape** — `CompleteAsync(IMessageProcessor, uint
subscriptionId, ct)` with the
> last known server id passed as an argument. The
`IMessageProcessor.LastServerId` member this
> branch had added is dropped again. What remains here is the orthogonal
hardening #4114 does not
> cover, plus all of the #4100 work.

## 1. Remaining subscription-retirement hardening (on top of #4114)

#4114 fixed the id-collision itself. Three related holes in the same
paths are still open:

- **Retirement was not atomic with removal.** `RetireSubscriptionId` is
now called *under*
`m_subscriptionLock` from both `CompleteAsync` and
`RemoveSecondaryPartitionAsync`. `GetById`
resolves incoming publish responses under the same lock, so a worker
could otherwise observe the
subscription as removed while its id had not yet reached the retired-id
ring — and send a
redundant `DeleteSubscriptions` for an already-retired id.
`RemoveSecondaryPartitionAsync` did not
  record the retired id at all.
- **The retired-id ring was far too small.** `kMaxSubscriptionHistory`
10 → 256, so subscription
  churn cannot evict live history.
- **The orphan delete was unguarded.** The publish worker no longer
sends `DeleteSubscriptions` for
an unresolved id while any subscription is still awaiting its id. A
publish response can overtake
the `CreateSubscription` continuation that assigns the id, and deleting
in that window kills a
healthy subscription. Leaving a genuine orphan alive until its lifetime
expires is the safe
  trade-off.
- **`PublishControllerAsync` lost resize signals.** It re-created its
`AsyncAutoResetEvent` waiter on
every loop iteration; the abandoned waiter stayed enqueued and swallowed
a later `Set()`, so the
publish worker pool stopped resizing. This is a second, latent
starvation path independent of the
id collision. The wait is now created once and only re-armed after it
completes.

## 2. Managed client does not refresh the namespace table on model
change (#4100)

`NodeManagerLifecycle.NotifyCommittedChangeAsync` updates
`NamespaceArray`, bumps `UrisVersion`, and
then reports a **`BaseModelChangeEvent`** — an event shape that carries
no `Changes` payload. The
client tracker *did* observe it and cleared the `INodeCache`, but it
held only an
`IStreamingSubscription`, an `INodeCache` and an `ILogger`, so nothing
could re-read the namespace
table. A tracking client therefore resolved NodeIds from a namespace
added by a live NodeManager
registration against a stale `NamespaceUris` table.

- **New `INamespaceTableRefresher`** (`NamespaceUris` +
`FetchNamespaceTablesAsync`). The member
signatures match what `Session` and `ManagedSession` already expose, so
both satisfy the interface
without any new members. `ISession` is deliberately left untouched —
moving its members onto a base
  interface would be a binary break for 1.5.378 consumers.
- **`ModelChangeTracker`** takes the refresher as an optional *trailing*
constructor argument, which
keeps every existing call site source-compatible and makes `null` the
opt-out. A refresh runs when
the event carries no per-node detail, when an affected node is
`Server_NamespaceArray`, or when an
affected namespace index is beyond the client's table. It runs
**before** the cache is invalidated
and before `ModelChanged` is raised, so subscribers that re-browse from
the handler already see the
  new uris. A failing refresh is logged and swallowed.
- **`ModelChangedEventArgs.NamespaceTableRefreshed`** reports the
refresh to applications.
- **`ManagedSession.EnableModelChangeTrackingAsync`** wires the session
in, so opting into tracking
is all an application has to do — no new option, no change to the
positional `CreateAsync`
  signature.
- **`ModelChange.TypeDefinition`** changes from `NodeId?` to `NodeId`.
`NodeId` is a readonly struct
implementing `INullable` and must not be wrapped in
`System.Nullable<T>`; use `NodeId.Null` and
`IsNull` instead. The type is 2.0-preview only (absent from 1.5.378),
and the only producer is the
tracker itself, which already assigns a non-null `AffectedType`. Per
review feedback the type is
also now a `readonly record struct`, matching `DataValueChange` /
`EventNotification`.

## Tests

Every new regression test was run against a temporarily re-introduced
defect and **fails** there:

| Test | Covers |
|---|---|
|
`CompleteAsyncRetiresOnlyTheCompletedSubscriptionWhenOthersArePendingCreationAsync`
| retire-by-identity (also guards #4114's fix) |
| `CompleteAsyncRemembersRetiredServerIdAfterIdWasResetAsync` |
retired-id history |
|
`PublishWorkerDefersDeletingUnknownSubscriptionWhileCreationIsPendingAsync`
| orphan-delete guard |
| `PublishControllerKeepsResizingAfterWorkersExitAsync` |
publish-controller signal loss |
| `ConcurrentAddAndCompleteRetireOnlyCompletedSubscriptionsAsync` |
parallel add/retire registry integrity |
| `ConcurrentSubscriptionChurnNeverStarvesASubscription` (integration) |
#4113 repro against the reference server |
| 7 × `ModelChangeTrackerTests` namespace-refresh cases | each trigger,
no-trigger, throwing refresher, `null` refresher, ordering |
| `LiveNodeManagerAddRefreshesTheClientNamespaceTable` (integration) |
#4100 repro — times out without the wiring |

`FakeMessageAckQueue` records both the completed id (upstream's
assertions) and the completing
instance (the identity assertion in `MessageProcessorTests`), so neither
side's coverage was lost in
the merge.

Results after merging `origin/master`:

- `Opc.Ua.Client.Tests` — **2119/2119** pass on net10.0; the new
`ModelChange` and
  `SubscriptionManager` fixtures also pass on **net48**.
- `Opc.Ua.Subscriptions.Tests` churn + coverage fixtures — **28/28**
(net10.0); churn fixture also
  passes on net48.
- `Opc.Ua.Server.Tests` `NodeManagerLifecycle*` — **49/49** on net10.0;
the new fixture also passes
  on net48.
- No new analyzer warnings; only the three pre-existing `CA1873` hits in
`NodeCacheResolver.cs` and
  `ClassicSubscriptionEngine.cs` (untouched files) remain.

## Documentation

- `docs/ModelChangeTracking.md` — new **Namespace table refresh**
section with the trigger table,
updated `ModelChangedEventArgs` / `ModelChange` snippets, updated
manual-construction snippet, and
  a quick-reference row.
- `docs/NodeManagerRegistration.md` — client-side half of the
**Namespaces** section.

No `docs/migrationguide.md` entry: every changed type is either
`internal` or 2.0-preview-only.

## Related Issues

- Fixes #4100
- Follow-up hardening for #4113 (closed by #4114)
- Related to #4094, #3993

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e22f9b3c-1682-40bd-8b72-31a11352fa2c
2026-07-30 12:17:00 +02:00

844 lines
36 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
// CA2007: tests run without a SynchronizationContext; ConfigureAwait(false)
// adds noise without a behavioural benefit. Disabled file-level for the suite.
#pragma warning disable CA2007
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Client.Subscriptions.Fakes;
using Opc.Ua.Tests;
namespace Opc.Ua.Client.Subscriptions
{
[TestFixture]
public sealed class MessageProcessorTests
{
[SetUp]
public void SetUp()
{
m_completion = new FakeMessageAckQueue();
m_telemetry = NUnitTelemetryContext.Create();
m_mockServices = new Mock<ISubscriptionServiceSetClientMethods>();
}
[Test]
public async Task DisposeAsyncShouldCompleteMessageWriterAndCancelTokenAsync()
{
// Arrange
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 3
};
// Act
await sut.DisposeAsync().ConfigureAwait(false);
// Assert
Assert.That(sut.PublishState, Is.EqualTo(PublishState.Completed));
Assert.That(m_completion.CompletedSubscriptions,
Is.EqualTo(new uint[] { 3 }));
// The subscription must be retired by identity, not only by id.
Assert.That(m_completion.CompletedProcessors,
Is.EqualTo(new IMessageProcessor[] { sut }));
}
[Test]
public async Task OnPublishReceivedKeepAliveShouldDispatchKeepAliveAsync()
{
// Arrange
var message = new NotificationMessage
{
SequenceNumber = 3
};
var availableSequenceNumbers = new List<uint> { 1, 2, 3 };
var stringTable = new List<string> { "test" };
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 3
};
await using (sut.ConfigureAwait(false))
{
// Act
await sut.OnPublishReceivedAsync(message, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.KeepAliveNotificationReceived.WaitAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
// Assert
Assert.That(sut.KeepAliveNotificationReceived.IsSet, Is.True);
Assert.That(sut.AvailableInRetransmissionQueue, Is.EqualTo(availableSequenceNumbers));
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(3));
Assert.That(sut.DataChangeNotificationReceived.IsSet, Is.False);
Assert.That(m_completion.QueuedAcks, Is.Empty);
// Arrange
sut.KeepAliveNotificationReceived.Reset();
sut.DataChangeNotificationReceived.Reset();
message = new NotificationMessage
{
SequenceNumber = 4,
NotificationData =
[
new ExtensionObject(new DataChangeNotification
{
MonitoredItems =
[
new MonitoredItemNotification()
]
})
]
};
// Act
await sut.OnPublishReceivedAsync(message, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.DataChangeNotificationReceived.WaitAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
// Assert
Assert.That(sut.AvailableInRetransmissionQueue, Is.EqualTo(availableSequenceNumbers));
Assert.That(sut.DataChangeNotificationReceived.IsSet, Is.True);
Assert.That(sut.KeepAliveNotificationReceived.IsSet, Is.False);
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(4));
Assert.That(m_completion.QueuedAcks, Has.Count.EqualTo(1));
Assert.That(m_completion.QueuedAcks[0].SubscriptionId, Is.EqualTo(3u));
Assert.That(m_completion.QueuedAcks[0].SequenceNumber, Is.EqualTo(4u));
}
}
[Test]
public async Task ProcessMessageAsyncShouldRepublishMissingMessagesAsync()
{
// Arrange
var availableSequenceNumbers = new List<uint> { 1, 2, 3 };
var stringTable = new List<string> { "test" };
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 2
};
await using (sut.ConfigureAwait(false))
{
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 1
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
m_mockServices
.Setup(c => c.RepublishAsync(
It.IsAny<RequestHeader>(),
It.Is<uint>(id => id == sut.Id),
It.Is<uint>(s => s == 2),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new RepublishResponse
{
NotificationMessage = new NotificationMessage
{
SequenceNumber = 2,
NotificationData =
[
new ExtensionObject(new DataChangeNotification
{
MonitoredItems =
[
new MonitoredItemNotification()
]
})
]
}
})
.Verifiable(Times.Once);
// Act
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 3,
NotificationData =
[
new ExtensionObject(new EventNotificationList
{
Events =
[
new EventFieldList()
]
})
]
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.EventNotificationReceived.WaitAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
// Assert
Assert.That(sut.EventNotificationReceived.IsSet, Is.True);
Assert.That(sut.KeepAliveNotificationReceived.IsSet, Is.True);
Assert.That(sut.DataChangeNotificationReceived.IsSet, Is.True);
m_mockServices.Verify();
}
}
[Test]
public async Task ProcessReceivedMessagesAsyncShouldProcessMessagesInOrderAsync()
{
var availableSequenceNumbers = new List<uint> { 1, 2, 3 };
var stringTable = new List<string> { "test" };
// Arrange
NotificationMessage[] messages = [.. Enumerable.Range(2, 99).Select(i => new NotificationMessage
{
SequenceNumber = (uint)i
})];
UnsecureRandom.Shared.Shuffle(messages);
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 3
};
await using (sut.ConfigureAwait(false))
{
sut.Block.Wait();
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 1u
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
foreach (NotificationMessage message in messages)
{
await sut.OnPublishReceivedAsync(message, availableSequenceNumbers, stringTable).ConfigureAwait(false);
}
sut.Block.Release();
// Act
await Task.Delay(10).ConfigureAwait(false);
Assert.That(sut.ReceivedSequenceNumbers, Is.EqualTo(
Enumerable.Range(1, 100).Select(i => (uint)i)));
Assert.That(sut.AvailableInRetransmissionQueue, Is.EqualTo(availableSequenceNumbers));
Assert.That(sut.DataChangeNotificationReceived.IsSet, Is.False);
Assert.That(sut.KeepAliveNotificationReceived.IsSet, Is.True);
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(100));
}
}
[Test]
public async Task DuplicateSequenceNumberShouldNotRedispatchAsync()
{
var availableSequenceNumbers = new List<uint> { 1, 2, 3 };
var stringTable = new List<string> { "test" };
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 7
};
await using (sut.ConfigureAwait(false))
{
var message = new NotificationMessage
{
SequenceNumber = 5,
NotificationData =
[
new ExtensionObject(new DataChangeNotification
{
MonitoredItems =
[
new MonitoredItemNotification()
]
})
]
};
// First arrival
await sut.OnPublishReceivedAsync(message, availableSequenceNumbers, stringTable)
.ConfigureAwait(false);
await sut.DataChangeNotificationReceived.WaitAsync()
.WaitAsync(TimeSpan.FromSeconds(5))
.ConfigureAwait(false);
int firstCount = sut.ReceivedSequenceNumbers.Count;
Assert.That(firstCount, Is.GreaterThanOrEqualTo(1));
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(5));
// Reset signaling
sut.DataChangeNotificationReceived.Reset();
// Same sequence number arriving again
await sut.OnPublishReceivedAsync(message, availableSequenceNumbers, stringTable)
.ConfigureAwait(false);
// Give the processor a moment in case it tries to dispatch
await Task.Delay(50).ConfigureAwait(false);
// The duplicate should not re-fire the data-change handler.
Assert.That(
sut.DataChangeNotificationReceived.IsSet, Is.False,
"Duplicate sequence number must not re-dispatch the notification");
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(5));
}
}
[Test]
public async Task KeepAliveInterleavedWithNotificationsAsync()
{
var availableSequenceNumbers = new List<uint> { 1, 2, 3 };
var stringTable = new List<string> { "test" };
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 5
};
await using (sut.ConfigureAwait(false))
{
// 1: notification
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 1,
NotificationData =
[
new ExtensionObject(new DataChangeNotification
{
MonitoredItems = [new MonitoredItemNotification()]
})
]
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.DataChangeNotificationReceived.WaitAsync()
.WaitAsync(TimeSpan.FromSeconds(5))
.ConfigureAwait(false);
// 2: keep-alive (no NotificationData)
sut.KeepAliveNotificationReceived.Reset();
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 2
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.KeepAliveNotificationReceived.WaitAsync()
.WaitAsync(TimeSpan.FromSeconds(5))
.ConfigureAwait(false);
// 3: another notification
sut.DataChangeNotificationReceived.Reset();
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 3,
NotificationData =
[
new ExtensionObject(new DataChangeNotification
{
MonitoredItems = [new MonitoredItemNotification()]
})
]
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.DataChangeNotificationReceived.WaitAsync()
.WaitAsync(TimeSpan.FromSeconds(5))
.ConfigureAwait(false);
// All three sequence numbers should appear in order.
Assert.That(sut.ReceivedSequenceNumbers,
Is.EqualTo(new uint[] { 1, 2, 3 }));
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(3));
}
}
[Test]
public async Task EmptyNotificationDataIsTreatedAsKeepAliveAsync()
{
var availableSequenceNumbers = new List<uint> { 1, 2, 3 };
var stringTable = new List<string> { "test" };
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 9
};
await using (sut.ConfigureAwait(false))
{
// NotificationData is null/default.
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 7
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.KeepAliveNotificationReceived.WaitAsync()
.WaitAsync(TimeSpan.FromSeconds(5))
.ConfigureAwait(false);
Assert.That(sut.KeepAliveNotificationReceived.IsSet, Is.True);
Assert.That(sut.DataChangeNotificationReceived.IsSet, Is.False);
Assert.That(sut.EventNotificationReceived.IsSet, Is.False);
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(7));
}
}
[Test]
public async Task RepublishFailureLogsButContinuesProcessingAsync()
{
var availableSequenceNumbers = new List<uint> { 1, 2, 3 };
var stringTable = new List<string> { "test" };
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 11
};
await using (sut.ConfigureAwait(false))
{
// First message at sequence 1.
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 1
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
// Republish for the gap (sequence 2) returns an error.
m_mockServices
.Setup(c => c.RepublishAsync(
It.IsAny<RequestHeader>(),
It.Is<uint>(id => id == sut.Id),
It.Is<uint>(s => s == 2),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(
StatusCodes.BadMessageNotAvailable))
.Verifiable(Times.AtLeastOnce);
// Skip to sequence 3 to force the gap.
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 3,
NotificationData =
[
new ExtensionObject(new EventNotificationList
{
Events = [new EventFieldList()]
})
]
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.EventNotificationReceived.WaitAsync()
.WaitAsync(TimeSpan.FromSeconds(5))
.ConfigureAwait(false);
// Even though republish failed, the next message must still
// be dispatched.
Assert.That(sut.EventNotificationReceived.IsSet, Is.True);
m_mockServices.Verify();
}
}
[Test]
public async Task SequenceNumberWraparoundAdvancesLastProcessedAsync()
{
// Per OPC UA Part 4 §7.30.5, sequence numbers wrap from
// uint.MaxValue to 1 (skipping 0). The processor must accept
// this wrap as forward progress, not silently drop the
// post-wrap message as "old".
var availableSequenceNumbers = new List<uint>();
var stringTable = new List<string> { "test" };
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 13
};
await using (sut.ConfigureAwait(false))
{
// Republish for the first message's "missing" gap (we start
// from LastSequenceNumberProcessed=0 so no gap is computed
// for this first one) — but for the second message, the gap
// is empty because we wrap directly from uint.MaxValue to 1.
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = uint.MaxValue
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.KeepAliveNotificationReceived.WaitAsync()
.WaitAsync(TimeSpan.FromSeconds(5))
.ConfigureAwait(false);
Assert.That(sut.LastSequenceNumberProcessed,
Is.EqualTo(uint.MaxValue));
sut.KeepAliveNotificationReceived.Reset();
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 1
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.KeepAliveNotificationReceived.WaitAsync()
.WaitAsync(TimeSpan.FromSeconds(5))
.ConfigureAwait(false);
Assert.That(sut.KeepAliveNotificationReceived.IsSet, Is.True,
"Wrapped sequence number must be accepted as forward progress, " +
"not dropped as duplicate/old.");
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(1));
}
}
[Test]
public async Task ReceivingTransferStatusUpdateShouldUpdatePublishStateAsync()
{
// Arrange
var availableSequenceNumbers = new List<uint> { 1, 2, 3 };
var stringTable = new List<string> { "test" };
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 3
};
await using (sut.ConfigureAwait(false))
{
// Act
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 3,
NotificationData =
[
new ExtensionObject(new StatusChangeNotification
{
Status = StatusCodes.GoodSubscriptionTransferred
})
]
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.StatusChangeNotificationReceived.WaitAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
// Assert
Assert.That(sut.StatusChangeNotificationReceived.IsSet, Is.True);
Assert.That(sut.ReceivedSequenceNumbers, Does.Contain(3));
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(3));
Assert.That(sut.PublishState, Is.EqualTo(PublishState.Transferred));
sut.StatusChangeNotificationReceived.Reset();
// Act
await sut.OnPublishReceivedAsync(new NotificationMessage
{
SequenceNumber = 4,
NotificationData =
[
new ExtensionObject(new StatusChangeNotification
{
Status = StatusCodes.BadTimeout
})
]
}, availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.StatusChangeNotificationReceived.WaitAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
// Assert
Assert.That(sut.StatusChangeNotificationReceived.IsSet, Is.True);
Assert.That(sut.ReceivedSequenceNumbers, Does.Contain(4));
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(4));
Assert.That(sut.PublishState, Is.EqualTo(PublishState.Timeout));
}
}
/// <summary>
/// V2 equivalent of the classic-engine regression captured by
/// <c>OldMessagesAbandonedAfterRepublishTimeoutInSequentialModeAsync</c>
/// (currently <c>[Explicit]</c> in <c>Classic/SubscriptionUnitTests.cs</c>).
/// </summary>
/// <remarks>
/// Mirrors the classic test's exact arrival order — <c>[3]</c>,
/// <c>[2]</c>, <c>[4]</c>, <c>[1]</c>, <c>[5]</c> — but queues all five
/// while the worker is parked inside a sentinel keep-alive dispatch.
/// The new <see cref="MessageProcessor"/> uses
/// <see cref="System.Threading.Channels.Channel.CreateUnboundedPrioritized"/>
/// keyed by <see cref="NotificationMessage.SequenceNumber"/>, so any
/// items present in the channel at a single read are sorted by
/// sequence number. With the consumer held, the five data messages
/// land together and drain as <c>[1, 2, 3, 4, 5]</c> with no
/// republishes — the reliability guarantee the classic engine fails
/// to provide. (Without the sentinel, a fast consumer could read the
/// first arrival before the rest queue, which is what the priority
/// channel cannot recover from on its own.)
/// </remarks>
[Test]
public async Task OutOfOrderArrivalsReorderedByPriorityChannelAsync()
{
var availableSequenceNumbers = new List<uint> { 1, 2, 3, 4, 5 };
var stringTable = new List<string> { "test" };
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 11
};
await using (sut.ConfigureAwait(false))
{
// Arrange: drain the gate, then park the worker inside a
// sentinel keep-alive dispatch so all five data messages
// queue up in the priority channel before any are read.
// The keep-alive does not advance the data dedup gate
// (LastDataSequenceNumberProcessed) so the burst is still
// processed as a fresh sequence starting at 1.
sut.Block.Wait();
await sut.OnPublishReceivedAsync(
new NotificationMessage { SequenceNumber = 1u },
availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.KeepAliveNotificationReceived.WaitAsync()
.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
foreach (uint seq in new uint[] { 3, 2, 4, 1, 5 })
{
await sut.OnPublishReceivedAsync(BuildDataChangeMessage(seq),
availableSequenceNumbers, stringTable).ConfigureAwait(false);
}
sut.Block.Release();
// Act
await WaitForLastSeqNumberAsync(sut, 5).ConfigureAwait(false);
// Assert: the first entry is the sentinel keep-alive; the
// remaining entries must be the data burst in strict
// sequence-number order.
Assert.That(sut.ReceivedSequenceNumbers,
Has.Count.EqualTo(6));
Assert.That(sut.ReceivedSequenceNumbers[0], Is.EqualTo(1u),
"first entry should be the sentinel keep-alive");
Assert.That(sut.ReceivedSequenceNumbers.Skip(1),
Is.EqualTo(new uint[] { 1, 2, 3, 4, 5 }));
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(5));
Assert.That(sut.MissingMessageCount, Is.Zero);
Assert.That(sut.RepublishMessageCount, Is.Zero);
m_mockServices.Verify(
c => c.RepublishAsync(
It.IsAny<RequestHeader>(),
It.IsAny<uint>(),
It.IsAny<uint>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
}
/// <summary>
/// V2 equivalent of the classic-engine regression captured by
/// <c>OldMessagesAbandonedAfterRepublishTimeoutInSequentialModeAsync</c>,
/// stressed in serial timing instead of as a burst.
/// </summary>
/// <remarks>
/// Same arrival order — <c>[3]</c>, <c>[2]</c>, <c>[4]</c>, <c>[1]</c>,
/// <c>[5]</c> — but each forward-progressing push waits for delivery
/// before the next is queued. The V2 engine's monotonic dedup gate
/// (<c>LastDataSequenceNumberProcessed</c>) silently discards the
/// stale <c>[2]</c> and <c>[1]</c> instead of re-inserting them
/// out of order; <c>[3, 4, 5]</c> are delivered in strict ascending
/// order with no republishes.
/// </remarks>
[Test]
public async Task LateArrivalsDiscardedAfterDedupGateAdvancedAsync()
{
var availableSequenceNumbers = new List<uint>();
var stringTable = new List<string> { "test" };
var sut = new TestMessageProcessor(m_mockServices.Object,
m_completion, m_telemetry)
{
Id = 12
};
await using (sut.ConfigureAwait(false))
{
// [3]: first data message after create. The empty
// AvailableInRetransmissionQueue suppresses the
// first-after-create speculative republish path, so [3]
// goes straight to the handler.
await sut.OnPublishReceivedAsync(BuildDataChangeMessage(3),
availableSequenceNumbers, stringTable).ConfigureAwait(false);
await WaitForLastSeqNumberAsync(sut, 3).ConfigureAwait(false);
// [2] is strictly behind LastDataSeq=3. Queue [2] then [4]
// back-to-back; the priority channel picks [2] first
// (lowest SequenceNumber) so the worker reads [2], discards
// it silently, then reads [4] and delivers it. Waiting for
// LastSeq == 4 confirms both happened in that order.
await sut.OnPublishReceivedAsync(BuildDataChangeMessage(2),
availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.OnPublishReceivedAsync(BuildDataChangeMessage(4),
availableSequenceNumbers, stringTable).ConfigureAwait(false);
await WaitForLastSeqNumberAsync(sut, 4).ConfigureAwait(false);
// Same shape for [1] (even staler) followed by [5].
await sut.OnPublishReceivedAsync(BuildDataChangeMessage(1),
availableSequenceNumbers, stringTable).ConfigureAwait(false);
await sut.OnPublishReceivedAsync(BuildDataChangeMessage(5),
availableSequenceNumbers, stringTable).ConfigureAwait(false);
await WaitForLastSeqNumberAsync(sut, 5).ConfigureAwait(false);
// Assert: only forward-progressing messages were delivered,
// in strict order; the late [2] and [1] were discarded.
Assert.That(sut.ReceivedSequenceNumbers,
Is.EqualTo(new uint[] { 3, 4, 5 }));
Assert.That(sut.LastSequenceNumberProcessed, Is.EqualTo(5));
Assert.That(sut.MissingMessageCount, Is.Zero);
Assert.That(sut.RepublishMessageCount, Is.Zero);
m_mockServices.Verify(
c => c.RepublishAsync(
It.IsAny<RequestHeader>(),
It.IsAny<uint>(),
It.IsAny<uint>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
}
private static NotificationMessage BuildDataChangeMessage(uint sequenceNumber)
{
return new NotificationMessage
{
SequenceNumber = sequenceNumber,
NotificationData =
[
new ExtensionObject(new DataChangeNotification
{
MonitoredItems =
[
new MonitoredItemNotification()
]
})
]
};
}
private static async Task WaitForLastSeqNumberAsync(
TestMessageProcessor sut,
uint expected,
int timeoutSeconds = 5)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow.AddSeconds(timeoutSeconds);
while (sut.LastSequenceNumberProcessed < expected)
{
if (DateTimeOffset.UtcNow > deadline)
{
Assert.Fail(
$"Timeout: LastSequenceNumberProcessed = {sut.LastSequenceNumberProcessed}, " +
$"expected = {expected}, received = " +
$"[{string.Join(",", sut.ReceivedSequenceNumbers)}]");
}
await Task.Delay(10).ConfigureAwait(false);
}
}
private sealed class TestMessageProcessor : MessageProcessor
{
public TestMessageProcessor(ISubscriptionServiceSetClientMethods session,
IMessageAckQueue completion, ITelemetryContext telemetry)
: base(session, completion, telemetry)
{
}
public new IReadOnlyList<uint> AvailableInRetransmissionQueue
{
get => base.AvailableInRetransmissionQueue;
set => base.AvailableInRetransmissionQueue = value;
}
public SemaphoreSlim Block { get; } = new(1, 1);
public AsyncManualResetEvent DataChangeNotificationReceived { get; } = new();
public AsyncManualResetEvent EventNotificationReceived { get; } = new();
public AsyncManualResetEvent KeepAliveNotificationReceived { get; } = new();
public new uint LastSequenceNumberProcessed
{
get => base.LastSequenceNumberProcessed;
set => base.LastSequenceNumberProcessed = value;
}
public PublishState PublishState { get; set; }
public List<uint> ReceivedSequenceNumbers { get; } = [];
public AsyncManualResetEvent StatusChangeNotificationReceived { get; } = new();
public async ValueTask WaitAsync()
{
await Block.WaitAsync().ConfigureAwait(false);
Block.Release();
}
protected override ValueTask OnDataChangeNotificationAsync(uint sequenceNumber,
DateTime publishTime, DataChangeNotification notification,
PublishState publishStateMask, IReadOnlyList<string> stringTable)
{
ReceivedSequenceNumbers.Add(sequenceNumber);
if (publishStateMask != PublishState.None)
{
PublishState = publishStateMask;
}
DataChangeNotificationReceived.Set();
return WaitAsync();
}
protected override ValueTask OnEventDataNotificationAsync(uint sequenceNumber,
DateTime publishTime, EventNotificationList notification,
PublishState publishStateMask, IReadOnlyList<string> stringTable)
{
ReceivedSequenceNumbers.Add(sequenceNumber);
if (publishStateMask != PublishState.None)
{
PublishState = publishStateMask;
}
EventNotificationReceived.Set();
return WaitAsync();
}
protected override ValueTask OnKeepAliveNotificationAsync(uint sequenceNumber,
DateTime publishTime, PublishState publishStateMask)
{
ReceivedSequenceNumbers.Add(sequenceNumber);
if (publishStateMask != PublishState.None)
{
PublishState = publishStateMask;
}
KeepAliveNotificationReceived.Set();
return WaitAsync();
}
protected override void OnPublishStateChanged(PublishState stateMask)
{
PublishState = stateMask;
base.OnPublishStateChanged(stateMask);
}
protected override async ValueTask OnStatusChangeNotificationAsync(uint sequenceNumber,
DateTime publishTime, StatusChangeNotification notification,
PublishState publishStateMask, IReadOnlyList<string> stringTable)
{
ReceivedSequenceNumbers.Add(sequenceNumber);
if (publishStateMask != PublishState.None)
{
PublishState = publishStateMask;
}
StatusChangeNotificationReceived.Set();
await WaitAsync().ConfigureAwait(false);
}
}
private FakeMessageAckQueue m_completion;
private ITelemetryContext m_telemetry;
private Mock<ISubscriptionServiceSetClientMethods> m_mockServices;
}
}