/* ======================================================================== * 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.Globalization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Time.Testing; using Moq; using NUnit.Framework; using Opc.Ua.Server.Historian; using Opc.Ua.Server.Historian.InMemory; namespace Opc.Ua.Server.Tests.Historian { /// /// Verifies in-memory historical values, modifications, annotations, paging, and time-bound semantics. /// [TestFixture] [Category("Historian")] [Parallelizable(ParallelScope.All)] public class InMemoryHistorianProviderTests { private const ushort NamespaceIndex = 1; /// /// Verifies that inserted historical values are returned by raw-history reads. /// [Test] public async Task InsertAsyncStoresValuesAndRawReadReturnsThemAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("test.var", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); var values = new List { MakeValue(BaseTime.AddSeconds(10), 1.0), MakeValue(BaseTime.AddSeconds(20), 2.0), MakeValue(BaseTime.AddSeconds(30), 3.0) }; HistorianUpdateOutcome insertOutcome = await provider.InsertAsync( context, nodeId, values, CancellationToken.None).ConfigureAwait(false); Assert.That(insertOutcome.OperationResults, Has.Count.EqualTo(3)); foreach (StatusCode sc in insertOutcome.OperationResults) { Assert.That(StatusCode.IsGood(sc), Is.True); } HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), MaxValues = 0, IsForward = true, ReturnBounds = false }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(3)); Assert.That(page.IsFinal, Is.True); Assert.That( Convert.ToDouble(page.Values[0].Value.WrappedValue.AsBoxedObject(), CultureInfo.InvariantCulture), Is.EqualTo(1.0)); Assert.That( Convert.ToDouble(page.Values[2].Value.WrappedValue.AsBoxedObject(), CultureInfo.InvariantCulture), Is.EqualTo(3.0)); } /// /// Verifies that insertion rejects duplicate source timestamps. /// [Test] public async Task InsertRejectsDuplicateSourceTimestampAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("dup.var", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); DateTime when = BaseTime.AddSeconds(10); HistorianUpdateOutcome first = await provider.InsertAsync( context, nodeId, [MakeValue(when, 1.0)], CancellationToken.None).ConfigureAwait(false); Assert.That(StatusCode.IsGood(first.OperationResults[0]), Is.True); HistorianUpdateOutcome second = await provider.InsertAsync( context, nodeId, [MakeValue(when, 1.5)], CancellationToken.None).ConfigureAwait(false); Assert.That(second.OperationResults[0].Code, Is.EqualTo(StatusCodes.BadEntryExists.Code)); } /// /// Verifies that replacement fails when no historical entry exists. /// [Test] public async Task ReplaceFailsWhenNoEntryExistsAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("rep.var", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); HistorianUpdateOutcome outcome = await provider.ReplaceAsync( context, nodeId, [MakeValue(BaseTime.AddSeconds(15), 42.0)], CancellationToken.None).ConfigureAwait(false); Assert.That(outcome.OperationResults[0].Code, Is.EqualTo(StatusCodes.BadNoEntryExists.Code)); } /// /// Verifies that update inserts or replaces values and records modification metadata. /// [Test] public async Task UpdateUpsertsAndLogsModificationAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("up.var", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); DateTime when = BaseTime.AddSeconds(10); await provider.InsertAsync( context, nodeId, [MakeValue(when, 1.0)], CancellationToken.None).ConfigureAwait(false); await provider.UpdateAsync( context, nodeId, [MakeValue(when, 2.0)], CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(1)); Assert.That( Convert.ToDouble(page.Values[0].Value.WrappedValue.AsBoxedObject(), CultureInfo.InvariantCulture), Is.EqualTo(2.0)); HistorianPage mod = await provider.ReadModifiedAsync( context, new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); // The insert (brand-new value) and the update-that-replaces it // are both modifications of the historized record. Assert.That(mod.Values, Has.Count.EqualTo(2)); Assert.That(mod.Values[0].Info.UpdateType, Is.EqualTo(HistoryUpdateType.Update)); Assert.That(mod.Values[1].Info.UpdateType, Is.EqualTo(HistoryUpdateType.Insert)); } /// /// Verifies that at-time deletion removes the matching entries. /// [Test] public async Task DeleteAtTimeRemovesEntriesAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("del.var", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); await provider.InsertAsync(context, nodeId, [MakeValue(BaseTime.AddSeconds(10), 1.0), MakeValue(BaseTime.AddSeconds(20), 2.0)], CancellationToken.None).ConfigureAwait(false); HistorianUpdateOutcome result = await provider.DeleteAtTimeAsync( context, nodeId, [(DateTimeUtc)BaseTime.AddSeconds(10)], CancellationToken.None).ConfigureAwait(false); Assert.That(StatusCode.IsGood(result.OperationResults[0]), Is.True); HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(1)); Assert.That( Convert.ToDouble(page.Values[0].Value.WrappedValue.AsBoxedObject(), CultureInfo.InvariantCulture), Is.EqualTo(2.0)); } /// /// Verifies that history pagination follows resume tokens across pages. /// [Test] public async Task PaginationFollowsResumeTokensAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("page.var", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); var values = new List(50); for (int i = 0; i < 50; i++) { values.Add(MakeValue(BaseTime.AddSeconds(i), i)); } await provider.InsertAsync(context, nodeId, values, CancellationToken.None).ConfigureAwait(false); const uint pageSize = 10; var allReturned = new List(); HistorianResumeToken token = default; int pages = 0; while (true) { HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(2), MaxValues = pageSize, IsForward = true }, token, CancellationToken.None).ConfigureAwait(false); foreach (HistoricalDataValue v in page.Values) { allReturned.Add(v.Value); } pages++; if (page.IsFinal) { break; } token = page.NextToken; Assert.That(pages, Is.LessThan(20), "Pagination did not terminate."); } Assert.That(allReturned, Has.Count.EqualTo(50)); Assert.That( Convert.ToInt32(allReturned[0].WrappedValue.AsBoxedObject(), CultureInfo.InvariantCulture), Is.Zero); Assert.That( Convert.ToInt32(allReturned[^1].WrappedValue.AsBoxedObject(), CultureInfo.InvariantCulture), Is.EqualTo(49)); } /// /// Verifies that equal raw-history start and end times return the exact matching value. /// [Test] public async Task ReadRawWithEqualTimesReturnsExactValueAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("equal.var", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); DateTime requestedTime = BaseTime.AddSeconds(20); await provider.InsertAsync( context, nodeId, [ MakeValue(BaseTime.AddSeconds(10), 1.0), MakeValue(requestedTime, 2.0), MakeValue(BaseTime.AddSeconds(30), 3.0) ], CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = requestedTime, EndTime = requestedTime, MaxValues = 0, IsForward = true, ReturnBounds = false }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(1)); Assert.That(page.Values[0].Value.SourceTimestamp, Is.EqualTo(requestedTime)); Assert.That(page.IsFinal, Is.True); } /// /// Verifies that equal-time bounded reads include the next value unless the value limit is one. /// [TestCase(0, 2)] [TestCase(1, 1)] [TestCase(2, 2)] public async Task ReadRawEqualExactWithBoundsReturnsNextValueUnlessMaximumIsOneAsync( int maxValues, int expectedCount) { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId($"equal-bounds-{maxValues}", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); DateTime requestedTime = BaseTime.AddSeconds(20); DateTime nextTime = BaseTime.AddSeconds(30); await provider.InsertAsync( context, nodeId, [ MakeValue(BaseTime.AddSeconds(10), 1.0), MakeValue(requestedTime, 2.0), MakeValue(nextTime, 3.0) ], CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = requestedTime, EndTime = requestedTime, MaxValues = (uint)maxValues, IsForward = true, ReturnBounds = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(expectedCount)); Assert.That(page.Values[0].Value.SourceTimestamp, Is.EqualTo(requestedTime)); if (expectedCount == 2) { Assert.That(page.Values[1].Value.SourceTimestamp, Is.EqualTo(nextTime)); Assert.That(page.Values[1].IsBound, Is.True); } Assert.That(page.IsFinal, Is.True); } /// /// Verifies that bounds count toward page limits and remaining values continue on the next page. /// [Test] public async Task ReadRawBoundsCountTowardsMaximumAndContinueOnNextPageAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("bounded-page.var", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); var values = new List(); for (int i = 0; i < 6; i++) { values.Add(MakeValue(BaseTime.AddSeconds(i), i)); } await provider.InsertAsync(context, nodeId, values, CancellationToken.None).ConfigureAwait(false); var request = new HistorianRawReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddSeconds(4).AddMilliseconds(3), MaxValues = 5, IsForward = true, ReturnBounds = true }; HistorianPage first = await provider.ReadRawAsync( context, request, default, CancellationToken.None).ConfigureAwait(false); Assert.That(first.Values, Has.Count.EqualTo(5)); Assert.That(first.Values[0].Value.SourceTimestamp, Is.EqualTo(BaseTime)); Assert.That(first.Values[^1].Value.SourceTimestamp, Is.EqualTo(BaseTime.AddSeconds(4))); Assert.That(first.IsFinal, Is.False); HistorianPage second = await provider.ReadRawAsync( context, request, first.NextToken, CancellationToken.None).ConfigureAwait(false); Assert.That(second.Values, Has.Count.EqualTo(1)); Assert.That(second.Values[0].Value.SourceTimestamp, Is.EqualTo(BaseTime.AddSeconds(5))); Assert.That(second.Values[0].IsBound, Is.True); Assert.That(second.IsFinal, Is.True); } /// /// Verifies that missing raw-history bounds produce BadBoundNotFound at the requested timestamps. /// [Test] public async Task ReadRawMissingBoundsReturnsBadBoundNotFoundAtRequestedTimesAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("missing-bounds.var", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); await provider.InsertAsync( context, nodeId, [ MakeValue(BaseTime.AddSeconds(10), 1.0), MakeValue(BaseTime.AddSeconds(20), 2.0) ], CancellationToken.None).ConfigureAwait(false); DateTime startTime = BaseTime; DateTime endTime = BaseTime.AddSeconds(30); HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = startTime, EndTime = endTime, MaxValues = 0, IsForward = true, ReturnBounds = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(4)); Assert.That(page.Values[0].Value.StatusCode, Is.EqualTo(StatusCodes.BadBoundNotFound)); Assert.That(page.Values[0].Value.SourceTimestamp, Is.EqualTo(startTime)); Assert.That(page.Values[^1].Value.StatusCode, Is.EqualTo(StatusCodes.BadBoundNotFound)); Assert.That(page.Values[^1].Value.SourceTimestamp, Is.EqualTo(endTime)); } /// /// Verifies that a one-sided raw-history request returns the requested count without a continuation. /// [TestCase(true)] [TestCase(false)] public async Task ReadRawOneSidedRequestReturnsRequestedCountWithoutContinuationAsync(bool isForward) { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId($"one-sided-{isForward}", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); var values = new List(); for (int i = 0; i < 10; i++) { values.Add(MakeValue(BaseTime.AddSeconds(i), i)); } await provider.InsertAsync(context, nodeId, values, CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = isForward ? BaseTime.AddSeconds(2) : DateTimeUtc.MinValue, EndTime = isForward ? DateTimeUtc.MaxValue : BaseTime.AddSeconds(7), MaxValues = 5, IsForward = isForward, ReturnBounds = false }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(5)); Assert.That(page.Values[0].Value.SourceTimestamp, Is.EqualTo(isForward ? BaseTime.AddSeconds(2) : BaseTime.AddSeconds(7))); Assert.That(page.Values[^1].Value.SourceTimestamp, Is.EqualTo(isForward ? BaseTime.AddSeconds(6) : BaseTime.AddSeconds(3))); Assert.That(page.IsFinal, Is.True); } /// /// Verifies that one-sided bounded reads add a missing-bound marker after archive exhaustion. /// [TestCase(true)] [TestCase(false)] public async Task ReadRawOneSidedBoundsAddMissingBoundaryWhenArchiveIsExhaustedAsync(bool isForward) { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId($"one-sided-missing-{isForward}", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); var values = new List(); for (int i = 0; i < 3; i++) { values.Add(MakeValue(BaseTime.AddSeconds(i), i)); } await provider.InsertAsync(context, nodeId, values, CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = isForward ? BaseTime : DateTimeUtc.MinValue, EndTime = isForward ? DateTimeUtc.MaxValue : BaseTime.AddSeconds(2), MaxValues = 5, IsForward = isForward, ReturnBounds = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(4)); Assert.That(page.Values[0].Value.SourceTimestamp, Is.EqualTo(isForward ? BaseTime : BaseTime.AddSeconds(2))); Assert.That(page.Values[^1].Value.StatusCode, Is.EqualTo(StatusCodes.BadBoundNotFound)); Assert.That(page.Values[^1].Value.SourceTimestamp, Is.EqualTo(isForward ? BaseTime.AddSeconds(3) : BaseTime.AddSeconds(-1))); Assert.That(page.IsFinal, Is.True); } /// /// Verifies that one-sided bounded reads stop at the requested maximum without a continuation. /// [TestCase(true)] [TestCase(false)] public async Task ReadRawOneSidedBoundsStopAtRequestedMaximumWithoutContinuationAsync(bool isForward) { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId($"one-sided-bounds-max-{isForward}", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); var values = new List(); for (int i = 0; i < 10; i++) { values.Add(MakeValue(BaseTime.AddSeconds(i), i)); } await provider.InsertAsync(context, nodeId, values, CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = isForward ? BaseTime.AddSeconds(2) : DateTimeUtc.MinValue, EndTime = isForward ? DateTimeUtc.MaxValue : BaseTime.AddSeconds(7), MaxValues = 5, IsForward = isForward, ReturnBounds = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(5)); Assert.That(page.Values[^1].Value.StatusCode, Is.Not.EqualTo(StatusCodes.BadBoundNotFound)); Assert.That(page.IsFinal, Is.True); } /// /// Verifies that an annotation can be inserted, read with its message intact, and deleted successfully. /// [Test] public async Task AnnotationLifecycleAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("ann.var", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); DateTime when = BaseTime.AddSeconds(10); var annotation = new Annotation { Message = "test", UserName = "alice", AnnotationTime = when }; HistorianUpdateOutcome insert = await provider.InsertAnnotationsAsync( context, nodeId, [annotation], CancellationToken.None).ConfigureAwait(false); Assert.That(StatusCode.IsGood(insert.OperationResults[0]), Is.True); HistorianPage page = await provider.ReadAnnotationsAsync( context, new HistorianAnnotationReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(1)); Assert.That(page.Values[0].Message, Is.EqualTo("test")); HistorianUpdateOutcome del = await provider.DeleteAnnotationsAsync( context, nodeId, [(DateTimeUtc)when], CancellationToken.None).ConfigureAwait(false); Assert.That(StatusCode.IsGood(del.OperationResults[0]), Is.True); } /// /// Verifies exact-instant modified reads retain every modification but no adjacent timestamp. /// [TestCase(true, 1u, true)] [TestCase(false, 1u, true)] [TestCase(true, 0u, true)] [TestCase(false, 0u, true)] [TestCase(true, 1u, false)] [TestCase(false, 1u, false)] public async Task ModifiedReadAtExactInstantReturnsEveryModificationAsync( bool forward, uint pageSize, bool matchingInstant) { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.exact.instant", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); using OperationContext operation = context.OperationContext; DateTime instant = BaseTime.AddSeconds(10); context.DefaultModificationInfo.ModificationTime = BaseTime; await provider.InsertAsync( context, nodeId, [ MakeValue(instant.AddTicks(-1), -1), MakeValue(instant, 10), MakeValue(instant.AddTicks(1), 999) ], CancellationToken.None).ConfigureAwait(false); context.DefaultModificationInfo.ModificationTime = BaseTime.AddSeconds(1); await provider.ReplaceAsync( context, nodeId, [MakeValue(instant, 20)], CancellationToken.None).ConfigureAwait(false); context.DefaultModificationInfo.ModificationTime = BaseTime.AddSeconds(2); await provider.ReplaceAsync( context, nodeId, [MakeValue(instant, 30)], CancellationToken.None).ConfigureAwait(false); DateTime requested = matchingInstant ? instant : instant.AddTicks(2); var request = new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = requested, EndTime = requested, IsForward = forward, MaxValues = pageSize }; var actual = new List(); HistorianResumeToken token = default; do { HistorianPage page = await provider.ReadModifiedAsync( context, request, token, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values.Count, Is.LessThanOrEqualTo(pageSize == 0 ? 3 : pageSize)); Assert.That(page.IsFinal || page.Values.Count > 0, Is.True); actual.AddRange(page.Values); Assert.That(actual, Has.Count.LessThanOrEqualTo(3), "A continuation must not repeat records."); token = page.NextToken; } while (!token.IsEmpty); Assert.That(actual, Has.Count.EqualTo(matchingInstant ? 3 : 0)); double[] expectedValues = forward ? [20, 10, 10] : [10, 10, 20]; for (int i = 0; i < actual.Count; i++) { int modification = forward ? 2 - i : i; Assert.That(actual[i].Value.SourceTimestamp, Is.EqualTo((DateTimeUtc)instant)); Assert.That(actual[i].Value.WrappedValue.TryGetValue(out double value), Is.True); Assert.That(value, Is.EqualTo(expectedValues[i])); Assert.That(actual[i].Info.ModificationTime, Is.EqualTo((DateTimeUtc)BaseTime.AddSeconds(modification))); Assert.That(actual[i].Info.UpdateType, Is.EqualTo(modification == 0 ? HistoryUpdateType.Insert : HistoryUpdateType.Replace)); } } /// /// Verifies that an exactly filled final modified-history page has no continuation. /// [Test] public async Task ExactModifiedPageDoesNotReturnContinuationAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.final.page", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); await provider.InsertAsync( context, nodeId, [ MakeValue(BaseTime.AddSeconds(10), 1), MakeValue(BaseTime.AddSeconds(20), 2) ], CancellationToken.None).ConfigureAwait(false); await provider.ReplaceAsync( context, nodeId, [ MakeValue(BaseTime.AddSeconds(10), 10), MakeValue(BaseTime.AddSeconds(20), 20) ], CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadModifiedAsync( context, new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), MaxValues = 4, IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); // 2 inserts + 2 replaces == 4 modified-history entries. Assert.That(page.Values, Has.Count.EqualTo(4)); Assert.That(page.IsFinal, Is.True); } /// /// Verifies that reverse modified-history reads include the start and exclude the end timestamp. /// [Test] public async Task ReverseModifiedHistoryIncludesStartAndExcludesEndAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.reverse.boundaries", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); DateTime lower = BaseTime; DateTime middle = BaseTime.AddSeconds(5); DateTime upper = BaseTime.AddSeconds(10); await provider.InsertAsync( context, nodeId, [ MakeValue(lower, 0), MakeValue(middle, 5), MakeValue(upper, 10) ], CancellationToken.None).ConfigureAwait(false); await provider.ReplaceAsync( context, nodeId, [ MakeValue(lower, 100), MakeValue(middle, 105), MakeValue(upper, 110) ], CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadModifiedAsync( context, new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = upper, EndTime = lower, IsForward = false }, default, CancellationToken.None).ConfigureAwait(false); // 2 inserts + 2 replaces qualify (middle, upper); lower is // excluded by the reverse-read boundary rule. Assert.That(page.Values, Has.Count.EqualTo(4)); int upperCount = 0; int middleCount = 0; int lowerCount = 0; foreach (ModifiedDataValue v in page.Values) { if (v.Value.SourceTimestamp == (DateTimeUtc)upper) { upperCount++; } else if (v.Value.SourceTimestamp == (DateTimeUtc)middle) { middleCount++; } else if (v.Value.SourceTimestamp == (DateTimeUtc)lower) { lowerCount++; } } Assert.That(upperCount, Is.EqualTo(2)); Assert.That(middleCount, Is.EqualTo(2)); Assert.That(lowerCount, Is.Zero); } /// /// Verifies that a forward modification cursor includes backdated modifications. /// [Test] public async Task ModifiedHistoryForwardCursorIncludesBackdatedModificationAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.forward.cursor", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext insertContext = CreateContext(); HistorianOperationContext earlierContext = CreateContext(); HistorianOperationContext middleContext = CreateContext(); HistorianOperationContext laterContext = CreateContext(); // The insert's own INSERT modified-history entry must sort after // all the backdated replaces below (it has the oldest // modification time of the four). insertContext.DefaultModificationInfo.ModificationTime = BaseTime; earlierContext.DefaultModificationInfo.ModificationTime = BaseTime.AddMinutes(1); middleContext.DefaultModificationInfo.ModificationTime = BaseTime.AddMinutes(2); laterContext.DefaultModificationInfo.ModificationTime = BaseTime.AddMinutes(3); DateTime sourceTimestamp = BaseTime.AddSeconds(10); await provider.InsertAsync( insertContext, nodeId, [MakeValue(sourceTimestamp, 0)], CancellationToken.None).ConfigureAwait(false); await provider.ReplaceAsync( laterContext, nodeId, [MakeValue(sourceTimestamp, 1)], CancellationToken.None).ConfigureAwait(false); await provider.ReplaceAsync( middleContext, nodeId, [MakeValue(sourceTimestamp, 2)], CancellationToken.None).ConfigureAwait(false); var request = new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), MaxValues = 1, IsForward = true }; HistorianPage first = await provider.ReadModifiedAsync( laterContext, request, default, CancellationToken.None).ConfigureAwait(false); await provider.ReplaceAsync( earlierContext, nodeId, [MakeValue(sourceTimestamp, 3)], CancellationToken.None).ConfigureAwait(false); HistorianPage second = await provider.ReadModifiedAsync( laterContext, request, first.NextToken, CancellationToken.None).ConfigureAwait(false); HistorianPage third = await provider.ReadModifiedAsync( laterContext, request, second.NextToken, CancellationToken.None).ConfigureAwait(false); HistorianPage fourth = await provider.ReadModifiedAsync( laterContext, request, third.NextToken, CancellationToken.None).ConfigureAwait(false); Assert.That(first.Values, Has.Count.EqualTo(1)); Assert.That(first.Values[0].Value.WrappedValue.TryGetValue(out double firstValue), Is.True); Assert.That(firstValue, Is.Zero); Assert.That(first.IsFinal, Is.False); Assert.That(first.NextToken.TryGetCursor(out HistorianResumeCursor firstCursor), Is.True); Assert.That(firstCursor.Key.IsEmpty, Is.False); Assert.That(second.Values, Has.Count.EqualTo(1)); Assert.That(second.Values[0].Value.WrappedValue.TryGetValue(out double secondValue), Is.True); Assert.That(secondValue, Is.EqualTo(1)); Assert.That(second.IsFinal, Is.False); Assert.That(third.Values, Has.Count.EqualTo(1)); Assert.That(third.Values[0].Value.WrappedValue.TryGetValue(out double thirdValue), Is.True); Assert.That(thirdValue, Is.EqualTo(2)); Assert.That(third.IsFinal, Is.False); // The insert's own INSERT entry (the brand-new value 0) sorts // last: it has the oldest modification time of the four. Assert.That(fourth.Values, Has.Count.EqualTo(1)); Assert.That(fourth.Values[0].Value.WrappedValue.TryGetValue(out double fourthValue), Is.True); Assert.That(fourthValue, Is.Zero); Assert.That(fourth.Values[0].Info.UpdateType, Is.EqualTo(HistoryUpdateType.Insert)); Assert.That(fourth.IsFinal, Is.True); } /// /// Verifies that a reverse modification cursor uses the complete ordering tuple. /// [Test] public async Task ModifiedHistoryReverseCursorUsesCompleteOrderingTupleAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.reverse.cursor", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext insertContext = CreateContext(); HistorianOperationContext earlierContext = CreateContext(); HistorianOperationContext laterContext = CreateContext(); earlierContext.DefaultModificationInfo.ModificationTime = BaseTime.AddMinutes(1); laterContext.DefaultModificationInfo.ModificationTime = BaseTime.AddMinutes(2); // The insert's own INSERT modified-history entry must sort last // in this reverse read: give it the newest modification time of // the three. insertContext.DefaultModificationInfo.ModificationTime = BaseTime.AddMinutes(3); DateTime sourceTimestamp = BaseTime.AddSeconds(10); await provider.InsertAsync( insertContext, nodeId, [MakeValue(sourceTimestamp, 0)], CancellationToken.None).ConfigureAwait(false); await provider.ReplaceAsync( laterContext, nodeId, [MakeValue(sourceTimestamp, 1)], CancellationToken.None).ConfigureAwait(false); await provider.ReplaceAsync( earlierContext, nodeId, [MakeValue(sourceTimestamp, 2)], CancellationToken.None).ConfigureAwait(false); var request = new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = BaseTime.AddMinutes(1), EndTime = BaseTime, MaxValues = 1, IsForward = false }; HistorianPage first = await provider.ReadModifiedAsync( laterContext, request, default, CancellationToken.None).ConfigureAwait(false); HistorianPage second = await provider.ReadModifiedAsync( laterContext, request, first.NextToken, CancellationToken.None).ConfigureAwait(false); HistorianPage third = await provider.ReadModifiedAsync( laterContext, request, second.NextToken, CancellationToken.None).ConfigureAwait(false); Assert.That(first.Values, Has.Count.EqualTo(1)); Assert.That(first.Values[0].Value.WrappedValue.TryGetValue(out double firstValue), Is.True); Assert.That(firstValue, Is.EqualTo(1)); Assert.That(first.IsFinal, Is.False); Assert.That(second.Values, Has.Count.EqualTo(1)); Assert.That(second.Values[0].Value.WrappedValue.TryGetValue(out double secondValue), Is.True); Assert.That(secondValue, Is.Zero); Assert.That(second.IsFinal, Is.False); Assert.That(third.Values, Has.Count.EqualTo(1)); Assert.That(third.Values[0].Value.WrappedValue.TryGetValue(out double thirdValue), Is.True); Assert.That(thirdValue, Is.Zero); Assert.That(third.Values[0].Info.UpdateType, Is.EqualTo(HistoryUpdateType.Insert)); Assert.That(third.IsFinal, Is.True); } /// /// Verifies that modified-history reads accept legacy sequence-only cursors. /// [Test] public async Task ModifiedHistoryAcceptsLegacySequenceOnlyCursorAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.legacy.cursor", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext insertContext = CreateContext(); HistorianOperationContext earlierContext = CreateContext(); HistorianOperationContext laterContext = CreateContext(); earlierContext.DefaultModificationInfo.ModificationTime = BaseTime.AddMinutes(1); laterContext.DefaultModificationInfo.ModificationTime = BaseTime.AddMinutes(2); // The insert's own INSERT entry must sort ahead of (be // considered already consumed relative to) the boundary below: // give it the newest modification time of the three. insertContext.DefaultModificationInfo.ModificationTime = BaseTime.AddMinutes(3); DateTime sourceTimestamp = BaseTime.AddSeconds(10); await provider.InsertAsync( insertContext, nodeId, [MakeValue(sourceTimestamp, 0)], CancellationToken.None).ConfigureAwait(false); await provider.ReplaceAsync( laterContext, nodeId, [MakeValue(sourceTimestamp, 1)], CancellationToken.None).ConfigureAwait(false); await provider.ReplaceAsync( earlierContext, nodeId, [MakeValue(sourceTimestamp, 2)], CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadModifiedAsync( laterContext, new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), MaxValues = 1, IsForward = true }, HistorianResumeToken.FromCursor( new HistorianResumeCursor( sourceTimestamp, ByteString.Empty, // Sequence 2 is now the laterContext replace entry: // the insert consumes sequence 1. 2)), CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(1)); Assert.That(page.Values[0].Value.WrappedValue.TryGetValue(out double value), Is.True); Assert.That(value, Is.EqualTo(1)); Assert.That(page.IsFinal, Is.True); } /// /// Verifies that modified-history reads reject unknown cursor-key versions. /// [Test] public async Task ModifiedHistoryRejectsUnknownCursorKeyVersionAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.invalid.cursor", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); DateTime sourceTimestamp = BaseTime.AddSeconds(10); await provider.InsertAsync( context, nodeId, [MakeValue(sourceTimestamp, 0)], CancellationToken.None).ConfigureAwait(false); await provider.ReplaceAsync( context, nodeId, [MakeValue(sourceTimestamp, 1)], CancellationToken.None).ConfigureAwait(false); var malformed = HistorianResumeToken.FromCursor( new HistorianResumeCursor( sourceTimestamp, ByteString.From([1]), 1)); ServiceResultException exception = Assert.ThrowsAsync( async () => await provider.ReadModifiedAsync( context, new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), IsForward = true }, malformed, CancellationToken.None).ConfigureAwait(false)); Assert.That( exception.StatusCode, Is.EqualTo(StatusCodes.BadContinuationPointInvalid)); } /// /// Verifies that an exactly filled final annotation page has no continuation. /// [Test] public async Task ExactAnnotationPageDoesNotReturnContinuationAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("annotation.final.page", NamespaceIndex); provider.Register(nodeId); HistorianOperationContext context = CreateContext(); await provider.InsertAnnotationsAsync( context, nodeId, [ new Annotation { AnnotationTime = BaseTime.AddSeconds(10), Message = "first" }, new Annotation { AnnotationTime = BaseTime.AddSeconds(20), Message = "second" } ], CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadAnnotationsAsync( context, new HistorianAnnotationReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), MaxValues = 2, IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(2)); Assert.That(page.IsFinal, Is.True); } /// /// Verifies that a history-update insert of a brand-new value is /// retained as an INSERT entry in modified history: an explicit /// HistoryUpdate insert is itself a modification of the historized /// record, distinct from raw auto-capture inserts. /// [Test] public async Task InsertedValueAppearsInModifiedHistoryAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.insert", NamespaceIndex); HistorianOperationContext context = CreateContext(); DateTime timestamp = BaseTime.AddSeconds(10); DateTime modificationTime = BaseTime.AddMinutes(3); context.DefaultModificationInfo.ModificationTime = modificationTime; context.DefaultModificationInfo.UserName = "insert-user"; await provider.InsertAsync( context, nodeId, [MakeValue(timestamp, 1)], CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadModifiedAsync( context, new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(1)); Assert.That(page.Values[0].Info.UpdateType, Is.EqualTo(HistoryUpdateType.Insert)); Assert.That(page.Values[0].Value.SourceTimestamp, Is.EqualTo(timestamp)); Assert.That(page.Values[0].Info.UserName, Is.EqualTo("insert-user")); Assert.That(page.Values[0].Info.ModificationTime, Is.EqualTo(modificationTime)); } /// /// Verifies that a HistoryUpdate Update call that inserts a /// brand-new value (no prior entry existed) is also retained as an /// INSERT entry in modified history. /// [Test] public async Task UpdateThatInsertsAppearsInModifiedHistoryAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.update-inserts", NamespaceIndex); HistorianOperationContext context = CreateContext(); DateTime timestamp = BaseTime.AddSeconds(10); DateTime modificationTime = BaseTime.AddMinutes(4); context.DefaultModificationInfo.ModificationTime = modificationTime; context.DefaultModificationInfo.UserName = "update-user"; HistorianUpdateOutcome outcome = await provider.UpdateAsync( context, nodeId, [MakeValue(timestamp, 1)], CancellationToken.None).ConfigureAwait(false); Assert.That(outcome.OperationResults[0].Code, Is.EqualTo(StatusCodes.GoodEntryInserted.Code)); HistorianPage page = await provider.ReadModifiedAsync( context, new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(1)); Assert.That(page.Values[0].Info.UpdateType, Is.EqualTo(HistoryUpdateType.Insert)); Assert.That(page.Values[0].Value.SourceTimestamp, Is.EqualTo(timestamp)); Assert.That(page.Values[0].Info.UserName, Is.EqualTo("update-user")); Assert.That(page.Values[0].Info.ModificationTime, Is.EqualTo(modificationTime)); } /// /// Verifies that a transactional (atomic) HistoryUpdate insert is /// also retained as an INSERT entry in modified history. /// [Test] public async Task TransactionalInsertAppearsInModifiedHistoryAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.insert-atomic", NamespaceIndex); HistorianOperationContext context = CreateContext(); DateTime timestamp = BaseTime.AddSeconds(10); HistorianUpdateOutcome outcome = await provider.InsertAtomicAsync( context, nodeId, [MakeValue(timestamp, 1)], CancellationToken.None).ConfigureAwait(false); Assert.That(outcome.TransactionRolledBack, Is.False); Assert.That(outcome.OperationResults[0].Code, Is.EqualTo(StatusCodes.GoodEntryInserted.Code)); HistorianPage page = await provider.ReadModifiedAsync( context, new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(1)); Assert.That(page.Values[0].Info.UpdateType, Is.EqualTo(HistoryUpdateType.Insert)); Assert.That(page.Values[0].Value.SourceTimestamp, Is.EqualTo(timestamp)); } /// /// Verifies that the bulk auto-capture path (used by the /// framework's historizing pipeline, not by an explicit /// HistoryUpdate service call) does NOT log inserted values into /// modified history: only explicit HistoryUpdate operations are /// modifications of the historized record. /// [Test] public async Task AutoCaptureInsertBatchDoesNotAppearInModifiedHistoryAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("modified.auto-capture", NamespaceIndex); HistorianOperationContext context = CreateContext(); DateTime timestamp = BaseTime.AddSeconds(10); ArrayOf> outcomes = await provider.InsertBatchAsync( context, [new HistorianDataBatch(nodeId, [MakeValue(timestamp, 1)])], CancellationToken.None).ConfigureAwait(false); Assert.That(outcomes[0].OperationResults[0].Code, Is.EqualTo(StatusCodes.GoodEntryInserted.Code)); HistorianPage page = await provider.ReadModifiedAsync( context, new HistorianModifiedReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = BaseTime.AddMinutes(1), IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(0)); } /// /// Verifies that a provider page limit produces a continuation when data remains. /// [Test] public async Task RawServerPageLimitProducesContinuationAsync() { using var provider = new InMemoryHistorianProvider( new InMemoryHistorianOptions(), new FakeTimeProvider(BaseTime)); var nodeId = new NodeId("raw.server-page-limit", NamespaceIndex); HistorianOperationContext context = CreateContext(); await provider.InsertAsync( context, nodeId, [ MakeValue(BaseTime.AddSeconds(1), 1), MakeValue(BaseTime.AddSeconds(2), 2), MakeValue(BaseTime.AddSeconds(3), 3) ], CancellationToken.None).ConfigureAwait(false); HistorianPage page = await provider.ReadRawAsync( context, new HistorianRawReadRequest { NodeId = nodeId, StartTime = BaseTime, EndTime = DateTimeUtc.MaxValue, MaxValues = 3, PageLimit = 2, IsForward = true }, default, CancellationToken.None).ConfigureAwait(false); Assert.That(page.Values, Has.Count.EqualTo(2)); Assert.That(page.IsFinal, Is.False); } private static readonly DateTime BaseTime = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static DataValue MakeValue(DateTime sourceTimestamp, double value) { return new DataValue(new Variant(value), StatusCodes.Good, sourceTimestamp: sourceTimestamp, serverTimestamp: sourceTimestamp); } private static HistorianOperationContext CreateContext() { var mockTelemetry = new Mock(); var mockServer = new Mock(); mockServer.Setup(s => s.NamespaceUris).Returns(new NamespaceTable()); mockServer.Setup(s => s.ServerUris).Returns(new StringTable()); mockServer.Setup(s => s.TypeTree).Returns(new TypeTable(new NamespaceTable())); mockServer.Setup(s => s.Factory).Returns(EncodeableFactory.Create()); mockServer.Setup(s => s.Telemetry).Returns(mockTelemetry.Object); var opContext = new OperationContext( new RequestHeader(), null, RequestType.HistoryUpdate, RequestLifetime.None); var systemContext = new ServerSystemContext(mockServer.Object, opContext); return new HistorianOperationContext( systemContext, opContext, null, HistoryUpdateType.Insert); } } }