C#: database server answers writes with write_admitted / error.v1 (write-receipt spec) #61

Merged
libby merged 14 commits from libby/cs-write-receipt-server into main 2026-11-08 19:51:53 +00:00
15 changed files with 2602 additions and 53 deletions

View file

@ -1017,6 +1017,8 @@ namespace GameCult.Networking
/// <summary>
/// Applies a raw document mutation after checking shard authority. This is the host's own door (and replication's):
/// it asks no remote write authority. A peer's put goes through <see cref="CultNetDatabaseServer"/>, which does.
/// Returning normally means the put is committed and visible to readers: a receipt-capable peer is told
/// <c>write_admitted</c> on the strength of it, so a write that has only been queued must not return.
/// </summary>
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
@ -1047,6 +1049,7 @@ namespace GameCult.Networking
/// <summary>
/// Applies a raw document delete after checking shard authority. This is the host's own door: it asks no remote
/// write authority. A peer's delete goes through <see cref="CultNetDatabaseServer"/>, which does.
/// Returning normally means the delete is committed and visible to readers (see <see cref="ApplyPutAsync(CultNetDocumentPutRawMessage)"/>).
/// </summary>
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>

View file

@ -22,6 +22,7 @@ namespace GameCult.Networking
private readonly Func<CultNetSnapshotRequestV1Message, CultNetServerPeer, Task> _snapshotHandlerV1;
private readonly Func<CultNetDocumentPutRawMessage, CultNetServerPeer, Task> _putHandler;
private readonly Func<CultNetDocumentDeleteMessage, CultNetServerPeer, Task> _deleteHandler;
private readonly Func<CultNetHelloMessage, CultNetServerPeer, Task> _helloHandler;
private readonly Func<CultNetShardCatalogRequestMessage, CultNetServerPeer, Task> _shardCatalogHandler;
private readonly Func<CultNetShardLogRequestMessage, CultNetServerPeer, Task> _shardLogHandler;
private readonly Func<CultNetDatabaseSubscribeMessage, CultNetServerPeer, Task> _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<CultNetSnapshotRequestV1Message>(_snapshotHandlerV1);
_server.RemoveCultNetMessageListener<CultNetDocumentPutRawMessage>(_putHandler);
_server.RemoveCultNetMessageListener<CultNetDocumentDeleteMessage>(_deleteHandler);
_server.RemoveCultNetMessageListener<CultNetHelloMessage>(_helloHandler);
_server.RemoveCultNetMessageListener<CultNetShardCatalogRequestMessage>(_shardCatalogHandler);
_server.RemoveCultNetMessageListener<CultNetShardLogRequestMessage>(_shardLogHandler);
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribeHandler);
@ -262,7 +266,7 @@ namespace GameCult.Networking
catch (Exception ex)
{
_server.Logger.LogError($"CultNet snapshot v1 request failed: {ex.Message}");
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
peer.SendCultNet(new CultNetErrorMessage { Error = "the snapshot request could not be answered" });
}
return Task.CompletedTask;
@ -282,7 +286,8 @@ namespace GameCult.Networking
}
catch (Exception ex)
{
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
_server.Logger.LogError($"CultNet shard log request failed: {ex.Message}");
peer.SendCultNet(new CultNetErrorMessage { Error = "the shard log request could not be answered" });
}
return Task.CompletedTask;
@ -323,74 +328,256 @@ namespace GameCult.Networking
};
}
private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer)
// 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 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. An
// answer that cannot be queued ends the connection: the peer settles its write as session_ended, never as silence.
private void Answer(CultNetServerPeer peer, ICultNetSchemaMessage message)
{
try
{
await _database.ApplyPutAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch))
.ConfigureAwait(false);
Send(peer, message);
}
catch (Exception ex)
{
_server.Logger.LogWarning($"CultNet write answer could not be sent; ending the connection: {ex.Message}");
peer.Transport?.End();
}
}
private static void Send(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.
// An empty or blank schemaId or recordKey names no record: refused as a frame that does not read, exactly as the Rust
// server's decoder refuses it (write-receipt.md, "Required fields"), never handed to the store.
private static bool IsUnreadableWriteTarget(string? schemaId, string? recordKey) =>
string.IsNullOrWhiteSpace(schemaId) || string.IsNullOrWhiteSpace(recordKey);
private static CultNetErrorMessage UnreadableWriteTarget() =>
CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read");
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}");
// Only a forward that provably never left (not sent) may be forwarded again. Every other failure, silence or not, is
// unconfirmed: the primary may have applied it, so the id is never forwarded again on this session.
if (failure is CultNetWriteNotSentException) write.NotSent();
else write.LeftSilent();
}
private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer)
{
var write = BeginReceipted(peer, "put", message.MessageId, message.Document?.SchemaId, message.Document?.RecordKey, message.Document?.Payload);
var idRefusal = write?.IdRefusal;
if (idRefusal != null)
{
write!.Refused(idRefusal);
return;
}
var missing = message.Document == null ? "document" : message.Document.SchemaId == null ? "schemaId" : null;
if (missing != null)
{
Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid(missing, $"{missing} is required"));
return;
}
if (IsUnreadableWriteTarget(message.Document!.SchemaId, message.Document.RecordKey))
{
Refuse(peer, write, UnreadableWriteTarget());
return;
}
if (write != null && !write.Begin()) return;
var authorize = Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch);
try
{
await _database.ApplyPutAsync(message, authorize).ConfigureAwait(false);
write?.Admitted();
}
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 = CultNetWriteReceiptSession.WriteFailedText });
}
}
private async Task HandleDeleteAsync(CultNetDocumentDeleteMessage message, CultNetServerPeer peer)
{
var write = BeginReceipted(peer, "delete", message.MessageId, message.SchemaId, message.RecordKey, null);
var idRefusal = write?.IdRefusal;
if (idRefusal != null)
{
write!.Refused(idRefusal);
return;
}
if (message.SchemaId == null)
{
Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid("schemaId", "schemaId is required"));
return;
}
if (IsUnreadableWriteTarget(message.SchemaId, message.RecordKey))
{
Refuse(peer, write, UnreadableWriteTarget());
return;
}
if (write != null && !write.Begin()) 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, authorize).ConfigureAwait(false);
write?.Admitted();
}
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 = CultNetWriteReceiptSession.WriteFailedText });
}
}
@ -588,7 +775,7 @@ namespace GameCult.Networking
var refusedKey = change.GetType().GetProperty("Key")?.GetValue(change) is CultRecordKey changed ? changed.Value : string.Empty;
lock (refused)
refused.Add(refusedKey);
sendError(new CultNetErrorMessage { Error = refusal.Message });
sendError(new CultNetErrorMessage { Error = "the record is a variant CultNet cannot carry" });
return null;
}
@ -666,11 +853,14 @@ namespace GameCult.Networking
exception is CultNetShardLogException ||
exception is AggregateException aggregate && aggregate.Flatten().InnerExceptions.Any(inner => inner is CultNetShardLogException);
// Fixed text: the routing refusal never carries an exception's own message (the shard and reason travel in the hint).
internal const string RoutingRefusalText = "the write belongs to another shard primary";
private static CultNetErrorMessage CreateRoutingError(CultNetShardAuthorityException exception)
{
return new CultNetErrorMessage
{
Error = exception.Message,
Error = RoutingRefusalText,
RoutingHint = new CultNetShardRoutingHint
{
Reason = exception.Reason,

View file

@ -27,7 +27,8 @@ namespace GameCult.Networking
/// <summary>
/// Sends one schema-v0 message through the transport. Fire-and-forget: an ack or a return is not admission;
/// read the result frame (<see cref="CultNetWriteCompletion"/>).
/// read the result frame (<see cref="CultNetWriteCompletion"/>). Throws <see cref="CultNetWriteNotSentException"/> only
/// when it can prove no byte was written; any other exception leaves the send ambiguous and a forwarded write unconfirmed.
/// </summary>
void SendCultNet<T>(T message)
where T : ICultNetSchemaMessage;

View file

@ -53,6 +53,8 @@ namespace GameCult.Networking
CultNetSchemaVersions.Verify => Deserialize<CultNetVerifyMessage>(payload),
CultNetSchemaVersions.LoginSuccess => Deserialize<CultNetLoginSuccessMessage>(payload),
CultNetSchemaVersions.Error => Deserialize<CultNetErrorMessage>(payload),
CultNetSchemaVersions.ErrorV1 => Deserialize<CultNetErrorV1Message>(payload),
CultNetSchemaVersions.WriteAdmitted => Deserialize<CultNetWriteAdmittedMessage>(payload),
CultNetSchemaVersions.SampleChangeName => Deserialize<CultNetSampleChangeNameMessage>(payload),
CultNetSchemaVersions.SampleChat => Deserialize<CultNetSampleChatMessage>(payload),
CultNetSchemaVersions.OperationRequest => Deserialize<CultNetOperationRequestMessage>(payload),

View file

@ -48,6 +48,14 @@ namespace GameCult.Networking
/// </summary>
public const string Error = "cultnet.error.v0";
/// <summary>
/// error v1 contract identifier: error v0 plus the id of the request it answers (write-receipt.md).
/// </summary>
public const string ErrorV1 = "cultnet.error.v1";
/// <summary>
/// write admitted receipt contract identifier (write-receipt.md).
/// </summary>
public const string WriteAdmitted = "cultnet.write_admitted.v0";
/// <summary>
/// sample change name contract identifier.
/// </summary>
public const string SampleChangeName = "cultnet.sample.change_name.v0";
@ -538,7 +546,7 @@ namespace GameCult.Networking
public static CultNetErrorMessage ForUnownedSchema(CultNetUnownedSchemaException ex) =>
new CultNetErrorMessage
{
Error = $"unowned_schema: {ex.Message}",
Error = "unowned_schema: no shard owns the schema and key",
Code = "unowned_schema",
Details = new CultNetErrorDetails { Field = "schemaId", Value = ex.SchemaId }
};
@ -621,6 +629,80 @@ namespace GameCult.Networking
};
}
/// <summary>
/// <c>cultnet.error.v1</c> (contracts/cultnet/interop/write-receipt.md): the v0 error plus <see cref="MessageId"/>,
/// the id of the request it answers. Sent only to a connection that declared the write-receipt capability.
/// </summary>
[MessagePackObject]
public class CultNetErrorV1Message : ICultNetSchemaMessage
{
/// <summary>Gets or sets the schema version.</summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.ErrorV1;
/// <summary>Gets or sets the error.</summary>
[Key("error")] public string Error { get; set; } = string.Empty;
/// <summary>Gets or sets optional shard routing details for authority failures.</summary>
[Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; }
/// <summary>Gets or sets the machine-readable refusal code (the v0 enum).</summary>
[Key("code")] public string? Code { get; set; }
/// <summary>Gets or sets the code-specific structured detail.</summary>
[Key("details")] public CultNetErrorDetails? Details { get; set; }
/// <summary>Gets or sets the id of the request this answers; null only when no id could be read.</summary>
[Key("messageId")] public string? MessageId { get; set; }
/// <summary>Builds the v1 form of <paramref name="error"/> answering <paramref name="messageId"/>.</summary>
public static CultNetErrorV1Message From(CultNetErrorMessage error, string? messageId)
{
if (error == null) throw new ArgumentNullException(nameof(error));
return new CultNetErrorV1Message
{
Error = error.Error,
RoutingHint = error.RoutingHint,
Code = error.Code,
Details = error.Details,
MessageId = string.IsNullOrEmpty(messageId) ? null : messageId
};
}
/// <summary>
/// The refusal of a write a session has no room to remember (write-receipt.md, rule 5): code <c>limit_reached</c>,
/// details present and all nil. Exists only as error.v1; v0 has no such code.
/// </summary>
public static CultNetErrorV1Message LimitReached(string? messageId) => new CultNetErrorV1Message
{
Error = "limit_reached: the session's remembered-write limit is full",
Code = "limit_reached",
Details = new CultNetErrorDetails(),
MessageId = string.IsNullOrEmpty(messageId) ? null : messageId
};
/// <summary>Gets the v0 form, dropping the id.</summary>
public CultNetErrorMessage ToV0() => new CultNetErrorMessage
{
Error = Error,
RoutingHint = RoutingHint,
Code = Code,
Details = Details
};
}
/// <summary>
/// <c>cultnet.write_admitted.v0</c>: the receipt for one admitted put or delete, sent after its commit point.
/// </summary>
[MessagePackObject]
public class CultNetWriteAdmittedMessage : ICultNetSchemaMessage
{
/// <summary>Gets or sets the schema version.</summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.WriteAdmitted;
/// <summary>Gets or sets the messageId of the admitted write, echoed.</summary>
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
/// <summary>Gets or sets <c>put</c> or <c>delete</c>.</summary>
[Key("operation")] public string Operation { get; set; } = string.Empty;
/// <summary>Gets or sets the schemaId of the admitted write, echoed.</summary>
[Key("schemaId")] public string SchemaId { get; set; } = string.Empty;
/// <summary>Gets or sets the recordKey of the admitted write, echoed.</summary>
[Key("recordKey")] public string RecordKey { get; set; } = string.Empty;
}
/// <summary>
/// Code-specific structured detail carried on <see cref="CultNetErrorMessage"/> (R-N). Fields are
/// populated only for the code that defines them; every field is otherwise omitted (nil on the wire).

View file

@ -10,12 +10,16 @@ namespace GameCult.Networking
public interface ICultNetShardWriteForwarder
{
/// <summary>
/// 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 <see cref="CultNetWriteRefusedException"/> when it refused, <see cref="CultNetWriteNotSentException"/> when
/// nothing could have reached it (the only failure after which the id may be forwarded again), and any other exception
/// when the frame may have gone out and was not answered (unconfirmed, including <see cref="CultNetWriteUnconfirmedException"/>). The server relays to the original peer exactly what the owner answered.
/// </summary>
Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message);
/// <summary>
/// Forwards a raw document delete to the shard owner.
/// Forwards a raw document delete to the shard owner; completes, refuses and goes unconfirmed as
/// <see cref="ForwardPutAsync"/> does.
/// </summary>
Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message);
}
@ -70,10 +74,12 @@ namespace GameCult.Networking
public Func<ICultNetSchemaClient>? CreateClient { get; set; }
/// <summary>
/// Gets or sets how long after a forwarded write the primary's refusal may still arrive; a refusal fails the
/// forward with <see cref="CultNetWriteRefusedException"/>. Defaults to <see cref="CultNetWriteCompletion.DefaultResultWindow"/>.
/// 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 <see cref="CultNetWriteRefusedException"/>, and silence fails it with
/// <see cref="CultNetWriteUnconfirmedException"/>. Defaults to <see cref="CultNetWriteCompletion.ReceiptResultWindow"/>; a
/// primary that predates write receipts never answers, so forwarding to one is unconfirmed after this window.
/// </summary>
public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow;
public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.ReceiptResultWindow;
}
/// <summary>
@ -92,29 +98,29 @@ namespace GameCult.Networking
}
/// <summary>
/// Forwards a put and completes on the primary's result frame: a refusal throws
/// <see cref="CultNetWriteRefusedException"/>, 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 <see cref="CultNetWriteRefusedException"/> and silence throws <see cref="CultNetWriteUnconfirmedException"/>.
/// </summary>
public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
var endpoint = ResolvePrimaryEndpoint(shard);
if (shard == null) throw new ArgumentNullException(nameof(shard));
message.ShardId ??= shard.ShardId;
message.ShardEpoch ??= shard.Epoch;
await SendAsync(endpoint, message).ConfigureAwait(false);
await SendAsync(shard, message, message.MessageId).ConfigureAwait(false);
}
/// <summary>
/// Forwards a delete and completes on the primary's result frame: a refusal throws
/// <see cref="CultNetWriteRefusedException"/>.
/// Forwards a delete under its original message id; completes, refuses and goes unconfirmed as
/// <see cref="ForwardPutAsync"/> does.
/// </summary>
public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
var endpoint = ResolvePrimaryEndpoint(shard);
if (shard == null) throw new ArgumentNullException(nameof(shard));
message.ShardId ??= shard.ShardId;
message.ShardEpoch ??= shard.Epoch;
await SendAsync(endpoint, message).ConfigureAwait(false);
await SendAsync(shard, message, message.MessageId).ConfigureAwait(false);
}
/// <summary>
@ -138,19 +144,50 @@ namespace GameCult.Networking
return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port);
}
private async Task SendAsync<T>(string endpoint, T message) where T : ICultNetSchemaMessage
// The write frame has not been handed to the transport until this returns: any failure here is CultNetWriteNotSentException,
// the one failure the caller may retry under the same id.
private async Task<ICultNetSchemaClient> ConnectBeforeSendAsync(CultNetShardDescriptor shard, string messageId)
{
var (host, port) = ParseEndpoint(endpoint);
using var client = CreateClient(endpoint);
client.Connect(host, port);
await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false);
await CultNetWriteCompletion.SendAndAwaitResultAsync(
refuse =>
ICultNetSchemaClient? client = null;
try
{
var endpoint = ResolvePrimaryEndpoint(shard);
var (host, port) = ParseEndpoint(endpoint);
client = CreateClient(endpoint);
client.Connect(host, port);
await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false);
// 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]
});
return client;
}
catch (Exception failure)
{
client?.Dispose();
if (failure is CultNetWriteNotSentException) throw;
throw new CultNetWriteNotSentException(messageId, failure);
}
}
private async Task SendAsync<T>(CultNetShardDescriptor shard, T message, string messageId) where T : ICultNetSchemaMessage
{
using var client = await ConnectBeforeSendAsync(shard, messageId).ConfigureAwait(false);
// From the write's send on, every failure (timeout, reset, silence, an exception from the send itself) propagates as it
// is: the frame may have gone out, so the caller treats it as unconfirmed.
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);
}

