diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 6433c6c3..f515b09d 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -22,6 +22,7 @@ namespace GameCult.Networking private readonly Func _snapshotHandlerV1; private readonly Func _putHandler; private readonly Func _deleteHandler; + private readonly Func _helloHandler; private readonly Func _shardCatalogHandler; private readonly Func _shardLogHandler; private readonly Func _subscribeHandler; @@ -45,6 +46,7 @@ namespace GameCult.Networking _snapshotHandlerV1 = HandleSnapshotRequestV1Async; _putHandler = HandlePutAsync; _deleteHandler = HandleDeleteAsync; + _helloHandler = HandleHelloAsync; _shardCatalogHandler = HandleShardCatalogRequestAsync; _shardLogHandler = HandleShardLogRequestAsync; _subscribeHandler = HandleSubscribeAsync; @@ -55,6 +57,7 @@ namespace GameCult.Networking _server.OnCultNet(_snapshotHandlerV1); _server.OnCultNet(_putHandler); _server.OnCultNet(_deleteHandler); + _server.OnCultNet(_helloHandler); _server.OnCultNet(_shardCatalogHandler); _server.OnCultNet(_shardLogHandler); _server.OnCultNet(_subscribeHandler); @@ -217,6 +220,7 @@ namespace GameCult.Networking _server.RemoveCultNetMessageListener(_snapshotHandlerV1); _server.RemoveCultNetMessageListener(_putHandler); _server.RemoveCultNetMessageListener(_deleteHandler); + _server.RemoveCultNetMessageListener(_helloHandler); _server.RemoveCultNetMessageListener(_shardCatalogHandler); _server.RemoveCultNetMessageListener(_shardLogHandler); _server.RemoveCultNetMessageListener(_subscribeHandler); @@ -323,74 +327,208 @@ namespace GameCult.Networking }; } + // A hello decides the connection's write framing if it is the first thing to (write-receipt.md, "Capability"). The + // server answers a receipt-capable hello with its own, so the client can tell a receipt server from a legacy one. + private Task HandleHelloAsync(CultNetHelloMessage hello, CultNetServerPeer peer) + { + if (peer.Transport != null && peer.Transport.SettleWriteFraming(hello)) + { + Answer(peer, new CultNetHelloMessage + { + RuntimeId = "gamecult-networking-database-server", + RuntimeKind = "dotnet", + SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1] + }); + } + + return Task.CompletedTask; + } + + // The write's receipt state, or null when its connection did not opt in (it is then answered exactly as before). + private static CultNetReceiptedWrite? BeginReceipted( + CultNetServerPeer peer, + string operation, + string? messageId, + string? schemaId, + string? recordKey, + byte[]? payload) + { + var transport = peer.Transport; + if (transport == null) return null; + transport.SettleWriteFraming(null); + var session = transport.WriteReceipts; + return session == null + ? null + : new CultNetReceiptedWrite(session, message => Answer(peer, message), operation, messageId, schemaId, recordKey, payload); + } + + // Every frame a write can be answered with. Mock point for tests: the answer leaves through the peer's transport. + internal static void Answer(CultNetServerPeer peer, ICultNetSchemaMessage message) + { + switch (message) + { + case CultNetWriteAdmittedMessage admitted: peer.SendCultNet(admitted); break; + case CultNetErrorV1Message error: peer.SendCultNet(error); break; + case CultNetErrorMessage error: peer.SendCultNet(error); break; + case CultNetHelloMessage hello: peer.SendCultNet(hello); break; + default: throw new ArgumentException($"{message.GetType().Name} is not a write answer.", nameof(message)); + } + } + + // A refusal: error.v1 carrying the write's id for a receipt-capable connection, the v0 error for any other. + private static void Refuse(CultNetServerPeer peer, CultNetReceiptedWrite? write, CultNetErrorMessage error) + { + if (write != null) write.Refused(error); + else peer.SendCultNet(error); + } + + // Rule 6: a forward is answered with what the primary answered, under the original id. A refusal the primary sent is + // relayed; silence or any other failure relays nothing, so the client settles it unconfirmed. + private void RelayForwardFailure(CultNetReceiptedWrite write, Exception failure) + { + if (failure is CultNetWriteRefusedException refused) + { + write.Refused(refused.Refusal); + return; + } + + _server.Logger.LogWarning($"CultNet forwarded write was not answered by its primary: {failure.Message}"); + write.Unanswered(); + } + private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer) { + var write = BeginReceipted(peer, "put", message.MessageId, message.Document?.SchemaId, message.Document?.RecordKey, message.Document?.Payload); + if (write != null && !write.HasId) + { + write.Refused(CultNetErrorMessage.ForMessageInvalid("messageId", "a write needs a messageId")); + return; + } + + var authorize = Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch); try { - await _database.ApplyPutAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch)) - .ConfigureAwait(false); + await _database.ApplyPutAsync(message, write == null ? authorize : write.Guard(authorize)).ConfigureAwait(false); + write?.Admitted(); + } + catch (CultNetWriteResendException resend) when (write != null) + { + write.Resent(resend); } catch (CultNetRemoteWriteRefusedException ex) { _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); - peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); + Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } catch (CultNetShardAuthorityException ex) { - if (!await TryForwardPutAsync(ex, message).ConfigureAwait(false)) + bool forwarded; + try + { + forwarded = await TryForwardPutAsync(ex, message).ConfigureAwait(false); + } + catch (CultNetWriteUnconfirmedException) when (write == null) + { + // A peer that did not opt in is told nothing of a forward its primary left unanswered. + return; + } + catch (Exception failure) when (write != null) + { + RelayForwardFailure(write, failure); + return; + } + + if (forwarded) + { + write?.Admitted(); + } + else { _server.Logger.LogError($"CultNet raw put authority failed: {ex.Message}"); - peer.SendCultNet(CreateRoutingError(ex)); + Refuse(peer, write, CreateRoutingError(ex)); } } catch (CultNetUnownedSchemaException ex) { _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); - peer.SendCultNet(CultNetErrorMessage.ForUnownedSchema(ex)); + Refuse(peer, write, CultNetErrorMessage.ForUnownedSchema(ex)); } catch (Exception ex) when (CommittedWithLogFailure(ex)) { // The write committed and was published; only its shard log failed, so the peer is not told it was refused. _server.Logger.LogWarning($"CultNet raw put committed but its shard log failed: {ex.Message}"); + write?.Admitted(); } catch (Exception ex) { _server.Logger.LogError($"CultNet raw put failed: {ex.Message}"); - peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); + Refuse(peer, write, new CultNetErrorMessage { Error = ex.Message }); } } private async Task HandleDeleteAsync(CultNetDocumentDeleteMessage message, CultNetServerPeer peer) { + var write = BeginReceipted(peer, "delete", message.MessageId, message.SchemaId, message.RecordKey, null); + if (write != null && !write.HasId) + { + write.Refused(CultNetErrorMessage.ForMessageInvalid("messageId", "a write needs a messageId")); + return; + } + + var authorize = Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch); try { - await _database.ApplyDeleteAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch)) - .ConfigureAwait(false); + await _database.ApplyDeleteAsync(message, write == null ? authorize : write.Guard(authorize)).ConfigureAwait(false); + write?.Admitted(); + } + catch (CultNetWriteResendException resend) when (write != null) + { + write.Resent(resend); } catch (CultNetRemoteWriteRefusedException ex) { _server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}"); - peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); + Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } catch (CultNetShardAuthorityException ex) { - if (!await TryForwardDeleteAsync(ex, message).ConfigureAwait(false)) + bool forwarded; + try { - peer.SendCultNet(CreateRoutingError(ex)); + forwarded = await TryForwardDeleteAsync(ex, message).ConfigureAwait(false); + } + catch (CultNetWriteUnconfirmedException) when (write == null) + { + return; + } + catch (Exception failure) when (write != null) + { + RelayForwardFailure(write, failure); + return; + } + + if (forwarded) + { + write?.Admitted(); + } + else + { + Refuse(peer, write, CreateRoutingError(ex)); } } catch (CultNetUnownedSchemaException ex) { _server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}"); - peer.SendCultNet(CultNetErrorMessage.ForUnownedSchema(ex)); + Refuse(peer, write, CultNetErrorMessage.ForUnownedSchema(ex)); } catch (Exception ex) when (CommittedWithLogFailure(ex)) { _server.Logger.LogWarning($"CultNet raw delete committed but its shard log failed: {ex.Message}"); + write?.Admitted(); } catch (Exception ex) { - peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); + Refuse(peer, write, new CultNetErrorMessage { Error = ex.Message }); } } diff --git a/src/GameCult.Networking/CultNetShardWriteForwarding.cs b/src/GameCult.Networking/CultNetShardWriteForwarding.cs index 0fa6c4f7..cb390308 100644 --- a/src/GameCult.Networking/CultNetShardWriteForwarding.cs +++ b/src/GameCult.Networking/CultNetShardWriteForwarding.cs @@ -10,12 +10,15 @@ namespace GameCult.Networking public interface ICultNetShardWriteForwarder { /// - /// Forwards a raw document put to the shard owner. + /// Forwards a raw document put to the shard owner. Completes only when the owner answered that it admitted the write; + /// throws when it refused and + /// when it answered nothing. The server relays to the original peer exactly what the owner answered. /// Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message); /// - /// Forwards a raw document delete to the shard owner. + /// Forwards a raw document delete to the shard owner; completes, refuses and goes unconfirmed as + /// does. /// Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message); } @@ -70,10 +73,12 @@ namespace GameCult.Networking public Func? CreateClient { get; set; } /// - /// Gets or sets how long after a forwarded write the primary's refusal may still arrive; a refusal fails the - /// forward with . Defaults to . + /// Gets or sets how long the primary has to answer a forwarded write (from the send). Its receipt completes the forward, + /// its refusal fails it with , and silence fails it with + /// . Defaults to ; a + /// primary that predates write receipts never answers, so forwarding to one is unconfirmed after this window. /// - public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow; + public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.ReceiptResultWindow; } /// @@ -92,8 +97,8 @@ namespace GameCult.Networking } /// - /// Forwards a put and completes on the primary's result frame: a refusal throws - /// , the same failure path as an unreachable primary. + /// Forwards a put under its original message id and completes on the primary's result frame: a receipt returns, a refusal + /// throws and silence throws . /// public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) { @@ -101,12 +106,12 @@ namespace GameCult.Networking var endpoint = ResolvePrimaryEndpoint(shard); message.ShardId ??= shard.ShardId; message.ShardEpoch ??= shard.Epoch; - await SendAsync(endpoint, message).ConfigureAwait(false); + await SendAsync(endpoint, message, message.MessageId).ConfigureAwait(false); } /// - /// Forwards a delete and completes on the primary's result frame: a refusal throws - /// . + /// Forwards a delete under its original message id; completes, refuses and goes unconfirmed as + /// does. /// public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) { @@ -114,7 +119,7 @@ namespace GameCult.Networking var endpoint = ResolvePrimaryEndpoint(shard); message.ShardId ??= shard.ShardId; message.ShardEpoch ??= shard.Epoch; - await SendAsync(endpoint, message).ConfigureAwait(false); + await SendAsync(endpoint, message, message.MessageId).ConfigureAwait(false); } /// @@ -138,19 +143,29 @@ namespace GameCult.Networking return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port); } - private async Task SendAsync(string endpoint, T message) where T : ICultNetSchemaMessage + private async Task SendAsync(string endpoint, T message, string messageId) where T : ICultNetSchemaMessage { var (host, port) = ParseEndpoint(endpoint); using var client = CreateClient(endpoint); client.Connect(host, port); await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false); - await CultNetWriteCompletion.SendAndAwaitResultAsync( - refuse => + // The hello comes first on the ordered schema channel: it opts this connection in to the primary's receipts. + client.SendCultNet(new CultNetHelloMessage + { + RuntimeId = "gamecult-networking-shard-forwarder", + RuntimeKind = "dotnet", + SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1] + }); + await CultNetWriteCompletion.SendAndAwaitReceiptAsync( + (admitted, refuse, refuseV1) => { + client.OnCultNet(admitted); client.OnCultNet(refuse); + client.OnCultNet(refuseV1); return null; }, () => client.SendCultNet(message), + messageId, _options.ResultWindow).ConfigureAwait(false); } diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index da465326..06e2d93b 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -3533,7 +3533,7 @@ namespace GameCult.Networking /// public sealed class LiteNetLibTransportConnection { - private readonly NetPeer? _peer; + private readonly Action? _deliver; private readonly CultNetTransportStats _stats = new CultNetTransportStats(); /// @@ -3541,10 +3541,50 @@ namespace GameCult.Networking /// public LiteNetLibTransportConnection(NetPeer peer, CultNetTransportProfile? profile = null) { - _peer = peer ?? throw new ArgumentNullException(nameof(peer)); + if (peer == null) throw new ArgumentNullException(nameof(peer)); + _deliver = payload => peer.Send(payload, DeliveryMethod.ReliableOrdered); Profile = profile ?? CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer"); } + // The mock point for what leaves the connection: the peer's reliable ordered send. + internal LiteNetLibTransportConnection(Action deliver) + { + _deliver = deliver ?? throw new ArgumentNullException(nameof(deliver)); + Profile = CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer"); + } + + /// Answers a schema frame that did not decode with a message_invalid error.v1 naming its id when it holds one. + internal void AnswerUnreadableSchemaFrame(byte[] payload) => + SendSchema(CultNetErrorV1Message.From( + CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"), + CultNetReadableMessageId.From(payload))); + + private readonly object _writeFramingGate = new(); + private bool _writeFramingSettled; + + /// + /// The write-receipt state of a connection that opted in (write-receipt.md); null for every other connection, which is + /// answered exactly as before. + /// + internal CultNetWriteReceiptSession? WriteReceipts { get; private set; } + + /// + /// Decides this connection's write framing once: by its first hello, or by its first write if no hello came first. A + /// later hello changes nothing. The capability changes framing only; it never authorizes. Returns true when this call + /// opted the connection in. + /// + internal bool SettleWriteFraming(CultNetHelloMessage? hello) + { + lock (_writeFramingGate) + { + if (_writeFramingSettled) return false; + _writeFramingSettled = true; + if (hello == null || !CultNetWriteReceiptCapability.Declares(hello)) return false; + WriteReceipts = new CultNetWriteReceiptSession(); + return true; + } + } + /// /// Initializes a receive-only channel-aware adapter for LiteNetLib payload classification. /// @@ -3580,14 +3620,14 @@ namespace GameCult.Networking public void Send(string channelId, byte[] payload) { if (payload == null) throw new ArgumentNullException(nameof(payload)); - if (_peer == null) throw new InvalidOperationException("A LiteNetLib peer is required to send through this transport."); + if (_deliver == null) throw new InvalidOperationException("A LiteNetLib peer is required to send through this transport."); if (!string.Equals(channelId, "schema", StringComparison.Ordinal) && !string.Equals(channelId, "legacy", StringComparison.Ordinal)) { throw new InvalidOperationException($"litenetlib transport only supports schema and legacy channels, got \"{channelId}\"."); } - _peer.Send(payload, DeliveryMethod.ReliableOrdered); + _deliver(payload); _stats.BytesSent += payload.Length; _stats.FramesSent++; } diff --git a/src/GameCult.Networking/CultNetWriteReceipts.cs b/src/GameCult.Networking/CultNetWriteReceipts.cs index 2cc65db8..0d09a92f 100644 --- a/src/GameCult.Networking/CultNetWriteReceipts.cs +++ b/src/GameCult.Networking/CultNetWriteReceipts.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Security.Cryptography; +using GameCult.Caching; using MessagePack; namespace GameCult.Networking @@ -26,7 +27,8 @@ namespace GameCult.Networking if (payload == null) writer.WriteNil(); else writer.Write(payload); writer.Flush(); - return SHA256.HashData(buffer.WrittenSpan); + using var sha = SHA256.Create(); + return sha.ComputeHash(buffer.WrittenSpan.ToArray()); } } @@ -101,4 +103,176 @@ namespace GameCult.Networking } } } + + /// + /// The messageId of a schema frame that does not decode (write-receipt.md, rule 3: a refusal carries the write's id whenever + /// the frame is readable enough to find it). + /// + internal static class CultNetReadableMessageId + { + /// The frame's top-level string messageId, or null when the bytes are not a map holding one. + public static string? From(byte[] payload) + { + try + { + var reader = new MessagePackReader(payload); + var entries = reader.ReadMapHeader(); + for (var index = 0; index < entries; index++) + { + var key = reader.NextMessagePackType == MessagePackType.String ? reader.ReadString() : null; + if (key == "messageId" && reader.NextMessagePackType == MessagePackType.String) + { + var id = reader.ReadString(); + return string.IsNullOrEmpty(id) ? null : id; + } + + if (key == null) reader.Skip(); + reader.Skip(); + } + } + catch (Exception) + { + // Not a readable map: no id. + } + + return null; + } + } + + /// A resend the receipt session answered without applying it again, or refused as a reused id. + internal sealed class CultNetWriteResendException : Exception + { + public CultNetWriteResendException(CultNetWriteReceiptSession.Begin outcome) + : base("write answered from the connection's remembered writes") + { + Outcome = outcome; + } + + public CultNetWriteReceiptSession.Begin Outcome { get; } + } + + /// + /// One write on a receipt-capable connection: its dedupe claim and its single answer (write-receipt.md, rules 1 to 5). + /// + internal sealed class CultNetReceiptedWrite + { + private readonly CultNetWriteReceiptSession _session; + private readonly Action _send; + private readonly string _operation; + private readonly string _messageId; + private readonly string _schemaId; + private readonly string _recordKey; + private readonly byte[]? _payload; + private bool _claimed; + private bool _answered; + + public CultNetReceiptedWrite( + CultNetWriteReceiptSession session, + Action send, + string operation, + string? messageId, + string? schemaId, + string? recordKey, + byte[]? payload) + { + _session = session; + _send = send; + _operation = operation; + _messageId = messageId ?? string.Empty; + _schemaId = schemaId ?? string.Empty; + _recordKey = recordKey ?? string.Empty; + _payload = payload; + } + + /// + /// Wraps the server's authorization so the connection's remembered writes are consulted only after the actor is + /// authorized: authorize first, then claim the id. A resend of an admitted write throws + /// instead of applying it again. + /// + public Action Guard(Action authorize) => (schemaId, key) => + { + authorize(schemaId, key); + if (_messageId.Length == 0) return; + var outcome = _session.TryBegin(_messageId, CultNetWriteContentHash.Compute(_operation, _schemaId, _recordKey, _payload)); + if (outcome == CultNetWriteReceiptSession.Begin.New) + { + _claimed = true; + return; + } + + throw new CultNetWriteResendException(outcome); + }; + + /// The write committed: remember it and send its one receipt. + public void Admitted() + { + if (_answered) return; + _answered = true; + if (_messageId.Length > 0) _session.Admitted(_messageId); + _send(new CultNetWriteAdmittedMessage + { + MessageId = _messageId, + Operation = _operation, + SchemaId = _schemaId, + RecordKey = _recordKey + }); + } + + /// The write was refused: forget its claim so a repeat is judged afresh, and send its one error.v1. + public void Refused(CultNetErrorMessage error) + { + if (_answered) return; + _answered = true; + Release(); + _send(CultNetErrorV1Message.From(error, _messageId)); + } + + /// No answer is owed or possible (a forward the primary never answered): forget the claim, send nothing. + public void Unanswered() + { + if (_answered) return; + _answered = true; + Release(); + } + + /// Answers a resend per rule 5. + public void Resent(CultNetWriteResendException resend) + { + switch (resend.Outcome) + { + case CultNetWriteReceiptSession.Begin.AdmittedBefore: + _answered = true; + _send(new CultNetWriteAdmittedMessage + { + MessageId = _messageId, + Operation = _operation, + SchemaId = _schemaId, + RecordKey = _recordKey + }); + break; + case CultNetWriteReceiptSession.Begin.InFlight: + // The first attempt is still being applied and will answer this id. + _answered = true; + break; + case CultNetWriteReceiptSession.Begin.DifferentContent: + Refused(CultNetErrorMessage.ForMessageInvalid("messageId", "the message id was already used for different content")); + break; + default: + Refused(new CultNetErrorMessage { Error = "too many writes are remembered for this connection" }); + break; + } + } + + /// Whether the write has had its answer (or has none owed). + public bool Settled => _answered; + + /// A missing id cannot be answered under rule 1; refuse it with no id. + public bool HasId => _messageId.Length > 0; + + private void Release() + { + if (_claimed) _session.Abandoned(_messageId); + _claimed = false; + } + } } diff --git a/src/GameCult.Networking/Server.cs b/src/GameCult.Networking/Server.cs index 128eb47d..f158b1d2 100644 --- a/src/GameCult.Networking/Server.cs +++ b/src/GameCult.Networking/Server.cs @@ -406,7 +406,19 @@ namespace GameCult.Networking var frame = transport.Receive(bytes); if (string.Equals(frame.ChannelId, "schema", StringComparison.Ordinal)) { - var cultNetMessage = LiteNetLibTransportConnection.DecodeSchema(frame); + ICultNetSchemaMessage cultNetMessage; + try + { + cultNetMessage = LiteNetLibTransportConnection.DecodeSchema(frame); + } + catch (Exception ex) when (transport.WriteReceipts != null) + { + // A receipt-capable peer is told which frame was unreadable (by its id, if the frame still holds one). + Logger.LogWarning($"CultNet schema frame unreadable: {ex.Message}"); + transport.AnswerUnreadableSchemaFrame(frame.Payload); + return; + } + Logger.LogDebug($"Received CultNet schema message {cultNetMessage.SchemaVersion}"); await HandleCultNetSchemaMessageAsync(peer, user, cultNetMessage).ConfigureAwait(false); return; diff --git a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs new file mode 100644 index 00000000..9625c364 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs @@ -0,0 +1,521 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using GameCult.Caching; +using NUnit.Framework; +using static GameCult.Networking.Tests.NetworkingTests; + +namespace GameCult.Networking.Tests +{ + // The database server's half of contracts/cultnet/interop/write-receipt.md. A peer whose hello lists both new frames is + // answered with write_admitted or error.v1, each naming the write's id; any other peer is answered as before. The handlers + // are reached through their private delegates; a peer's transport is a capture of what leaves it. + public sealed class CultNetWriteReceiptServerTests + { + private const string ShardId = "receipt-shard"; + private static readonly CultRecordKey KeyA = new("tests:receipt:a"); + private static readonly CultRecordKey KeyB = new("tests:receipt:b"); + + private sealed class Authority : ICultNetRemoteWriteAuthority + { + public HashSet Denied { get; } = new(); + public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) => + Denied.Contains(request.RecordKey.Value) ? CultNetRemoteWriteDecision.Refuse("denied") : CultNetRemoteWriteDecision.Grant(); + } + + private sealed class Wire + { + public List Sent { get; } = new(); + public LiteNetLibTransportConnection Transport { get; } + public CultNetServerPeer Peer { get; } + + public Wire() + { + Transport = new LiteNetLibTransportConnection(bytes => + Sent.Add(LiteNetLibTransportConnection.DecodeSchema(LiteNetLibTransportConnection.Decode(bytes)))); + Peer = (CultNetServerPeer)RuntimeHelpers.GetUninitializedObject(typeof(CultNetServerPeer)); + typeof(CultNetServerPeer).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(Peer, Transport); + } + } + + private sealed class Rig + { + public CultCache Cache = null!; + public CultNetDatabase Database = null!; + public CultNetDatabaseServer DatabaseServer = null!; + public Authority Authority = null!; + public string SchemaId = string.Empty; + public Func PutHandler = null!; + public Func DeleteHandler = null!; + public Func HelloHandler = null!; + + public int LogLength => Database.GetMutationLog(ShardId).Count; + public string? TextOf(CultRecordKey key) => Cache.Get(key)?.Text; + + public CultNetDocumentPutRawMessage PutOf(string id, CultRecordKey key, string text) + { + var message = Database.Documents.CreateRawDocumentPutMessage(id, new CultRecordHandle(key), + new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = text }); + message.ShardId = ShardId; + message.ShardEpoch = 1; + return message; + } + + public CultNetDocumentDeleteMessage DeleteOf(string id, CultRecordKey key) => + new() { MessageId = id, SchemaId = SchemaId, RecordKey = key.Value, ShardId = ShardId, ShardEpoch = 1 }; + + public Task Put(Wire wire, string id, CultRecordKey key, string text) => PutHandler(PutOf(id, key, text), wire.Peer); + public Task Delete(Wire wire, string id, CultRecordKey key) => DeleteHandler(DeleteOf(id, key), wire.Peer); + public Task Hello(Wire wire, params string[] versions) => HelloHandler(HelloOf(versions), wire.Peer); + } + + private static CultNetHelloMessage HelloOf(params string[] versions) => + new() { RuntimeId = "test-client", RuntimeKind = "test", SupportedMessageVersions = versions }; + + private static readonly string[] BothFrames = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1]; + + private static Rig Build(Authority? authority = null, bool primary = true, ICultNetShardWriteForwarder? forwarder = null) + { + var cache = new CultCache(); + var schemaId = cache.Registry.GetRequired().SchemaId; + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + Shards = + [ + new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: primary, schemaIds: [schemaId], + primaryEndpoints: primary ? null : ["cultnet://runtime-owner:3075"]) + ] + }); + var host = new Server(cache, ServerSecurityOptions.Development()); + authority ??= new Authority(); + var databaseServer = new CultNetDatabaseServer(host, database, new CultNetDatabaseServerOptions + { + RemoteWriteAuthority = authority, + ForwardNonPrimaryWrites = forwarder != null, + WriteForwarder = forwarder + }); + const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance; + T Handler(string field) => (T)typeof(CultNetDatabaseServer).GetField(field, flags)!.GetValue(databaseServer)!; + return new Rig + { + Cache = cache, + Database = database, + DatabaseServer = databaseServer, + Authority = authority, + SchemaId = schemaId, + PutHandler = Handler>("_putHandler"), + DeleteHandler = Handler>("_deleteHandler"), + HelloHandler = Handler>("_helloHandler") + }; + } + + private static async Task<(Rig Rig, Wire Wire)> Capable() + { + var rig = Build(); + var wire = new Wire(); + await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); // the server's own hello is checked on its own + return (rig, wire); + } + + private static CultNetWriteAdmittedMessage AssertAdmitted(ICultNetSchemaMessage message, string id, string operation, string recordKey) + { + var admitted = (CultNetWriteAdmittedMessage)message; + Assert.That(admitted.MessageId, Is.EqualTo(id)); + Assert.That(admitted.Operation, Is.EqualTo(operation)); + Assert.That(admitted.RecordKey, Is.EqualTo(recordKey)); + return admitted; + } + + private static CultNetErrorV1Message AssertRefused(ICultNetSchemaMessage message, string? id, string? code) + { + var error = (CultNetErrorV1Message)message; + Assert.That(error.MessageId, Is.EqualTo(id)); + Assert.That(error.Code, Is.EqualTo(code)); + return error; + } + + [Test] + public async Task AnAdmittedPutOnAReceiptConnectionIsAnsweredAfterItsCommit() + { + var (rig, wire) = await Capable(); + rig.Authority.Denied.Clear(); + + await rig.Put(wire, "m1", KeyA, "one"); + + Assert.That(rig.TextOf(KeyA), Is.EqualTo("one")); + var admitted = AssertAdmitted(wire.Sent.Single(), "m1", "put", KeyA.Value); + Assert.That(admitted.SchemaId, Is.EqualTo(rig.SchemaId)); + } + + [Test] + public async Task AnAdmittedDeleteIsAnsweredWithAReceiptNamingTheDelete() + { + var (rig, wire) = await Capable(); + await rig.Put(wire, "m1", KeyA, "one"); + wire.Sent.Clear(); + + await rig.Delete(wire, "d1", KeyA); + + Assert.That(rig.TextOf(KeyA), Is.Null); + AssertAdmitted(wire.Sent.Single(), "d1", "delete", KeyA.Value); + } + + [Test] + public async Task TheServerAnswersAReceiptHelloWithItsOwnListingBothFrames() + { + var rig = Build(); + var wire = new Wire(); + + await rig.Hello(wire, BothFrames); + + var hello = (CultNetHelloMessage)wire.Sent.Single(); + Assert.That(hello.SupportedMessageVersions, Is.EquivalentTo(BothFrames)); + } + + [TestCase("")] + [TestCase("cultnet.write_admitted.v0")] + [TestCase("cultnet.error.v1")] + public async Task APeerThatDoesNotListBothFramesIsAnsweredAsBeforeAndTheServerSaysNothingOfReceipts(string listed) + { + var rig = Build(); + var wire = new Wire(); + await rig.Hello(wire, listed.Length == 0 ? [] : [listed]); + await rig.Put(wire, "m1", KeyA, "one"); + + Assert.That(rig.TextOf(KeyA), Is.EqualTo("one"), "admitted all the same"); + Assert.That(wire.Sent, Is.Empty, "no receipt, no hello"); + + rig.Authority.Denied.Add(KeyB.Value); + await rig.Put(wire, "m2", KeyB, "two"); + Assert.That(wire.Sent.Single(), Is.TypeOf(), "a refusal is the v0 error, without an id"); + } + + [Test] + public async Task APeerThatNeverSaidHelloGetsNoReceipt() + { + var rig = Build(); + var wire = new Wire(); + + await rig.Put(wire, "m1", KeyA, "one"); + + Assert.That(wire.Sent, Is.Empty); + } + + [Test] + public async Task ASecondHelloDoesNotChangeTheCapability() + { + var (rig, wire) = await Capable(); + await rig.Hello(wire); + await rig.Put(wire, "m1", KeyA, "one"); + AssertAdmitted(wire.Sent.Single(), "m1", "put", KeyA.Value); + + var late = Build(); + var legacy = new Wire(); + await late.Put(legacy, "m1", KeyA, "one"); + await late.Hello(legacy, BothFrames); + await late.Put(legacy, "m2", KeyB, "two"); + Assert.That(legacy.Sent, Is.Empty, "a hello after the first write does not opt the connection in"); + } + + [Test] + public async Task ACapabilityNeverAuthorizes() + { + var (rig, wire) = await Capable(); + rig.Authority.Denied.Add(KeyA.Value); + + await rig.Put(wire, "m1", KeyA, "one"); + await rig.Delete(wire, "d1", KeyA); + + Assert.That(rig.TextOf(KeyA), Is.Null); + AssertRefused(wire.Sent[0], "m1", "write_not_authorized"); + AssertRefused(wire.Sent[1], "d1", "write_not_authorized"); + Assert.That(wire.Sent, Has.Count.EqualTo(2), "one answer per write"); + } + + [Test] + public async Task AResendOfAnAdmittedWriteIsReceiptedAgainWithoutBeingAppliedAgain() + { + var (rig, wire) = await Capable(); + await rig.Put(wire, "m1", KeyA, "one"); + var logAfterFirst = rig.LogLength; + + await rig.Put(wire, "m1", KeyA, "one"); + + Assert.That(wire.Sent, Has.Count.EqualTo(2)); + AssertAdmitted(wire.Sent[1], "m1", "put", KeyA.Value); + Assert.That(rig.LogLength, Is.EqualTo(logAfterFirst), "the resend was not committed again"); + } + + [Test] + public async Task TheSameIdWithOtherContentIsRefusedAndNeverReReceipted() + { + var (rig, wire) = await Capable(); + await rig.Put(wire, "m1", KeyA, "one"); + + await rig.Put(wire, "m1", KeyA, "other"); + await rig.Put(wire, "m1", KeyB, "one"); + await rig.Delete(wire, "m1", KeyA); + + Assert.That(rig.TextOf(KeyA), Is.EqualTo("one")); + Assert.That(rig.TextOf(KeyB), Is.Null); + Assert.That(wire.Sent, Has.Count.EqualTo(4)); + for (var index = 1; index < 4; index++) + { + var error = AssertRefused(wire.Sent[index], "m1", "message_invalid"); + Assert.That(error.Details!.Field, Is.EqualTo("messageId")); + } + } + + [Test] + public async Task AuthorizationIsDecidedBeforeAReusedIdIsLookedAt() + { + var (rig, wire) = await Capable(); + await rig.Put(wire, "m1", KeyA, "one"); + rig.Authority.Denied.Add(KeyB.Value); + + await rig.Put(wire, "m1", KeyB, "other"); + + AssertRefused(wire.Sent[1], "m1", "write_not_authorized"); + } + + [Test] + public async Task ARefusedWriteIsForgottenSoARepeatIsJudgedAfresh() + { + var (rig, wire) = await Capable(); + rig.Authority.Denied.Add(KeyA.Value); + await rig.Put(wire, "m1", KeyA, "one"); + rig.Authority.Denied.Clear(); + + await rig.Put(wire, "m1", KeyA, "one"); + + AssertRefused(wire.Sent[0], "m1", "write_not_authorized"); + AssertAdmitted(wire.Sent[1], "m1", "put", KeyA.Value); + Assert.That(rig.TextOf(KeyA), Is.EqualTo("one")); + } + + [Test] + public async Task AFullDedupeTableRefusesNewWritesAndStillAnswersRememberedOnes() + { + var (rig, wire) = await Capable(); + for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) + { + await rig.Put(wire, $"w{index}", new CultRecordKey($"tests:receipt:{index}"), "x"); + } + + wire.Sent.Clear(); + await rig.Put(wire, "over", KeyA, "x"); + await rig.Put(wire, "w0", new CultRecordKey("tests:receipt:0"), "x"); + + AssertRefused(wire.Sent[0], "over", null); + AssertAdmitted(wire.Sent[1], "w0", "put", "tests:receipt:0"); + Assert.That(rig.TextOf(KeyA), Is.Null); + } + + [Test] + public async Task AWriteWithNoIdIsRefusedWithNoId() + { + var (rig, wire) = await Capable(); + + var message = rig.PutOf("placeholder", KeyA, "one"); + message.MessageId = string.Empty; + await rig.PutHandler(message, wire.Peer); + + var error = AssertRefused(wire.Sent.Single(), null, "message_invalid"); + Assert.That(error.Details!.Field, Is.EqualTo("messageId")); + Assert.That(rig.TextOf(KeyA), Is.Null); + } + + [Test] + public async Task AFrameThatDoesNotDecodeIsRefusedWithItsIdWhenItHoldsOne() + { + var (_, wire) = await Capable(); + var withId = MessagePack.MessagePackSerializer.Serialize(new Dictionary + { + ["schemaVersion"] = "cultnet.document_put_raw.v9", + ["messageId"] = "bad-1" + }); + + wire.Transport.AnswerUnreadableSchemaFrame(withId); + wire.Transport.AnswerUnreadableSchemaFrame(new byte[] { 0xC1 }); + + var error = AssertRefused(wire.Sent[0], "bad-1", "message_invalid"); + Assert.That(error.Details!.Field, Is.EqualTo("message")); + AssertRefused(wire.Sent[1], null, "message_invalid"); + } + + [Test] + public void ReadableMessageIdFindsOnlyANonEmptyStringIdInATopLevelMap() + { + string? IdOf(object value) => CultNetReadableMessageId.From(MessagePack.MessagePackSerializer.Serialize(value)); + + Assert.That(IdOf(new Dictionary { ["a"] = new[] { 1, 2 }, ["messageId"] = "x" }), Is.EqualTo("x")); + Assert.That(IdOf(new Dictionary { ["messageId"] = 5 }), Is.Null); + Assert.That(IdOf(new Dictionary { ["messageId"] = "" }), Is.Null); + Assert.That(IdOf(new Dictionary { ["nested"] = new Dictionary { ["messageId"] = "deep" } }), Is.Null); + Assert.That(IdOf("messageId"), Is.Null); + } + + [Test] + public void TheContentHashMatchesTheSpecVectors() + { + byte[] a = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x31]; + byte[] b = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x32]; + string Hex(byte[] hash) => Convert.ToHexString(hash).ToLowerInvariant(); + + Assert.That(Hex(CultNetWriteContentHash.Compute("put", "note.v0", "note:1", a)), Is.EqualTo("7051c10071123b6ade50fbd5075e3859cae4ea70ce358a24a0b0c6980090e95d")); + Assert.That(Hex(CultNetWriteContentHash.Compute("put", "note.v0", "note:1", b)), Is.EqualTo("58d3fc6b858d2605a68550c9d4811ccdca6b9db7a0c3bfebf304d279257f6f9f")); + Assert.That(Hex(CultNetWriteContentHash.Compute("put", "note.v0", "note:2", a)), Is.EqualTo("a248cd920e58d41bc6ccca9e108030b1d491239000047fef088122364a32b6f8")); + Assert.That(Hex(CultNetWriteContentHash.Compute("delete", "note.v0", "note:1", null)), Is.EqualTo("232b0f96651b28aa8ab7673a6106944a9f6a8d66852b6e21291a23ed084b8b37")); + } + + [Test] + public void TheNewFramesRoundTripOnTheWire() + { + var admitted = new CultNetWriteAdmittedMessage { MessageId = "m", Operation = "put", SchemaId = "s", RecordKey = "k" }; + var error = new CultNetErrorV1Message { Error = "e", Code = "message_invalid", MessageId = null }; + + var admittedBack = (CultNetWriteAdmittedMessage)CultNetSchemaMessageSerialization.Deserialize(CultNetSchemaMessageSerialization.Serialize(admitted)); + var errorBack = (CultNetErrorV1Message)CultNetSchemaMessageSerialization.Deserialize(CultNetSchemaMessageSerialization.Serialize(error)); + + Assert.That(admittedBack.MessageId, Is.EqualTo("m")); + Assert.That(admittedBack.RecordKey, Is.EqualTo("k")); + Assert.That(errorBack.MessageId, Is.Null); + Assert.That(errorBack.Code, Is.EqualTo("message_invalid")); + } + + private sealed class ScriptedForwarder : ICultNetShardWriteForwarder + { + public Func OnPut { get; set; } = _ => Task.CompletedTask; + public Func OnDelete { get; set; } = _ => Task.CompletedTask; + public int Forwarded { get; private set; } + + public Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) + { + Forwarded++; + return OnPut(message); + } + + public Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) + { + Forwarded++; + return OnDelete(message); + } + } + + [Test] + public async Task AForwardedWriteIsReceiptedUnderItsOriginalIdOnlyWhenThePrimaryAnswered() + { + var forwarder = new ScriptedForwarder(); + var rig = Build(primary: false, forwarder: forwarder); + var wire = new Wire(); + await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); + + await rig.Put(wire, "fwd-1", KeyA, "one"); + await rig.Delete(wire, "fwd-2", KeyA); + + AssertAdmitted(wire.Sent[0], "fwd-1", "put", KeyA.Value); + AssertAdmitted(wire.Sent[1], "fwd-2", "delete", KeyA.Value); + Assert.That(forwarder.Forwarded, Is.EqualTo(2)); + } + + [Test] + public async Task AForwardedWritesRefusalIsRelayedAsErrorV1UnderTheOriginalId() + { + var forwarder = new ScriptedForwarder + { + OnPut = _ => Task.FromException(new CultNetWriteRefusedException(new CultNetErrorMessage { Error = "primary says no", Code = "write_not_authorized" })), + OnDelete = _ => Task.FromException(new CultNetWriteRefusedException(CultNetErrorMessage.ForMessageInvalid("recordKey", "bad"))) + }; + var rig = Build(primary: false, forwarder: forwarder); + var wire = new Wire(); + await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); + + await rig.Put(wire, "fwd-1", KeyA, "one"); + await rig.Delete(wire, "fwd-2", KeyA); + + AssertRefused(wire.Sent[0], "fwd-1", "write_not_authorized"); + Assert.That(((CultNetErrorV1Message)wire.Sent[0]).Error, Is.EqualTo("primary says no")); + AssertRefused(wire.Sent[1], "fwd-2", "message_invalid"); + } + + [TestCase(true)] + [TestCase(false)] + public async Task AForwardThePrimaryNeverAnsweredRelaysNothingAndMayBeRetried(bool unconfirmed) + { + Exception failure = unconfirmed ? new CultNetWriteUnconfirmedException("fwd-1") : new TimeoutException("primary unreachable"); + var forwarder = new ScriptedForwarder { OnPut = _ => Task.FromException(failure) }; + var rig = Build(primary: false, forwarder: forwarder); + var wire = new Wire(); + await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); + + await rig.Put(wire, "fwd-1", KeyA, "one"); + Assert.That(wire.Sent, Is.Empty, "no receipt, no error, no synthesized refusal"); + + forwarder.OnPut = _ => Task.CompletedTask; + await rig.Put(wire, "fwd-1", KeyA, "one"); + AssertAdmitted(wire.Sent.Single(), "fwd-1", "put", KeyA.Value); + Assert.That(forwarder.Forwarded, Is.EqualTo(2), "the unanswered attempt left no claim behind"); + } + + [Test] + public async Task APeerThatDidNotOptInIsToldNothingOfAnUnansweredForward() + { + var forwarder = new ScriptedForwarder { OnPut = _ => Task.FromException(new CultNetWriteUnconfirmedException("fwd-1")) }; + var rig = Build(primary: false, forwarder: forwarder); + var wire = new Wire(); + + await rig.Put(wire, "fwd-1", KeyA, "one"); + + Assert.That(wire.Sent, Is.Empty); + } + + [Test] + public async Task AReceiptConnectionNotForwardableIsRefusedWithTheRoutingErrorAndItsId() + { + var rig = Build(primary: false); + var wire = new Wire(); + await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); + + await rig.Put(wire, "fwd-1", KeyA, "one"); + + var error = AssertRefused(wire.Sent.Single(), "fwd-1", null); + Assert.That(error.RoutingHint, Is.Not.Null); + } + + [Test] + public async Task AResendWhileTheFirstAttemptIsStillBeingAppliedIsNotAnsweredTwice() + { + var release = new TaskCompletionSource(); + var forwarder = new ScriptedForwarder { OnPut = _ => release.Task }; + var rig = Build(primary: false, forwarder: forwarder); + var wire = new Wire(); + await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); + + var first = rig.Put(wire, "fwd-1", KeyA, "one"); + await rig.Put(wire, "fwd-1", KeyA, "one"); + Assert.That(wire.Sent, Is.Empty, "the resend waits for the first attempt's answer"); + release.SetResult(); + await first; + + AssertAdmitted(wire.Sent.Single(), "fwd-1", "put", KeyA.Value); + Assert.That(forwarder.Forwarded, Is.EqualTo(1), "and is not forwarded again"); + } + + [Test] + public void TheForwarderWaitsTheReceiptWindowByDefault() + { + Assert.That(new CultNetSchemaWriteForwarderOptions().ResultWindow, Is.EqualTo(CultNetWriteCompletion.ReceiptResultWindow)); + } + } +} diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 2a75b5c2..606d5625 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -5189,7 +5189,9 @@ namespace GameCult.Networking.Tests [Test] public async Task CultNetSchemaWriteForwarder_Uses_SchemaClientPort() { - var fake = new CapturingSchemaClient(_ => null); + var fake = new CapturingSchemaClient(message => message is CultNetDocumentDeleteMessage delete + ? new CultNetWriteAdmittedMessage { MessageId = delete.MessageId, Operation = "delete", SchemaId = delete.SchemaId, RecordKey = delete.RecordKey } + : null); var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions { CreateClient = () => fake @@ -5203,11 +5205,16 @@ namespace GameCult.Networking.Tests await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { + MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" }); - var sent = (CultNetDocumentDeleteMessage)fake.SentMessages.Single(); + var hello = (CultNetHelloMessage)fake.SentMessages[0]; + Assert.That(hello.SupportedMessageVersions, Is.EquivalentTo(new[] { CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1 }), + "the hello opts the forwarding connection in before the write"); + var sent = (CultNetDocumentDeleteMessage)fake.SentMessages[1]; + Assert.That(sent.MessageId, Is.EqualTo("fwd-del"), "the write goes under its original id"); Assert.That(fake.ConnectedHost, Is.EqualTo("primary.example.test")); Assert.That(sent.ShardId, Is.EqualTo("notes")); Assert.That(sent.ShardEpoch, Is.EqualTo(3)); @@ -5241,7 +5248,7 @@ namespace GameCult.Networking.Tests } [Test] - public async Task CultNetSchemaWriteForwarder_DoesNotCompleteOnTheSendReturning() + public void CultNetSchemaWriteForwarder_SilenceIsUnconfirmedNotSuccess() { var fake = new CapturingSchemaClient(_ => null); var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions @@ -5254,11 +5261,51 @@ namespace GameCult.Networking.Tests primaryEndpoints: ["cultnet://primary.example.test:4075"]); var started = DateTime.UtcNow; - await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { SchemaId = "note.v0", RecordKey = "note:1" }); + var unconfirmed = Assert.ThrowsAsync(() => + forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" })); + Assert.That(unconfirmed!.MessageId, Is.EqualTo("fwd-del")); Assert.That(DateTime.UtcNow - started, Is.GreaterThanOrEqualTo(TimeSpan.FromMilliseconds(380))); } + [Test] + public void CultNetSchemaWriteForwarder_AnswersNamingAnotherWriteSettleNothing() + { + var fake = new CapturingSchemaClient(_ => null); + var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions + { + CreateClient = () => fake, + ResultWindow = TimeSpan.FromMilliseconds(300) + }); + var shard = new CultNetShardDescriptor( + "notes", "primary", 3, isPrimary: false, + primaryEndpoints: ["cultnet://primary.example.test:4075"]); + fake.OnSend = () => + { + fake.Deliver(new CultNetWriteAdmittedMessage { MessageId = "someone-else", Operation = "delete", SchemaId = "note.v0", RecordKey = "note:1" }); + fake.Deliver(new CultNetErrorV1Message { Error = "refused", Code = "write_not_authorized", MessageId = "someone-else" }); + }; + + Assert.ThrowsAsync(() => + forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" })); + } + + [Test] + public void CultNetSchemaWriteForwarder_RejectsWithTheErrorV1NamingItsWrite() + { + var fake = new CapturingSchemaClient(_ => null); + var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions { CreateClient = () => fake }); + var shard = new CultNetShardDescriptor( + "notes", "primary", 3, isPrimary: false, + primaryEndpoints: ["cultnet://primary.example.test:4075"]); + fake.OnSend = () => fake.Deliver(new CultNetErrorV1Message { Error = "refused", Code = "write_not_authorized", MessageId = "fwd-del" }); + + var refused = Assert.ThrowsAsync(() => + forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" })); + + Assert.That(refused!.Code, Is.EqualTo("write_not_authorized")); + } + [Test] public void CultMeshVerseCatalog_Finds_CompatibleTransferTargets() {