CultLib/src/GameCult.Networking/CultNetWriteReceipts.cs
2026-11-08 17:01:59 +00:00

396 lines
17 KiB
C#

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;
}
}