View file

@ -3533,7 +3533,8 @@ namespace GameCult.Networking
/// </summary>
public sealed class LiteNetLibTransportConnection
{
private readonly NetPeer? _peer;
private readonly Action<byte[]>? _deliver;
private readonly Action? _end;
private readonly CultNetTransportStats _stats = new CultNetTransportStats();
/// <summary>
@ -3541,10 +3542,99 @@ namespace GameCult.Networking
/// </summary>
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);
_end = peer.Disconnect;
Profile = profile ?? CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer");
}
// The mock point for what leaves the connection: the peer's reliable ordered send.
internal LiteNetLibTransportConnection(Action<byte[]> deliver, Action? end = null)
{
_deliver = deliver ?? throw new ArgumentNullException(nameof(deliver));
_end = end;
Profile = CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer");
}
/// <summary>
/// Ends the connection. A write that can be neither answered nor refused is answered by this goodbye: the peer sees
/// its session end and settles the write as unconfirmed (<c>session_ended</c>), never as silence.
/// </summary>
internal void End() => _end?.Invoke();
/// <summary>
/// Answers a schema frame that did not decode with a <c>message_invalid</c> error.v1 naming its id when it holds one;
/// when that answer cannot be sent the connection ends instead.
/// </summary>
internal void AnswerUnreadableSchemaFrame(byte[] payload)
{
try
{
SendSchema(CultNetErrorV1Message.From(
CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"),
CultNetReadableMessageId.From(payload)));
}
catch (Exception)
{
End();
}
}
/// <summary>
/// A put or delete that arrived on an unreliable or sequenced channel of a receipt session (write-receipt.md, Reliable channel only):
/// answered <c>message_invalid</c>, details <c>channel</c>, fixed text, carrying the write's id, and not applied. True when
/// the frame was such a write and has been answered here.
/// </summary>
internal bool RefuseUnreliableWrite(ICultNetSchemaMessage message, DeliveryMethod method)
{
if (WriteReceipts == null) return false;
if (method is DeliveryMethod.ReliableOrdered or DeliveryMethod.ReliableUnordered) return false;
var messageId = message switch
{
CultNetDocumentPutRawMessage put => put.MessageId,
CultNetDocumentDeleteMessage delete => delete.MessageId,
_ => null
};
if (messageId == null && message is not (CultNetDocumentPutRawMessage or CultNetDocumentDeleteMessage)) return false;
try
{
SendSchema(CultNetErrorV1Message.From(
CultNetErrorMessage.ForMessageInvalid("channel", "receipt sessions require the reliable channel"), messageId));
}
catch (Exception)
{
End();
}
return true;
}
private readonly object _writeFramingGate = new();
private bool _writeFramingSettled;
/// <summary>
/// The write-receipt state of a connection that opted in (write-receipt.md); null for every other connection, which is
/// answered exactly as before.
/// </summary>
internal CultNetWriteReceiptSession? WriteReceipts { get; private set; }
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// Initializes a receive-only channel-aware adapter for LiteNetLib payload classification.
/// </summary>
@ -3580,14 +3670,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++;
}

