2026-05-19 17:47:43 +01:00
using System ;
using System.Linq ;
2026-05-19 17:40:19 +01:00
using System.Threading.Tasks ;
namespace GameCult.Networking
{
/// <summary>
/// Forwards shard writes to an authoritative owner.
/// </summary>
public interface ICultNetShardWriteForwarder
{
/// <summary>
2026-11-08 14:04:37 +00:00
/// Forwards a raw document put to the shard owner. Completes only when the owner answered that it admitted the write;
2026-11-08 15:02:56 +00:00
/// 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.
2026-05-19 17:40:19 +01:00
/// </summary>
Task ForwardPutAsync ( CultNetShardDescriptor shard , CultNetDocumentPutRawMessage message ) ;
/// <summary>
2026-11-08 14:04:37 +00:00
/// Forwards a raw document delete to the shard owner; completes, refuses and goes unconfirmed as
/// <see cref="ForwardPutAsync"/> does.
2026-05-19 17:40:19 +01:00
/// </summary>
Task ForwardDeleteAsync ( CultNetShardDescriptor shard , CultNetDocumentDeleteMessage message ) ;
}
/// <summary>
/// Options controlling shard write forwarding from a database server bridge.
/// </summary>
public sealed class CultNetDatabaseServerOptions
{
/// <summary>
/// Gets or sets whether non-primary writes may be forwarded to shard owners.
/// </summary>
public bool ForwardNonPrimaryWrites { get ; set ; }
/// <summary>
/// Gets or sets the forwarder used when non-primary write forwarding is enabled.
/// </summary>
public ICultNetShardWriteForwarder ? WriteForwarder { get ; set ; }
2026-10-28 00:47:24 +01:00
/// <summary>
/// Gets or sets the authority asked for every peer put and delete. None configured refuses them all: a connection
/// key admits a peer to the transport, never to write. A write forwarded to a primary is judged here for the
/// original peer and again at the primary for the forwarder's connection.
/// </summary>
public ICultNetRemoteWriteAuthority ? RemoteWriteAuthority { get ; set ; }
2026-05-19 17:40:19 +01:00
}
2026-05-19 17:47:43 +01:00
/// <summary>
/// Options for the schema-v0 client-based shard write forwarder.
/// </summary>
public sealed class CultNetSchemaWriteForwarderOptions
{
/// <summary>
/// Gets or sets client security options used to connect to primary endpoints.
/// </summary>
public ClientSecurityOptions ? Security { get ; set ; }
/// <summary>
/// Gets or sets how long to wait for a connection before failing the forwarded write.
/// </summary>
public TimeSpan ConnectTimeout { get ; set ; } = TimeSpan . FromSeconds ( 5 ) ;
/// <summary>
/// Gets or sets a callback used to customize each ephemeral forwarding client.
/// </summary>
public Action < Client > ? ConfigureClient { get ; set ; }
2026-06-16 14:22:41 +01:00
/// <summary>
2026-06-17 13:27:56 +01:00
/// Gets or sets the schema-v0 client factory. Defaults to an endpoint-aware adapter:
/// rudp:// endpoints use the C# RUDP schema client and cultnet:// endpoints use LiteNetLib.
2026-06-16 14:22:41 +01:00
/// </summary>
public Func < ICultNetSchemaClient > ? CreateClient { get ; set ; }
2026-11-08 13:24:27 +00:00
/// <summary>
2026-11-08 14:04:37 +00:00
/// 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.
2026-11-08 13:24:27 +00:00
/// </summary>
2026-11-08 14:04:37 +00:00
public TimeSpan ResultWindow { get ; set ; } = CultNetWriteCompletion . ReceiptResultWindow ;
2026-05-19 17:47:43 +01:00
}
/// <summary>
/// Forwards shard writes over the CultNet schema-v0 client transport.
/// </summary>
public sealed class CultNetSchemaWriteForwarder : ICultNetShardWriteForwarder
{
private readonly CultNetSchemaWriteForwarderOptions _options ;
/// <summary>
/// Creates a schema-v0 write forwarder.
/// </summary>
public CultNetSchemaWriteForwarder ( CultNetSchemaWriteForwarderOptions ? options = null )
{
_options = options ? ? new CultNetSchemaWriteForwarderOptions ( ) ;
}
2026-11-08 13:24:27 +00:00
/// <summary>
2026-11-08 14:04:37 +00:00
/// 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"/>.
2026-11-08 13:24:27 +00:00
/// </summary>
2026-05-19 17:47:43 +01:00
public async Task ForwardPutAsync ( CultNetShardDescriptor shard , CultNetDocumentPutRawMessage message )
{
if ( message = = null ) throw new ArgumentNullException ( nameof ( message ) ) ;
2026-11-08 15:02:56 +00:00
if ( shard = = null ) throw new ArgumentNullException ( nameof ( shard ) ) ;
2026-05-19 17:47:43 +01:00
message . ShardId ? ? = shard . ShardId ;
message . ShardEpoch ? ? = shard . Epoch ;
2026-11-08 15:02:56 +00:00
await SendAsync ( shard , message , message . MessageId ) . ConfigureAwait ( false ) ;
2026-05-19 17:47:43 +01:00
}
2026-11-08 13:24:27 +00:00
/// <summary>
2026-11-08 14:04:37 +00:00
/// Forwards a delete under its original message id; completes, refuses and goes unconfirmed as
/// <see cref="ForwardPutAsync"/> does.
2026-11-08 13:24:27 +00:00
/// </summary>
2026-05-19 17:47:43 +01:00
public async Task ForwardDeleteAsync ( CultNetShardDescriptor shard , CultNetDocumentDeleteMessage message )
{
if ( message = = null ) throw new ArgumentNullException ( nameof ( message ) ) ;
2026-11-08 15:02:56 +00:00
if ( shard = = null ) throw new ArgumentNullException ( nameof ( shard ) ) ;
2026-05-19 17:47:43 +01:00
message . ShardId ? ? = shard . ShardId ;
message . ShardEpoch ? ? = shard . Epoch ;
2026-11-08 15:02:56 +00:00
await SendAsync ( shard , message , message . MessageId ) . ConfigureAwait ( false ) ;
2026-05-19 17:47:43 +01:00
}
/// <summary>
/// Parses a CultNet endpoint URI into host and port.
/// </summary>
public static ( string Host , int Port ) ParseEndpoint ( string endpoint )
{
if ( string . IsNullOrWhiteSpace ( endpoint ) )
{
throw new ArgumentException ( "Endpoint must be non-empty." , nameof ( endpoint ) ) ;
}
if ( ! Uri . TryCreate ( endpoint , UriKind . Absolute , out var uri ) | |
2026-06-17 13:27:56 +01:00
! IsSupportedSchemaEndpointScheme ( uri . Scheme ) | |
2026-05-19 17:47:43 +01:00
string . IsNullOrWhiteSpace ( uri . Host ) )
{
2026-07-19 17:57:26 +01:00
throw new FormatException (
$"CultNet endpoint '{endpoint}' must use cultnet+tcp://host:port, cultnet://host:port, or rudp://host:port." ) ;
2026-05-19 17:47:43 +01:00
}
return ( uri . Host , uri . IsDefaultPort ? 3075 : uri . Port ) ;
}
2026-11-08 15:02:56 +00:00
// 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 )
2026-05-19 17:47:43 +01:00
{
2026-11-08 15:02:56 +00:00
ICultNetSchemaClient ? client = null ;
try
2026-11-08 14:04:37 +00:00
{
2026-11-08 15:02:56 +00:00
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.
2026-11-08 14:04:37 +00:00
await CultNetWriteCompletion . SendAndAwaitReceiptAsync (
( admitted , refuse , refuseV1 ) = >
2026-11-08 13:24:27 +00:00
{
2026-11-08 14:04:37 +00:00
client . OnCultNet ( admitted ) ;
2026-11-08 13:24:27 +00:00
client . OnCultNet ( refuse ) ;
2026-11-08 14:04:37 +00:00
client . OnCultNet ( refuseV1 ) ;
2026-11-08 13:24:27 +00:00
return null ;
} ,
( ) = > client . SendCultNet ( message ) ,
2026-11-08 14:04:37 +00:00
messageId ,
2026-11-08 13:24:27 +00:00
_options . ResultWindow ) . ConfigureAwait ( false ) ;
2026-05-19 17:47:43 +01:00
}
2026-06-17 13:27:56 +01:00
private ICultNetSchemaClient CreateClient ( string endpoint )
2026-06-16 14:22:41 +01:00
{
return _options . CreateClient ? . Invoke ( )
2026-06-17 13:27:56 +01:00
? ? CultNetSchemaClients . CreateForEndpoint ( endpoint , _options . Security , _options . ConfigureClient ) ;
}
private static bool IsSupportedSchemaEndpointScheme ( string scheme )
{
2026-07-19 17:57:26 +01:00
return string . Equals ( scheme , "cultnet+tcp" , StringComparison . OrdinalIgnoreCase ) | |
string . Equals ( scheme , "cultnet" , StringComparison . OrdinalIgnoreCase ) | |
2026-06-17 13:27:56 +01:00
string . Equals ( scheme , "rudp" , StringComparison . OrdinalIgnoreCase ) ;
2026-06-16 14:22:41 +01:00
}
private async Task WaitForConnectionAsync ( ICultNetSchemaClient client , string endpoint )
2026-05-19 17:47:43 +01:00
{
var deadline = DateTimeOffset . UtcNow + _options . ConnectTimeout ;
while ( ! client . Connected )
{
if ( DateTimeOffset . UtcNow > = deadline )
{
throw new TimeoutException ( $"Timed out connecting to shard primary endpoint {endpoint}." ) ;
}
await Task . Delay ( TimeSpan . FromMilliseconds ( 25 ) ) . ConfigureAwait ( false ) ;
}
}
private static string ResolvePrimaryEndpoint ( CultNetShardDescriptor shard )
{
if ( shard = = null ) throw new ArgumentNullException ( nameof ( shard ) ) ;
return shard . PrimaryEndpoints . FirstOrDefault ( )
? ? throw new InvalidOperationException (
$"Shard '{shard.ShardId}' does not advertise a primary endpoint." ) ;
}
}
2026-05-19 17:40:19 +01:00
}