using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
namespace GameCult.Networking
{
/// Controls whether subscription payloads become replica state or remain ephemeral typed events.
public enum CultNetDatabaseSubscriptionDeliveryMode
{
/// Decode each payload and write it into the local CultCache before notifying observers.
ReplicateToCache,
/// Decode each payload and notify observers without writing replica state.
Live
}
///
/// Replicates selected remote database records into a local typed CultCache over one retained session.
///
public sealed class CultNetDatabaseSubscriptionClient : IDisposable
{
private readonly ICultNetSchemaClient _client;
private readonly CultCache _cache;
private readonly CultNetDocumentRegistry _documents;
private readonly ConcurrentDictionary _initialSnapshots = new();
private readonly ConcurrentDictionary _deliveryModes =
new(StringComparer.Ordinal);
private readonly object _queueLock = new();
private Task _queue = Task.CompletedTask;
private bool _disposed;
public CultNetDatabaseSubscriptionClient(
ICultNetSchemaClient client,
CultCache cache,
CultNetDocumentRegistry documents)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_documents = documents ?? throw new ArgumentNullException(nameof(documents));
_client.OnCultNet(HandleSnapshot);
_client.OnCultNet(HandleChange);
}
/// Raised after a live change has been applied to the local typed cache.
public event Action? Changed;
/// Gets the local reactive cache receiving replicated documents.
public CultCache Cache => _cache;
/// Completes if the retained transport pump terminates with an unrecoverable error.
public Task? BackgroundFailure =>
(_client as RudpCultNetSchemaClient)?.BackgroundFailure;
///
/// Subscribes to one exact typed record as an ephemeral reactive value. The returned value
/// owns filtering and unsubscribe; no received payload is written into the local cache.
///
public async Task> SubscribeLiveValueAsync(
string subscriptionId,
string recordKey,
CancellationToken cancellationToken = default)
where TDocument : class
{
if (string.IsNullOrWhiteSpace(recordKey))
throw new ArgumentException("Record key is required.", nameof(recordKey));
var schemaId = _documents.GetByDocumentType(typeof(TDocument))?.SchemaId ??
_cache.Registry.GetRequired().SchemaId;
var value = new CultNetLiveValue(this, subscriptionId, recordKey);
try
{
var initial = await SubscribeAsync(
subscriptionId,
recordKeys: new[] { recordKey },
schemaIds: new[] { schemaId },
deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
var documents = initial.OfType().ToArray();
if (documents.Length > 1)
throw new InvalidOperationException(
$"Live value subscription '{subscriptionId}' expected at most one '{schemaId}' record '{recordKey}' but received {documents.Length}.");
if (documents.Length == 1)
value.Initialize(documents[0]);
return value;
}
catch
{
value.Dispose();
throw;
}
}
///
/// Starts a remote subscription and waits until its initial matching snapshot is in the local cache.
///
public Task> SubscribeAsync(
string subscriptionId,
IEnumerable? recordKeys = null,
IEnumerable? schemaIds = null,
bool includeSnapshot = true,
CancellationToken cancellationToken = default,
string? consumerRuntimeId = null,
IEnumerable? bodyIds = null,
IEnumerable? supportedBodyTransports = null,
CultNetDatabaseSubscriptionDeliveryMode deliveryMode = CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache)
{
if (_disposed) throw new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient));
if (!_client.Connected) throw new InvalidOperationException("CultNet schema client must be connected before subscribing.");
if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription id is required.", nameof(subscriptionId));
var messageId = Guid.NewGuid().ToString("N");
var completion = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!_initialSnapshots.TryAdd(messageId, new PendingSubscription(subscriptionId, deliveryMode, completion)))
throw new InvalidOperationException($"Duplicate database subscription message id '{messageId}'.");
_deliveryModes[subscriptionId] = deliveryMode;
CancellationTokenRegistration cancellation = default;
if (cancellationToken.CanBeCanceled)
{
cancellation = cancellationToken.Register(() =>
{
if (_initialSnapshots.TryRemove(messageId, out var pending))
pending.Completion.TrySetCanceled(cancellationToken);
});
_ = completion.Task.ContinueWith(
_ => cancellation.Dispose(),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
try
{
_client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = messageId,
SubscriptionId = subscriptionId,
RecordKeys = recordKeys?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray(),
SchemaIds = schemaIds?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray(),
IncludeSnapshot = includeSnapshot,
ConsumerRuntimeId = consumerRuntimeId,
BodyIds = bodyIds?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray(),
SupportedBodyTransports = supportedBodyTransports?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray()
});
}
catch
{
_deliveryModes.TryRemove(subscriptionId, out _);
if (_initialSnapshots.TryRemove(messageId, out var pending))
pending.Completion.TrySetException(new InvalidOperationException("CultNet subscription request could not be sent."));
throw;
}
return completion.Task;
}
/// Stops one live remote subscription.
public void Unsubscribe(string subscriptionId)
{
if (_disposed) return;
if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription id is required.", nameof(subscriptionId));
_deliveryModes.TryRemove(subscriptionId, out _);
_client.SendCultNet(new CultNetDatabaseUnsubscribeMessage
{
MessageId = Guid.NewGuid().ToString("N"),
SubscriptionId = subscriptionId
});
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var pending in _initialSnapshots.Values)
pending.Completion.TrySetException(new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient)));
_initialSnapshots.Clear();
_deliveryModes.Clear();
_client.Dispose();
}
private void HandleSnapshot(CultNetSnapshotResponseRawMessage message)
{
Enqueue(async () =>
{
if (!_initialSnapshots.TryRemove(message.MessageId, out var pending)) return;
try
{
IReadOnlyList