View file

@ -24,6 +24,40 @@ namespace GameCult.Networking
public CultNetErrorMessage Refusal { get; }
}
/// <summary>
/// A write nothing answered within its window: neither admitted nor refused (write-receipt.md, "unconfirmed").
/// </summary>
public sealed class CultNetWriteUnconfirmedException : Exception
{
/// <summary>Creates the exception for the write <paramref name="messageId"/>.</summary>
public CultNetWriteUnconfirmedException(string messageId)
: base($"No admission or refusal arrived for write '{messageId}'.")
{
MessageId = messageId;
}
/// <summary>Gets the id of the unconfirmed write.</summary>
public string MessageId { get; }
}
/// <summary>
/// A forwarded write that provably never left this process: the connection failed, or the send failed before any byte was
/// written. It is the only forwarding failure after which the same id may be forwarded again. A send port that cannot prove
/// nothing was written must not throw this; every other failure of a forward is unconfirmed.
/// </summary>
public sealed class CultNetWriteNotSentException : Exception
{
/// <summary>Creates the exception for the write <paramref name="messageId"/>; <paramref name="cause"/> is for logs only.</summary>
public CultNetWriteNotSentException(string messageId, Exception? cause = null)
: base($"Write '{messageId}' was not sent.", cause)
{
MessageId = messageId;
}
/// <summary>Gets the id of the write that was not sent.</summary>
public string MessageId { get; }
}
/// <summary>
/// Completes a write on the peer's result frame, never on a send's return or a transport acknowledgement:
/// an ack proves delivery, not admission.
@ -33,6 +67,49 @@ namespace GameCult.Networking
/// <summary>How long after a send a refusal may still arrive when the caller names no window.</summary>
public static readonly TimeSpan DefaultResultWindow = TimeSpan.FromMilliseconds(500);
/// <summary>How long a receipt-capable peer has to answer a write (write-receipt.md, <c>RECEIPT_RESULT_WINDOW_MS</c>).</summary>
public static readonly TimeSpan ReceiptResultWindow = TimeSpan.FromMilliseconds(5000);
/// <summary>
/// Sends one write to a receipt-capable peer and waits <paramref name="resultWindow"/> for its answer: a
/// <c>write_admitted</c> naming <paramref name="messageId"/> returns, an <c>error.v1</c> naming it throws
/// <see cref="CultNetWriteRefusedException"/>, and an id-less error (v0, or v1 with no id) is attributed to this write
/// because it is the only one in flight on the connection. Silence throws <see cref="CultNetWriteUnconfirmedException"/>.
/// </summary>
public static async Task SendAndAwaitReceiptAsync(
Func<Action<CultNetWriteAdmittedMessage>, Action<CultNetErrorMessage>, Action<CultNetErrorV1Message>, IDisposable?> subscribe,
Action send,
string messageId,
TimeSpan resultWindow,
CancellationToken cancellationToken = default)
{
if (subscribe == null) throw new ArgumentNullException(nameof(subscribe));
if (send == null) throw new ArgumentNullException(nameof(send));
var answer = new TaskCompletionSource<CultNetErrorMessage?>(TaskCreationOptions.RunContinuationsAsynchronously);
using var registration = subscribe(
admitted =>
{
if (string.Equals(admitted.MessageId, messageId, StringComparison.Ordinal)) answer.TrySetResult(null);
},
error => answer.TrySetResult(error),
error =>
{
if (error.MessageId == null || string.Equals(error.MessageId, messageId, StringComparison.Ordinal))
answer.TrySetResult(error.ToV0());
});
send();
var window = Task.Delay(resultWindow, cancellationToken);
await Task.WhenAny(answer.Task, window).ConfigureAwait(false);
if (!answer.Task.IsCompleted)
{
await window.ConfigureAwait(false);
throw new CultNetWriteUnconfirmedException(messageId);
}
var refusal = await answer.Task.ConfigureAwait(false);
if (refusal != null) throw new CultNetWriteRefusedException(refusal);
}
/// <summary>
/// Sends one write and waits <paramref name="resultWindow"/> for the peer's answer. A refusal frame rejects with
/// <see cref="CultNetWriteRefusedException"/> whatever its order against the transport acknowledgement. A peer

