CultLib/src/GameCult.Mesh/CultMeshClient.cs
2026-08-18 05:59:11 +02:00

976 lines
45 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Networking;
using MessagePack;
using R3;
namespace GameCult.Mesh
{
/// <summary>Configures one application-lifetime CultMesh client.</summary>
public sealed class CultMeshClientOptions
{
/// <summary>Gets or sets the rendezvous endpoints used to bootstrap stable identities.</summary>
public IReadOnlyList<string> RendezvousEndpoints { get; set; } = Array.Empty<string>();
/// <summary>Gets or sets Verse discovery transport and persistence options.</summary>
public CultMeshVerseDiscoveryClientOptions Discovery { get; set; } = new();
/// <summary>Gets or sets session path and diagnostic policy.</summary>
public CultMeshSessionManagerOptions Sessions { get; set; } = new();
/// <summary>Gets or sets schema connectors. Length-prefixed TCP is used by default.</summary>
public IReadOnlyList<ICultMeshTransportConnector>? Connectors { get; set; }
/// <summary>
/// Gets or sets streaming content connectors. TCP content delivery is used by default;
/// applications may replace it or explicitly add legacy RUDP.
/// </summary>
public IReadOnlyList<ICultMeshContentTransportConnector>? ContentConnectors { get; set; }
/// <summary>
/// Gets or sets realtime state connectors. No connector is installed implicitly;
/// promoted runtimes should register QUIC and may explicitly add a legacy fallback.
/// </summary>
public IReadOnlyList<ICultMeshRealtimeTransportConnector> RealtimeConnectors { get; set; } =
Array.Empty<ICultMeshRealtimeTransportConnector>();
/// <summary>Gets or sets the deadline before an unanswered subscription intent is replayed.</summary>
public TimeSpan SubscriptionResponseTimeout { get; set; } = TimeSpan.FromSeconds(2);
/// <summary>Gets or sets the deadline for one correlated typed operation response.</summary>
public TimeSpan OperationResponseTimeout { get; set; } = TimeSpan.FromSeconds(10);
}
/// <summary>
/// Carries one provider-authored operation response. Transport delivery does not by itself
/// establish that the provider accepted or committed the requested gameplay change.
/// </summary>
public sealed class CultMeshOperationResult<TResponse>
{
internal CultMeshOperationResult(CultNetOperationResponseMessage envelope, TResponse value)
{
MessageId = envelope.MessageId;
ServiceId = envelope.ServiceId;
Operation = envelope.Operation;
Status = envelope.Status;
PayloadSchema = envelope.PayloadSchema;
SourceRuntimeId = envelope.SourceRuntimeId;
Diagnostics = envelope.Diagnostics ?? Array.Empty<string>();
Value = value;
}
/// <summary>Gets the durable caller-supplied idempotency key.</summary>
public string MessageId { get; }
/// <summary>Gets the provider service that answered.</summary>
public string ServiceId { get; }
/// <summary>Gets the operation that answered.</summary>
public string Operation { get; }
/// <summary>Gets the provider-authored operation status.</summary>
public string Status { get; }
/// <summary>Gets the portable response payload schema.</summary>
public string PayloadSchema { get; }
/// <summary>Gets the responding runtime identity, when supplied.</summary>
public string? SourceRuntimeId { get; }
/// <summary>Gets provider or transport diagnostics carried by the response.</summary>
public IReadOnlyList<string> Diagnostics { get; }
/// <summary>Gets the decoded typed response payload.</summary>
public TResponse Value { get; }
}
/// <summary>Correlated framework-level failure returned before a typed domain reply exists.</summary>
public sealed class CultMeshRemoteOperationException : Exception
{
internal CultMeshRemoteOperationException(
CultNetOperationResponseMessage envelope,
CultNetOperationFailure failure)
: base(failure?.Message ?? "CultMesh operation failed.")
{
Status = envelope?.Status ?? "error";
Code = failure?.Code ?? "operation-error";
Diagnostics = envelope?.Diagnostics ?? Array.Empty<string>();
}
/// <summary>Gets the provider-authored failure status.</summary>
public string Status { get; }
/// <summary>Gets the stable machine-readable failure code.</summary>
public string Code { get; }
/// <summary>Gets provider diagnostics carried by the response.</summary>
public IReadOnlyList<string> Diagnostics { get; }
}
/// <summary>Owns one shared live document subscription until disposed.</summary>
public sealed class CultMeshDocumentLease<TDocument> : IDisposable where TDocument : class
{
private Action? _release;
internal CultMeshDocumentLease(CultMeshDocumentHandle<TDocument> handle, Action release)
{
Handle = handle ?? throw new ArgumentNullException(nameof(handle));
_release = release ?? throw new ArgumentNullException(nameof(release));
}
/// <summary>Gets the live typed document handle owned by this lease.</summary>
public CultMeshDocumentHandle<TDocument> Handle { get; }
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
}
/// <summary>Owns one shared live collection subscription until disposed.</summary>
public sealed class CultMeshCollectionLease<TDocument> : IDisposable where TDocument : class
{
private Action? _release;
internal CultMeshCollectionLease(CultMeshCollectionHandle<TDocument> handle, Action release)
{
Handle = handle ?? throw new ArgumentNullException(nameof(handle));
_release = release ?? throw new ArgumentNullException(nameof(release));
}
/// <summary>Gets the live typed collection handle owned by this lease.</summary>
public CultMeshCollectionHandle<TDocument> Handle { get; }
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
}
/// <summary>
/// Owns discovery and reusable sessions for one application lifetime.
/// Applications address stable identities; CultMesh owns physical routes and reconnection.
/// </summary>
public sealed class CultMeshClient : IDisposable
{
private readonly CultMeshDiscoveryService _discovery;
private readonly CultMeshSessionManager _sessions;
private readonly TimeSpan _subscriptionResponseTimeout;
private readonly TimeSpan _operationResponseTimeout;
private readonly ConcurrentDictionary<string, SharedResource> _documents = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SharedResource> _collections = new(StringComparer.Ordinal);
private bool _disposed;
public CultMeshClient(CultMeshClientOptions options)
{
if (options == null) throw new ArgumentNullException(nameof(options));
if (options.SubscriptionResponseTimeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(options.SubscriptionResponseTimeout));
if (options.OperationResponseTimeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(options.OperationResponseTimeout));
var endpoints = options.RendezvousEndpoints
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (endpoints.Length == 0)
throw new ArgumentException("At least one CultMesh rendezvous endpoint is required.", nameof(options));
var discoveryClient = new CultMeshVerseDiscoveryClient(options.Discovery);
_discovery = new CultMeshDiscoveryService(
endpoints.Select(endpoint => new RendezvousLookupSource(endpoint, discoveryClient, options.Discovery)),
new CultMeshDiscoveryServiceOptions
{
Clock = options.Discovery.Clock,
Diagnostics = options.Discovery.Diagnostics,
Store = options.Discovery.DiscoveryStore
});
_sessions = new CultMeshSessionManager(
_discovery,
options.Connectors ?? new ICultMeshTransportConnector[]
{
new CultMeshTcpSchemaTransportConnector(clock: options.Sessions.Clock)
},
options.ContentConnectors ?? new ICultMeshContentTransportConnector[]
{
new CultMeshTcpContentTransportConnector()
},
options.RealtimeConnectors,
options.Sessions);
_subscriptionResponseTimeout = options.SubscriptionResponseTimeout;
_operationResponseTimeout = options.OperationResponseTimeout;
}
/// <summary>Connects to a stable endpoint identity using one application protocol.</summary>
public Task<CultMeshSession> ConnectAsync(
string endpointId,
CultMeshProtocolId protocol,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
return _sessions.ConnectAsync(CultMeshEndpointId.Parse(endpointId), protocol, cancellationToken);
}
/// <summary>Connects the reusable realtime state plane by stable endpoint identity.</summary>
public Task<CultMeshRealtimeSession> ConnectRealtimeAsync(
string endpointId,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
return _sessions.ConnectRealtimeAsync(CultMeshEndpointId.Parse(endpointId), cancellationToken);
}
/// <summary>Connects to a stable endpoint identity using one application protocol.</summary>
public Task<CultMeshSession> ConnectAsync(
CultMeshEndpointId endpointId,
CultMeshProtocolId protocol,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
return _sessions.ConnectAsync(endpointId, protocol, cancellationToken);
}
/// <summary>Creates a verified-transfer provider over this client's reusable content session owner.</summary>
public CultMeshSessionContentProvider ContentProvider(
string providerId,
string endpointId)
{
ThrowIfDisposed();
return new CultMeshSessionContentProvider(
providerId,
_sessions,
CultMeshEndpointId.Parse(endpointId));
}
/// <summary>Creates a direct body provider over this client's reusable negotiated session owner.</summary>
public CultMeshSessionBodyProvider BodyProvider(
string providerId,
string endpointId,
CultMeshSessionBodyProviderOptions? options = null)
{
ThrowIfDisposed();
return new CultMeshSessionBodyProvider(
providerId,
_sessions,
CultMeshEndpointId.Parse(endpointId),
options);
}
/// <summary>Leases one shared live typed document by stable provider identity and record key.</summary>
public async Task<CultMeshDocumentLease<TDocument>> LeaseDocumentAsync<TDocument>(
string endpointId,
string recordKey,
CancellationToken cancellationToken = default)
where TDocument : class
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(endpointId)) throw new ArgumentException("Endpoint identity is required.", nameof(endpointId));
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Record key is required.", nameof(recordKey));
var key = endpointId.Trim() + "\u001f" + typeof(TDocument).AssemblyQualifiedName + "\u001f" + recordKey.Trim();
while (true)
{
var resource = _documents.GetOrAdd(key, _ => new SharedResource(
async owner => await OpenDocumentAsync<TDocument>(endpointId.Trim(), recordKey.Trim(), owner).ConfigureAwait(false)));
if (!resource.TryAcquire())
{
RemoveExact(_documents, key, resource);
continue;
}
try
{
var binding = (RemoteDocumentBinding<TDocument>)await AwaitForCallerAsync(resource.Value, cancellationToken)
.ConfigureAwait(false);
return new CultMeshDocumentLease<TDocument>(
binding.Handle,
() => Release(_documents, key, resource));
}
catch
{
Release(_documents, key, resource);
throw;
}
}
}
/// <summary>Leases a shared live collection of one typed schema by stable provider identity.</summary>
public Task<CultMeshCollectionLease<TDocument>> LeaseCollectionAsync<TDocument>(
string endpointId,
CancellationToken cancellationToken)
where TDocument : class =>
LeaseCollectionAsync<TDocument>(endpointId, includeInitialSnapshot: true, cancellationToken);
/// <summary>Leases a live collection and optionally skips replaying its initial snapshot.</summary>
public async Task<CultMeshCollectionLease<TDocument>> LeaseCollectionAsync<TDocument>(
string endpointId,
bool includeInitialSnapshot = true,
CancellationToken cancellationToken = default)
where TDocument : class
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(endpointId)) throw new ArgumentException("Endpoint identity is required.", nameof(endpointId));
var identity = endpointId.Trim();
var key = identity + "\u001f" + typeof(TDocument).AssemblyQualifiedName + "\u001f" + includeInitialSnapshot;
while (true)
{
var resource = _collections.GetOrAdd(key, _ => new SharedResource(
async owner => await OpenCollectionAsync<TDocument>(identity, includeInitialSnapshot, owner).ConfigureAwait(false)));
if (!resource.TryAcquire())
{
RemoveExact(_collections, key, resource);
continue;
}
try
{
var binding = (RemoteCollectionBinding<TDocument>)await AwaitForCallerAsync(resource.Value, cancellationToken)
.ConfigureAwait(false);
return new CultMeshCollectionLease<TDocument>(
binding.Handle,
() => Release(_collections, key, resource));
}
catch
{
Release(_collections, key, resource);
throw;
}
}
}
/// <summary>
/// Submits one typed document to a provider by stable identity. Submission is uncommitted input;
/// only a provider-authored receipt or resulting state can establish acceptance.
/// </summary>
public async Task SubmitDocumentAsync<TDocument>(
string endpointId,
string recordKey,
TDocument document,
string sourceRuntimeId,
string sourceRole,
CancellationToken cancellationToken = default)
where TDocument : class
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(endpointId)) throw new ArgumentException("Endpoint identity is required.", nameof(endpointId));
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Record key is required.", nameof(recordKey));
if (document == null) throw new ArgumentNullException(nameof(document));
if (string.IsNullOrWhiteSpace(sourceRuntimeId)) throw new ArgumentException("Source runtime identity is required.", nameof(sourceRuntimeId));
if (string.IsNullOrWhiteSpace(sourceRole)) throw new ArgumentException("Source role is required.", nameof(sourceRole));
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(typeof(TDocument));
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(new[] { typeof(TDocument) }, cacheRegistry);
var message = networkRegistry.CreateRawDocumentPutMessage(
"cultmesh-client-submit-" + Guid.NewGuid().ToString("N"),
new CultRecordHandle<TDocument>(new CultRecordKey(recordKey.Trim())),
document,
new CultNetDocumentMessageOptions
{
SourceRuntimeId = sourceRuntimeId.Trim(),
SourceRole = sourceRole.Trim()
});
var session = await ConnectAsync(endpointId.Trim(), CultMeshProtocols.Documents, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
session.OpenSchemaClient().SendCultNet(message);
}
/// <summary>
/// Invokes one typed provider operation through the reusable identity-owned session.
/// The caller supplies a durable idempotency key; the returned status and payload remain
/// provider-authored evidence rather than client-side authority over canonical state.
/// </summary>
public async Task<CultMeshOperationResult<TResponse>> InvokeAsync<TRequest, TResponse>(
string endpointId,
string serviceId,
string operation,
string requestSchema,
string responseSchema,
TRequest request,
string sourceRuntimeId,
string idempotencyKey,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(endpointId)) throw new ArgumentException("Endpoint identity is required.", nameof(endpointId));
if (string.IsNullOrWhiteSpace(serviceId)) throw new ArgumentException("Service identity is required.", nameof(serviceId));
if (string.IsNullOrWhiteSpace(operation)) throw new ArgumentException("Operation identity is required.", nameof(operation));
if (string.IsNullOrWhiteSpace(requestSchema)) throw new ArgumentException("Request schema is required.", nameof(requestSchema));
if (string.IsNullOrWhiteSpace(responseSchema)) throw new ArgumentException("Response schema is required.", nameof(responseSchema));
if (request is null) throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(sourceRuntimeId)) throw new ArgumentException("Source runtime identity is required.", nameof(sourceRuntimeId));
if (string.IsNullOrWhiteSpace(idempotencyKey)) throw new ArgumentException("A durable idempotency key is required.", nameof(idempotencyKey));
var identity = endpointId.Trim();
var expectedService = serviceId.Trim();
var expectedOperation = operation.Trim();
var expectedResponseSchema = responseSchema.Trim();
var messageId = idempotencyKey.Trim();
var session = await ConnectAsync(identity, CultMeshProtocols.Documents, cancellationToken).ConfigureAwait(false);
var completion = new TaskCompletionSource<CultNetOperationResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
using var subscription = session.OnCultNet<CultNetOperationResponseMessage>(response =>
{
if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal))
completion.TrySetResult(response);
});
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_operationResponseTimeout);
using var cancellation = timeout.Token.Register(() => completion.TrySetCanceled(timeout.Token));
session.SendCultNet(new CultNetOperationRequestMessage
{
MessageId = messageId,
ServiceId = expectedService,
Operation = expectedOperation,
PayloadSchema = requestSchema.Trim(),
PayloadEncoding = "messagepack-base64",
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(request, CultNetSchemaMessageSerialization.Options)),
SourceRuntimeId = sourceRuntimeId.Trim()
});
CultNetOperationResponseMessage envelope;
try
{
envelope = await completion.Task.ConfigureAwait(false);
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException(
$"CultMesh operation '{expectedService}/{expectedOperation}' did not answer idempotency key '{messageId}' " +
$"within {_operationResponseTimeout}.");
}
cancellationToken.ThrowIfCancellationRequested();
if (!string.Equals(envelope.ServiceId, expectedService, StringComparison.Ordinal) ||
!string.Equals(envelope.Operation, expectedOperation, StringComparison.Ordinal))
throw new InvalidOperationException("CultMesh operation response did not match the requested service and operation.");
if (string.Equals(envelope.PayloadSchema, CultNetOperationServer.FailureSchemaId, StringComparison.Ordinal))
{
if (!string.Equals(envelope.PayloadEncoding, "messagepack-base64", StringComparison.Ordinal))
throw new InvalidOperationException(
$"CultMesh operation failure returned unsupported payload encoding '{envelope.PayloadEncoding}'.");
CultNetOperationFailure failure;
try
{
failure = MessagePackSerializer.Deserialize<CultNetOperationFailure>(
Convert.FromBase64String(envelope.Payload),
CultNetSchemaMessageSerialization.Options);
}
catch (Exception error) when (error is FormatException || error is MessagePackSerializationException)
{
throw new InvalidOperationException("CultMesh operation failure payload was malformed.", error);
}
throw new CultMeshRemoteOperationException(envelope, failure);
}
if (!string.Equals(envelope.PayloadSchema, expectedResponseSchema, StringComparison.Ordinal))
throw new InvalidOperationException(
$"CultMesh operation returned payload schema '{envelope.PayloadSchema}', expected '{expectedResponseSchema}'.");
if (!string.Equals(envelope.PayloadEncoding, "messagepack-base64", StringComparison.Ordinal))
throw new InvalidOperationException(
$"CultMesh operation returned unsupported payload encoding '{envelope.PayloadEncoding}'.");
if (string.IsNullOrWhiteSpace(envelope.Status))
throw new InvalidOperationException("CultMesh operation response omitted its provider-authored status.");
TResponse value;
try
{
value = MessagePackSerializer.Deserialize<TResponse>(
Convert.FromBase64String(envelope.Payload),
CultNetSchemaMessageSerialization.Options);
}
catch (FormatException error)
{
throw new InvalidOperationException("CultMesh operation response payload was not valid base64.", error);
}
if (value is null) throw new InvalidOperationException("CultMesh operation response decoded to null.");
return new CultMeshOperationResult<TResponse>(envelope, value);
}
/// <summary>Gets the number of currently leased shared document subscriptions.</summary>
public int ActiveDocumentResourceCount => _documents.Count;
/// <summary>Gets the number of currently leased shared collection subscriptions.</summary>
public int ActiveCollectionResourceCount => _collections.Count;
/// <summary>Reads one typed record once through the reusable document session.</summary>
public async Task<TDocument> ReadAsync<TDocument>(
string endpointId,
string recordKey,
CancellationToken cancellationToken = default)
where TDocument : class
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(endpointId)) throw new ArgumentException("Endpoint identity is required.", nameof(endpointId));
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Record key is required.", nameof(recordKey));
var documents = await ReadManyAsync<TDocument>(endpointId, new[] { recordKey }, cancellationToken)
.ConfigureAwait(false);
return documents.FirstOrDefault()
?? throw new InvalidOperationException(
$"CultMesh endpoint '{endpointId}' did not publish {typeof(TDocument).FullName} record '{recordKey}'.");
}
/// <summary>Reads one typed record once with an explicit response deadline.</summary>
public async Task<TDocument> ReadAsync<TDocument>(
string endpointId,
string recordKey,
TimeSpan responseTimeout,
CancellationToken cancellationToken = default)
where TDocument : class
{
var documents = await ReadManyCoreAsync<TDocument>(
endpointId,
new[] { recordKey },
responseTimeout,
cancellationToken)
.ConfigureAwait(false);
return documents.FirstOrDefault()
?? throw new InvalidOperationException(
$"CultMesh endpoint '{endpointId}' did not publish {typeof(TDocument).FullName} record '{recordKey}'.");
}
/// <summary>Reads typed records once in one request through the reusable document session.</summary>
public async Task<IReadOnlyList<TDocument>> ReadManyAsync<TDocument>(
string endpointId,
IReadOnlyList<string> recordKeys,
CancellationToken cancellationToken = default)
where TDocument : class
=> await ReadManyCoreAsync<TDocument>(
endpointId,
recordKeys,
TimeSpan.FromSeconds(10),
cancellationToken)
.ConfigureAwait(false);
private async Task<IReadOnlyList<TDocument>> ReadManyCoreAsync<TDocument>(
string endpointId,
IReadOnlyList<string> recordKeys,
TimeSpan responseTimeout,
CancellationToken cancellationToken)
where TDocument : class
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(endpointId)) throw new ArgumentException("Endpoint identity is required.", nameof(endpointId));
if (recordKeys == null || recordKeys.Count == 0 || recordKeys.Any(string.IsNullOrWhiteSpace))
throw new ArgumentException("At least one non-empty record key is required.", nameof(recordKeys));
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(typeof(TDocument));
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(new[] { typeof(TDocument) }, cacheRegistry);
using var snapshot = await CultMeshSnapshotSession.ConnectAsync(
_sessions,
CultMeshEndpointId.Parse(endpointId),
CultMeshProtocols.Content,
new CultMeshSnapshotRequestOptions
{
ConnectTimeout = TimeSpan.FromSeconds(5),
ResponseTimeout = responseTimeout,
MessageIdPrefix = "cultmesh-client-read"
},
networkRegistry,
cancellationToken)
.ConfigureAwait(false);
return await snapshot.FetchDocumentsAsync<TDocument>(
recordKeys: recordKeys,
schemaIds: new[] { cacheRegistry.GetRequired<TDocument>().SchemaId })
.ConfigureAwait(false);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var resource in _documents.Values.Concat(_collections.Values).Distinct()) resource.Dispose();
_documents.Clear();
_collections.Clear();
_sessions.Dispose();
_discovery.Dispose();
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(CultMeshClient));
}
private async Task<object> OpenDocumentAsync<TDocument>(
string endpointId,
string recordKey,
SharedResource owner)
where TDocument : class
{
var session = await ConnectAsync(endpointId, CultMeshProtocols.Documents).ConfigureAwait(false);
var binding = new RemoteDocumentBinding<TDocument>(session, endpointId, recordKey, _subscriptionResponseTimeout);
owner.Attach(binding);
try
{
await binding.StartAsync().ConfigureAwait(false);
return binding;
}
catch
{
owner.Detach(binding);
binding.Dispose();
throw;
}
}
private async Task<object> OpenCollectionAsync<TDocument>(
string endpointId,
bool includeInitialSnapshot,
SharedResource owner)
where TDocument : class
{
var session = await ConnectAsync(endpointId, CultMeshProtocols.Documents).ConfigureAwait(false);
var binding = new RemoteCollectionBinding<TDocument>(session, endpointId, includeInitialSnapshot, _subscriptionResponseTimeout);
owner.Attach(binding);
try
{
await binding.StartAsync().ConfigureAwait(false);
return binding;
}
catch
{
owner.Detach(binding);
binding.Dispose();
throw;
}
}
private static async Task<T> AwaitForCallerAsync<T>(Task<T> shared, CancellationToken cancellationToken)
{
if (!cancellationToken.CanBeCanceled) return await shared.ConfigureAwait(false);
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using (cancellationToken.Register(() => cancelled.TrySetResult(true)))
{
if (await Task.WhenAny(shared, cancelled.Task).ConfigureAwait(false) != shared)
throw new OperationCanceledException(cancellationToken);
}
return await shared.ConfigureAwait(false);
}
private static void Release(
ConcurrentDictionary<string, SharedResource> resources,
string key,
SharedResource resource)
{
if (!resource.Release()) return;
RemoveExact(resources, key, resource);
resource.Dispose();
}
private static void RemoveExact(
ConcurrentDictionary<string, SharedResource> resources,
string key,
SharedResource resource)
{
((ICollection<KeyValuePair<string, SharedResource>>)resources)
.Remove(new KeyValuePair<string, SharedResource>(key, resource));
}
private sealed class SharedResource : IDisposable
{
private readonly object _gate = new();
private readonly Lazy<Task<object>> _value;
private IDisposable? _attached;
private int _leases;
private bool _retired;
public SharedResource(Func<SharedResource, Task<object>> open)
{
if (open == null) throw new ArgumentNullException(nameof(open));
_value = new Lazy<Task<object>>(() => open(this), LazyThreadSafetyMode.ExecutionAndPublication);
}
public Task<object> Value => _value.Value;
public bool TryAcquire()
{
lock (_gate)
{
if (_retired) return false;
_leases++;
return true;
}
}
public bool Release()
{
lock (_gate)
{
if (_leases <= 0) return false;
_leases--;
if (_leases != 0) return false;
_retired = true;
return true;
}
}
public void Attach(IDisposable resource)
{
if (resource == null) throw new ArgumentNullException(nameof(resource));
lock (_gate)
{
if (_retired)
{
resource.Dispose();
throw new ObjectDisposedException(nameof(SharedResource));
}
if (_attached != null) throw new InvalidOperationException("CultMesh shared resource already has a live binding.");
_attached = resource;
}
}
public void Detach(IDisposable resource)
{
lock (_gate)
{
if (ReferenceEquals(_attached, resource)) _attached = null;
}
}
public void Dispose()
{
IDisposable? attached;
lock (_gate)
{
_retired = true;
attached = _attached;
_attached = null;
}
attached?.Dispose();
}
}
private sealed class RemoteDocumentBinding<TDocument> : IDisposable where TDocument : class
{
private readonly CultMeshSession _session;
private readonly string _recordKey;
private readonly string _subscriptionId;
private readonly CultCache _cache;
private readonly CultNetDatabaseSubscriptionClient _subscription;
private readonly TimeSpan _responseTimeout;
private readonly CancellationTokenSource _lifetime = new();
private readonly TaskCompletionSource<bool> _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly object _subscribeLock = new();
private Task? _subscribeTask;
private long _subscribedGeneration = -1;
private IDisposable? _stateWatch;
private bool _disposed;
public RemoteDocumentBinding(
CultMeshSession session,
string endpointId,
string recordKey,
TimeSpan responseTimeout)
{
_session = session;
_responseTimeout = responseTimeout;
_recordKey = recordKey;
_subscriptionId = "cultmesh-document:" + endpointId + ":" + recordKey;
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(typeof(TDocument));
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(new[] { typeof(TDocument) }, cacheRegistry);
_cache = new CultCache(cacheRegistry);
_subscription = new CultNetDatabaseSubscriptionClient(session.OpenSchemaClient(), _cache, networkRegistry);
Handle = CultMesh.Document<TDocument>(
_cache,
new CultRecordKey(recordKey),
CultMesh.Verse(endpointId, "cultmesh-client").Context,
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, endpointId));
}
public CultMeshDocumentHandle<TDocument> Handle { get; }
public async Task StartAsync()
{
_stateWatch = _session.WatchState()
.Where(state => state.Status == CultMeshSessionStatus.Online)
.Subscribe(state => { _ = EnsureSubscribedAsync(); });
_ = EnsureSubscribedAsync();
await _ready.Task.ConfigureAwait(false);
}
private Task EnsureSubscribedAsync()
{
lock (_subscribeLock)
{
var generation = _session.PhysicalGeneration;
if (_subscribedGeneration == generation && _subscribeTask != null) return _subscribeTask;
_subscribedGeneration = generation;
return _subscribeTask = SubscribeAsync();
}
}
private async Task SubscribeAsync()
{
try
{
while (!_disposed)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
deadline.CancelAfter(_responseTimeout);
try
{
await _subscription.SubscribeAsync(
_subscriptionId,
recordKeys: new[] { _recordKey },
schemaIds: new[] { CultDocumentRegistry.Shared.GetRequired<TDocument>().SchemaId },
cancellationToken: deadline.Token)
.ConfigureAwait(false);
_ready.TrySetResult(true);
return;
}
catch (OperationCanceledException) when (!_lifetime.IsCancellationRequested)
{
}
}
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
}
catch (ObjectDisposedException) when (_disposed)
{
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_lifetime.Cancel();
_stateWatch?.Dispose();
_subscription.Dispose();
_ready.TrySetException(new ObjectDisposedException(GetType().FullName));
_cache.Dispose();
_lifetime.Dispose();
}
}
private sealed class RemoteCollectionBinding<TDocument> : IDisposable where TDocument : class
{
private readonly CultMeshSession _session;
private readonly string _subscriptionId;
private readonly CultCache _cache;
private readonly CultNetDatabaseSubscriptionClient _subscription;
private readonly TimeSpan _responseTimeout;
private readonly CancellationTokenSource _lifetime = new();
private readonly bool _includeInitialSnapshot;
private readonly TaskCompletionSource<bool> _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly object _subscribeLock = new();
private Task? _subscribeTask;
private long _subscribedGeneration = -1;
private IDisposable? _stateWatch;
private bool _disposed;
public RemoteCollectionBinding(
CultMeshSession session,
string endpointId,
bool includeInitialSnapshot,
TimeSpan responseTimeout)
{
_session = session;
_responseTimeout = responseTimeout;
_subscriptionId = "cultmesh-collection:" + endpointId + ":" + typeof(TDocument).FullName;
_includeInitialSnapshot = includeInitialSnapshot;
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(typeof(TDocument));
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(new[] { typeof(TDocument) }, cacheRegistry);
_cache = new CultCache(cacheRegistry);
_subscription = new CultNetDatabaseSubscriptionClient(session.OpenSchemaClient(), _cache, networkRegistry);
Handle = CultMesh.Collection<TDocument>(
_cache,
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, endpointId));
}
public CultMeshCollectionHandle<TDocument> Handle { get; }
public async Task StartAsync()
{
_stateWatch = _session.WatchState()
.Where(state => state.Status == CultMeshSessionStatus.Online)
.Subscribe(state => { _ = EnsureSubscribedAsync(); });
_ = EnsureSubscribedAsync();
await _ready.Task.ConfigureAwait(false);
}
private Task EnsureSubscribedAsync()
{
lock (_subscribeLock)
{
var generation = _session.PhysicalGeneration;
if (_subscribedGeneration == generation && _subscribeTask != null) return _subscribeTask;
_subscribedGeneration = generation;
return _subscribeTask = SubscribeAsync();
}
}
private async Task SubscribeAsync()
{
try
{
while (!_disposed)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
deadline.CancelAfter(_responseTimeout);
try
{
await _subscription.SubscribeAsync(
_subscriptionId,
schemaIds: new[] { CultDocumentRegistry.Shared.GetRequired<TDocument>().SchemaId },
includeSnapshot: _includeInitialSnapshot,
cancellationToken: deadline.Token)
.ConfigureAwait(false);
_ready.TrySetResult(true);
return;
}
catch (OperationCanceledException) when (!_lifetime.IsCancellationRequested)
{
}
}
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
}
catch (ObjectDisposedException) when (_disposed)
{
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_lifetime.Cancel();
_stateWatch?.Dispose();
_subscription.Dispose();
_ready.TrySetException(new ObjectDisposedException(GetType().FullName));
_cache.Dispose();
_lifetime.Dispose();
}
}
private sealed class RendezvousLookupSource : ICultMeshLookupSource
{
private readonly string _endpoint;
private readonly CultMeshVerseDiscoveryClient _client;
private readonly CultMeshVerseDiscoveryClientOptions _options;
public RendezvousLookupSource(
string endpoint,
CultMeshVerseDiscoveryClient client,
CultMeshVerseDiscoveryClientOptions options)
{
_endpoint = endpoint;
_client = client;
_options = options;
}
public string SourceId => "rendezvous:" + _endpoint;
public async Task<IReadOnlyList<CultMeshDiscoveryObservation>> LookupAsync(
CultMeshDiscoveryQuery query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var response = await _client.FetchAsync(
_endpoint,
new CultMeshVerseCatalogRequestMessage
{
VerseIds = query.VerseIds.Count == 0 ? null : query.VerseIds.ToArray(),
TransportVersion = _options.TransportVersion
}).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var observedAt = _options.Clock.UtcNow;
return response.Verses.Select(message => new CultMeshDiscoveryObservation(
message.ToVerseDescriptor(),
SourceId,
observedAt,
observedAt + _options.ObservationTtl,
CultMeshDiscoveryTrust.Unsigned,
response.SchemaVersion)).ToArray();
}
}
}
}