using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using game.Account; namespace Core.KV { public enum KVSessionOpenError { None, Validation, Unauthenticated, Busy, RecoveryRequired, StorageError, ReadFailed, Unauthorized, Stale, Cancelled } public sealed class KvSessionOpenResult { public bool Success => Error == KVSessionOpenError.None; public KVSessionOpenError Error { get; } public KvSessionOwner Owner { get; } internal KvSessionOpenResult(KVSessionOpenError error, KvSessionOwner owner = null) { Error = error; Owner = owner; } } /// Owns one immutable account/epoch/cache/transport tuple. public sealed class KvSessionOwner { private sealed class OperationLease : IDisposable { private KvSessionOwner owner; internal OperationLease(KvSessionOwner owner) { this.owner = owner; } public void Dispose() { Interlocked.Exchange(ref owner, null)?.ExitOperation(); } } private readonly object admission = new object(); private readonly GameServerAccountSession session; private readonly CookieContainer cookies; private readonly CancellationTokenSource lifetime; private readonly Func isCurrent; private readonly KvSyncState state; private readonly KvSyncCoordinator coordinator; private readonly TaskCompletionSource drained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); private readonly string ownerId = Guid.NewGuid().ToString("N"); private int activeOperations; private bool closing, closed; private TaskCompletionSource closeCompletion; public string OwnerInstanceId => ownerId; public Uri ApiBaseUri { get; } public string PlayerId { get; } public long Epoch { get; } public KVSyncStatus Status => state.Status; private KvSessionOwner(GameServerAccountSession session, CookieContainer cookies, Uri api, string playerId, long epoch, Func isCurrent, CancellationTokenSource lifetime, KvSyncState state, HttpClient http) { this.session = session; this.cookies = cookies; ApiBaseUri = api; PlayerId = playerId; Epoch = epoch; this.isCurrent = isCurrent; this.lifetime = lifetime; this.state = state; coordinator = new KvSyncCoordinator(state, new KvClient(http, api), http, IsCapturedOwnerCurrent, lifetime.Token); } internal static async Task CreateAsync(GameServerAccountSession session, string cacheRoot, string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func isCurrent, CancellationToken lifetimeToken, Func handlerFactory) { if (session == null || expectedApiBaseUri == null || isCurrent == null || handlerFactory == null || epoch < 0 || !long.TryParse(expectedPlayerId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0) { return new KvSessionOpenResult(KVSessionOpenError.Validation); } var playerId = numericId.ToString(CultureInfo.InvariantCulture); Uri expectedApi; try { expectedApi = NormalizeApi(expectedApiBaseUri); } catch (ArgumentException) { return new KvSessionOpenResult(KVSessionOpenError.Validation); } if (lifetimeToken.IsCancellationRequested) { return new KvSessionOpenResult(KVSessionOpenError.Cancelled); } if (!session.IsAuthenticated || session.Cookies == null) { return new KvSessionOpenResult(KVSessionOpenError.Unauthenticated); } var capturedCookies = session.Cookies; var capturedApi = NormalizeApi(session.ApiBaseUri); if (session.PlayerId != playerId || capturedApi != expectedApi || !isCurrent(epoch)) { return new KvSessionOpenResult(KVSessionOpenError.Stale); } var opened = KvSyncState.Open(cacheRoot, capturedApi, playerId); if (!opened.Success) { return new KvSessionOpenResult(MapOpen(opened.Error)); } HttpClient http = null; CancellationTokenSource lifetime = null; KvSessionOwner owner = null; try { var handler = handlerFactory(capturedCookies); if (handler == null) { throw new ArgumentException("Transport factory returned null."); } http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) }; lifetime = CancellationTokenSource.CreateLinkedTokenSource(lifetimeToken); owner = new KvSessionOwner(session, capturedCookies, capturedApi, playerId, epoch, isCurrent, lifetime, opened.State, http); http = null; lifetime = null; KvResult read; using (var lease = owner.TryEnter()) { if (lease == null) { return await owner.FailCreate(KVSessionOpenError.Cancelled); } read = await owner.coordinator.InitializeAsync(); } if (!read.Success) { return await owner.FailCreate(read.Error == KVError.Unauthorized ? KVSessionOpenError.Unauthorized : read.Error == KVError.Cancelled ? KVSessionOpenError.Cancelled : KVSessionOpenError.ReadFailed); } if (!owner.IsCapturedOwnerCurrent()) { return await owner.FailCreate(KVSessionOpenError.Stale); } return new KvSessionOpenResult(KVSessionOpenError.None, owner: owner); } catch (OperationCanceledException) { if (owner != null) { return await owner.FailCreate(KVSessionOpenError.Cancelled); } opened.State.Dispose(); return new KvSessionOpenResult(KVSessionOpenError.Cancelled); } catch (Exception) { if (owner != null) { return await owner.FailCreate(KVSessionOpenError.Validation); } http?.Dispose(); lifetime?.Dispose(); opened.State.Dispose(); return new KvSessionOpenResult(KVSessionOpenError.Validation); } } /// Reads the existing state under an operation lease; does not open another cache. public KvLocalReadResult ReadLocalSnapshot() { using (var lease = TryEnter()) { if (lease == null) { return ReadFailure(KVLocalReadError.StaleOrClosing); } try { var data = state.ReadLocal(); lock (admission) { if (!IsCapturedOwnerCurrent() || closing || closed) { return ReadFailure(KVLocalReadError.StaleOrClosing); } return new KvLocalReadResult(KVLocalReadError.None, data); } } catch (InvalidOperationException) { lock (admission) return ReadFailure(!IsCapturedOwnerCurrent() || closing || closed ? KVLocalReadError.StaleOrClosing : KVLocalReadError.NotReady); } } } private KvLocalReadResult ReadFailure(KVLocalReadError error) => new KvLocalReadResult(error); internal KvStateResult CompareDatas(long nowTicks) { using (var lease = TryEnter()) { return lease == null ? new KvStateResult(KVChangeError.Blocked) : coordinator.CompareDatas(nowTicks); } } internal KvStateResult Update(string key, string value, bool remoteStore, long updateTicks) { using (var lease = TryEnter()) { return lease == null ? ClosedEdit() : coordinator.Update(key, value, remoteStore, updateTicks); } } public Task SyncDataAsync() { var lease = TryEnter(); if (lease == null) { return Task.FromResult(new KvFlushResult(1, 0, KVError.Cancelled)); } return RunOrdinarySyncAsync(lease); } private async Task RunOrdinarySyncAsync(OperationLease lease) { using (lease) { try { return await coordinator.SyncDataAsync(); } catch (Exception) { return new KvFlushResult(1, 0, KVError.Protocol); } } } public async Task SyncDataImmidiate(string key, string value) { using (var lease = TryEnter()) { if (lease == null) { return false; } return await coordinator.SyncDataImmidiate(key, value); } } internal async Task SyncDataImmidiate(IReadOnlyDictionary values) { using (var lease = TryEnter()) { if (lease == null) { return false; } return await coordinator.SyncDataImmidiate(values); } } private OperationLease TryEnter() { lock (admission) { if (closing || closed || !IsCapturedOwnerCurrent()) { return null; } activeOperations++; return new OperationLease(this); } } private void ExitOperation() { lock (admission) { activeOperations--; if (closing && activeOperations == 0) { drained.TrySetResult(true); } } } public Task CloseAsync() { TaskCompletionSource completion; lock (admission) { if (closeCompletion != null) { return closeCompletion.Task; } closing = true; completion = closeCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (activeOperations == 0) { drained.TrySetResult(true); } } _ = CloseCoreAsync(completion); return completion.Task; } private async Task CloseCoreAsync(TaskCompletionSource completion) { try { lifetime.Cancel(); coordinator.CloseTickets(); await drained.Task; coordinator.Dispose(); state.Dispose(); lifetime.Dispose(); lock (admission) closed = true; completion.TrySetResult(true); } catch (Exception ex) { completion.TrySetException(ex); } } private async Task FailCreate(KVSessionOpenError error) { await CloseAsync(); return new KvSessionOpenResult(error); } private bool IsCapturedOwnerCurrent() { if (lifetime.IsCancellationRequested || !isCurrent(Epoch) || state.AuthenticationInvalid) { return false; } return session.IsAuthenticated && ReferenceEquals(session.Cookies, cookies) && session.PlayerId == PlayerId && NormalizeApi(session.ApiBaseUri) == ApiBaseUri; } private static Uri NormalizeApi(Uri api) { if (api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") || api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0) { throw new ArgumentException("Invalid API base URI."); } return new Uri(api.AbsoluteUri.TrimEnd('/') + "/"); } private static KVSessionOpenError MapOpen(KVOpenError error) { if (error == KVOpenError.Busy) { return KVSessionOpenError.Busy; } if (error == KVOpenError.RecoveryRequired) { return KVSessionOpenError.RecoveryRequired; } if (error == KVOpenError.StorageError) { return KVSessionOpenError.StorageError; } return KVSessionOpenError.Validation; } private KvStateResult ClosedEdit() { return new KvStateResult(KVChangeError.Blocked); } } public enum KVLocalReadError { None, StaleOrClosing, NotReady } /// A detached snapshot. Failed reads have no data, never a pretend empty save. public sealed class KvLocalReadResult { public bool Success => Error == KVLocalReadError.None; public KVLocalReadError Error { get; } public IReadOnlyDictionary Data { get; } internal KvLocalReadResult(KVLocalReadError error, IReadOnlyDictionary data = null) { Error = error; if (error != KVLocalReadError.None) { return; } if (data == null) { throw new ArgumentNullException(nameof(data)); } var copy = new Dictionary(StringComparer.Ordinal); foreach (var item in data) { copy.Add(item.Key, item.Value); } Data = new ReadOnlyDictionary(copy); } } }