View file

@ -0,0 +1,396 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Networking
{
/// <summary>
/// The content hash a write-receipt server binds to a (peer, messageId) dedupe key
/// (contracts/cultnet/interop/write-receipt.md, rule 5).
/// </summary>
public static class CultNetWriteContentHash
{
/// <summary>
/// SHA-256 of the MessagePack array <c>[operation, schemaId, recordKey, payload]</c>, <c>payload</c> a <c>bin</c>
/// for a put and nil for a delete.
/// </summary>
public static byte[] Compute(string operation, string schemaId, string recordKey, byte[]? payload)
{
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(4);
writer.Write(operation);
writer.Write(schemaId);
writer.Write(recordKey);
if (payload == null) writer.WriteNil();
else writer.Write(payload);
writer.Flush();
using var sha = SHA256.Create();
return sha.ComputeHash(buffer.WrittenSpan.ToArray());
}
}
/// <summary>
/// Whether a peer's hello opted in to write receipts: it lists both <c>cultnet.write_admitted.v0</c> and
/// <c>cultnet.error.v1</c>. The capability changes framing only; it never authorizes.
/// </summary>
internal static class CultNetWriteReceiptCapability
{
public static bool Declares(CultNetHelloMessage hello)
{
var versions = hello.SupportedMessageVersions;
return versions != null
&& Array.IndexOf(versions, CultNetSchemaVersions.WriteAdmitted) >= 0
&& Array.IndexOf(versions, CultNetSchemaVersions.ErrorV1) >= 0;
}
}
/// <summary>A SHA-256 digest held by value: the fixed-size stand-in for an id or a content hash.</summary>
internal readonly struct CultNetDigest : IEquatable<CultNetDigest>
{
private readonly ulong _a, _b, _c, _d;
private CultNetDigest(ulong a, ulong b, ulong c, ulong d) { _a = a; _b = b; _c = c; _d = d; }
public static CultNetDigest Of(byte[] bytes)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(bytes);
return new CultNetDigest(
BitConverter.ToUInt64(hash, 0), BitConverter.ToUInt64(hash, 8),
BitConverter.ToUInt64(hash, 16), BitConverter.ToUInt64(hash, 24));
}
public bool Equals(CultNetDigest other) => _a == other._a && _b == other._b && _c == other._c && _d == other._d;
public override bool Equals(object? obj) => obj is CultNetDigest other && Equals(other);
public override int GetHashCode() => unchecked((int)_a ^ (int)(_a >> 32));
public static bool operator ==(CultNetDigest left, CultNetDigest right) => left.Equals(right);
public static bool operator !=(CultNetDigest left, CultNetDigest right) => !left.Equals(right);
}
/// <summary>
/// What a session keeps of a refusal (write-receipt.md, What is remembered): its code, the field its details name, and its
/// routing hint (a shard descriptor the server owns). Never the write's strings or an authority's reason: the replay is built
/// from fixed text and the fields of the resend, which carries the same content.
/// </summary>
internal readonly struct CultNetRememberedRefusal
{
public string? Code { get; }
public bool HasDetails { get; }
public string? Field { get; }
public CultNetShardRoutingHint? Hint { get; }
public CultNetRememberedRefusal(string? code, bool hasDetails, string? field, CultNetShardRoutingHint? hint)
{
Code = code; HasDetails = hasDetails; Field = field; Hint = hint;
}
public static CultNetRememberedRefusal Of(CultNetErrorMessage error) =>
new(error.Code, error.Details != null, error.Details?.Field, error.RoutingHint);
private static string? NullIfEmpty(string value) => value.Length == 0 ? null : value;
/// <summary>The refusal, in fixed text, naming the write's own record key or schema id where its details do.</summary>
public CultNetErrorMessage ToError(string schemaId, string recordKey) => new()
{
Error = Code switch
{
"unowned_schema" => "unowned_schema: no shard owns the schema and key",
null => Hint != null ? CultNetDatabaseServer.RoutingRefusalText : CultNetWriteReceiptSession.WriteFailedText,
_ => Code
},
Code = Code,
RoutingHint = Hint,
Details = HasDetails
? new CultNetErrorDetails
{
Field = Field,
Value = Field switch { "recordKey" => NullIfEmpty(recordKey), "schemaId" => NullIfEmpty(schemaId), _ => null }
}
: null
};
}
/// <summary>
/// One receipt-capable peer's writes: message id to content hash and the answer given, refusals included, so a resend of the
/// same content is answered again without being decided again and the same id with other content is refused. Ids and content
/// hashes are held as fixed-size digests; the answer is held as its kind (and, for a refusal, its code and the field its details name),
/// never the strings of the write.
/// </summary>
internal sealed class CultNetWriteReceiptSession
{
/// <summary>The most writes a peer is remembered for, refused ones included; a peer at the limit has new writes refused.</summary>
internal const int MaxRememberedWrites = 1024;
/// <summary>The most ids refused at the limit that are remembered, so their resend is judged the same way.</summary>
internal const int MaxRememberedLimitRefusals = 1024;
/// <summary>The longest messageId a write may carry, in UTF-8 bytes (write-receipt.md, Id size).</summary>
internal const int MaxMessageIdBytes = 256;
/// <summary>What a write is refused with when it failed for a reason no typed refusal names. Never an exception's own text.</summary>
internal const string WriteFailedText = "the write could not be applied";
private readonly object _gate = new();
private readonly Dictionary<CultNetDigest, Entry> _writes = new();
private readonly Dictionary<CultNetDigest, CultNetDigest> _limitRefusals = new();
private sealed class Entry
{
public Entry(CultNetDigest hash) { Hash = hash; }
public CultNetDigest Hash { get; set; }
public bool Abandoned { get; set; }
public bool Admitted { get; set; }
public CultNetRememberedRefusal? Refusal { get; set; }
}
public enum Begin { New, ReplayAdmitted, ReplayRefusal, Unanswered, DifferentContent, Full }
private static CultNetDigest IdDigest(string messageId) => CultNetDigest.Of(System.Text.Encoding.UTF8.GetBytes(messageId));
/// <summary>
/// Looks <paramref name="messageId"/> up first. An id not seen is claimed for this content (<see cref="Begin.New"/>);
/// otherwise the outcome says what already holds it, and <paramref name="refusal"/> is the refusal to repeat.
/// </summary>
public Begin TryBegin(string messageId, byte[] hash, out CultNetRememberedRefusal? refusal)
{
var id = IdDigest(messageId);
var content = CultNetDigest.Of(hash);
lock (_gate)
{
refusal = null;
if (_writes.TryGetValue(id, out var entry))
{
if (entry.Abandoned)
{
// Not sent, so judged afresh in the slot it already holds; no slot is ever freed.
entry.Abandoned = false;
entry.Hash = content;
return Begin.New;
}
if (entry.Hash != content) return Begin.DifferentContent;
if (entry.Admitted) return Begin.ReplayAdmitted;
if (entry.Refusal != null)
{
refusal = entry.Refusal;
return Begin.ReplayRefusal;
}
// No answer yet: the first attempt is still being applied, or its primary left it unanswered for good.
return Begin.Unanswered;
}
if (_writes.Count >= MaxRememberedWrites)
{
if (_limitRefusals.TryGetValue(id, out var refused)) return refused == content ? Begin.Full : Begin.DifferentContent;
if (_limitRefusals.Count < MaxRememberedLimitRefusals) _limitRefusals[id] = content;
return Begin.Full;
}
_writes[id] = new Entry(content);
return Begin.New;
}
}
/// <summary>The write was admitted: remember it so a resend repeats the receipt.</summary>
public void Admitted(string messageId)
{
lock (_gate)
{
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Admitted = true;
}
}
/// <summary>The write was refused: remember the refusal so a resend repeats it.</summary>
public void Refused(string messageId, CultNetRememberedRefusal refusal)
{
lock (_gate)
{
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Refusal = refusal;
}
}
/// <summary>
/// The write never reached anyone who could have applied it: a repeat is judged afresh. The entry keeps its slot, so
/// forgetting a write never makes room for an id that was refused at the limit.
/// </summary>
public void Abandoned(string messageId)
{
lock (_gate)
{
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Abandoned = true;
}
}
}
/// <summary>
/// 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).
/// </summary>
internal static class CultNetReadableMessageId
{
/// <summary>The frame's top-level string <c>messageId</c>, or null when the bytes are not a map holding one.</summary>
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;
}
}
/// <summary>
/// One write on a receipt-capable connection: its dedupe claim and its single answer (write-receipt.md, rules 1 to 5).
/// </summary>
internal sealed class CultNetReceiptedWrite
{
private readonly CultNetWriteReceiptSession _session;
private readonly Action<ICultNetSchemaMessage> _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<ICultNetSchemaMessage> 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;
}
/// <summary>
/// Looks the write's id up before anything else is decided (rule 5). True when the id is new and the write should go on
/// to authorization and the commit; false when it was answered here (a replay, a reused id, a full table) or is owed no
/// further answer (still in flight, or left silent by its primary).
/// </summary>
public bool Begin()
{
var outcome = _session.TryBegin(_messageId, CultNetWriteContentHash.Compute(_operation, _schemaId, _recordKey, _payload), out var refusal);
switch (outcome)
{
case CultNetWriteReceiptSession.Begin.New:
_claimed = true;
return true;
case CultNetWriteReceiptSession.Begin.ReplayAdmitted:
_answered = true;
_send(AdmittedReceipt());
return false;
case CultNetWriteReceiptSession.Begin.ReplayRefusal:
_answered = true;
_send(CultNetErrorV1Message.From(refusal!.Value.ToError(_schemaId, _recordKey), _messageId));
return false;
case CultNetWriteReceiptSession.Begin.Unanswered:
// The first attempt is still being applied and will answer this id, or its primary left it unanswered:
// then the claim stays, so the id is never sent again on this session, and the write stays unconfirmed.
_answered = true;
return false;
case CultNetWriteReceiptSession.Begin.DifferentContent:
Refused(CultNetErrorMessage.ForMessageInvalid("messageId", "the message id was already used for different content"));
return false;
default:
_answered = true;
_send(CultNetErrorV1Message.LimitReached(_messageId));
return false;
}
}
private CultNetWriteAdmittedMessage AdmittedReceipt() => new()
{
MessageId = _messageId,
Operation = _operation,
SchemaId = _schemaId,
RecordKey = _recordKey
};
/// <summary>The write committed: remember the receipt and send it.</summary>
public void Admitted()
{
if (_answered) return;
_answered = true;
if (_claimed) _session.Admitted(_messageId);
_send(AdmittedReceipt());
}
/// <summary>The write was refused: remember the refusal (a resend repeats it) and send its one error.v1.</summary>
public void Refused(CultNetErrorMessage error)
{
if (_answered) return;
_answered = true;
if (_claimed)
{
// A remembered refusal is answered from what is remembered, so the replay is the refusal again.
var kept = CultNetRememberedRefusal.Of(error);
_session.Refused(_messageId, kept);
error = kept.ToError(_schemaId, _recordKey);
}
_send(CultNetErrorV1Message.From(error, _messageId));
}
/// <summary>
/// A forward that may have reached its primary and is unconfirmed (silence, reset, timeout, an ambiguous send): no answer is
/// owed or possible, and the claim stays so the id is never forwarded again on this session. Sends nothing.
/// </summary>
public void LeftSilent()
{
if (_answered) return;
_answered = true;
}
/// <summary>A forward that was provably not sent (connect failed, or the send failed before any byte was written): forget the claim, send nothing.</summary>
public void NotSent()
{
if (_answered) return;
_answered = true;
if (_claimed) _session.Abandoned(_messageId);
_claimed = false;
}
/// <summary>
/// Why the write's id cannot be used at all, before anything is remembered: it is missing (refused with no id) or longer
/// than <see cref="CultNetWriteReceiptSession.MaxMessageIdBytes"/> UTF-8 bytes. Null when the id is usable.
/// </summary>
public CultNetErrorMessage? IdRefusal =>
_messageId.Length == 0
? CultNetErrorMessage.ForMessageInvalid("messageId", "a write needs a messageId")
: System.Text.Encoding.UTF8.GetByteCount(_messageId) > CultNetWriteReceiptSession.MaxMessageIdBytes
? CultNetErrorMessage.ForMessageInvalid("messageId", "messageId exceeds 256 bytes")
: null;
}
}

