/* ========================================================================
* 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/
* ======================================================================*/
// CA2000: system-under-test disposables are created per test and released at teardown;
// there is no cross-test resource leak. Suppressed file-level for the suite.
#pragma warning disable CA2000 // Dispose objects before losing scope
// IDE0230: the byte[] fixtures here are arbitrary binary payloads (e.g. forged/rogue
// records), not text; rewriting them as UTF-8 (u8) string literals would misrepresent
// the intent. Suppressed file-level for the suite.
#pragma warning disable IDE0230 // Use UTF-8 string literal
// 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.Buffers.Binary;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Crdt;
using Crdt.Transport;
using Moq;
using NUnit.Framework;
using Opc.Ua.Redundancy;
using Opc.Ua.Redundancy.Server;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.Redundancy
{
///
/// Unit tests for , including a real
/// NodeState binary round-trip through the store.
///
[TestFixture]
[Category("Distributed")]
[Parallelizable(ParallelScope.All)]
public class InMemoryNodeStateStoreTests
{
private const ushort NamespaceIndex = 1;
private ServiceMessageContext m_messageContext;
private SystemContext m_systemContext;
[OneTimeSetUp]
public void OneTimeSetUp()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var messageContext = ServiceMessageContext.CreateEmpty(telemetry);
messageContext.NamespaceUris.GetIndexOrAppend("urn:test:distributed");
m_messageContext = messageContext;
m_systemContext = new SystemContext(telemetry)
{
NamespaceUris = messageContext.NamespaceUris,
ServerUris = messageContext.ServerUris,
EncodeableFactory = messageContext.Factory
};
}
[Test]
public async Task UpsertTryGetAndDeleteNodeAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var nodeId = new NodeId("var1", NamespaceIndex);
var payload = ByteString.From(new byte[] { 1, 2, 3, 4 });
await store.UpsertNodeAsync(new StoredNode(nodeId, payload)).ConfigureAwait(false);
IStoredNode? loaded = await store.TryGetNodeAsync(nodeId).ConfigureAwait(false);
bool deleted = await store.DeleteNodeAsync(nodeId).ConfigureAwait(false);
IStoredNode? afterDelete = await store.TryGetNodeAsync(nodeId).ConfigureAwait(false);
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.NodeId, Is.EqualTo(nodeId));
Assert.That(loaded.Payload.ToArray(), Is.EqualTo(payload.ToArray()));
Assert.That(deleted, Is.True);
Assert.That(afterDelete, Is.Null);
}
[Test]
public async Task EnumerateReturnsAllStoredNodesAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var a = new NodeId("a", NamespaceIndex);
var b = new NodeId("b", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(a, ByteString.From(new byte[] { 1 }))).ConfigureAwait(false);
await store.UpsertNodeAsync(new StoredNode(b, ByteString.From(new byte[] { 2 }))).ConfigureAwait(false);
var ids = new List();
await foreach (IStoredNode node in store.EnumerateAsync())
{
ids.Add(node.NodeId);
}
Assert.That(ids, Is.EquivalentTo([a, b]));
}
[Test]
public async Task WriteAndReadValueRoundTripsAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var nodeId = new NodeId("v", NamespaceIndex);
var original = new DataValue(new Variant(42.0), StatusCodes.Good, DateTimeUtc.Now);
await store.WriteValueAsync(nodeId, original).ConfigureAwait(false);
(bool found, DataValue read) = await store.TryReadValueAsync(nodeId).ConfigureAwait(false);
Assert.That(found, Is.True);
Assert.That(read.WrappedValue, Is.EqualTo(original.WrappedValue));
Assert.That(read.StatusCode, Is.EqualTo(original.StatusCode));
Assert.That(read.SourceTimestamp, Is.EqualTo(original.SourceTimestamp));
}
[Test]
public async Task TryReadValueMissingReturnsFalseAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
(bool found, DataValue read) = await store.TryReadValueAsync(new NodeId("nope", NamespaceIndex)).ConfigureAwait(false);
Assert.That(found, Is.False);
Assert.That(read.IsNull, Is.True);
}
[Test]
public async Task EnumerateValuesReturnsAllStoredValuesInOnePassAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var a = new NodeId("a", NamespaceIndex);
var b = new NodeId("b", NamespaceIndex);
await store.WriteValueAsync(a, new DataValue(new Variant(1.0), StatusCodes.Good, DateTimeUtc.Now)).ConfigureAwait(false);
await store.WriteValueAsync(b, new DataValue(new Variant(2.0), StatusCodes.Good, DateTimeUtc.Now)).ConfigureAwait(false);
var seen = new Dictionary();
await foreach ((NodeId nodeId, DataValue value) in store.EnumerateValuesAsync())
{
seen[nodeId] = value;
}
Assert.That(seen.Keys, Is.EquivalentTo([a, b]));
Assert.That(seen[a].WrappedValue, Is.EqualTo(new Variant(1.0)));
Assert.That(seen[b].WrappedValue, Is.EqualTo(new Variant(2.0)));
}
[Test]
public async Task EnumerateValuesRoundTripsThroughRecordProtectorAsync()
{
using var kv = new InMemorySharedKeyValueStore();
byte[] key = new byte[32];
for (int i = 0; i < key.Length; i++)
{
key[i] = (byte)(i + 1);
}
using var protector = new AesCbcHmacRecordProtector(key);
using var store = new InMemoryNodeStateStore(kv, m_messageContext, protector);
var nodeId = new NodeId("protected", NamespaceIndex);
await store.WriteValueAsync(nodeId, new DataValue(new Variant(7.0), StatusCodes.Good, DateTimeUtc.Now)).ConfigureAwait(false);
var seen = new Dictionary();
await foreach ((NodeId id, DataValue value) in store.EnumerateValuesAsync())
{
seen[id] = value;
}
Assert.That(seen.Keys, Is.EquivalentTo([nodeId]));
Assert.That(seen[nodeId].WrappedValue, Is.EqualTo(new Variant(7.0)));
}
[TestCase(false)]
[TestCase(true)]
public async Task ProtectedPrimaryRecordsRejectDifferentKeysAsync(bool valueRecord)
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(41));
using var store = new InMemoryNodeStateStore(kv, m_messageContext, protector);
var source = new NodeId("source", NamespaceIndex);
var target = new NodeId("target", NamespaceIndex);
var payload = ByteString.From(new byte[] { 1, 2, 3 });
if (valueRecord)
{
await store.WriteValueAsync(source, new DataValue(Variant.From(true))).ConfigureAwait(false);
}
else
{
await store.UpsertNodeAsync(new StoredNode(source, payload)).ConfigureAwait(false);
}
string prefix = valueRecord ? "v/" : "n/";
(bool stored, ByteString record) = await kv.TryGetAsync(prefix + source).ConfigureAwait(false);
Assert.That(stored, Is.True);
await kv.SetAsync(prefix + target, record).ConfigureAwait(false);
if (valueRecord)
{
(bool found, DataValue value) = await store.TryReadValueAsync(target).ConfigureAwait(false);
Assert.That(found, Is.False);
Assert.That(value.IsNull, Is.True);
(found, value) = await store.TryReadValueAsync(source).ConfigureAwait(false);
Assert.That(found, Is.True);
Assert.That(value.WrappedValue, Is.EqualTo(Variant.From(true)));
}
else
{
Assert.That(await store.TryGetNodeAsync(target).ConfigureAwait(false), Is.Null);
IStoredNode? original = await store.TryGetNodeAsync(source).ConfigureAwait(false);
Assert.That(original, Is.Not.Null);
Assert.That(original!.Payload, Is.EqualTo(payload));
}
Assert.That(
async () => await store.WriteSnapshotAsync().ConfigureAwait(false),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadDecodingError));
}
[Test]
public async Task ProtectedPartitionMarkerCannotInitializeAnotherPartitionAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(42));
using var store = new InMemoryNodeStateStore(kv, m_messageContext, protector);
var partitions = (INodeStatePartitionStore)store;
const string source = "area/source";
const string target = "area/target";
await partitions.MarkPartitionInitializedAsync(source, default).ConfigureAwait(false);
(bool found, ByteString record) = await kv.TryGetAsync(
"partition/" + Uri.EscapeDataString(source)).ConfigureAwait(false);
Assert.That(found, Is.True);
await kv.SetAsync("partition/" + Uri.EscapeDataString(target), record).ConfigureAwait(false);
Assert.That(
async () => await partitions.IsPartitionInitializedAsync(target, default).ConfigureAwait(false),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadDecodingError));
Assert.That(await partitions.IsPartitionInitializedAsync(source, default).ConfigureAwait(false), Is.True);
}
[TestCase("dlog/00000000000000000900")]
[TestCase("dlog/1")]
public async Task ProtectedDeltaRejectsDifferentSequenceKeyAsync(string targetKey)
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(43));
using var store = new InMemoryNodeStateStore(kv, m_messageContext, protector);
var nodeId = new NodeId("delta", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(nodeId, ByteString.From(new byte[] { 1 })))
.ConfigureAwait(false);
List changes = await ReadChangesAsync(store.ReadDeltaLogAsync(0)).ConfigureAwait(false);
Assert.That(changes, Has.Count.EqualTo(1));
Assert.That(changes[0].Sequence, Is.EqualTo(1));
Assert.That(changes[0].NodeId, Is.EqualTo(nodeId));
(bool found, ByteString record) = await kv.TryGetAsync("dlog/00000000000000000001")
.ConfigureAwait(false);
Assert.That(found, Is.True);
await kv.SetAsync(targetKey, record).ConfigureAwait(false);
Assert.That(
() => ReadChangesAsync(store.ReadDeltaLogAsync(0)),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadDecodingError));
Assert.That(store.CurrentSequence, Is.EqualTo(1), "A forged key must not advance the local horizon.");
}
[Test]
public async Task ProtectedSequenceRejectsPrimaryRecordAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(44));
using var store = new InMemoryNodeStateStore(kv, m_messageContext, protector);
var nodeId = new NodeId("sequence", NamespaceIndex);
byte[] payload = new byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64BigEndian(payload, 1);
await store.UpsertNodeAsync(new StoredNode(nodeId, ByteString.From(payload))).ConfigureAwait(false);
(_, ByteString record) = await kv.TryGetAsync("n/" + nodeId).ConfigureAwait(false);
await kv.SetAsync(InMemoryNodeStateStore.SequenceKey, record).ConfigureAwait(false);
Assert.That(
async () => await store.UpsertNodeAsync(new StoredNode(nodeId, ByteString.From(payload)))
.ConfigureAwait(false),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadDecodingError));
(_, ByteString retained) = await kv.TryGetAsync(InMemoryNodeStateStore.SequenceKey).ConfigureAwait(false);
Assert.That(retained, Is.EqualTo(record));
Assert.That(store.CurrentSequence, Is.EqualTo(1));
}
[Test]
public async Task ProtectedSnapshotChunkRejectsAnotherGenerationAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(45));
using var store = new InMemoryNodeStateStore(kv, m_messageContext, protector);
var nodeId = new NodeId("snapshot", NamespaceIndex);
var originalPayload = ByteString.From(new byte[] { 1 });
await store.UpsertNodeAsync(new StoredNode(nodeId, originalPayload)).ConfigureAwait(false);
await store.WriteSnapshotAsync().ConfigureAwait(false);
NodeStateSnapshot? original = await store.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(original, Is.Not.Null);
var chunks = new List>();
await foreach (KeyValuePair entry in kv.ScanAsync("snap/").ConfigureAwait(false))
{
chunks.Add(entry);
}
Assert.That(chunks, Has.Count.EqualTo(1));
KeyValuePair source = chunks[0];
await store.UpsertNodeAsync(new StoredNode(nodeId, ByteString.From(new byte[] { 2 })))
.ConfigureAwait(false);
await store.WriteSnapshotAsync().ConfigureAwait(false);
chunks.Clear();
await foreach (KeyValuePair entry in kv.ScanAsync("snap/").ConfigureAwait(false))
{
if (entry.Key != source.Key)
{
chunks.Add(entry);
}
}
Assert.That(chunks, Has.Count.EqualTo(1));
await kv.SetAsync(chunks[0].Key, source.Value).ConfigureAwait(false);
NodeStateSnapshot? current = await store.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(current, Is.Not.Null);
Assert.That(
() => ReadChangesAsync(current!.Entries),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadDecodingError));
List oldEntries = await ReadChangesAsync(original!.Entries).ConfigureAwait(false);
Assert.That(oldEntries, Has.Count.EqualTo(1));
Assert.That(oldEntries[0].Node!.Payload, Is.EqualTo(originalPayload));
}
[Test]
public async Task ProtectedSnapshotChunkRejectsDifferentOrdinalAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(46));
var context = ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create());
using var store = new InMemoryNodeStateStore(kv, context, protector);
var payload = ByteString.From(new byte[context.MaxByteStringLength]);
await store.UpsertNodeAsync(new StoredNode(new NodeId("first", 1), payload)).ConfigureAwait(false);
await store.UpsertNodeAsync(new StoredNode(new NodeId("second", 1), payload)).ConfigureAwait(false);
await store.WriteSnapshotAsync().ConfigureAwait(false);
var chunks = new List>();
await foreach (KeyValuePair entry in kv.ScanAsync("snap/").ConfigureAwait(false))
{
chunks.Add(entry);
}
Assert.That(chunks, Has.Count.EqualTo(2));
chunks.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Key, right.Key));
await kv.SetAsync(chunks[1].Key, chunks[0].Value).ConfigureAwait(false);
NodeStateSnapshot? snapshot = await store.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(snapshot, Is.Not.Null);
Assert.That(
() => ReadChangesAsync(snapshot!.Entries),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadDecodingError));
}
[Test]
public async Task ProtectedSnapshotManifestRejectsEmptyContextAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(47));
using var store = new InMemoryNodeStateStore(kv, m_messageContext, protector);
await store.WriteSnapshotAsync().ConfigureAwait(false);
(bool found, ByteString record) = await kv.TryGetAsync("snapmeta/manifest").ConfigureAwait(false);
Assert.That(found, Is.True);
Assert.That(protector.TryUnprotect(
RecordProtectionContext.Create("node-state-manifest", "snapmeta/manifest"),
record, out ByteString manifest), Is.True);
await kv.SetAsync("snapmeta/manifest", protector.Protect(default, manifest))
.ConfigureAwait(false);
Assert.That(await store.TryReadSnapshotAsync().ConfigureAwait(false), Is.Null);
}
[Test]
public async Task EnumerateValuesOnEmptyStoreYieldsNothingAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
int count = 0;
await foreach ((NodeId _, DataValue _) in store.EnumerateValuesAsync())
{
count++;
}
Assert.That(count, Is.Zero);
}
[Test]
public async Task WriteAndReadSnapshotRoundTripsEntriesAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var a = new NodeId("a", NamespaceIndex);
var b = new NodeId("b", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(a, ByteString.From(new byte[] { 1, 2 }))).ConfigureAwait(false);
await store.UpsertNodeAsync(new StoredNode(b, ByteString.From(new byte[] { 3 }))).ConfigureAwait(false);
await store.WriteValueAsync(a, new DataValue(new Variant(5.0), StatusCodes.Good, DateTimeUtc.Now)).ConfigureAwait(false);
await store.WriteSnapshotAsync().ConfigureAwait(false);
NodeStateSnapshot? snapshot = await store.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(snapshot, Is.Not.Null);
var upserts = new HashSet();
var values = new Dictionary();
await foreach (NodeStateChange entry in snapshot!.Entries)
{
if (entry.Kind == NodeStateChangeKind.Upsert)
{
upserts.Add(entry.NodeId);
}
else if (entry.Kind == NodeStateChangeKind.Value)
{
values[entry.NodeId] = entry.Value;
}
}
Assert.That(upserts, Is.EquivalentTo([a, b]));
Assert.That(values.Keys, Is.EquivalentTo([a]));
Assert.That(values[a].WrappedValue, Is.EqualTo(new Variant(5.0)));
Assert.That(snapshot.Sequence, Is.GreaterThanOrEqualTo(3));
}
[Test]
public async Task TryReadSnapshotReturnsNullWhenNonePublishedAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
NodeStateSnapshot? snapshot = await store.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(snapshot, Is.Null);
}
[Test]
public async Task TryReadSnapshotRejectsInvalidGuidLengthsAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
using var encoder = new BinaryEncoder(m_messageContext);
encoder.WriteByteString(null, new ByteString(new byte[15]));
encoder.WriteInt32(null, 0);
encoder.WriteUInt64(null, 0);
encoder.WriteByteString(null, new ByteString(new byte[16]));
byte[]? manifest = encoder.CloseAndReturnBuffer();
Assert.That(manifest, Is.Not.Null);
await kv.SetAsync(
"snapmeta/manifest",
new ByteString(manifest!)).ConfigureAwait(false);
NodeStateSnapshot? snapshot = await store.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(snapshot, Is.Null);
}
[Test]
public async Task DeltaLogReplaysOnlyChangesAfterSequenceAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var a = new NodeId("a", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(a, ByteString.From(new byte[] { 1 }))).ConfigureAwait(false);
ulong afterUpsert = store.CurrentSequence;
await store.WriteValueAsync(a, new DataValue(new Variant(9.0), StatusCodes.Good, DateTimeUtc.Now)).ConfigureAwait(false);
var replayed = new List();
await foreach (NodeStateChange change in store.ReadDeltaLogAsync(afterUpsert))
{
replayed.Add(change);
}
Assert.That(replayed, Has.Count.EqualTo(1));
Assert.That(replayed[0].Kind, Is.EqualTo(NodeStateChangeKind.Value));
Assert.That(replayed[0].NodeId, Is.EqualTo(a));
Assert.That(replayed[0].Sequence, Is.GreaterThan(afterUpsert));
}
[Test]
public async Task WriteSnapshotTrimsDeltaLogAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var a = new NodeId("a", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(a, ByteString.From(new byte[] { 1 }))).ConfigureAwait(false);
await store.WriteValueAsync(a, new DataValue(new Variant(2.0), StatusCodes.Good, DateTimeUtc.Now)).ConfigureAwait(false);
await store.WriteSnapshotAsync().ConfigureAwait(false);
var replayed = new List();
await foreach (NodeStateChange change in store.ReadDeltaLogAsync(0))
{
replayed.Add(change);
}
Assert.That(replayed, Is.Empty);
}
///
/// Prevents another store from snapshotting an unfinished write and prevents a delayed
/// older primary CAS from overwriting a newer completed row.
///
[TestCase(false)]
[TestCase(true)]
public async Task SnapshotRejectsUnfinishedPublicationAcrossStoreInstancesAsync(bool beforePrimary)
{
using var kv = new InMemorySharedKeyValueStore();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var nodeId = new NodeId("overlap", NamespaceIndex);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Mock controlled = CreateStoreMock(kv);
if (beforePrimary)
{
controlled.Setup(s => s.CompareAndSwapAsync(
"n/" + nodeId,
It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(WritePrimaryAsync);
}
else
{
controlled.Setup(s => s.SetAsync(
"dlog/00000000000000000001", It.IsAny(), It.IsAny()))
.Returns(WriteDeltaAsync);
}
async ValueTask WritePrimaryAsync(
string key,
ByteString expected,
ByteString value,
CancellationToken ct)
{
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
return await kv.CompareAndSwapAsync(key, expected, value, ct).ConfigureAwait(false);
}
async ValueTask WriteDeltaAsync(string key, ByteString value, CancellationToken ct)
{
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
await kv.SetAsync(key, value, ct).ConfigureAwait(false);
}
using var earlier = new InMemoryNodeStateStore(controlled.Object, m_messageContext);
using var later = new InMemoryNodeStateStore(kv, m_messageContext);
using var snapshotWriter = new InMemoryNodeStateStore(kv, m_messageContext);
Task publication = earlier.UpsertNodeAsync(
new StoredNode(nodeId, new ByteString(new byte[] { 1 })), cts.Token).AsTask();
try
{
await entered.Task.WaitAsync(cts.Token).ConfigureAwait(false);
await later.UpsertNodeAsync(
new StoredNode(nodeId, new ByteString(new byte[] { 2 })), cts.Token).ConfigureAwait(false);
await Assert.ThatAsync(
async () => await snapshotWriter.WriteSnapshotAsync(cts.Token).ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.Contains("unfinished")).ConfigureAwait(false);
Assert.That(await snapshotWriter.TryReadSnapshotAsync(cts.Token).ConfigureAwait(false), Is.Null);
List retained = await ReadChangesAsync(snapshotWriter.ReadDeltaLogAsync(0, cts.Token))
.ConfigureAwait(false);
Assert.That(retained, Has.Count.EqualTo(1));
Assert.That(retained[0].Sequence, Is.EqualTo(2));
}
finally
{
release.TrySetResult(true);
await publication.ConfigureAwait(false);
}
IStoredNode? newest = await snapshotWriter.TryGetNodeAsync(nodeId, cts.Token).ConfigureAwait(false);
Assert.That(newest, Is.Not.Null);
Assert.That(newest!.Payload, Is.EqualTo(new ByteString(new byte[] { 2 })));
await snapshotWriter.WriteSnapshotAsync(cts.Token).ConfigureAwait(false);
NodeStateSnapshot? snapshot = await snapshotWriter.TryReadSnapshotAsync(cts.Token).ConfigureAwait(false);
Assert.That(snapshot, Is.Not.Null);
Assert.That(snapshot!.Sequence, Is.EqualTo(2));
Assert.That(await CountKeysAsync(kv, "dlog/").ConfigureAwait(false), Is.Zero);
}
///
/// Keeps failed or cancelled reservations in shared state so restart cannot trim later completed deltas.
///
/// The test backend deliberately rejects a delta publication.
[TestCase(false)]
[TestCase(true)]
public async Task UnfinishedPublicationSurvivesFailureAndRestartAsync(bool cancel)
{
using var kv = new InMemorySharedKeyValueStore();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var blocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Mock controlled = CreateStoreMock(kv);
controlled.Setup(s => s.SetAsync(
"dlog/00000000000000000001", It.IsAny(), It.IsAny()))
.Returns(FailPublicationAsync);
async ValueTask FailPublicationAsync(string key, ByteString value, CancellationToken ct)
{
entered.TrySetResult(true);
if (cancel)
{
await blocked.Task.WaitAsync(ct).ConfigureAwait(false);
}
throw new IOException($"The delta '{key}' ({value.Length} bytes) could not be published.");
}
using (var writer = new InMemoryNodeStateStore(controlled.Object, m_messageContext))
{
Task publication = writer.UpsertNodeAsync(new StoredNode(
new NodeId("unfinished", NamespaceIndex),
new ByteString(new byte[] { 1 })), cts.Token).AsTask();
await entered.Task.WaitAsync(cts.Token).ConfigureAwait(false);
if (cancel)
{
cts.Cancel();
await Assert.ThatAsync(
async () => await publication.ConfigureAwait(false),
Throws.InstanceOf()).ConfigureAwait(false);
}
else
{
await Assert.ThatAsync(
async () => await publication.ConfigureAwait(false),
Throws.TypeOf()).ConfigureAwait(false);
}
}
using var restarted = new InMemoryNodeStateStore(kv, m_messageContext);
await restarted.UpsertNodeAsync(new StoredNode(
new NodeId("completed", NamespaceIndex),
new ByteString(new byte[] { 2 }))).ConfigureAwait(false);
await Assert.ThatAsync(
async () => await restarted.WriteSnapshotAsync().ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.Contains("publication 1 is unfinished"))
.ConfigureAwait(false);
List retained = await ReadChangesAsync(restarted.ReadDeltaLogAsync(0))
.ConfigureAwait(false);
Assert.That(retained, Has.Count.EqualTo(1));
Assert.That(retained[0].Sequence, Is.EqualTo(2));
(bool found, ByteString coordinator) = await kv.TryGetAsync(InMemoryNodeStateStore.SequenceKey)
.ConfigureAwait(false);
Assert.That(found, Is.True);
Assert.That(coordinator.Length, Is.EqualTo(2 * sizeof(ulong)));
Assert.That(BinaryPrimitives.ReadUInt64BigEndian(coordinator.Span), Is.EqualTo(2));
Assert.That(BinaryPrimitives.ReadUInt64BigEndian(coordinator.Span[sizeof(ulong)..]), Is.EqualTo(1));
}
///
/// Revalidates shared coordination after both scans, preserving the previous manifest and all newer deltas.
///
[Test]
public async Task SnapshotRejectsConcurrentPublicationDuringScansAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var writer = new InMemoryNodeStateStore(kv, m_messageContext);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var nodeId = new NodeId("baseline", NamespaceIndex);
await writer.UpsertNodeAsync(new StoredNode(nodeId, new ByteString(new byte[] { 1 })))
.ConfigureAwait(false);
await writer.WriteSnapshotAsync().ConfigureAwait(false);
(_, ByteString manifest) = await kv.TryGetAsync("snapmeta/manifest").ConfigureAwait(false);
await writer.UpsertNodeAsync(new StoredNode(nodeId, new ByteString(new byte[] { 2 })))
.ConfigureAwait(false);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Mock controlled = CreateStoreMock(kv);
controlled.Setup(s => s.ScanAsync("v/", It.IsAny()))
.Returns((string prefix, CancellationToken ct) => ScanValuesAsync(prefix, ct));
async IAsyncEnumerable> ScanValuesAsync(
string prefix,
[EnumeratorCancellation] CancellationToken ct)
{
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
await foreach (KeyValuePair entry in kv.ScanAsync(prefix, ct)
.ConfigureAwait(false))
{
yield return entry;
}
}
using var snapshotWriter = new InMemoryNodeStateStore(controlled.Object, m_messageContext);
Task snapshot = snapshotWriter.WriteSnapshotAsync(cts.Token).AsTask();
try
{
await entered.Task.WaitAsync(cts.Token).ConfigureAwait(false);
await writer.UpsertNodeAsync(new StoredNode(
new NodeId("concurrent", NamespaceIndex),
new ByteString(new byte[] { 3 })), cts.Token).ConfigureAwait(false);
}
finally
{
release.TrySetResult(true);
}
await Assert.ThatAsync(
async () => await snapshot.ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.Contains("changed")).ConfigureAwait(false);
(_, ByteString unchanged) = await kv.TryGetAsync("snapmeta/manifest").ConfigureAwait(false);
Assert.That(unchanged, Is.EqualTo(manifest));
Assert.That(await CountSnapshotGenerationsAsync(kv).ConfigureAwait(false), Is.EqualTo(1));
List retained = await ReadChangesAsync(writer.ReadDeltaLogAsync(0)).ConfigureAwait(false);
Assert.That(retained, Has.Count.EqualTo(2));
Assert.That(retained[0].Sequence, Is.EqualTo(2));
Assert.That(retained[1].Sequence, Is.EqualTo(3));
}
///
/// Retains chunks when an indeterminate manifest CAS actually committed before its caller observed failure.
///
/// The backend injects cancellation after manifest commit.
/// The backend injects a lost response after manifest commit.
[TestCase(false)]
[TestCase(true)]
public async Task IndeterminateManifestPublicationRetainsPossiblyPublishedChunksAsync(bool cancel)
{
using var kv = new InMemorySharedKeyValueStore();
using var writer = new InMemoryNodeStateStore(kv, m_messageContext);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var nodeId = new NodeId("manifest", NamespaceIndex);
await writer.UpsertNodeAsync(new StoredNode(nodeId, new ByteString(new byte[] { 1 })))
.ConfigureAwait(false);
await writer.WriteSnapshotAsync().ConfigureAwait(false);
await writer.UpsertNodeAsync(new StoredNode(nodeId, new ByteString(new byte[] { 2 })))
.ConfigureAwait(false);
Mock controlled = CreateStoreMock(kv);
controlled.Setup(s => s.CompareAndSwapAsync(
"snapmeta/manifest",
It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(PublishThenFailAsync);
async ValueTask PublishThenFailAsync(
string key,
ByteString expected,
ByteString value,
CancellationToken ct)
{
bool published = await kv.CompareAndSwapAsync(key, expected, value, ct).ConfigureAwait(false);
Assert.That(published, Is.True);
if (cancel)
{
cts.Cancel();
throw new OperationCanceledException(ct);
}
throw new IOException("The committed manifest response was lost.");
}
using var snapshotWriter = new InMemoryNodeStateStore(controlled.Object, m_messageContext);
Task publication = snapshotWriter.WriteSnapshotAsync(cts.Token).AsTask();
if (cancel)
{
await Assert.ThatAsync(
async () => await publication.ConfigureAwait(false),
Throws.InstanceOf()).ConfigureAwait(false);
}
else
{
await Assert.ThatAsync(
async () => await publication.ConfigureAwait(false),
Throws.TypeOf()).ConfigureAwait(false);
}
NodeStateSnapshot? snapshot = await writer.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(snapshot, Is.Not.Null);
Assert.That(snapshot!.Sequence, Is.EqualTo(2));
List entries = await ReadChangesAsync(snapshot.Entries).ConfigureAwait(false);
Assert.That(entries, Has.Count.EqualTo(1));
Assert.That(entries[0].Node!.Payload, Is.EqualTo(new ByteString(new byte[] { 2 })));
Assert.That(await CountSnapshotGenerationsAsync(kv).ConfigureAwait(false), Is.EqualTo(2));
Assert.That(await CountKeysAsync(kv, "dlog/").ConfigureAwait(false), Is.EqualTo(1));
}
///
/// Removes only the losing attempt's chunks when another instance publishes a snapshot first.
///
[Test]
public async Task ConcurrentSnapshotLoserCleansOnlyItsOwnGenerationAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var winner = new InMemoryNodeStateStore(kv, m_messageContext);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await winner.UpsertNodeAsync(new StoredNode(
new NodeId("snapshot", NamespaceIndex),
new ByteString(new byte[] { 1 }))).ConfigureAwait(false);
await winner.WriteSnapshotAsync().ConfigureAwait(false);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Mock controlled = CreateStoreMock(kv);
controlled.Setup(s => s.CompareAndSwapAsync(
"snapmeta/manifest",
It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(PublishLastAsync);
async ValueTask PublishLastAsync(
string key,
ByteString expected,
ByteString value,
CancellationToken ct)
{
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
return await kv.CompareAndSwapAsync(key, expected, value, ct).ConfigureAwait(false);
}
using var loser = new InMemoryNodeStateStore(controlled.Object, m_messageContext);
Task publication = loser.WriteSnapshotAsync(cts.Token).AsTask();
ByteString manifest;
try
{
await entered.Task.WaitAsync(cts.Token).ConfigureAwait(false);
await winner.WriteSnapshotAsync(cts.Token).ConfigureAwait(false);
(_, manifest) = await kv.TryGetAsync("snapmeta/manifest", cts.Token).ConfigureAwait(false);
}
finally
{
release.TrySetResult(true);
}
await Assert.ThatAsync(
async () => await publication.ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.Contains("concurrently")).ConfigureAwait(false);
(_, ByteString unchanged) = await kv.TryGetAsync("snapmeta/manifest").ConfigureAwait(false);
Assert.That(unchanged, Is.EqualTo(manifest));
Assert.That(await CountSnapshotGenerationsAsync(kv).ConfigureAwait(false), Is.EqualTo(2));
NodeStateSnapshot? snapshot = await winner.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(snapshot, Is.Not.Null);
Assert.That(await ReadChangesAsync(snapshot!.Entries).ConfigureAwait(false), Has.Count.EqualTo(1));
}
[Test]
public void ObserveSequenceRaisesHighWaterMarkOnly()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
store.ObserveSequence(50);
Assert.That(store.CurrentSequence, Is.EqualTo(50));
store.ObserveSequence(10);
Assert.That(store.CurrentSequence, Is.EqualTo(50));
}
///
/// Preserves the unsigned high-water mark across the signed interlocked storage boundary.
///
[Test]
public async Task ObserveSequencePreservesUnsignedHighWaterMarkAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
const ulong highWaterMark = (ulong)long.MaxValue + 2;
store.ObserveSequence(highWaterMark);
store.ObserveSequence(1);
await store.UpsertNodeAsync(
new StoredNode(new NodeId("unsigned", NamespaceIndex), new ByteString(new byte[] { 1 })))
.ConfigureAwait(false);
Assert.That(store.CurrentSequence, Is.EqualTo(highWaterMark + 1));
List deltas = await ReadChangesAsync(store.ReadDeltaLogAsync(0)).ConfigureAwait(false);
Assert.That(deltas, Has.Count.EqualTo(1));
Assert.That(deltas[0].Sequence, Is.EqualTo(highWaterMark + 1));
}
///
/// Rejects exhausted shared or observed sequence numbers without wrapping or writing a node.
///
[TestCase(false)]
[TestCase(true)]
public async Task SequenceExhaustionRejectsBeforePrimaryMutationAsync(bool shared)
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
if (shared)
{
await kv.SetAsync(
InMemoryNodeStateStore.SequenceKey,
MakeRecord(ulong.MaxValue, ByteString.Empty)).ConfigureAwait(false);
}
else
{
store.ObserveSequence(ulong.MaxValue);
}
await Assert.ThatAsync(
async () => await store.UpsertNodeAsync(
new StoredNode(new NodeId("exhausted", NamespaceIndex), new ByteString(new byte[] { 1 })))
.ConfigureAwait(false),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadOutOfRange)).ConfigureAwait(false);
Assert.That(await CountKeysAsync(kv, "n/").ConfigureAwait(false), Is.Zero);
Assert.That(await CountKeysAsync(kv, "dlog/").ConfigureAwait(false), Is.Zero);
}
///
/// Assigns unique shared sequences across concurrent stores and a newly constructed writer.
///
[Test]
public async Task SharedCoordinatorAssignsUniqueSequencesAcrossStoreInstancesAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var contendersReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
int contenders = 0;
Mock controlled = CreateStoreMock(kv);
controlled.Setup(s => s.CompareAndSwapAsync(
InMemoryNodeStateStore.SequenceKey,
It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(ReserveConcurrentlyAsync);
async ValueTask ReserveConcurrentlyAsync(
string key,
ByteString expected,
ByteString value,
CancellationToken ct)
{
int contender = Interlocked.Increment(ref contenders);
if (contender <= 32)
{
if (contender == 32)
{
contendersReady.TrySetResult(true);
}
await release.Task.WaitAsync(ct).ConfigureAwait(false);
}
return await kv.CompareAndSwapAsync(key, expected, value, ct).ConfigureAwait(false);
}
using var first = new InMemoryNodeStateStore(controlled.Object, m_messageContext);
using var second = new InMemoryNodeStateStore(controlled.Object, m_messageContext);
var writes = new List();
for (int i = 0; i < 32; i++)
{
InMemoryNodeStateStore writer = i % 2 == 0 ? first : second;
writes.Add(writer.UpsertNodeAsync(new StoredNode(
new NodeId(i.ToString(CultureInfo.InvariantCulture), NamespaceIndex),
new ByteString(new byte[] { 1 })), cts.Token).AsTask());
}
try
{
await contendersReady.Task.WaitAsync(cts.Token).ConfigureAwait(false);
}
finally
{
release.TrySetResult(true);
}
await Task.WhenAll(writes).ConfigureAwait(false);
using var restarted = new InMemoryNodeStateStore(kv, m_messageContext);
await restarted.UpsertNodeAsync(new StoredNode(
new NodeId("restarted", NamespaceIndex),
new ByteString(new byte[] { 2 }))).ConfigureAwait(false);
List deltas = await ReadChangesAsync(restarted.ReadDeltaLogAsync(0))
.ConfigureAwait(false);
Assert.That(deltas, Has.Count.EqualTo(33));
for (int i = 0; i < deltas.Count; i++)
{
Assert.That(deltas[i].Sequence, Is.EqualTo((ulong)i + 1));
}
Assert.That(restarted.CurrentSequence, Is.EqualTo(33));
}
///
/// Leaves the coordinator and payload untouched when cancellation precedes sequence reservation.
///
[Test]
public async Task CancelledWriterDoesNotReserveASequenceAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThatAsync(
async () => await store.UpsertNodeAsync(
new StoredNode(new NodeId("cancelled", NamespaceIndex), new ByteString(new byte[] { 1 })),
cts.Token).ConfigureAwait(false),
Throws.InstanceOf()).ConfigureAwait(false);
Assert.That(await CountKeysAsync(kv, string.Empty).ConfigureAwait(false), Is.Zero);
Assert.That(store.CurrentSequence, Is.Zero);
}
///
/// Fails a bare CRDT writer before any coordinator, primary, or delta mutation.
///
[Test]
public async Task BareCrdtWriterRejectsBeforeAnyMutationAsync()
{
await using var network = new InMemoryNetwork();
await using var crdt = new ReplicatedSharedKeyValueStore(
ReplicaId.New(), network.CreateTransport(), TimeProvider.System, CrdtReaderOptions.Default);
using var store = new InMemoryNodeStateStore(crdt, m_messageContext);
await Assert.ThatAsync(
async () => await store.UpsertNodeAsync(
new StoredNode(new NodeId("unsupported", NamespaceIndex), new ByteString(new byte[] { 1 })))
.ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.Contains(InMemoryNodeStateStore.SequenceKey))
.ConfigureAwait(false);
Assert.That(await CountKeysAsync(crdt, string.Empty).ConfigureAwait(false), Is.Zero);
}
///
/// Does not mistake an undisclosed store's CAS method for a declared linearizable coordinator.
///
[Test]
public async Task UndeclaredCoordinatorRejectsBeforeCallingTheBackendAsync()
{
var kv = new Mock(MockBehavior.Strict);
using var store = new InMemoryNodeStateStore(kv.Object, m_messageContext);
await Assert.ThatAsync(
async () => await store.UpsertNodeAsync(
new StoredNode(new NodeId("unsupported", NamespaceIndex), new ByteString(new byte[] { 1 })))
.ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.Contains("declared linearizable"))
.ConfigureAwait(false);
kv.VerifyNoOtherCalls();
}
///
/// Rejects a private in-memory counter for replicated payloads while retaining ordinary local construction.
///
[TestCase(false)]
[TestCase(true)]
public async Task ReplicatedPayloadRejectsProcessLocalCoordinatorAsync(bool onlyDeltaReplicated)
{
await using var network = new InMemoryNetwork();
await using var crdt = new ReplicatedSharedKeyValueStore(
ReplicaId.New(), network.CreateTransport(), TimeProvider.System, CrdtReaderOptions.Default);
using var coordinator = new InMemorySharedKeyValueStore();
ArrayOf prefixes = onlyDeltaReplicated ? ["election/", "n/", "v/"] : default;
await using var hybrid = new HybridSharedKeyValueStore(crdt, coordinator, prefixes);
using var store = new InMemoryNodeStateStore(hybrid, m_messageContext);
await Assert.ThatAsync(
async () => await store.UpsertNodeAsync(
new StoredNode(new NodeId("unsupported", NamespaceIndex), new ByteString(new byte[] { 1 })))
.ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.Contains("process-local")).ConfigureAwait(false);
Assert.That(await CountKeysAsync(crdt, string.Empty).ConfigureAwait(false), Is.Zero);
Assert.That(await CountKeysAsync(coordinator, string.Empty).ConfigureAwait(false), Is.Zero);
}
///
/// Preserves a corrupt coordinator record and rejects publication before mutating primary state.
///
[TestCase(false)]
[TestCase(true)]
public async Task InvalidCoordinatorRejectsBeforePrimaryMutationAsync(bool invalidReservation)
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
ByteString record = invalidReservation
? MakeRecord(1, MakeRecord(2, ByteString.Empty))
: new ByteString(new byte[] { 1, 2, 3 });
await kv.SetAsync(InMemoryNodeStateStore.SequenceKey, record).ConfigureAwait(false);
await Assert.ThatAsync(
async () => await store.UpsertNodeAsync(
new StoredNode(new NodeId("invalid", NamespaceIndex), new ByteString(new byte[] { 1 })))
.ConfigureAwait(false),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadDecodingError)).ConfigureAwait(false);
Assert.That(await CountKeysAsync(kv, "n/").ConfigureAwait(false), Is.Zero);
(bool found, ByteString unchanged) = await kv.TryGetAsync(InMemoryNodeStateStore.SequenceKey)
.ConfigureAwait(false);
Assert.That(found, Is.True);
Assert.That(unchanged, Is.EqualTo(record));
}
[Test]
public async Task SubscribeObservesTopologyAndValueChangesAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
using var cts = new CancellationTokenSource();
var nodeId = new NodeId("watched", NamespaceIndex);
await using IAsyncEnumerator changes =
store.SubscribeChangesAsync(cts.Token).GetAsyncEnumerator();
ValueTask upsert = changes.MoveNextAsync();
await store.UpsertNodeAsync(new StoredNode(nodeId, ByteString.From(new byte[] { 7 }))).ConfigureAwait(false);
Assert.That(await upsert.ConfigureAwait(false), Is.True);
Assert.That(changes.Current.Kind, Is.EqualTo(NodeStateChangeKind.Upsert));
Assert.That(changes.Current.NodeId, Is.EqualTo(nodeId));
Assert.That(changes.Current.Node, Is.Not.Null);
ValueTask valueChange = changes.MoveNextAsync();
await store.WriteValueAsync(nodeId, new DataValue(new Variant(1.0), StatusCodes.Good, DateTimeUtc.Now)).ConfigureAwait(false);
Assert.That(await valueChange.ConfigureAwait(false), Is.True);
Assert.That(changes.Current.Kind, Is.EqualTo(NodeStateChangeKind.Value));
Assert.That(changes.Current.NodeId, Is.EqualTo(nodeId));
ValueTask delete = changes.MoveNextAsync();
await store.DeleteNodeAsync(nodeId).ConfigureAwait(false);
Assert.That(await delete.ConfigureAwait(false), Is.True);
Assert.That(changes.Current.Kind, Is.EqualTo(NodeStateChangeKind.Delete));
Assert.That(changes.Current.NodeId, Is.EqualTo(nodeId));
cts.Cancel();
}
[Test]
public async Task SubscribePollsWhenStoreHasNoWatchAsync()
{
// A CRDT store has no change-feed (WatchAsync throws NotSupported),
// so the standby subscription must fall back to scan-polling instead
// of silently stopping.
await using var network = new InMemoryNetwork();
await using var crdt = new ReplicatedSharedKeyValueStore(
ReplicaId.New(), network.CreateTransport(), TimeProvider.System, CrdtReaderOptions.Default);
await using var coordinator = new RaftSharedKeyValueStore(
DefaultRaftConsensus.CreateSingleNode(), ownsConsensus: true);
await using var hybrid = new HybridSharedKeyValueStore(crdt, coordinator);
var baseline = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Mock observed = CreateStoreMock(hybrid);
observed.Setup(s => s.ScanAsync("dlog/", It.IsAny()))
.Returns((string prefix, CancellationToken ct) => ObserveBaselineAsync(prefix, ct));
async IAsyncEnumerable> ObserveBaselineAsync(
string prefix,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (KeyValuePair entry in hybrid.ScanAsync(prefix, ct)
.ConfigureAwait(false))
{
yield return entry;
}
baseline.TrySetResult(true);
}
using var store = new InMemoryNodeStateStore(
observed.Object, m_messageContext, null, TimeSpan.FromMilliseconds(50));
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var nodeId = new NodeId("polled", NamespaceIndex);
await using IAsyncEnumerator changes =
store.SubscribeChangesAsync(cts.Token).GetAsyncEnumerator();
Task pending = changes.MoveNextAsync().AsTask();
try
{
await baseline.Task.WaitAsync(cts.Token).ConfigureAwait(false);
await store.UpsertNodeAsync(
new StoredNode(nodeId, ByteString.From(new byte[] { 9 })), cts.Token).ConfigureAwait(false);
Assert.That(await pending.ConfigureAwait(false), Is.True);
Assert.That(changes.Current.Kind, Is.EqualTo(NodeStateChangeKind.Upsert));
Assert.That(changes.Current.NodeId, Is.EqualTo(nodeId));
pending = changes.MoveNextAsync().AsTask();
await store.DeleteNodeAsync(nodeId, cts.Token).ConfigureAwait(false);
await Assert.ThatAsync(
async () => await store.WriteSnapshotAsync(cts.Token).ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.Contains("eventually consistent"))
.ConfigureAwait(false);
Assert.That(await pending.ConfigureAwait(false), Is.True);
Assert.That(changes.Current.Kind, Is.EqualTo(NodeStateChangeKind.Delete));
Assert.That(changes.Current.NodeId, Is.EqualTo(nodeId));
}
finally
{
cts.Cancel();
try
{
await pending.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
// Drain an interrupted MoveNext before disposing the async iterator.
}
}
}
[Test]
public async Task PreparedPollingSubscriptionEmitsCurrentStateEachTimeAsync()
{
await using var network = new InMemoryNetwork();
await using var crdt = new ReplicatedSharedKeyValueStore(
ReplicaId.New(),
network.CreateTransport(),
TimeProvider.System,
CrdtReaderOptions.Default);
await using var coordinator = new RaftSharedKeyValueStore(
DefaultRaftConsensus.CreateSingleNode(), ownsConsensus: true);
await using var hybrid = new HybridSharedKeyValueStore(crdt, coordinator);
using var store = new InMemoryNodeStateStore(
hybrid,
m_messageContext,
null,
TimeSpan.FromMilliseconds(25));
var nodeId = new NodeId("prepared", NamespaceIndex);
await store.UpsertNodeAsync(
new StoredNode(nodeId, ByteString.From(new byte[] { 9 }))).ConfigureAwait(false);
for (int attempt = 0; attempt < 2; attempt++)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
((INodeStateSubscriptionPreparer)store).PrepareSubscription();
await using IAsyncEnumerator changes =
store.SubscribeChangesAsync(cts.Token).GetAsyncEnumerator();
Assert.That(await changes.MoveNextAsync().ConfigureAwait(false), Is.True);
Assert.That(changes.Current.Kind, Is.EqualTo(NodeStateChangeKind.Upsert));
Assert.That(changes.Current.NodeId, Is.EqualTo(nodeId));
cts.Cancel();
}
}
///
/// Replays a retained higher-sequence delta when a delayed earlier CRDT write wins the raw row.
///
[Test]
public async Task HybridPollingRecoversNewerDeltaAfterDelayedPrimaryWriteAsync()
{
await using var network = new InMemoryNetwork();
await using var crdt = new ReplicatedSharedKeyValueStore(
ReplicaId.New(), network.CreateTransport(), TimeProvider.System, CrdtReaderOptions.Default);
await using var coordinator = new RaftSharedKeyValueStore(
DefaultRaftConsensus.CreateSingleNode(), ownsConsensus: true);
await using var hybrid = new HybridSharedKeyValueStore(crdt, coordinator);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var nodeId = new NodeId("delayed", NamespaceIndex);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Mock controlled = CreateStoreMock(hybrid);
controlled.Setup(s => s.SetAsync("n/" + nodeId, It.IsAny(), It.IsAny()))
.Returns(DelayPrimaryAsync);
async ValueTask DelayPrimaryAsync(string key, ByteString value, CancellationToken ct)
{
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
await hybrid.SetAsync(key, value, ct).ConfigureAwait(false);
}
using var earlier = new InMemoryNodeStateStore(controlled.Object, m_messageContext);
using var later = new InMemoryNodeStateStore(hybrid, m_messageContext);
Task delayed = earlier.UpsertNodeAsync(
new StoredNode(nodeId, new ByteString(new byte[] { 1 })), cts.Token).AsTask();
try
{
await entered.Task.WaitAsync(cts.Token).ConfigureAwait(false);
await later.UpsertNodeAsync(
new StoredNode(nodeId, new ByteString(new byte[] { 2 })), cts.Token).ConfigureAwait(false);
}
finally
{
release.TrySetResult(true);
await delayed.ConfigureAwait(false);
}
IStoredNode? raw = await later.TryGetNodeAsync(nodeId, cts.Token).ConfigureAwait(false);
Assert.That(raw, Is.Not.Null);
Assert.That(raw!.Payload, Is.EqualTo(new ByteString(new byte[] { 1 })));
Assert.That(((INodeStateStoreReadConsistency)later).HasAuthoritativeReads, Is.False);
Assert.That(((INodeStateStoreReadConsistency)later).SupportsSnapshots, Is.False);
await Assert.ThatAsync(
async () => await later.WriteSnapshotAsync(cts.Token).ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.Contains("must be retained")).ConfigureAwait(false);
List deltas = await ReadChangesAsync(later.ReadDeltaLogAsync(0, cts.Token))
.ConfigureAwait(false);
Assert.That(deltas, Has.Count.EqualTo(2));
Assert.That(deltas[1].Sequence, Is.EqualTo(2));
((INodeStateSubscriptionPreparer)later).PrepareSubscription();
await using IAsyncEnumerator changes =
later.SubscribeChangesAsync(cts.Token).GetAsyncEnumerator();
do
{
Assert.That(await changes.MoveNextAsync().ConfigureAwait(false), Is.True);
}
while (changes.Current.Sequence < 2);
Assert.That(changes.Current.Sequence, Is.EqualTo(2));
Assert.That(changes.Current.NodeId, Is.EqualTo(nodeId));
Assert.That(changes.Current.Node!.Payload, Is.EqualTo(new ByteString(new byte[] { 2 })));
cts.Cancel();
(bool bulkHasCounter, _) = await crdt.TryGetAsync(InMemoryNodeStateStore.SequenceKey)
.ConfigureAwait(false);
Assert.That(bulkHasCounter, Is.False, "the real CRDT backend must not hold or CAS the coordinator key");
}
[Test]
public async Task NodeStateBinaryRoundTripThroughStoreAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var nodeId = new NodeId("sensor", NamespaceIndex);
var original = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("Sensor", NamespaceIndex),
DisplayName = new LocalizedText("Sensor"),
DataType = DataTypeIds.Double,
ValueRank = ValueRanks.Scalar,
Value = new Variant(3.14)
};
using var stream = new MemoryStream();
original.SaveAsBinary(m_systemContext, stream);
await store.UpsertNodeAsync(new StoredNode(nodeId, ByteString.From(stream.ToArray()))).ConfigureAwait(false);
IStoredNode? stored = await store.TryGetNodeAsync(nodeId).ConfigureAwait(false);
Assert.That(stored, Is.Not.Null);
var restored = new BaseDataVariableState(null);
using var loadStream = new MemoryStream(stored!.Payload.ToArray());
restored.LoadAsBinary(m_systemContext, loadStream);
Assert.That(restored.BrowseName, Is.EqualTo(original.BrowseName));
Assert.That(restored.Value, Is.EqualTo(original.Value));
}
[Test]
public async Task ProtectedNodeAndValueRoundTripAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(11));
using var store = new InMemoryNodeStateStore(kv, m_messageContext, protector);
var nodeId = new NodeId("secret", NamespaceIndex);
var payload = ByteString.From(new byte[] { 9, 8, 7, 6 });
await store.UpsertNodeAsync(new StoredNode(nodeId, payload)).ConfigureAwait(false);
await store.WriteValueAsync(nodeId, new DataValue(new Variant(99.0), StatusCodes.Good)).ConfigureAwait(false);
IStoredNode? node = await store.TryGetNodeAsync(nodeId).ConfigureAwait(false);
(bool found, DataValue value) = await store.TryReadValueAsync(nodeId).ConfigureAwait(false);
// The bytes persisted in the backend must not be the plaintext.
(bool rawFound, ByteString raw) = await kv.TryGetAsync("n/" + nodeId).ConfigureAwait(false);
Assert.That(rawFound, Is.True);
Assert.That(raw.ToArray(), Is.Not.EqualTo(payload.ToArray()));
Assert.That(node, Is.Not.Null);
Assert.That(node!.Payload.ToArray(), Is.EqualTo(payload.ToArray()));
Assert.That(found, Is.True);
Assert.That(value.WrappedValue, Is.EqualTo(new Variant(99.0)));
}
[Test]
public async Task TamperedProtectedNodeIsRejectedFailClosedAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(12));
using var store = new InMemoryNodeStateStore(kv, m_messageContext, protector);
var nodeId = new NodeId("tampered", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(nodeId, ByteString.From(new byte[] { 1, 2, 3 }))).ConfigureAwait(false);
// A compromised store / rogue replica forges the persisted record.
await kv.SetAsync("n/" + nodeId, ByteString.From(new byte[] { 66, 66, 66, 66, 66 })).ConfigureAwait(false);
IStoredNode? node = await store.TryGetNodeAsync(nodeId).ConfigureAwait(false);
Assert.That(node, Is.Null);
}
[Test]
public async Task NodeProtectedUnderDifferentKeyIsRejectedAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var writerProtector = new AesCbcHmacRecordProtector(MakeKey(13));
using var readerProtector = new AesCbcHmacRecordProtector(MakeKey(14));
var writer = new InMemoryNodeStateStore(kv, m_messageContext, writerProtector);
var reader = new InMemoryNodeStateStore(kv, m_messageContext, readerProtector);
var nodeId = new NodeId("crosskey", NamespaceIndex);
await writer.UpsertNodeAsync(new StoredNode(nodeId, ByteString.From(new byte[] { 5, 5 }))).ConfigureAwait(false);
IStoredNode? node = await reader.TryGetNodeAsync(nodeId).ConfigureAwait(false);
Assert.That(node, Is.Null);
}
///
/// Rejects an unauthenticated sequence coordinator without changing its protected record or stored nodes.
///
[Test]
public async Task ProtectedCoordinatorRejectsWriterWithDifferentKeyAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var originalProtector = new AesCbcHmacRecordProtector(MakeKey(23));
using var otherProtector = new AesCbcHmacRecordProtector(MakeKey(24));
using var original = new InMemoryNodeStateStore(kv, m_messageContext, originalProtector);
using var other = new InMemoryNodeStateStore(kv, m_messageContext, otherProtector);
await original.UpsertNodeAsync(new StoredNode(
new NodeId("original", NamespaceIndex),
new ByteString(new byte[] { 1 }))).ConfigureAwait(false);
(_, ByteString record) = await kv.TryGetAsync(InMemoryNodeStateStore.SequenceKey).ConfigureAwait(false);
await Assert.ThatAsync(
async () => await other.UpsertNodeAsync(new StoredNode(
new NodeId("unauthenticated", NamespaceIndex),
new ByteString(new byte[] { 2 }))).ConfigureAwait(false),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadDecodingError)).ConfigureAwait(false);
(_, ByteString unchanged) = await kv.TryGetAsync(InMemoryNodeStateStore.SequenceKey).ConfigureAwait(false);
Assert.That(unchanged, Is.EqualTo(record));
Assert.That(await CountKeysAsync(kv, "n/").ConfigureAwait(false), Is.EqualTo(1));
Assert.That(await CountKeysAsync(kv, "dlog/").ConfigureAwait(false), Is.EqualTo(1));
}
[Test]
public void UpsertNodeRejectsNullArgument()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
Assert.That(
async () => await store.UpsertNodeAsync(null!).ConfigureAwait(false),
Throws.ArgumentNullException);
}
[Test]
public async Task DeltaLogReplaysUpsertAndDeleteChangesAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var a = new NodeId("a", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(a, ByteString.From(new byte[] { 1 }))).ConfigureAwait(false);
bool deleted = await store.DeleteNodeAsync(a).ConfigureAwait(false);
var replayed = new List();
await foreach (NodeStateChange change in store.ReadDeltaLogAsync(0))
{
replayed.Add(change);
}
Assert.That(deleted, Is.True);
Assert.That(replayed, Has.Count.EqualTo(2));
Assert.That(replayed[0].Kind, Is.EqualTo(NodeStateChangeKind.Upsert));
Assert.That(replayed[0].NodeId, Is.EqualTo(a));
Assert.That(replayed[0].Node, Is.Not.Null);
Assert.That(replayed[1].Kind, Is.EqualTo(NodeStateChangeKind.Delete));
Assert.That(replayed[1].NodeId, Is.EqualTo(a));
}
[Test]
public async Task WriteSnapshotOnEmptyStoreProducesEmptySnapshotAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
await store.WriteSnapshotAsync().ConfigureAwait(false);
NodeStateSnapshot? snapshot = await store.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(snapshot, Is.Not.Null);
int count = 0;
await foreach (NodeStateChange _ in snapshot!.Entries)
{
count++;
}
Assert.That(count, Is.Zero);
}
[Test]
public async Task RepeatedSnapshotsCollectSupersededGenerationAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var a = new NodeId("a", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(a, ByteString.From(new byte[] { 1 }))).ConfigureAwait(false);
await store.WriteSnapshotAsync().ConfigureAwait(false);
await store.WriteSnapshotAsync().ConfigureAwait(false);
await store.WriteSnapshotAsync().ConfigureAwait(false);
int generations = await CountSnapshotGenerationsAsync(kv).ConfigureAwait(false);
NodeStateSnapshot? snapshot = await store.TryReadSnapshotAsync().ConfigureAwait(false);
// The predecessor-of-predecessor generation is garbage-collected once
// the third manifest is published, leaving the live and one retained.
Assert.That(generations, Is.EqualTo(2));
Assert.That(snapshot, Is.Not.Null);
}
[Test]
public async Task EnumerateSkipsMalformedNodeKeysAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var valid = new NodeId("good", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(valid, ByteString.From(new byte[] { 1 }))).ConfigureAwait(false);
// A bare-prefix key and an unparseable NodeId suffix must both be skipped.
var record = ByteString.From(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 1 });
await kv.SetAsync("n/", record).ConfigureAwait(false);
await kv.SetAsync("n/i=abc", record).ConfigureAwait(false);
var ids = new List();
await foreach (IStoredNode node in store.EnumerateAsync())
{
ids.Add(node.NodeId);
}
Assert.That(ids, Is.EquivalentTo([valid]));
}
///
/// Rejects a truncated record under a valid node key instead of treating
/// an incomplete, nonempty hydration scan as authoritative.
///
[Test]
public async Task EnumerateRejectsTruncatedRecordAlongsideValidNodeAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var valid = new NodeId("good", NamespaceIndex);
var corrupt = new NodeId("corrupt", NamespaceIndex);
await store.UpsertNodeAsync(
new StoredNode(valid, ByteString.From(new byte[] { 1 }))).ConfigureAwait(false);
var ids = new List();
await foreach (IStoredNode node in store.EnumerateAsync().ConfigureAwait(false))
{
ids.Add(node.NodeId);
}
Assert.That(ids, Is.EquivalentTo([valid]));
await kv.SetAsync(
"n/" + corrupt,
ByteString.From(new byte[] { 1, 2, 3 })).ConfigureAwait(false);
ids.Clear();
Assert.That(
async () =>
{
await foreach (IStoredNode node in store.EnumerateAsync().ConfigureAwait(false))
{
ids.Add(node.NodeId);
}
},
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadDecodingError));
}
///
/// Rejects a valid node key's corrupt frame before or after a good record, including failed authentication.
///
[TestCase(false, false)]
[TestCase(false, true)]
[TestCase(true, false)]
[TestCase(true, true)]
public async Task EnumerateRejectsCorruptionAtEitherScanPositionAsync(bool corruptFirst, bool authenticated)
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(17));
Mock ordered = CreateStoreMock(kv);
ordered.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny()))
.Returns((string prefix, CancellationToken ct) => ScanSortedAsync(kv, prefix, ct));
using var store = new InMemoryNodeStateStore(
ordered.Object,
m_messageContext,
authenticated ? protector : NullRecordProtector.Instance);
var valid = new NodeId(corruptFirst ? "z-valid" : "a-valid", NamespaceIndex);
var corrupt = new NodeId(corruptFirst ? "a-corrupt" : "z-corrupt", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(valid, new ByteString(new byte[] { 1 })))
.ConfigureAwait(false);
byte[] frame = authenticated
? protector.Protect(
RecordProtectionContext.Create("node-state-record", "n/" + corrupt),
MakeRecord(2, new ByteString(new byte[] { 2 }))).ToArray()
: [1, 2, 3];
if (authenticated)
{
frame[^1] ^= 1;
}
await kv.SetAsync("n/" + corrupt, new ByteString(frame)).ConfigureAwait(false);
var ids = new List();
await Assert.ThatAsync(
async () =>
{
await foreach (IStoredNode node in store.EnumerateAsync().ConfigureAwait(false))
{
ids.Add(node.NodeId);
}
},
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadDecodingError)).ConfigureAwait(false);
Assert.That(ids, Has.Count.EqualTo(corruptFirst ? 0 : 1));
if (!corruptFirst)
{
Assert.That(ids[0], Is.EqualTo(valid));
}
Assert.That(await store.TryGetNodeAsync(corrupt).ConfigureAwait(false), Is.Null);
}
///
/// Retains the prior manifest, retained generations, and delta log after a mixed corrupt snapshot scan.
///
[TestCase(false)]
[TestCase(true)]
public async Task SnapshotCorruptionPreservesManifestLogAndOwnedGenerationsAsync(bool corruptValue)
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(18));
Mock ordered = CreateStoreMock(kv);
ordered.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny()))
.Returns((string prefix, CancellationToken ct) => ScanSortedAsync(kv, prefix, ct));
using var store = new InMemoryNodeStateStore(ordered.Object, m_messageContext, protector);
var valid = new NodeId("a-valid", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(valid, new ByteString(new byte[] { 1 })))
.ConfigureAwait(false);
await store.WriteSnapshotAsync().ConfigureAwait(false);
await store.WriteSnapshotAsync().ConfigureAwait(false);
(_, ByteString manifest) = await kv.TryGetAsync("snapmeta/manifest").ConfigureAwait(false);
Assert.That(await CountSnapshotGenerationsAsync(kv).ConfigureAwait(false), Is.EqualTo(2));
var largePayload = new ByteString(new byte[m_messageContext.MaxByteStringLength]);
await store.UpsertNodeAsync(new StoredNode(valid, largePayload))
.ConfigureAwait(false);
string corruptKey = (corruptValue ? "v/" : "n/") + new NodeId("z-corrupt", NamespaceIndex);
await kv.SetAsync(
corruptKey,
protector.Protect(
RecordProtectionContext.Create("node-state-record", corruptKey),
new ByteString(new byte[] { 1, 2, 3 }))).ConfigureAwait(false);
await Assert.ThatAsync(
async () => await store.WriteSnapshotAsync().ConfigureAwait(false),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadDecodingError)).ConfigureAwait(false);
(_, ByteString unchanged) = await kv.TryGetAsync("snapmeta/manifest").ConfigureAwait(false);
Assert.That(unchanged, Is.EqualTo(manifest));
Assert.That(await CountSnapshotGenerationsAsync(kv).ConfigureAwait(false), Is.EqualTo(2));
List retained = await ReadChangesAsync(store.ReadDeltaLogAsync(0)).ConfigureAwait(false);
Assert.That(retained, Has.Count.EqualTo(1));
Assert.That(retained[0].Sequence, Is.EqualTo(2));
NodeStateSnapshot? previous = await store.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(previous, Is.Not.Null);
List entries = await ReadChangesAsync(previous!.Entries).ConfigureAwait(false);
Assert.That(entries, Has.Count.EqualTo(1));
Assert.That(entries[0].Node!.Payload, Is.EqualTo(new ByteString(new byte[] { 1 })));
}
///
/// Treats valid topology tombstones as absence rather than corruption during scans and snapshot construction.
///
[Test]
public async Task AuthoritativeScansAcceptDeletionTombstonesAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var nodeId = new NodeId("deleted", NamespaceIndex);
await store.UpsertNodeAsync(new StoredNode(nodeId, new ByteString(new byte[] { 1 })))
.ConfigureAwait(false);
Assert.That(await store.DeleteNodeAsync(nodeId).ConfigureAwait(false), Is.True);
int count = 0;
await foreach (IStoredNode _ in store.EnumerateAsync().ConfigureAwait(false))
{
count++;
}
Assert.That(count, Is.Zero);
await store.WriteSnapshotAsync().ConfigureAwait(false);
NodeStateSnapshot? snapshot = await store.TryReadSnapshotAsync().ConfigureAwait(false);
Assert.That(snapshot, Is.Not.Null);
Assert.That(await ReadChangesAsync(snapshot!.Entries).ConfigureAwait(false), Is.Empty);
}
///
/// Rejects truncated value envelopes, empty values, trailing bytes, and unauthenticated value records.
///
[TestCase("envelope")]
[TestCase("empty")]
[TestCase("trailing")]
[TestCase("authentication")]
public async Task EnumerateValuesRejectsInvalidRecordsAsync(string corruption)
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(19));
using var store = new InMemoryNodeStateStore(
kv,
m_messageContext,
corruption == "authentication" ? protector : NullRecordProtector.Instance);
var valid = new NodeId("valid", NamespaceIndex);
var corrupt = new NodeId("corrupt", NamespaceIndex);
await store.WriteValueAsync(valid, new DataValue(new Variant(7))).ConfigureAwait(false);
ByteString frame = corruption switch
{
"empty" => MakeRecord(2, ByteString.Empty),
"trailing" => MakeRecord(2, new ByteString(new byte[] { 0, 1 })),
_ => new ByteString(new byte[] { 1, 2, 3 })
};
await kv.SetAsync("v/" + corrupt, frame).ConfigureAwait(false);
if (corruption == "empty")
{
(bool found, DataValue value) = await store.TryReadValueAsync(corrupt).ConfigureAwait(false);
Assert.That(found, Is.True);
Assert.That(value.IsNull, Is.True, "non-authoritative lookup compatibility is preserved");
}
await Assert.ThatAsync(
async () =>
{
await foreach ((NodeId _, DataValue _) in store.EnumerateValuesAsync().ConfigureAwait(false))
{
}
},
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadDecodingError)).ConfigureAwait(false);
}
///
/// Surfaces malformed delta envelopes, kinds, identifiers, payloads, and
/// trailing bytes instead of skipping them.
///
[TestCase("envelope")]
[TestCase("kind")]
[TestCase("node")]
[TestCase("payload")]
[TestCase("trailing")]
[TestCase("authentication")]
public async Task DeltaReplayRejectsInvalidFramesAsync(string corruption)
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(20));
using var store = new InMemoryNodeStateStore(
kv,
m_messageContext,
corruption == "authentication" ? protector : NullRecordProtector.Instance);
ByteString frame;
if (corruption is "envelope" or "authentication")
{
frame = new ByteString(new byte[] { 1, 2, 3 });
}
else
{
using var encoder = new BinaryEncoder(m_messageContext);
encoder.WriteByte(null, corruption == "kind" ? byte.MaxValue : (byte)NodeStateChangeKind.Upsert);
encoder.WriteNodeId(null, corruption == "node" ? NodeId.Null : new NodeId("delta", NamespaceIndex));
encoder.WriteByteString(
null,
corruption == "payload" ? ByteString.Empty : new ByteString(new byte[] { 1 }));
if (corruption == "trailing")
{
encoder.WriteByte(null, 0);
}
frame = new ByteString(encoder.CloseAndReturnBuffer()!);
}
await kv.SetAsync("dlog/00000000000000000001", frame).ConfigureAwait(false);
await Assert.ThatAsync(
() => ReadChangesAsync(store.ReadDeltaLogAsync(0)),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadDecodingError)).ConfigureAwait(false);
}
///
/// Does not wrap overflowing delta key suffixes into valid earlier sequence numbers.
///
[Test]
public async Task DeltaReplaySkipsOverflowedSequenceKeysAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
await kv.SetAsync("dlog/18446744073709551617", new ByteString(new byte[] { 1, 2, 3 }))
.ConfigureAwait(false);
Assert.That(await ReadChangesAsync(store.ReadDeltaLogAsync(0)).ConfigureAwait(false), Is.Empty);
}
///
/// Reports corrupt node, value, and delta frames received on a live backend feed.
///
[TestCase("n/")]
[TestCase("v/")]
[TestCase("dlog/")]
public async Task WatchRejectsCorruptLiveFramesAsync(string prefix)
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(21));
using var store = new InMemoryNodeStateStore(kv, m_messageContext, protector);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await using IAsyncEnumerator changes =
store.SubscribeChangesAsync(cts.Token).GetAsyncEnumerator();
Task pending = changes.MoveNextAsync().AsTask();
string key = prefix +
(prefix == "dlog/"
? "00000000000000000001"
: new NodeId("live", NamespaceIndex).ToString());
await kv.SetAsync(key, new ByteString(new byte[] { 1, 2, 3 }), cts.Token).ConfigureAwait(false);
await Assert.ThatAsync(
() => pending,
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadDecodingError)).ConfigureAwait(false);
cts.Cancel();
}
///
/// Reports corrupt CRDT rows even when they are already present in the polling baseline.
///
[TestCase("n/")]
[TestCase("v/")]
[TestCase("dlog/")]
public async Task PollingRejectsCorruptBaselineAsync(string prefix)
{
await using var network = new InMemoryNetwork();
await using var crdt = new ReplicatedSharedKeyValueStore(
ReplicaId.New(), network.CreateTransport(), TimeProvider.System, CrdtReaderOptions.Default);
using var protector = new AesCbcHmacRecordProtector(MakeKey(22));
using var store = new InMemoryNodeStateStore(crdt, m_messageContext, protector);
string key = prefix +
(prefix == "dlog/"
? "00000000000000000001"
: new NodeId("baseline", NamespaceIndex).ToString());
await crdt.SetAsync(key, new ByteString(new byte[] { 1, 2, 3 })).ConfigureAwait(false);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await using IAsyncEnumerator changes =
store.SubscribeChangesAsync(cts.Token).GetAsyncEnumerator();
await Assert.ThatAsync(
async () => await changes.MoveNextAsync().ConfigureAwait(false),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadDecodingError)).ConfigureAwait(false);
cts.Cancel();
}
[Test]
public async Task TryGetNodeReturnsNullForTruncatedRecordAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var store = new InMemoryNodeStateStore(kv, m_messageContext);
var nodeId = new NodeId("short", NamespaceIndex);
// A record shorter than the 8-byte sequence header cannot be split.
await kv.SetAsync("n/" + nodeId, ByteString.From(new byte[] { 1, 2, 3 })).ConfigureAwait(false);
IStoredNode? node = await store.TryGetNodeAsync(nodeId).ConfigureAwait(false);
Assert.That(node, Is.Null);
}
private static Mock CreateStoreMock(ISharedKeyValueStore inner)
{
var store = new Mock();
store.Setup(s => s.TryGetAsync(It.IsAny(), It.IsAny()))
.Returns((string key, CancellationToken ct) => inner.TryGetAsync(key, ct));
store.Setup(s => s.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.Returns((string key, ByteString value, CancellationToken ct) => inner.SetAsync(key, value, ct));
store.Setup(s => s.CompareAndSwapAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns((string key, ByteString expected, ByteString value, CancellationToken ct) =>
inner.CompareAndSwapAsync(key, expected, value, ct));
store.Setup(s => s.DeleteAsync(It.IsAny(), It.IsAny()))
.Returns((string key, CancellationToken ct) => inner.DeleteAsync(key, ct));
store.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny()))
.Returns((string prefix, CancellationToken ct) => inner.ScanAsync(prefix, ct));
store.Setup(s => s.WatchAsync(It.IsAny(), It.IsAny()))
.Returns((string prefix, CancellationToken ct) => inner.WatchAsync(prefix, ct));
store.As()
.Setup(s => s.IsLinearizable(It.IsAny()))
.Returns((string key) => inner is ISharedKeyValueStoreConsistency consistency &&
consistency.IsLinearizable(key));
store.As()
.Setup(s => s.IsProcessLocal(It.IsAny()))
.Returns((string key) => inner is not ISharedKeyValueStoreConsistency consistency ||
consistency.IsProcessLocal(key));
return store;
}
private static ByteString MakeRecord(ulong sequence, ByteString payload)
{
byte[] record = new byte[sizeof(ulong) + payload.Length];
BinaryPrimitives.WriteUInt64BigEndian(record, sequence);
payload.Span.CopyTo(record.AsSpan(sizeof(ulong)));
return new ByteString(record);
}
private static async Task> ReadChangesAsync(IAsyncEnumerable source)
{
var changes = new List();
await foreach (NodeStateChange change in source.ConfigureAwait(false))
{
changes.Add(change);
}
return changes;
}
private static async Task CountKeysAsync(ISharedKeyValueStore store, string prefix)
{
int count = 0;
await foreach (KeyValuePair _ in store.ScanAsync(prefix).ConfigureAwait(false))
{
count++;
}
return count;
}
private static async IAsyncEnumerable> ScanSortedAsync(
InMemorySharedKeyValueStore store,
string prefix,
[EnumeratorCancellation] CancellationToken ct)
{
var entries = new List>();
await foreach (KeyValuePair entry in store.ScanAsync(prefix, ct).ConfigureAwait(false))
{
entries.Add(entry);
}
entries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
foreach (KeyValuePair entry in entries)
{
yield return entry;
}
}
private static async Task CountSnapshotGenerationsAsync(InMemorySharedKeyValueStore kv)
{
var generations = new HashSet(StringComparer.Ordinal);
await foreach (KeyValuePair entry in kv.ScanAsync("snap/"))
{
string remainder = entry.Key["snap/".Length..];
string[] parts = remainder.Split('/');
if (parts.Length > 1 && parts[0].Length > 0)
{
generations.Add(parts[0]);
}
}
return generations.Count;
}
private static byte[] MakeKey(byte seed)
{
byte[] key = new byte[32];
for (int i = 0; i < key.Length; i++)
{
key[i] = (byte)(seed + i);
}
return key;
}
}
}