View file

@ -23,7 +23,7 @@ namespace GameCult.Networking
private const string EmailPattern =
@"^([0-9a-zA-Z]([\+\-_\.][0-9a-zA-Z]+)*)+@(([0-9a-zA-Z][-\w]*[0-9a-zA-Z]*\.)+[a-zA-Z0-9]{2,17})$";
private const string UsernamePattern = @"^[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*$";
private const int ServerPort = 3075;
private const int DefaultPort = 3075;
private const float SessionTimeoutSeconds = 1800; // 30 minutes
private const float SessionRefreshThresholdSeconds = 300; // 5 minutes
private const int MaxConnectionAttemptsPerMinute = 30;
@ -63,6 +63,12 @@ namespace GameCult.Networking
/// </summary>
public bool LogSensitivePayloads { get; set; }
/// <summary>
/// Gets or sets the UDP port <see cref="Start"/> binds (default 3075; 0 asks the OS for a free one). After
/// <see cref="Start"/> it holds the port actually bound.
/// </summary>
public int Port { get; set; } = DefaultPort;
private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d);
/// <summary>
@ -74,7 +80,7 @@ namespace GameCult.Networking
{
TransportId = "litenetlib-server",
Host = "0.0.0.0",
Port = ServerPort
Port = Port
});
/// <summary>
@ -357,7 +363,8 @@ namespace GameCult.Networking
UnsyncedEvents = true,
NatPunchEnabled = true
};
_netManager.Start(ServerPort);
_netManager.Start(Port);
Port = _netManager.LocalPort;
listener.NetworkErrorEvent += (point, code) => Logger.LogInfo($"{point.Address}: Error {code}");
@ -408,11 +415,25 @@ namespace GameCult.Networking
{
var cultNetMessage = LiteNetLibTransportConnection.DecodeSchema(frame);
Logger.LogDebug($"Received CultNet schema message {cultNetMessage.SchemaVersion}");
if (transport.RefuseUnreliableWrite(cultNetMessage, method)) return;
await HandleCultNetSchemaMessageAsync(peer, user, cultNetMessage).ConfigureAwait(false);
return;
}
var message = LiteNetLibTransportConnection.DecodeLegacy(frame);
Message message;
try
{
message = LiteNetLibTransportConnection.DecodeLegacy(frame);
}
catch (Exception ex) when (transport.WriteReceipts != null)
{
// A frame that decodes on neither channel is classed legacy, so this is where an unreadable frame lands.
// A receipt-capable peer is told which frame was unreadable (by its id, if the frame still holds one).
Logger.LogWarning($"CultNet frame unreadable: {ex.Message}");
transport.AnswerUnreadableSchemaFrame(frame.Payload);
return;
}
if (LogSensitivePayloads)
{
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(frame.Payload))}");
@ -507,7 +528,7 @@ namespace GameCult.Networking
}
});
Logger.LogInfo($"Server started on port {ServerPort}.");
Logger.LogInfo($"Server started on port {Port}.");
}
private async Task HandleCultNetSchemaMessageAsync(NetPeer peer, User user, ICultNetSchemaMessage message)

View file

@ -418,6 +418,7 @@ namespace GameCult.Networking.Tests
CultNetDatabaseChangeKind.Added, VariantKey, schemaId, database.Shards[0], cache.Get<VariantWireFixture>(VariantKey)!, previousDocument: null);
Assert.That(Deliver(databaseServer, added, selection, refused, errors), Is.Null);
Assert.That(errors, Has.Count.EqualTo(1));
Assert.That(errors[0].Error, Is.EqualTo("the record is a variant CultNet cannot carry"));
var removed = new CultNetDatabaseChange<VariantWireFixture>(
CultNetDatabaseChangeKind.Removed, VariantKey, schemaId, database.Shards[0], document: null, previousDocument: cache.Get<VariantWireFixture>(VariantKey)!);

View file

@ -178,6 +178,46 @@ namespace GameCult.Networking.Tests
Assert.That(scene.Logger.Errors, Has.Count.EqualTo(1));
}
// The RUDP schema server implements no receipt rules, so it must not advertise them (write-receipt.md, Capability and the
// conformance table): whatever hello it sends lists neither frame.
[Test]
public async Task TheRudpSchemaServerNeverAdvertisesTheReceiptFrames()
{
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "csharp-rudp-no-receipts",
ConnectionId = ConnectionId
});
var client = await Connect(server);
try
{
SendSchema(server, client, CultNetSchemaMessageSerialization.Serialize(new CultNetHelloMessage
{
RuntimeId = "receipt-client",
RuntimeKind = "test",
SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1]
}));
await server.PollAvailableAsync(16);
var hellos = new List<CultNetHelloMessage>();
foreach (var packet in Drain(client.Socket))
foreach (var frame in client.Session.Receive(packet, 1).Delivered)
if (CultNetSchemaMessageSerialization.Deserialize(frame.Payload) is CultNetHelloMessage hello)
hellos.Add(hello);
foreach (var hello in hellos)
{
var listed = hello.SupportedMessageVersions ?? Array.Empty<string>();
Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.WriteAdmitted));
Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.ErrorV1));
}
}
finally
{
client.Socket.Dispose();
}
}
private static List<CultNetErrorMessage> ErrorsReceivedBy(Client client)
{
var errors = new List<CultNetErrorMessage>();

View file

@ -0,0 +1,290 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using LiteNetLib;
using NUnit.Framework;
using static GameCult.Networking.Tests.NetworkingTests;
namespace GameCult.Networking.Tests
{
// The receipt server on a real socket: Server.Start on an ephemeral loopback port and real LiteNetLib clients. The
// handler-level tests (CultNetWriteReceiptServerTests) hand frames to the handlers; only a running server decides what
// a frame that does not decode is answered with.
public sealed class CultNetWriteReceiptLoopbackTests
{
private static readonly TimeSpan Cap = TimeSpan.FromSeconds(10);
private static readonly CultRecordKey KeyA = new("tests:loopback:a");
private static readonly CultRecordKey KeyB = new("tests:loopback:b");
private const string ShardId = "loopback-shard";
private sealed class Client : IDisposable
{
private readonly NetManager _manager;
private readonly NetPeer _peer;
private readonly BlockingCollection<ICultNetSchemaMessage> _received = new();
public int Closed;
public Client(int port, string key)
{
var listener = new EventBasedNetListener();
var connected = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var decoder = new LiteNetLibTransportConnection();
listener.PeerConnectedEvent += _ => connected.TrySetResult(true);
listener.PeerDisconnectedEvent += (_, _) => Interlocked.Increment(ref Closed);
listener.NetworkReceiveEvent += (_, reader, _, _) =>
_received.Add(LiteNetLibTransportConnection.DecodeSchema(decoder.Receive(reader.GetRemainingBytes())));
_manager = new NetManager(listener) { UnsyncedEvents = true };
_manager.Start(0);
_peer = _manager.Connect("127.0.0.1", port, key);
if (!connected.Task.Wait(Cap)) Assert.Fail("the client did not connect");
}
public void Send<T>(T message) where T : ICultNetSchemaMessage =>
_peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered);
public void SendUnreliable<T>(T message) where T : ICultNetSchemaMessage =>
_peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.Unreliable);
public void Send<T>(T message, DeliveryMethod method) where T : ICultNetSchemaMessage =>
_peer.Send(CultNetSchemaMessageSerialization.Serialize(message), method);
public void SendRaw(byte[] bytes) => _peer.Send(bytes, DeliveryMethod.ReliableOrdered);
public T Next<T>() where T : ICultNetSchemaMessage
{
if (!_received.TryTake(out var message, Cap)) Assert.Fail($"no {typeof(T).Name} arrived within {Cap}");
Assert.That(message, Is.InstanceOf<T>());
return (T)message!;
}
public void ExpectNothingMore(string because)
{
// A reliable ordered channel: anything the server sent before the probe's answer has already arrived.
Assert.That(_received.Count, Is.Zero, because);
}
public void Dispose()
{
_manager.Stop();
_received.Dispose();
}
}
private sealed class Authority : ICultNetRemoteWriteAuthority
{
public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) =>
request.RecordKey.Equals(KeyB) ? CultNetRemoteWriteDecision.Refuse("denied") : CultNetRemoteWriteDecision.Grant();
}
private sealed class Running : IDisposable
{
public Server Server = null!;
public CultCache Cache = null!;
public CultNetDatabase Database = null!;
public string SchemaId = string.Empty;
private CultNetDatabaseServer _databaseServer = null!;
public static Running Start()
{
var cache = new CultCache();
var schemaId = cache.Registry.GetRequired<NetworkSchemaNote>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards = [new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: true, schemaIds: [schemaId])]
});
var server = new Server(cache, ServerSecurityOptions.Development()) { Port = 0 };
var databaseServer = new CultNetDatabaseServer(server, database, new CultNetDatabaseServerOptions
{
RemoteWriteAuthority = new Authority()
});
server.Start();
return new Running { Server = server, Cache = cache, Database = database, SchemaId = schemaId, _databaseServer = databaseServer };
}
public Client Connect() => new(Server.Port, ServerSecurityOptions.Development().ConnectionKey);
public CultNetDocumentPutRawMessage PutOf(string id, CultRecordKey key, string text)
{
var message = Database.Documents.CreateRawDocumentPutMessage(id, new CultRecordHandle<NetworkSchemaNote>(key),
new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = text });
message.ShardId = ShardId;
message.ShardEpoch = 1;
return message;
}
public void Dispose()
{
_databaseServer.Dispose();
Server.Dispose();
}
}
private static CultNetHelloMessage HelloOf(params string[] versions) =>
new() { RuntimeId = "loopback-client", RuntimeKind = "test", SupportedMessageVersions = versions };
private static readonly string[] BothFrames = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1];
[Test]
public void StartBindsTheRequestedPortAndReportsTheOneBound()
{
using var running = Running.Start();
Assert.That(running.Server.Port, Is.Not.Zero.And.Not.EqualTo(3075));
}
// contracts/cultnet/interop/write-receipt-vectors.json, error_v1_unreadable_frame_has_null_message_id: code
// message_invalid, details.field "message", messageId null.
[TestCase(new byte[] { 0xC1 }, TestName = "AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId_NeverUsedByte")]
[TestCase(new byte[] { 0x92, 0x01, 0x02 }, TestName = "AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId_NotAMap")]
public void AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId(byte[] garbage)
{
using var running = Running.Start();
using var client = running.Connect();
client.Send(HelloOf(BothFrames));
client.Next<CultNetHelloMessage>();
client.SendRaw(garbage);
var error = client.Next<CultNetErrorV1Message>();
Assert.That(error.Code, Is.EqualTo("message_invalid"));
Assert.That(error.Details!.Field, Is.EqualTo("message"));
Assert.That(error.MessageId, Is.Null);
}
[Test]
public async Task AReceiptPeerIsStillServedAfterAnUnreadableFrameAndALegacyPeerIsAnsweredAsBefore()
{
using var running = Running.Start();
using var receipt = running.Connect();
receipt.Send(HelloOf(BothFrames));
receipt.Next<CultNetHelloMessage>();
receipt.SendRaw(new byte[] { 0xC1 });
receipt.Next<CultNetErrorV1Message>();
receipt.Send(running.PutOf("r1", KeyA, "one"));
Assert.That(receipt.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("r1"));
using var legacy = running.Connect();
legacy.Send(HelloOf()); // a hello that lists neither frame is answered with nothing
legacy.SendRaw(new byte[] { 0xC1 }); // unanswered: were it answered, error.v1 would arrive ahead of the refusal
legacy.Send(running.PutOf("l1", KeyB, "two"));
var refusal = legacy.Next<CultNetErrorMessage>();
Assert.That(refusal, Is.TypeOf<CultNetErrorMessage>());
Assert.That(refusal.Code, Is.EqualTo("write_not_authorized"));
legacy.Send(running.PutOf("l2", KeyA, "three"));
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "three");
legacy.ExpectNothingMore("a legacy peer is never sent a receipt or error.v1");
Assert.That(running.Cache.Get<NetworkSchemaNote>(KeyB), Is.Null);
}
// write-receipt.md, Reliable channel only: a write on an unreliable channel of a receipt session is refused
// message_invalid, details.field "channel", fixed text, carrying the write's id; it is not applied.
[Test]
public async Task AWriteOnAnUnreliableChannelOfAReceiptPeerIsRefusedAndNotApplied()
{
using var running = Running.Start();
using var client = running.Connect();
client.Send(HelloOf(BothFrames));
client.Next<CultNetHelloMessage>();
client.SendUnreliable(running.PutOf("u1", KeyA, "lost"));
var error = client.Next<CultNetErrorV1Message>();
Assert.That(error.Code, Is.EqualTo("message_invalid"));
Assert.That(error.Details!.Field, Is.EqualTo("channel"));
Assert.That(error.Error, Is.EqualTo("message_invalid: receipt sessions require the reliable channel"));
Assert.That(error.MessageId, Is.EqualTo("u1"));
// The session goes on, and the same id on the reliable channel is a new write, not a replayed refusal.
client.Send(running.PutOf("u1", KeyA, "kept"));
Assert.That(client.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("u1"));
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "kept");
}
// write-receipt.md, Reliable channel only: reliable and not sequenced. Reliable ordered and unordered qualify; reliable
// sequenced does not, and nor does any unreliable method.
[TestCase(DeliveryMethod.ReliableSequenced, true)]
[TestCase(DeliveryMethod.Sequenced, true)]
[TestCase(DeliveryMethod.Unreliable, true)]
[TestCase(DeliveryMethod.ReliableUnordered, false)]
[TestCase(DeliveryMethod.ReliableOrdered, false)]
public async Task OnlyAReliableNonSequencedChannelCarriesAWriteOfAReceiptPeer(DeliveryMethod method, bool refused)
{
using var running = Running.Start();
using var client = running.Connect();
client.Send(HelloOf(BothFrames));
client.Next<CultNetHelloMessage>();
client.Send(running.PutOf("c1", KeyA, "text"), method);
if (refused)
{
var error = client.Next<CultNetErrorV1Message>();
Assert.That(error.Details!.Field, Is.EqualTo("channel"));
Assert.That(error.MessageId, Is.EqualTo("c1"));
Assert.That(running.Cache.Get<NetworkSchemaNote>(KeyA), Is.Null);
}
else
{
Assert.That(client.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("c1"));
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "text");
}
}
[Test]
public void ADeleteOnAnUnreliableChannelOfAReceiptPeerIsRefusedWithItsId()
{
using var running = Running.Start();
using var client = running.Connect();
client.Send(HelloOf(BothFrames));
client.Next<CultNetHelloMessage>();
client.SendUnreliable(new CultNetDocumentDeleteMessage
{
MessageId = "ud1", SchemaId = running.SchemaId, RecordKey = KeyA.Value, ShardId = ShardId, ShardEpoch = 1
});
var error = client.Next<CultNetErrorV1Message>();
Assert.That(error.Code, Is.EqualTo("message_invalid"));
Assert.That(error.Details!.Field, Is.EqualTo("channel"));
Assert.That(error.MessageId, Is.EqualTo("ud1"));
}
[Test]
public async Task OnlyWritesAreRefusedOnTheUnreliableChannelAndALegacyPeerIsLeftAsBefore()
{
using var running = Running.Start();
using var receipt = running.Connect();
receipt.Send(HelloOf(BothFrames));
receipt.Next<CultNetHelloMessage>();
receipt.SendUnreliable(HelloOf(BothFrames)); // not a write: answered with nothing on the unreliable channel
receipt.Send(running.PutOf("r1", KeyA, "one"));
Assert.That(receipt.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("r1"), "nothing else was sent ahead of the receipt");
receipt.Send(running.PutOf("r2", KeyA, "two"));
Assert.That(receipt.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("r2"));
receipt.ExpectNothingMore("the unreliable hello was answered with nothing");
using var legacy = running.Connect();
legacy.Send(HelloOf());
legacy.SendUnreliable(running.PutOf("l1", KeyB, "two")); // refused by the authority, answered as v0 as before
Assert.That(legacy.Next<CultNetErrorMessage>().Code, Is.EqualTo("write_not_authorized"));
legacy.SendUnreliable(running.PutOf("l2", KeyA, "three"));
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "three");
legacy.ExpectNothingMore("a legacy peer is never sent a receipt, error.v1 or a channel refusal");
}
private static async Task WaitFor(Func<bool> condition)
{
var deadline = DateTime.UtcNow + Cap;
while (!condition())
{
if (DateTime.UtcNow > deadline) Assert.Fail("the condition did not hold within the cap");
await Task.Delay(5);
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<CultNetWriteUnconfirmedException>(() =>
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<CultNetWriteUnconfirmedException>(() =>
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<CultNetWriteRefusedException>(() =>
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()
{