refactor(kv): 整理存档接线并清理迁移冗余实现
This commit is contained in:
76
Client/Assets/Scripts/Core/KV/IPlayerSaveStore.cs
Normal file
76
Client/Assets/Scripts/Core/KV/IPlayerSaveStore.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Core.KV
|
||||
{
|
||||
// A store belongs to one login. Callers retain this instance across awaits.
|
||||
public interface IPlayerSaveStore
|
||||
{
|
||||
bool IsCurrent
|
||||
{
|
||||
get;
|
||||
}
|
||||
PlayerSaveReadResult Read(string key);
|
||||
PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true);
|
||||
Task<bool> SyncDataImmidiate(string key, string value);
|
||||
}
|
||||
|
||||
public sealed class PlayerSaveReadResult
|
||||
{
|
||||
public bool Success => Error == null;
|
||||
public bool? Found
|
||||
{
|
||||
get;
|
||||
}
|
||||
public string Value
|
||||
{
|
||||
get;
|
||||
}
|
||||
public string Error
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal PlayerSaveReadResult(bool found, string value)
|
||||
{
|
||||
Found = found;
|
||||
Value = value;
|
||||
}
|
||||
internal PlayerSaveReadResult(string error)
|
||||
{
|
||||
Error = error ?? "ReadFailed";
|
||||
}
|
||||
public string ValueOrThrow()
|
||||
{
|
||||
if (!Success)
|
||||
{
|
||||
throw new InvalidOperationException("Player save read failed: " + Error);
|
||||
}
|
||||
return Value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlayerSaveWriteResult
|
||||
{
|
||||
public bool Accepted => Error == null;
|
||||
public string Error
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal PlayerSaveWriteResult()
|
||||
{
|
||||
}
|
||||
internal PlayerSaveWriteResult(string error)
|
||||
{
|
||||
Error = error ?? "WriteRejected";
|
||||
}
|
||||
public void RequireAccepted()
|
||||
{
|
||||
if (!Accepted)
|
||||
{
|
||||
throw new InvalidOperationException("Player save write rejected: " + Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
11
Client/Assets/Scripts/Core/KV/IPlayerSaveStore.cs.meta
Normal file
11
Client/Assets/Scripts/Core/KV/IPlayerSaveStore.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f7a9c9b2dab8e5a4ca300f0bcf6637e8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
132
Client/Assets/Scripts/Core/KV/KvCacheStore.cs
Normal file
132
Client/Assets/Scripts/Core/KV/KvCacheStore.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Core.KV
|
||||
{
|
||||
internal sealed class KvCacheStore : IDisposable
|
||||
{
|
||||
private const long MaxBytes = 64L * 1024 * 1024;
|
||||
private readonly KvIdentity identity;
|
||||
private readonly FileStream lease;
|
||||
private readonly string main, temporary, backup;
|
||||
private bool disposed;
|
||||
|
||||
private KvCacheStore(KvIdentity identity, FileStream lease)
|
||||
{
|
||||
this.identity = identity;
|
||||
this.lease = lease;
|
||||
main = Path.Combine(identity.DirectoryPath, "state.json");
|
||||
temporary = main + ".tmp";
|
||||
backup = main + ".bak";
|
||||
}
|
||||
|
||||
internal static KVOpenError Open(KvIdentity identity,
|
||||
out KvCacheStore store, out KvSnapshot snapshot)
|
||||
{
|
||||
store = null;
|
||||
snapshot = null;
|
||||
FileStream lease = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(identity.DirectoryPath);
|
||||
try
|
||||
{
|
||||
lease = new FileStream(Path.Combine(identity.DirectoryPath, "state.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
|
||||
}
|
||||
catch (IOException ex) when ((ex.HResult & 0xffff) == 32 || (ex.HResult & 0xffff) == 33)
|
||||
{
|
||||
return KVOpenError.Busy;
|
||||
}
|
||||
store = new KvCacheStore(identity, lease);
|
||||
if (!File.Exists(store.main))
|
||||
{
|
||||
if (File.Exists(store.backup) || File.Exists(store.temporary))
|
||||
{
|
||||
throw KvSnapshot.Invalid();
|
||||
}
|
||||
snapshot = new KvSnapshot();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (new FileInfo(store.main).Length > MaxBytes)
|
||||
{
|
||||
throw KvSnapshot.Invalid();
|
||||
}
|
||||
snapshot = KvSnapshot.Decode(File.ReadAllText(store.main, new UTF8Encoding(false, true)), identity);
|
||||
}
|
||||
return KVOpenError.None;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException || ex is OverflowException || ex is FormatException || ex is DecoderFallbackException)
|
||||
{
|
||||
store?.Dispose();
|
||||
lease?.Dispose();
|
||||
store = null;
|
||||
return KVOpenError.RecoveryRequired;
|
||||
}
|
||||
catch (Exception ex) when (DiskError(ex))
|
||||
{
|
||||
store?.Dispose();
|
||||
lease?.Dispose();
|
||||
store = null;
|
||||
return KVOpenError.StorageError;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool Save(KvSnapshot snapshot, out bool mayHaveCommitted)
|
||||
{
|
||||
mayHaveCommitted = false;
|
||||
try
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
// Preserve the existing diagnostic ObjectName when renaming the implementation.
|
||||
throw new ObjectDisposedException("PlayerKVCacheStore");
|
||||
}
|
||||
snapshot.Validate();
|
||||
var bytes = new UTF8Encoding(false, true).GetBytes(snapshot.Encode(identity));
|
||||
if (bytes.LongLength > MaxBytes)
|
||||
{
|
||||
throw new IOException("KV cache capacity exceeded.");
|
||||
}
|
||||
using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
stream.Flush(true);
|
||||
}
|
||||
mayHaveCommitted = true;
|
||||
// The temp file is on the same filesystem. Never delete the main file first.
|
||||
if (File.Exists(main))
|
||||
{
|
||||
File.Replace(temporary, main, backup);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(temporary, main);
|
||||
}
|
||||
mayHaveCommitted = false;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (DiskError(ex) || ex is JsonException || ex is EncoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool DiskError(Exception ex) => ex is IOException || ex is UnauthorizedAccessException ||
|
||||
ex is SecurityException || ex is NotSupportedException || ex is ObjectDisposedException;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
lease.Dispose();
|
||||
// Keep files, including failed temporary writes, for explicit recovery diagnosis.
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Client/Assets/Scripts/Core/KV/KvCacheStore.cs.meta
Normal file
11
Client/Assets/Scripts/Core/KV/KvCacheStore.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb1e30eab9b24686a5b5110bc68c19b6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
247
Client/Assets/Scripts/Core/KV/KvClient.cs
Normal file
247
Client/Assets/Scripts/Core/KV/KvClient.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Core.KV
|
||||
{
|
||||
/// <summary>
|
||||
/// Private KV protocol for one fixed authenticated account/environment.
|
||||
/// Caller owns the HttpClient, cookies and cancellation lifetime; never reuse
|
||||
/// that transport for another account. This class neither retries nor logs payloads.
|
||||
/// Production login/storage integration is deliberately outside this adapter.
|
||||
/// </summary>
|
||||
public sealed class KvClient
|
||||
{
|
||||
private readonly HttpClient http;
|
||||
private readonly Uri api;
|
||||
|
||||
public KvClient(HttpClient http, Uri apiBaseUri)
|
||||
{
|
||||
this.http = http ?? throw new ArgumentNullException(nameof(http));
|
||||
if (apiBaseUri == null || !apiBaseUri.IsAbsoluteUri ||
|
||||
(apiBaseUri.Scheme != "http" && apiBaseUri.Scheme != "https") ||
|
||||
!string.IsNullOrEmpty(apiBaseUri.UserInfo) ||
|
||||
!string.IsNullOrEmpty(apiBaseUri.Query) || !string.IsNullOrEmpty(apiBaseUri.Fragment))
|
||||
{
|
||||
throw new ArgumentException("An absolute HTTP(S) API base URI is required.", nameof(apiBaseUri));
|
||||
}
|
||||
api = new Uri(apiBaseUri.AbsoluteUri.TrimEnd('/') + "/");
|
||||
}
|
||||
|
||||
public Task<KvResult> GetAsync(CancellationToken token = default)
|
||||
{
|
||||
var body = new JObject();
|
||||
return SendAsync("Get", body, false, token);
|
||||
}
|
||||
|
||||
public Task<KvResult> SetAsync(IDictionary<string, string> data, CancellationToken token = default)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
return Invalid();
|
||||
}
|
||||
var snapshot = new JObject();
|
||||
foreach (var pair in data)
|
||||
{
|
||||
if (!ValidKey(pair.Key) || pair.Value == null)
|
||||
{
|
||||
return Invalid();
|
||||
}
|
||||
snapshot.Add(pair.Key, pair.Value);
|
||||
}
|
||||
return SendAsync("Set", new JObject { ["data"] = snapshot }, true, token);
|
||||
}
|
||||
|
||||
public Task<KvResult> DeleteAsync(IEnumerable<string> keys, CancellationToken token = default)
|
||||
{
|
||||
if (!CopyKeys(keys, out var snapshot))
|
||||
{
|
||||
return Invalid();
|
||||
}
|
||||
return SendAsync("Delete", new JObject { ["keys"] = new JArray(snapshot) }, true, token);
|
||||
}
|
||||
|
||||
private static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128;
|
||||
|
||||
private static bool CopyKeys(IEnumerable<string> keys, out List<string> snapshot)
|
||||
{
|
||||
snapshot = new List<string>();
|
||||
if (keys == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!ValidKey(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
snapshot.Add(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Task<KvResult> Invalid() => Task.FromResult(new KvResult(KVError.Validation));
|
||||
|
||||
private async Task<KvResult> SendAsync(string action, JObject body, bool mutation, CancellationToken token)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return new KvResult(KVError.Cancelled);
|
||||
}
|
||||
int? status = null;
|
||||
bool enteredTransport = false;
|
||||
try
|
||||
{
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(api, "playerdata/" + action)))
|
||||
{
|
||||
request.Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json");
|
||||
token.ThrowIfCancellationRequested();
|
||||
enteredTransport = true;
|
||||
using (var response = await http.SendAsync(request, token))
|
||||
{
|
||||
status = (int)response.StatusCode;
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (response.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
return new KvResult(status == 401 || status == 403
|
||||
? KVError.Unauthorized : KVError.Http, mayHaveCommitted: mutation);
|
||||
}
|
||||
if (mutation)
|
||||
{
|
||||
return new KvResult(KVError.None);
|
||||
}
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
token.ThrowIfCancellationRequested();
|
||||
var data = ParseData(text, out var updatedAtUtc);
|
||||
return new KvResult(KVError.None, data, updatedAtUtc: updatedAtUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new KvResult(token.IsCancellationRequested ? KVError.Cancelled : KVError.Timeout, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return new KvResult(KVError.Cancelled, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return new KvResult(KVError.Network, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return new KvResult(KVError.Network, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new KvResult(KVError.Protocol, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseData(string text, out Dictionary<string, DateTime> updatedAtUtc)
|
||||
{
|
||||
updatedAtUtc = null;
|
||||
using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None })
|
||||
{
|
||||
var body = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error });
|
||||
if (reader.Read() || !(body["data"] is JObject data))
|
||||
{
|
||||
throw new JsonReaderException("Invalid KV response.");
|
||||
}
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var property in data.Properties())
|
||||
{
|
||||
if (property.Value.Type != JTokenType.String)
|
||||
{
|
||||
throw new JsonReaderException("KV value must be a string.");
|
||||
}
|
||||
result.Add(property.Name, property.Value.Value<string>());
|
||||
}
|
||||
var timestamps = body.Property("updatedAtUtc");
|
||||
if (timestamps != null)
|
||||
{
|
||||
if (!(timestamps.Value is JObject times) || times.Count != result.Count)
|
||||
{
|
||||
throw new JsonReaderException("Invalid KV timestamp metadata.");
|
||||
}
|
||||
updatedAtUtc = new Dictionary<string, DateTime>(StringComparer.Ordinal);
|
||||
var formats = new[] { "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'" };
|
||||
foreach (var property in times.Properties())
|
||||
{
|
||||
if (!result.ContainsKey(property.Name) || property.Value.Type != JTokenType.String)
|
||||
{
|
||||
throw new JsonReaderException("Invalid KV timestamp entry.");
|
||||
}
|
||||
var value = property.Value.Value<string>();
|
||||
if ((value.Length != 20 && (value.Length < 22 || value.Length > 28)) ||
|
||||
!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp))
|
||||
{
|
||||
throw new JsonReaderException("KV timestamp must be UTC ISO 8601.");
|
||||
}
|
||||
updatedAtUtc.Add(property.Name, timestamp);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum KVError
|
||||
{
|
||||
None,
|
||||
Validation,
|
||||
Unauthorized,
|
||||
Http,
|
||||
Network,
|
||||
Timeout,
|
||||
Cancelled,
|
||||
Protocol
|
||||
}
|
||||
|
||||
public sealed class KvResult
|
||||
{
|
||||
public bool Success => Error == KVError.None;
|
||||
public KVError Error
|
||||
{
|
||||
get;
|
||||
}
|
||||
// Only a successful Get has Data. Failure must never look like an empty save.
|
||||
public IReadOnlyDictionary<string, string> Data
|
||||
{
|
||||
get;
|
||||
}
|
||||
// Null means the response did not supply timestamp metadata; never substitute read time.
|
||||
public IReadOnlyDictionary<string, DateTime> UpdatedAtUtc
|
||||
{
|
||||
get;
|
||||
}
|
||||
// A failed write that entered the transport may already have reached the server.
|
||||
// This is conservative, including HTTP failures. Do not blindly replay the write.
|
||||
public bool MayHaveCommitted
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
internal KvResult(KVError error,
|
||||
Dictionary<string, string> data = null, bool mayHaveCommitted = false,
|
||||
Dictionary<string, DateTime> updatedAtUtc = null)
|
||||
{
|
||||
Error = error;
|
||||
Data = data == null ? null : new ReadOnlyDictionary<string, string>(data);
|
||||
UpdatedAtUtc = updatedAtUtc == null ? null : new ReadOnlyDictionary<string, DateTime>(updatedAtUtc);
|
||||
MayHaveCommitted = mayHaveCommitted;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Client/Assets/Scripts/Core/KV/KvClient.cs.meta
Normal file
11
Client/Assets/Scripts/Core/KV/KvClient.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bcdbe8122e0b4f9eb82d2d1579081a7d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
461
Client/Assets/Scripts/Core/KV/KvSessionOwner.cs
Normal file
461
Client/Assets/Scripts/Core/KV/KvSessionOwner.cs
Normal file
@@ -0,0 +1,461 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Owns one immutable account/epoch/cache/transport tuple.</summary>
|
||||
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<long, bool> isCurrent;
|
||||
private readonly KvSyncState state;
|
||||
|
||||
private readonly KvSyncCoordinator coordinator;
|
||||
private readonly TaskCompletionSource<bool> drained =
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly string ownerId = Guid.NewGuid().ToString("N");
|
||||
private int activeOperations;
|
||||
private bool closing, closed;
|
||||
private TaskCompletionSource<bool> 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<long, bool> 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<KvSessionOpenResult> CreateAsync(GameServerAccountSession session, string cacheRoot,
|
||||
string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func<long, bool> isCurrent,
|
||||
CancellationToken lifetimeToken, Func<CookieContainer, HttpMessageHandler> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the existing state under an operation lease; does not open another cache.</summary>
|
||||
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<KvFlushResult> SyncDataAsync()
|
||||
{
|
||||
var lease = TryEnter();
|
||||
if (lease == null)
|
||||
{
|
||||
return Task.FromResult(new KvFlushResult(1, 0, KVError.Cancelled));
|
||||
}
|
||||
return RunOrdinarySyncAsync(lease);
|
||||
}
|
||||
|
||||
private async Task<KvFlushResult> RunOrdinarySyncAsync(OperationLease lease)
|
||||
{
|
||||
using (lease)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await coordinator.SyncDataAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new KvFlushResult(1, 0, KVError.Protocol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SyncDataImmidiate(string key, string value)
|
||||
{
|
||||
using (var lease = TryEnter())
|
||||
{
|
||||
if (lease == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return await coordinator.SyncDataImmidiate(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> 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<bool> completion;
|
||||
lock (admission)
|
||||
{
|
||||
if (closeCompletion != null)
|
||||
{
|
||||
return closeCompletion.Task;
|
||||
}
|
||||
closing = true;
|
||||
completion = closeCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (activeOperations == 0)
|
||||
{
|
||||
drained.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
_ = CloseCoreAsync(completion);
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
private async Task CloseCoreAsync(TaskCompletionSource<bool> 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<KvSessionOpenResult> 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
|
||||
}
|
||||
|
||||
/// <summary>A detached snapshot. Failed reads have no data, never a pretend empty save.</summary>
|
||||
public sealed class KvLocalReadResult
|
||||
{
|
||||
public bool Success => Error == KVLocalReadError.None;
|
||||
public KVLocalReadError Error
|
||||
{
|
||||
get;
|
||||
}
|
||||
public IReadOnlyDictionary<string, string> Data
|
||||
{
|
||||
get;
|
||||
}
|
||||
|
||||
internal KvLocalReadResult(KVLocalReadError error, IReadOnlyDictionary<string, string> data = null)
|
||||
{
|
||||
Error = error;
|
||||
if (error != KVLocalReadError.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (data == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(data));
|
||||
}
|
||||
var copy = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var item in data)
|
||||
{
|
||||
copy.Add(item.Key, item.Value);
|
||||
}
|
||||
Data = new ReadOnlyDictionary<string, string>(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Client/Assets/Scripts/Core/KV/KvSessionOwner.cs.meta
Normal file
11
Client/Assets/Scripts/Core/KV/KvSessionOwner.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e46e2622d9b94d61aeb796d53cea5cdc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
671
Client/Assets/Scripts/Core/KV/KvSyncCoordinator.cs
Normal file
671
Client/Assets/Scripts/Core/KV/KvSyncCoordinator.cs
Normal file
@@ -0,0 +1,671 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Core.KV
|
||||
{
|
||||
public enum KVTicketStatus
|
||||
{
|
||||
Pending,
|
||||
Confirmed,
|
||||
Superseded,
|
||||
Blocked,
|
||||
Unknown,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
public sealed class KvTicketResult
|
||||
{
|
||||
public KVTicketStatus Status
|
||||
{
|
||||
get;
|
||||
}
|
||||
public KVError TransportError
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal KvTicketResult(KVTicketStatus status, KVError transportError)
|
||||
{
|
||||
Status = status;
|
||||
TransportError = transportError;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class KvFlushResult
|
||||
{
|
||||
public int Blocked
|
||||
{
|
||||
get;
|
||||
}
|
||||
public int Unknown
|
||||
{
|
||||
get;
|
||||
}
|
||||
public KVError TransportError
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal KvFlushResult(int blocked, int unknown, KVError transportError)
|
||||
{
|
||||
Blocked = blocked;
|
||||
Unknown = unknown;
|
||||
TransportError = transportError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One fixed owner's serialized Get/Set/Delete channel. No timers or automatic retries.</summary>
|
||||
public sealed class KvSyncCoordinator : IDisposable
|
||||
{
|
||||
private sealed class TicketRecord
|
||||
{
|
||||
internal KvTicketResult Result;
|
||||
internal bool Dispatched;
|
||||
}
|
||||
|
||||
private readonly object ticketGate = new object();
|
||||
private readonly Dictionary<long, TicketRecord> tickets = new Dictionary<long, TicketRecord>();
|
||||
private readonly SemaphoreSlim dispatch = new SemaphoreSlim(1, 1);
|
||||
private readonly KvSyncState state;
|
||||
private readonly KvClient client;
|
||||
private readonly System.Net.Http.HttpClient http;
|
||||
private readonly Func<bool> isOwnerCurrent;
|
||||
private readonly CancellationToken ownerToken;
|
||||
private bool disposed;
|
||||
private bool ordinaryDraining;
|
||||
private TaskCompletionSource<(KVError Error, Dictionary<long, KvTicketResult> Results)> ordinaryDrainCompletion;
|
||||
|
||||
internal KvSyncCoordinator(KvSyncState state, KvClient client,
|
||||
System.Net.Http.HttpClient http, Func<bool> isOwnerCurrent,
|
||||
CancellationToken ownerToken)
|
||||
{
|
||||
this.state = state;
|
||||
this.client = client;
|
||||
this.http = http;
|
||||
this.isOwnerCurrent = isOwnerCurrent;
|
||||
this.ownerToken = ownerToken;
|
||||
}
|
||||
|
||||
internal async Task<KvResult> InitializeAsync()
|
||||
{
|
||||
await dispatch.WaitAsync(ownerToken);
|
||||
try
|
||||
{
|
||||
if (!Current())
|
||||
{
|
||||
return CancelledResult();
|
||||
}
|
||||
if (state.HasPendingComparison)
|
||||
{
|
||||
return new KvResult(KVError.Protocol);
|
||||
}
|
||||
var result = await client.GetAsync(ownerToken);
|
||||
if (!Current())
|
||||
{
|
||||
return CancelledResult();
|
||||
}
|
||||
if (result.Success)
|
||||
{
|
||||
var reconciled = state.Reconcile(result.Data, result.UpdatedAtUtc);
|
||||
return reconciled.Success ? result : new KvResult(KVError.Protocol);
|
||||
}
|
||||
state.RecordReadFailure(result.Error == KVError.Unauthorized);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dispatch.Release();
|
||||
}
|
||||
}
|
||||
|
||||
internal KvStateResult CompareDatas(long nowTicks)
|
||||
{
|
||||
lock (ticketGate)
|
||||
{
|
||||
if (!Current())
|
||||
{
|
||||
return new KvStateResult(KVChangeError.Blocked);
|
||||
}
|
||||
var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
|
||||
var result = state.CompareDatas(nowTicks);
|
||||
if (result.Success)
|
||||
{
|
||||
CompleteRemovedSources(previous);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal KvStateResult Update(string key, string value, bool remoteStore, long updateTicks)
|
||||
{
|
||||
lock (ticketGate)
|
||||
{
|
||||
var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
|
||||
var result = state.Update(key, value, remoteStore, updateTicks);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
CompleteRemovedSources(previous);
|
||||
if (!remoteStore || !Unsent(state.Inspect()).Any(item => item.Sequence == result.AttemptSequence))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
tickets.Add(result.AttemptSequence, new TicketRecord());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<KvChange> Unsent(KvDiagnosticSnapshot value)
|
||||
{
|
||||
return value.OrdinaryBuffers.SelectMany(bucket => bucket.Values)
|
||||
.Concat(value.LegacyPending.Values).Concat(value.ExplicitDeletes.Values);
|
||||
}
|
||||
|
||||
private void CompleteRemovedSources(IEnumerable<long> previous)
|
||||
{
|
||||
var remaining = new HashSet<long>(Unsent(state.Inspect()).Select(item => item.Sequence));
|
||||
foreach (var sequence in previous)
|
||||
{
|
||||
if (!remaining.Contains(sequence) && tickets.TryGetValue(sequence, out var record) &&
|
||||
!record.Dispatched && record.Result == null)
|
||||
{
|
||||
Complete(record, KVTicketStatus.Superseded, KVError.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal Task<bool> SyncDataImmidiate(string key, string value)
|
||||
{
|
||||
if (!KvSnapshot.ValidKey(key) || value == null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
return SyncDataImmidiate(new Dictionary<string, string> { [key] = value });
|
||||
}
|
||||
|
||||
internal async Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var data = values.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
IReadOnlyList<KvChange> operations;
|
||||
lock (ticketGate)
|
||||
{
|
||||
var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
|
||||
if (!Current() || !state.PrepareImmediate(data, out operations).Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
CompleteRemovedSources(previous);
|
||||
}
|
||||
var acquired = false;
|
||||
var dispatched = false;
|
||||
try
|
||||
{
|
||||
await dispatch.WaitAsync(ownerToken);
|
||||
acquired = true;
|
||||
if (!Current() || state.Status != KVSyncStatus.Ready)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
dispatched = true;
|
||||
var result = await client.SetAsync(data, ownerToken);
|
||||
if (!Current())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (result.Success)
|
||||
{
|
||||
return state.CompleteImmediate(operations).Success;
|
||||
}
|
||||
if (result.Error == KVError.Unauthorized)
|
||||
{
|
||||
state.RecordReadFailure(true);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!dispatched)
|
||||
{
|
||||
state.CancelUndispatchedImmediate(operations);
|
||||
}
|
||||
if (acquired)
|
||||
{
|
||||
dispatch.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<KvChange> Outstanding(KvDiagnosticSnapshot snapshot)
|
||||
{
|
||||
return Unsent(snapshot).Concat(snapshot.PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
|
||||
.Concat(snapshot.OrdinaryUnknownBatches.SelectMany(batch => batch.Items))
|
||||
.Concat(snapshot.ActiveBatch == null ? Enumerable.Empty<KvChange>() : snapshot.ActiveBatch.Items)
|
||||
.Concat(snapshot.UnknownBatch == null ? Enumerable.Empty<KvChange>() : snapshot.UnknownBatch.Items);
|
||||
}
|
||||
|
||||
// This channel sends one imported legacy batch, never ordinary buckets.
|
||||
internal async Task<KvFlushResult> FlushThroughAsync(long watermark, long[] targetSequences)
|
||||
{
|
||||
var error = KVError.None;
|
||||
var observed = new Dictionary<long, KVTicketStatus>();
|
||||
var acquired = false;
|
||||
try
|
||||
{
|
||||
await dispatch.WaitAsync(ownerToken);
|
||||
acquired = true;
|
||||
var sent = 0;
|
||||
while (Current() && sent < 1)
|
||||
{
|
||||
KvStateResult begin;
|
||||
KvBatch batch;
|
||||
lock (ticketGate)
|
||||
{
|
||||
begin = state.BeginBatch(watermark, out batch);
|
||||
if (begin.Success && batch != null)
|
||||
{
|
||||
MarkDispatched(batch);
|
||||
}
|
||||
}
|
||||
if (!begin.Success)
|
||||
{
|
||||
MarkExplicitBlocked(watermark, KVError.None, observed);
|
||||
break;
|
||||
}
|
||||
if (batch == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
sent++;
|
||||
var result = await SendAsync(batch);
|
||||
if (!Current() && result.Success)
|
||||
{
|
||||
result = new KvResult(KVError.Cancelled, mayHaveCommitted: true);
|
||||
}
|
||||
if (result.Success)
|
||||
{
|
||||
var confirmed = state.ConfirmBatch(batch.Id);
|
||||
var outcome = confirmed.Success ? KVTicketStatus.Confirmed : KVTicketStatus.Unknown;
|
||||
RecordBatch(batch, outcome, KVError.None, observed);
|
||||
if (!confirmed.Success)
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
error = result.Error;
|
||||
state.FailBatch(batch.Id, result.MayHaveCommitted, result.Error == KVError.Unauthorized);
|
||||
var failedStatus = result.MayHaveCommitted ? KVTicketStatus.Unknown :
|
||||
ownerToken.IsCancellationRequested ? KVTicketStatus.Cancelled : KVTicketStatus.Blocked;
|
||||
RecordBatch(batch, failedStatus, result.Error, observed);
|
||||
break;
|
||||
}
|
||||
if (!Current())
|
||||
{
|
||||
error = KVError.Cancelled;
|
||||
CloseTickets();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
error = KVError.Cancelled;
|
||||
CloseTickets();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
error = KVError.Protocol;
|
||||
MarkExplicitBlocked(watermark, error, observed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (acquired)
|
||||
{
|
||||
dispatch.Release();
|
||||
}
|
||||
}
|
||||
return Summary(targetSequences ?? Array.Empty<long>(), error, observed);
|
||||
}
|
||||
|
||||
internal Task<KvFlushResult> SyncDataAsync()
|
||||
{
|
||||
TaskCompletionSource<(KVError Error, Dictionary<long, KvTicketResult> Results)> completion;
|
||||
bool start;
|
||||
long[] targets;
|
||||
lock (ticketGate)
|
||||
{
|
||||
var snapshot = state.Inspect();
|
||||
if (!Current())
|
||||
{
|
||||
return Task.FromResult(new KvFlushResult(1, 0, KVError.Cancelled));
|
||||
}
|
||||
if (snapshot.LegacyPending.Count != 0)
|
||||
{
|
||||
var watermark = snapshot.NextSequence - 1;
|
||||
return FlushThroughAsync(watermark, snapshot.LegacyPending.Values.Select(item => item.Sequence).ToArray());
|
||||
}
|
||||
var captured = state.CaptureOrdinaryBatch(out var batch);
|
||||
if (!captured.Success)
|
||||
{
|
||||
foreach (var item in snapshot.OrdinaryBuffers[0].Values)
|
||||
{
|
||||
if (tickets.TryGetValue(item.Sequence, out var record))
|
||||
{
|
||||
Complete(record, KVTicketStatus.Blocked, KVError.None);
|
||||
}
|
||||
}
|
||||
return Task.FromResult(new KvFlushResult(1, 0, KVError.None));
|
||||
}
|
||||
snapshot = state.Inspect();
|
||||
if (batch == null && ordinaryDraining)
|
||||
{
|
||||
// The existing caller owns the queued work; an empty tick has no new result to observe.
|
||||
return Task.FromResult(Summary(Array.Empty<long>(), KVError.None));
|
||||
}
|
||||
targets = batch != null ? batch.Items.Select(item => item.Sequence).ToArray() :
|
||||
snapshot.PreparedOrdinaryBatches.SelectMany(item => item.Items).Select(item => item.Sequence).ToArray();
|
||||
if (snapshot.PreparedOrdinaryBatches.Count == 0)
|
||||
{
|
||||
return Task.FromResult(Summary(targets, KVError.None));
|
||||
}
|
||||
start = !ordinaryDraining;
|
||||
if (start)
|
||||
{
|
||||
ordinaryDraining = true;
|
||||
ordinaryDrainCompletion = new TaskCompletionSource<(KVError Error, Dictionary<long, KvTicketResult> Results)>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
completion = ordinaryDrainCompletion;
|
||||
}
|
||||
if (start)
|
||||
{
|
||||
// The returned task waits for this actual worker, so the owner's lease covers its finally.
|
||||
_ = DrainOrdinaryAsync(completion);
|
||||
}
|
||||
return ObserveOrdinaryAsync(completion.Task, targets);
|
||||
}
|
||||
|
||||
private async Task<KvFlushResult> ObserveOrdinaryAsync(Task<(KVError Error, Dictionary<long, KvTicketResult> Results)> worker, long[] targets)
|
||||
{
|
||||
var completed = await worker;
|
||||
var error = targets.Where(completed.Results.ContainsKey).Select(sequence => completed.Results[sequence].TransportError)
|
||||
.FirstOrDefault(value => value != KVError.None);
|
||||
if (error == KVError.None && targets.Any(sequence => !completed.Results.ContainsKey(sequence)))
|
||||
{
|
||||
error = completed.Error;
|
||||
}
|
||||
return Summary(targets, error, completed.Results.ToDictionary(pair => pair.Key, pair => pair.Value.Status));
|
||||
}
|
||||
|
||||
private async Task DrainOrdinaryAsync(TaskCompletionSource<(KVError Error, Dictionary<long, KvTicketResult> Results)> completion)
|
||||
{
|
||||
var error = KVError.None;
|
||||
var observed = new Dictionary<long, KvTicketResult>();
|
||||
void RecordOrdinaryBatch(KvBatch batch, KVTicketStatus status, KVError transportError)
|
||||
{
|
||||
CompleteBatch(batch, status, transportError);
|
||||
foreach (var item in batch.Items)
|
||||
{
|
||||
observed[item.Sequence] = new KvTicketResult(status, transportError);
|
||||
}
|
||||
}
|
||||
KvBatch active = null;
|
||||
var acquired = false;
|
||||
var dispatched = false;
|
||||
try
|
||||
{
|
||||
while (Current())
|
||||
{
|
||||
await dispatch.WaitAsync(ownerToken);
|
||||
acquired = true;
|
||||
KvStateResult begin;
|
||||
lock (ticketGate)
|
||||
{
|
||||
begin = state.BeginOrdinaryBatch(out active);
|
||||
if (!begin.Success || active == null)
|
||||
{
|
||||
// Enqueue and the end-of-drain decision share this lock; no Task.IsCompleted race.
|
||||
ordinaryDraining = false;
|
||||
if (!begin.Success)
|
||||
{
|
||||
foreach (var item in state.Inspect().PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
|
||||
{
|
||||
if (tickets.TryGetValue(item.Sequence, out var record))
|
||||
{
|
||||
Complete(record, KVTicketStatus.Blocked, KVError.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
MarkDispatched(active);
|
||||
}
|
||||
dispatched = true;
|
||||
var result = await SendAsync(active);
|
||||
if (!Current() && result.Success)
|
||||
{
|
||||
result = new KvResult(KVError.Cancelled, mayHaveCommitted: true);
|
||||
}
|
||||
if (result.Success)
|
||||
{
|
||||
var confirmed = state.ConfirmBatch(active.Id);
|
||||
RecordOrdinaryBatch(active, confirmed.Success ? KVTicketStatus.Confirmed : KVTicketStatus.Unknown, KVError.None);
|
||||
if (!confirmed.Success)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
error = result.Error;
|
||||
var failed = state.FailBatch(active.Id, result.MayHaveCommitted, result.Error == KVError.Unauthorized);
|
||||
RecordOrdinaryBatch(active, result.MayHaveCommitted ? KVTicketStatus.Unknown :
|
||||
ownerToken.IsCancellationRequested ? KVTicketStatus.Cancelled : KVTicketStatus.Blocked, result.Error);
|
||||
if (!failed.Success || state.Status != KVSyncStatus.Ready)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
active = null;
|
||||
dispatched = false;
|
||||
dispatch.Release();
|
||||
acquired = false;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
error = KVError.Cancelled;
|
||||
if (active != null)
|
||||
{
|
||||
state.FailBatch(active.Id, dispatched);
|
||||
RecordOrdinaryBatch(active, dispatched ? KVTicketStatus.Unknown : KVTicketStatus.Cancelled, error);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
error = KVError.Protocol;
|
||||
if (active != null)
|
||||
{
|
||||
state.FailBatch(active.Id, dispatched);
|
||||
RecordOrdinaryBatch(active, dispatched ? KVTicketStatus.Unknown : KVTicketStatus.Blocked, error);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (acquired)
|
||||
{
|
||||
dispatch.Release();
|
||||
}
|
||||
lock (ticketGate)
|
||||
{
|
||||
if (ReferenceEquals(ordinaryDrainCompletion, completion))
|
||||
{
|
||||
ordinaryDraining = false;
|
||||
ordinaryDrainCompletion = null;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!Current())
|
||||
{
|
||||
CloseTickets();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
error = KVError.Protocol;
|
||||
}
|
||||
}
|
||||
completion.TrySetResult((error, observed));
|
||||
}
|
||||
}
|
||||
|
||||
private Task<KvResult> SendAsync(KvBatch batch)
|
||||
{
|
||||
if (batch.IsDelete)
|
||||
{
|
||||
return client.DeleteAsync(batch.Items.Select(item => item.Key), ownerToken);
|
||||
}
|
||||
return client.SetAsync(batch.Items.ToDictionary(item => item.Key, item => item.Target.Value, StringComparer.Ordinal), ownerToken);
|
||||
}
|
||||
|
||||
private void RecordBatch(KvBatch batch, KVTicketStatus status, KVError error,
|
||||
Dictionary<long, KVTicketStatus> observed)
|
||||
{
|
||||
CompleteBatch(batch, status, error);
|
||||
foreach (var item in batch.Items)
|
||||
{
|
||||
observed[item.Sequence] = status;
|
||||
}
|
||||
}
|
||||
|
||||
private void MarkExplicitBlocked(long watermark, KVError error,
|
||||
Dictionary<long, KVTicketStatus> observed)
|
||||
{
|
||||
lock (ticketGate)
|
||||
{
|
||||
var snapshot = state.Inspect();
|
||||
var source = snapshot.LegacyPending.Values;
|
||||
foreach (var item in source.Where(item => item.Sequence <= watermark))
|
||||
{
|
||||
var outcome = KVTicketStatus.Blocked;
|
||||
if (tickets.TryGetValue(item.Sequence, out var record))
|
||||
{
|
||||
outcome = record.Dispatched ? KVTicketStatus.Unknown : KVTicketStatus.Blocked;
|
||||
Complete(record, outcome, error);
|
||||
}
|
||||
observed[item.Sequence] = outcome;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void CloseTickets()
|
||||
{
|
||||
lock (ticketGate)
|
||||
{
|
||||
foreach (var record in tickets.Values)
|
||||
{
|
||||
if (record.Result == null)
|
||||
{
|
||||
Complete(record, record.Dispatched ? KVTicketStatus.Unknown : KVTicketStatus.Cancelled,
|
||||
KVError.Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void MarkDispatched(KvBatch batch)
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var item in batch.Items)
|
||||
if (tickets.TryGetValue(item.Sequence, out var record))
|
||||
{
|
||||
record.Dispatched = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteBatch(KvBatch batch, KVTicketStatus status, KVError error)
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var item in batch.Items)
|
||||
if (tickets.TryGetValue(item.Sequence, out var record))
|
||||
{
|
||||
Complete(record, status, error);
|
||||
}
|
||||
}
|
||||
|
||||
private void Complete(TicketRecord record, KVTicketStatus status, KVError error)
|
||||
{
|
||||
if (record.Result == null)
|
||||
{
|
||||
record.Result = new KvTicketResult(status, error);
|
||||
}
|
||||
}
|
||||
|
||||
private KvFlushResult Summary(IEnumerable<long> targetSequences, KVError error,
|
||||
Dictionary<long, KVTicketStatus> observed = null)
|
||||
{
|
||||
lock (ticketGate)
|
||||
{
|
||||
var counts = new Dictionary<KVTicketStatus, int>();
|
||||
var snapshot = state.Inspect();
|
||||
var unknown = new HashSet<long>(snapshot.OrdinaryUnknownBatches.SelectMany(batch => batch.Items).Select(item => item.Sequence));
|
||||
if (snapshot.UnknownBatch != null)
|
||||
{
|
||||
unknown.UnionWith(snapshot.UnknownBatch.Items.Select(item => item.Sequence));
|
||||
}
|
||||
var outstanding = new HashSet<long>(Outstanding(snapshot).Select(item => item.Sequence));
|
||||
foreach (var sequence in targetSequences.Distinct())
|
||||
{
|
||||
KVTicketStatus outcome;
|
||||
if (tickets.TryGetValue(sequence, out var record) && record.Result != null)
|
||||
{
|
||||
outcome = record.Result.Status;
|
||||
}
|
||||
else if (observed != null && observed.TryGetValue(sequence, out var actual))
|
||||
{
|
||||
outcome = actual;
|
||||
}
|
||||
else if (unknown.Contains(sequence))
|
||||
{
|
||||
outcome = KVTicketStatus.Unknown;
|
||||
}
|
||||
else if (outstanding.Contains(sequence) || tickets.ContainsKey(sequence))
|
||||
{
|
||||
outcome = KVTicketStatus.Pending;
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
counts[outcome] = counts.TryGetValue(outcome, out var count) ? count + 1 : 1;
|
||||
}
|
||||
int Count(KVTicketStatus status)
|
||||
{
|
||||
return counts.TryGetValue(status, out var count) ? count : 0;
|
||||
}
|
||||
return new KvFlushResult(Count(KVTicketStatus.Blocked) + Count(KVTicketStatus.Cancelled), Count(KVTicketStatus.Unknown), error);
|
||||
}
|
||||
}
|
||||
|
||||
private bool Current() => !ownerToken.IsCancellationRequested && isOwnerCurrent();
|
||||
private static KvResult CancelledResult() => new KvResult(KVError.Cancelled);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
http.Dispose();
|
||||
dispatch.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Client/Assets/Scripts/Core/KV/KvSyncCoordinator.cs.meta
Normal file
11
Client/Assets/Scripts/Core/KV/KvSyncCoordinator.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ff223eaf9574c4894c4acbb6b06a387
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
665
Client/Assets/Scripts/Core/KV/KvSyncModels.cs
Normal file
665
Client/Assets/Scripts/Core/KV/KvSyncModels.cs
Normal file
@@ -0,0 +1,665 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Core.KV
|
||||
{
|
||||
public enum KVSyncStatus
|
||||
{
|
||||
NotLoaded,
|
||||
Ready,
|
||||
ReadFailed,
|
||||
Conflict,
|
||||
UnknownOutcome,
|
||||
Unauthorized,
|
||||
StorageError,
|
||||
Closed
|
||||
}
|
||||
public enum KVOpenError
|
||||
{
|
||||
None,
|
||||
Validation,
|
||||
Busy,
|
||||
StorageError,
|
||||
RecoveryRequired
|
||||
}
|
||||
public enum KVChangeError
|
||||
{
|
||||
None,
|
||||
Validation,
|
||||
Blocked,
|
||||
StorageError
|
||||
}
|
||||
|
||||
public sealed class KvStateResult
|
||||
{
|
||||
public bool Success => Error == KVChangeError.None;
|
||||
public KVChangeError Error
|
||||
{
|
||||
get;
|
||||
}
|
||||
public long AttemptSequence
|
||||
{
|
||||
get;
|
||||
}
|
||||
public bool LocalCommitMayHaveSucceeded
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal KvStateResult(KVChangeError error, long sequence = 0, bool uncertain = false)
|
||||
{
|
||||
Error = error;
|
||||
AttemptSequence = sequence;
|
||||
LocalCommitMayHaveSucceeded = uncertain;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class KvOpenResult
|
||||
{
|
||||
public bool Success => Error == KVOpenError.None;
|
||||
public KVOpenError Error
|
||||
{
|
||||
get;
|
||||
}
|
||||
public KvSyncState State
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal KvOpenResult(KVOpenError error, KvSyncState state = null)
|
||||
{
|
||||
Error = error;
|
||||
State = state;
|
||||
}
|
||||
}
|
||||
|
||||
// Existence is separate from value: an empty string is not a deletion.
|
||||
public sealed class KvValue : IEquatable<KvValue>
|
||||
{
|
||||
public static readonly KvValue Missing = new KvValue(false, null);
|
||||
public bool Exists
|
||||
{
|
||||
get;
|
||||
}
|
||||
public string Value
|
||||
{
|
||||
get;
|
||||
}
|
||||
private KvValue(bool exists, string value)
|
||||
{
|
||||
Exists = exists;
|
||||
Value = value;
|
||||
}
|
||||
public static KvValue Present(string value) => new KvValue(true, value ?? throw new ArgumentNullException(nameof(value)));
|
||||
public bool Equals(KvValue other) => other != null && Exists == other.Exists && Value == other.Value;
|
||||
public override bool Equals(object obj) => Equals(obj as KvValue);
|
||||
public override int GetHashCode() => Exists ? (Value.GetHashCode() ^ 397) : 0;
|
||||
internal static KvValue At(IReadOnlyDictionary<string, string> data, string key) =>
|
||||
data.TryGetValue(key, out var value) ? Present(value) : Missing;
|
||||
}
|
||||
|
||||
public sealed class KvChange
|
||||
{
|
||||
public string Key
|
||||
{
|
||||
get;
|
||||
}
|
||||
public long Sequence
|
||||
{
|
||||
get;
|
||||
}
|
||||
public KvValue BaseValue
|
||||
{
|
||||
get;
|
||||
}
|
||||
public KvValue Target
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal KvChange(string key, long sequence, KvValue baseline, KvValue target)
|
||||
{
|
||||
Key = key;
|
||||
Sequence = sequence;
|
||||
BaseValue = baseline;
|
||||
Target = target;
|
||||
}
|
||||
internal KvChange WithBase(KvValue baseline) => new KvChange(Key, Sequence, baseline, Target);
|
||||
}
|
||||
|
||||
public sealed class KvBatch
|
||||
{
|
||||
public string Id
|
||||
{
|
||||
get;
|
||||
}
|
||||
public IReadOnlyList<KvChange> Items
|
||||
{
|
||||
get;
|
||||
}
|
||||
public bool IsDelete => !Items[0].Target.Exists;
|
||||
public bool IsOrdinary
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal KvBatch(string id, IEnumerable<KvChange> items, bool ordinary = false)
|
||||
{
|
||||
Id = id;
|
||||
Items = Array.AsReadOnly(items.ToArray());
|
||||
IsOrdinary = ordinary;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class KvDiagnosticSnapshot
|
||||
{
|
||||
public long NextSequence
|
||||
{
|
||||
get;
|
||||
}
|
||||
public KvBatch ActiveBatch
|
||||
{
|
||||
get;
|
||||
}
|
||||
public KvBatch UnknownBatch
|
||||
{
|
||||
get;
|
||||
}
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, KvChange>> OrdinaryBuffers
|
||||
{
|
||||
get;
|
||||
}
|
||||
public IReadOnlyList<KvBatch> PreparedOrdinaryBatches
|
||||
{
|
||||
get;
|
||||
}
|
||||
public IReadOnlyList<KvBatch> OrdinaryUnknownBatches
|
||||
{
|
||||
get;
|
||||
}
|
||||
public IReadOnlyDictionary<string, KvChange> LegacyPending
|
||||
{
|
||||
get;
|
||||
}
|
||||
public IReadOnlyDictionary<string, KvChange> ExplicitDeletes
|
||||
{
|
||||
get;
|
||||
}
|
||||
internal KvDiagnosticSnapshot(KvSnapshot source)
|
||||
{
|
||||
NextSequence = source.NextSequence;
|
||||
ActiveBatch = source.Active;
|
||||
UnknownBatch = source.Unknown;
|
||||
OrdinaryBuffers = Array.AsReadOnly(source.OrdinaryBuffers.Select(bucket => ReadOnly(bucket)).ToArray());
|
||||
PreparedOrdinaryBatches = Array.AsReadOnly(source.PreparedOrdinaryBatches.ToArray());
|
||||
OrdinaryUnknownBatches = Array.AsReadOnly(source.OrdinaryUnknownBatches.ToArray());
|
||||
LegacyPending = ReadOnly(source.LegacyPending);
|
||||
ExplicitDeletes = ReadOnly(source.ExplicitDeletes);
|
||||
}
|
||||
private static IReadOnlyDictionary<string, T> ReadOnly<T>(Dictionary<string, T> source) =>
|
||||
new ReadOnlyDictionary<string, T>(new Dictionary<string, T>(source, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
internal sealed class KvIdentity
|
||||
{
|
||||
internal readonly string Api, PlayerId, DirectoryPath;
|
||||
internal KvIdentity(string root, Uri api, string playerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root) || !Path.IsPathFullyQualified(root) || api == null || !api.IsAbsoluteUri ||
|
||||
(api.Scheme != "http" && api.Scheme != "https") || api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0 ||
|
||||
!long.TryParse(playerId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0)
|
||||
{
|
||||
throw new ArgumentException("Invalid KV cache identity.");
|
||||
}
|
||||
Api = api.AbsoluteUri.TrimEnd('/') + "/";
|
||||
PlayerId = id.ToString(CultureInfo.InvariantCulture);
|
||||
using (var hash = SHA256.Create())
|
||||
{
|
||||
var bytes = hash.ComputeHash(Encoding.UTF8.GetBytes(Api));
|
||||
var fingerprint = string.Concat(bytes.Select(b => b.ToString("x2", CultureInfo.InvariantCulture)));
|
||||
DirectoryPath = Path.Combine(Path.GetFullPath(root), "player-kv", "v1", fingerprint, PlayerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class KvSnapshot
|
||||
{
|
||||
internal long Revision, NextSequence = 1;
|
||||
internal bool HasBaseline, AuthenticationInvalid;
|
||||
internal Dictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
internal Dictionary<string, string> LocalData = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
internal Dictionary<string, long> LocalUpdateTicks = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
// A local origin has no second copy of its value. Ordinary origins can outlive a sent batch.
|
||||
internal Dictionary<string, (bool LocalOnly, long Sequence)> LocalOrigins =
|
||||
new Dictionary<string, (bool LocalOnly, long Sequence)>(StringComparer.Ordinal);
|
||||
internal HashSet<string> ImmediatePreservedKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
// Only v1/v2 conversion can populate this lower layer; the first successful Get removes it.
|
||||
internal Dictionary<string, string> LegacyLocalOnlyUnderImmediate = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
internal List<Dictionary<string, KvChange>> OrdinaryBuffers = Enumerable.Range(0, 5)
|
||||
.Select(_ => new Dictionary<string, KvChange>(StringComparer.Ordinal)).ToList();
|
||||
internal List<KvBatch> PreparedOrdinaryBatches = new List<KvBatch>();
|
||||
internal List<KvBatch> OrdinaryUnknownBatches = new List<KvBatch>();
|
||||
internal Dictionary<string, KvChange> LegacyPending = new Dictionary<string, KvChange>(StringComparer.Ordinal);
|
||||
internal Dictionary<string, KvChange> ExplicitDeletes = new Dictionary<string, KvChange>(StringComparer.Ordinal);
|
||||
internal Dictionary<string, KvValue> Conflicts = new Dictionary<string, KvValue>(StringComparer.Ordinal);
|
||||
internal Dictionary<long, KvChange> RemoteOnlyWrites = new Dictionary<long, KvChange>();
|
||||
internal KvBatch Active, Unknown;
|
||||
|
||||
internal IEnumerable<KvChange> Unsent()
|
||||
{
|
||||
return OrdinaryBuffers.SelectMany(bucket => bucket.Values).Concat(LegacyPending.Values).Concat(ExplicitDeletes.Values);
|
||||
}
|
||||
internal KvSnapshot Clone()
|
||||
{
|
||||
return new KvSnapshot
|
||||
{
|
||||
Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid,
|
||||
Baseline = new Dictionary<string, string>(Baseline, StringComparer.Ordinal),
|
||||
LocalData = new Dictionary<string, string>(LocalData, StringComparer.Ordinal),
|
||||
LocalUpdateTicks = new Dictionary<string, long>(LocalUpdateTicks, StringComparer.Ordinal),
|
||||
LocalOrigins = new Dictionary<string, (bool LocalOnly, long Sequence)>(LocalOrigins, StringComparer.Ordinal),
|
||||
ImmediatePreservedKeys = new HashSet<string>(ImmediatePreservedKeys, StringComparer.Ordinal),
|
||||
LegacyLocalOnlyUnderImmediate = new Dictionary<string, string>(LegacyLocalOnlyUnderImmediate, StringComparer.Ordinal),
|
||||
OrdinaryBuffers = OrdinaryBuffers.Select(bucket => new Dictionary<string, KvChange>(bucket, StringComparer.Ordinal)).ToList(),
|
||||
PreparedOrdinaryBatches = new List<KvBatch>(PreparedOrdinaryBatches),
|
||||
OrdinaryUnknownBatches = new List<KvBatch>(OrdinaryUnknownBatches),
|
||||
LegacyPending = new Dictionary<string, KvChange>(LegacyPending, StringComparer.Ordinal),
|
||||
ExplicitDeletes = new Dictionary<string, KvChange>(ExplicitDeletes, StringComparer.Ordinal),
|
||||
Conflicts = new Dictionary<string, KvValue>(Conflicts, StringComparer.Ordinal),
|
||||
Active = Active, Unknown = Unknown,
|
||||
RemoteOnlyWrites = new Dictionary<long, KvChange>(RemoteOnlyWrites)
|
||||
};
|
||||
}
|
||||
internal static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128;
|
||||
|
||||
internal string Encode(KvIdentity identity)
|
||||
{
|
||||
var conflicts = new JObject();
|
||||
foreach (var pair in Conflicts)
|
||||
{
|
||||
conflicts.Add(pair.Key, ValueJson(pair.Value));
|
||||
}
|
||||
var origins = new JObject();
|
||||
foreach (var pair in LocalOrigins)
|
||||
{
|
||||
origins.Add(pair.Key, new JObject { ["localOnly"] = pair.Value.LocalOnly, ["sequence"] = pair.Value.Sequence });
|
||||
}
|
||||
return new JObject
|
||||
{
|
||||
["formatVersion"] = 3, ["apiBaseUri"] = identity.Api, ["playerId"] = identity.PlayerId,
|
||||
["revision"] = Revision, ["nextSequence"] = NextSequence, ["hasBaseline"] = HasBaseline,
|
||||
["authenticationInvalid"] = AuthenticationInvalid, ["baseline"] = JObject.FromObject(Baseline),
|
||||
["localData"] = JObject.FromObject(LocalData), ["localUpdateTicks"] = JObject.FromObject(LocalUpdateTicks),
|
||||
["localOrigins"] = origins, ["immediatePreservedKeys"] = new JArray(ImmediatePreservedKeys),
|
||||
["legacyLocalOnlyUnderImmediate"] = JObject.FromObject(LegacyLocalOnlyUnderImmediate),
|
||||
["ordinaryBuffers"] = new JArray(OrdinaryBuffers.Select(bucket => (JToken)ChangesJson(bucket.Values))),
|
||||
["preparedOrdinaryBatches"] = new JArray(PreparedOrdinaryBatches.Select(BatchJson)),
|
||||
["ordinaryUnknownBatches"] = new JArray(OrdinaryUnknownBatches.Select(BatchJson)),
|
||||
["legacyPending"] = ChangesJson(LegacyPending.Values), ["explicitDeletes"] = ChangesJson(ExplicitDeletes.Values),
|
||||
["conflictObservations"] = conflicts, ["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown),
|
||||
["remoteOnlyWrites"] = ChangesJson(RemoteOnlyWrites.Values)
|
||||
}.ToString(Formatting.None);
|
||||
}
|
||||
private static JArray ChangesJson(IEnumerable<KvChange> values)
|
||||
{
|
||||
return new JArray(values.OrderBy(item => item.Sequence).Select(ChangeJson));
|
||||
}
|
||||
private static JObject ValueJson(KvValue value)
|
||||
{
|
||||
return new JObject { ["exists"] = value.Exists, ["value"] = value.Value };
|
||||
}
|
||||
private static JObject ChangeJson(KvChange item)
|
||||
{
|
||||
return new JObject { ["key"] = item.Key, ["sequence"] = item.Sequence,
|
||||
["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target) };
|
||||
}
|
||||
private static JToken BatchJson(KvBatch batch)
|
||||
{
|
||||
if (batch == null)
|
||||
{
|
||||
return JValue.CreateNull();
|
||||
}
|
||||
return new JObject { ["id"] = batch.Id, ["ordinary"] = batch.IsOrdinary, ["items"] = ChangesJson(batch.Items) };
|
||||
}
|
||||
|
||||
internal static KvSnapshot Decode(string text, KvIdentity identity)
|
||||
{
|
||||
JObject root;
|
||||
using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None })
|
||||
{
|
||||
root = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error });
|
||||
if (reader.Read())
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
var version = Integer(root, "formatVersion");
|
||||
if (version < 1 || version > 3 || String(root, "apiBaseUri") != identity.Api || String(root, "playerId") != identity.PlayerId)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
var result = new KvSnapshot
|
||||
{
|
||||
Revision = Integer(root, "revision"), NextSequence = Integer(root, "nextSequence"),
|
||||
HasBaseline = Boolean(root, "hasBaseline"), AuthenticationInvalid = Boolean(root, "authenticationInvalid"),
|
||||
Baseline = Strings(Field(root, "baseline", JTokenType.Object)),
|
||||
Active = Batch(root, "activeBatch", version == 3), Unknown = Batch(root, "unknownBatch", version == 3)
|
||||
};
|
||||
foreach (var property in ((JObject)Field(root, "conflictObservations", JTokenType.Object)).Properties())
|
||||
{
|
||||
if (!ValidKey(property.Name))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
result.Conflicts.Add(property.Name, Value(property.Value));
|
||||
}
|
||||
if (version >= 2)
|
||||
{
|
||||
foreach (var item in Field(root, "remoteOnlyWrites", JTokenType.Array))
|
||||
{
|
||||
var change = Change(item);
|
||||
if (!change.Target.Exists || result.RemoteOnlyWrites.ContainsKey(change.Sequence))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
result.RemoteOnlyWrites.Add(change.Sequence, change);
|
||||
}
|
||||
}
|
||||
if (version < 3)
|
||||
{
|
||||
DecodeLegacy(root, version, result);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.LocalData = Strings(Field(root, "localData", JTokenType.Object));
|
||||
result.LegacyLocalOnlyUnderImmediate = Strings(Field(root, "legacyLocalOnlyUnderImmediate", JTokenType.Object));
|
||||
foreach (var property in ((JObject)Field(root, "localUpdateTicks", JTokenType.Object)).Properties())
|
||||
{
|
||||
if (!ValidKey(property.Name) || property.Value.Type != JTokenType.Integer)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
result.LocalUpdateTicks.Add(property.Name, (long)property.Value);
|
||||
}
|
||||
foreach (var property in ((JObject)Field(root, "localOrigins", JTokenType.Object)).Properties())
|
||||
{
|
||||
if (!ValidKey(property.Name))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
result.LocalOrigins.Add(property.Name, (Boolean(property.Value, "localOnly"), Integer(property.Value, "sequence")));
|
||||
}
|
||||
foreach (var key in Field(root, "immediatePreservedKeys", JTokenType.Array))
|
||||
{
|
||||
if (key.Type != JTokenType.String || !ValidKey((string)key) || !result.ImmediatePreservedKeys.Add((string)key))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
result.OrdinaryBuffers = Field(root, "ordinaryBuffers", JTokenType.Array).Select(Changes).ToList();
|
||||
result.LegacyPending = Changes(Field(root, "legacyPending", JTokenType.Array));
|
||||
result.ExplicitDeletes = Changes(Field(root, "explicitDeletes", JTokenType.Array));
|
||||
result.PreparedOrdinaryBatches = ReadBatches(root, "preparedOrdinaryBatches");
|
||||
result.OrdinaryUnknownBatches = ReadBatches(root, "ordinaryUnknownBatches");
|
||||
}
|
||||
result.Validate();
|
||||
return result;
|
||||
}
|
||||
private static Dictionary<string, KvChange> Changes(JToken array)
|
||||
{
|
||||
if (array.Type != JTokenType.Array)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
var values = new Dictionary<string, KvChange>(StringComparer.Ordinal);
|
||||
foreach (var token in array)
|
||||
{
|
||||
var item = Change(token);
|
||||
if (values.ContainsKey(item.Key))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
values.Add(item.Key, item);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<KvBatch> ReadBatches(JToken root, string name)
|
||||
{
|
||||
var result = new List<KvBatch>();
|
||||
foreach (var item in Field(root, name, JTokenType.Array))
|
||||
{
|
||||
var wrapper = new JObject { ["batch"] = item.DeepClone() };
|
||||
var batch = Batch(wrapper, "batch", true);
|
||||
if (batch == null || !batch.IsOrdinary || batch.IsDelete)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
result.Add(batch);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void DecodeLegacy(JObject root, long version, KvSnapshot result)
|
||||
{
|
||||
var pending = Changes(Field(root, "pending", JTokenType.Array));
|
||||
var localOnly = Strings(Field(root, "localOnly", JTokenType.Object));
|
||||
var overrides = new Dictionary<string, KvValue>(StringComparer.Ordinal);
|
||||
if (version == 2)
|
||||
{
|
||||
foreach (var property in ((JObject)Field(root, "remoteLocalOverrides", JTokenType.Object)).Properties())
|
||||
{
|
||||
if (!ValidKey(property.Name))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
overrides.Add(property.Name, Value(property.Value));
|
||||
}
|
||||
}
|
||||
// Validate the old shape before converting; new format rules must not accept a corrupt old file.
|
||||
if (result.Revision < 1 || result.NextSequence < 1 || (result.Active != null && result.Unknown != null) ||
|
||||
(!result.HasBaseline && (result.Baseline.Count != 0 || localOnly.Count != 0 || pending.Count != 0 ||
|
||||
result.Active != null || result.Unknown != null || overrides.Count != 0 || result.RemoteOnlyWrites.Count != 0)))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
var sequences = new HashSet<long>();
|
||||
foreach (var item in pending.Values.Concat(result.RemoteOnlyWrites.Values))
|
||||
{
|
||||
if (item.Sequence < 1 || item.Sequence >= result.NextSequence || !sequences.Add(item.Sequence))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
if (result.Conflicts.Keys.Any(key => !pending.ContainsKey(key)))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
var oldBatch = result.Active ?? result.Unknown;
|
||||
if (oldBatch != null)
|
||||
{
|
||||
foreach (var item in oldBatch.Items)
|
||||
{
|
||||
var hasPending = pending.TryGetValue(item.Key, out var latest);
|
||||
var hasSource = result.RemoteOnlyWrites.Values.Any(source => source.Key == item.Key && source.Sequence > item.Sequence);
|
||||
var same = hasPending && latest.Sequence == item.Sequence && latest.BaseValue.Equals(item.BaseValue) && latest.Target.Equals(item.Target);
|
||||
if (item.Sequence < 1 || item.Sequence >= result.NextSequence || (sequences.Contains(item.Sequence) && !same) ||
|
||||
(!hasPending && !hasSource) || (hasPending && (latest.Sequence < item.Sequence || !latest.BaseValue.Equals(item.BaseValue) ||
|
||||
(latest.Sequence == item.Sequence && !same))))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
sequences.Add(item.Sequence);
|
||||
}
|
||||
}
|
||||
result.LegacyPending = pending;
|
||||
result.LocalData = new Dictionary<string, string>(result.Baseline, StringComparer.Ordinal);
|
||||
foreach (var item in pending.Values)
|
||||
{
|
||||
ApplyValue(result.LocalData, item.Key, item.Target);
|
||||
result.LocalOrigins[item.Key] = (false, item.Sequence);
|
||||
}
|
||||
foreach (var pair in localOnly)
|
||||
{
|
||||
result.LocalData[pair.Key] = pair.Value;
|
||||
// Old local-only writes had no stored sequence; zero is permitted only for this converted origin.
|
||||
result.LocalOrigins[pair.Key] = (true, 0);
|
||||
}
|
||||
foreach (var pair in overrides)
|
||||
{
|
||||
ApplyValue(result.LocalData, pair.Key, pair.Value);
|
||||
result.ImmediatePreservedKeys.Add(pair.Key);
|
||||
if (localOnly.TryGetValue(pair.Key, out var lower))
|
||||
{
|
||||
result.LegacyLocalOnlyUnderImmediate[pair.Key] = lower;
|
||||
}
|
||||
}
|
||||
}
|
||||
private static void ApplyValue(Dictionary<string, string> values, string key, KvValue value)
|
||||
{
|
||||
if (value.Exists)
|
||||
{
|
||||
values[key] = value.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
values.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (Revision < 1 || NextSequence < 1 || OrdinaryBuffers.Count != 5 ||
|
||||
(Active != null && Unknown != null) || (Unknown != null && Unknown.IsOrdinary) ||
|
||||
OrdinaryBuffers.Any(bucket => bucket.Count > 10 || bucket.Values.Any(item => !item.Target.Exists)) ||
|
||||
ExplicitDeletes.Values.Any(item => item.Target.Exists) ||
|
||||
(!HasBaseline && (Baseline.Count != 0 || LocalData.Count != 0 || Unsent().Any() || Active != null || Unknown != null ||
|
||||
PreparedOrdinaryBatches.Count != 0 || OrdinaryUnknownBatches.Count != 0 || RemoteOnlyWrites.Count != 0 ||
|
||||
LocalOrigins.Count != 0 || LocalUpdateTicks.Count != 0 || ImmediatePreservedKeys.Count != 0 || LegacyLocalOnlyUnderImmediate.Count != 0)))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
var sequences = new Dictionary<long, KvChange>();
|
||||
void Add(KvChange item, bool sourceReference = false)
|
||||
{
|
||||
if (!ValidKey(item.Key) || item.Sequence < 1 || item.Sequence >= NextSequence)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
if (sequences.TryGetValue(item.Sequence, out var prior))
|
||||
{
|
||||
if (!sourceReference || prior.Key != item.Key || !prior.BaseValue.Equals(item.BaseValue) || !prior.Target.Equals(item.Target))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sequences.Add(item.Sequence, item);
|
||||
}
|
||||
}
|
||||
foreach (var item in Unsent().Concat(RemoteOnlyWrites.Values))
|
||||
{
|
||||
Add(item);
|
||||
}
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var batch in PreparedOrdinaryBatches.Concat(OrdinaryUnknownBatches).Concat(new[] { Active, Unknown }).Where(batch => batch != null))
|
||||
{
|
||||
if (!Guid.TryParseExact(batch.Id, "N", out _) || !ids.Add(batch.Id) || batch.Items.Count < 1 || batch.Items.Count > 10 ||
|
||||
batch.Items.Select(item => item.Key).Distinct(StringComparer.Ordinal).Count() != batch.Items.Count ||
|
||||
batch.Items.Any(item => item.Target.Exists != batch.Items[0].Target.Exists) || (batch.IsOrdinary && batch.IsDelete) ||
|
||||
!batch.Items.Select(item => item.Sequence).SequenceEqual(batch.Items.Select(item => item.Sequence).OrderBy(value => value)))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
foreach (var item in batch.Items)
|
||||
{
|
||||
var reference = !batch.IsOrdinary && (LegacyPending.Values.Concat(ExplicitDeletes.Values)
|
||||
.Any(source => source.Sequence == item.Sequence));
|
||||
Add(item, reference);
|
||||
}
|
||||
}
|
||||
if (RemoteOnlyWrites.Any(pair => pair.Key != pair.Value.Sequence || !pair.Value.Target.Exists) ||
|
||||
PreparedOrdinaryBatches.Concat(OrdinaryUnknownBatches).Any(batch => !batch.IsOrdinary) ||
|
||||
LocalOrigins.Any(pair => !ValidKey(pair.Key) || pair.Value.Sequence < 0 || pair.Value.Sequence >= NextSequence ||
|
||||
(!pair.Value.LocalOnly && pair.Value.Sequence == 0)) ||
|
||||
LocalUpdateTicks.Any(pair => !ValidKey(pair.Key) || pair.Value < 0 || pair.Value > DateTime.MaxValue.Ticks) ||
|
||||
ImmediatePreservedKeys.Any(key => !ValidKey(key)) ||
|
||||
LegacyLocalOnlyUnderImmediate.Any(pair => !ImmediatePreservedKeys.Contains(pair.Key) ||
|
||||
!LocalOrigins.TryGetValue(pair.Key, out var origin) || !origin.LocalOnly) ||
|
||||
Conflicts.Keys.Any(key => !Unsent().Any(item => item.Key == key) && !PreparedOrdinaryBatches.Any(batch => batch.Items.Any(item => item.Key == key)) &&
|
||||
Unknown == null && OrdinaryUnknownBatches.Count == 0))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
private static Dictionary<string, string> Strings(JToken value)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var property in ((JObject)value).Properties())
|
||||
{
|
||||
if (!ValidKey(property.Name) || property.Value.Type != JTokenType.String)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
result.Add(property.Name, (string)property.Value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static KvChange Change(JToken item)
|
||||
{
|
||||
var key = String(item, "key");
|
||||
if (!ValidKey(key))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
return new KvChange(key, Integer(item, "sequence"), Value(Field(item, "base", JTokenType.Object)), Value(Field(item, "target", JTokenType.Object)));
|
||||
}
|
||||
private static KvValue Value(JToken item)
|
||||
{
|
||||
var exists = Boolean(item, "exists");
|
||||
var value = Field(item, "value", exists ? JTokenType.String : JTokenType.Null);
|
||||
return exists ? KvValue.Present((string)value) : KvValue.Missing;
|
||||
}
|
||||
private static KvBatch Batch(JToken parent, string name, bool withKind = false)
|
||||
{
|
||||
var node = parent[name];
|
||||
if (node == null)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
if (node.Type == JTokenType.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var id = String(node, "id");
|
||||
if (!Guid.TryParseExact(id, "N", out _))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
var items = Field(node, "items", JTokenType.Array).Select(Change).ToArray();
|
||||
if (items.Length == 0 || items.Length > 10 || items.Select(i => i.Key).Distinct(StringComparer.Ordinal).Count() != items.Length ||
|
||||
items.Select(i => i.Sequence).Distinct().Count() != items.Length || items.Any(i => i.Target.Exists != items[0].Target.Exists) ||
|
||||
!items.Select(i => i.Sequence).SequenceEqual(items.Select(i => i.Sequence).OrderBy(s => s)))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
return new KvBatch(id, items, withKind && Boolean(node, "ordinary"));
|
||||
}
|
||||
private static JToken Field(JToken parent, string name, JTokenType type)
|
||||
{
|
||||
if (!(parent is JObject obj) || obj[name] == null || obj[name].Type != type)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
return obj[name];
|
||||
}
|
||||
private static string String(JToken parent, string name) => (string)Field(parent, name, JTokenType.String);
|
||||
private static long Integer(JToken parent, string name) => (long)Field(parent, name, JTokenType.Integer);
|
||||
private static bool Boolean(JToken parent, string name) => (bool)Field(parent, name, JTokenType.Boolean);
|
||||
internal static JsonException Invalid() => new JsonException("Invalid KV cache snapshot.");
|
||||
}
|
||||
}
|
||||
11
Client/Assets/Scripts/Core/KV/KvSyncModels.cs.meta
Normal file
11
Client/Assets/Scripts/Core/KV/KvSyncModels.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c949512a03dc431b94569c20f06102af
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
739
Client/Assets/Scripts/Core/KV/KvSyncState.cs
Normal file
739
Client/Assets/Scripts/Core/KV/KvSyncState.cs
Normal file
@@ -0,0 +1,739 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace Core.KV
|
||||
{
|
||||
/// <summary>
|
||||
/// Synchronous, fixed-identity cache state. No HTTP, gameplay initialization or timers.
|
||||
/// All transitions and disk writes share a gate. The phase-3B owner must drain its
|
||||
/// admitted network operations before disposing this state and its filesystem lease.
|
||||
/// </summary>
|
||||
public sealed class KvSyncState : IDisposable
|
||||
{
|
||||
private readonly object gate = new object();
|
||||
private readonly KvCacheStore store;
|
||||
private KvSnapshot snapshot;
|
||||
private KVSyncStatus status;
|
||||
private readonly HashSet<long> recoveredImmediate;
|
||||
private bool recoverImmediateView = true;
|
||||
private bool recoveredOrdinaryUnknown;
|
||||
private KvSnapshot comparisonLocal;
|
||||
private Dictionary<string, string> comparisonRemote;
|
||||
private Dictionary<string, DateTime> comparisonTimes;
|
||||
private Dictionary<string, string> comparisonView;
|
||||
private HashSet<string> comparisonImmediateKeys;
|
||||
internal bool HasPendingComparison
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return comparisonLocal != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
internal bool AuthenticationInvalid
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return snapshot.AuthenticationInvalid;
|
||||
}
|
||||
}
|
||||
}
|
||||
public KVSyncStatus Status
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
private KvSyncState(KvCacheStore store, KvSnapshot snapshot)
|
||||
{
|
||||
this.store = store;
|
||||
this.snapshot = snapshot;
|
||||
recoveredImmediate = new HashSet<long>(snapshot.RemoteOnlyWrites.Keys);
|
||||
// Authentication belongs to the old owner. A new owner must obtain a fresh
|
||||
// successful Get before Ready; this never clears a persisted unknown batch.
|
||||
snapshot.AuthenticationInvalid = false;
|
||||
// A recovered active batch might have reached the server. Never acknowledge it by guessing.
|
||||
if (snapshot.Active != null)
|
||||
{
|
||||
if (snapshot.Active.IsOrdinary)
|
||||
{
|
||||
snapshot.OrdinaryUnknownBatches.Add(snapshot.Active);
|
||||
}
|
||||
else
|
||||
{
|
||||
snapshot.Unknown = snapshot.Active;
|
||||
}
|
||||
snapshot.Active = null;
|
||||
}
|
||||
recoveredOrdinaryUnknown = snapshot.OrdinaryUnknownBatches.Count != 0;
|
||||
status = snapshot.Unknown != null || recoveredOrdinaryUnknown
|
||||
? KVSyncStatus.UnknownOutcome : KVSyncStatus.NotLoaded;
|
||||
}
|
||||
|
||||
public static KvOpenResult Open(string root, Uri api, string playerId)
|
||||
{
|
||||
KvIdentity identity;
|
||||
try
|
||||
{
|
||||
identity = new KvIdentity(root, api, playerId);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException)
|
||||
{
|
||||
return new KvOpenResult(KVOpenError.Validation);
|
||||
}
|
||||
var error = KvCacheStore.Open(identity, out var store, out var snapshot);
|
||||
return error == KVOpenError.None ? new KvOpenResult(error, new KvSyncState(store, snapshot)) : new KvOpenResult(error);
|
||||
}
|
||||
|
||||
public KvDiagnosticSnapshot Inspect()
|
||||
{
|
||||
lock (gate)
|
||||
return new KvDiagnosticSnapshot(snapshot);
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> ReadLocal()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status != KVSyncStatus.Ready)
|
||||
{
|
||||
throw new InvalidOperationException("KV data is not ready.");
|
||||
}
|
||||
return new ReadOnlyDictionary<string, string>(ReadView());
|
||||
}
|
||||
}
|
||||
|
||||
// The caller must supply a successful complete Get. Failed reads must use RecordReadFailure.
|
||||
|
||||
internal KvStateResult Reconcile(IReadOnlyDictionary<string, string> remote,
|
||||
IReadOnlyDictionary<string, DateTime> updatedAtUtc)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (Terminal || snapshot.Active != null || snapshot.AuthenticationInvalid || comparisonLocal != null)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if (remote == null || remote.Any(pair => !KvSnapshot.ValidKey(pair.Key) || pair.Value == null))
|
||||
{
|
||||
return Invalid();
|
||||
}
|
||||
if (!recoverImmediateView || updatedAtUtc == null ||
|
||||
updatedAtUtc.Count != remote.Count || remote.Keys.Any(key => !updatedAtUtc.ContainsKey(key)) ||
|
||||
updatedAtUtc.Values.Any(time => time.Kind != DateTimeKind.Utc))
|
||||
{
|
||||
return Invalid();
|
||||
}
|
||||
var original = snapshot;
|
||||
var copy = snapshot.Clone();
|
||||
copy.Conflicts.Clear();
|
||||
if (copy.Unknown != null)
|
||||
{
|
||||
foreach (var item in copy.Unsent())
|
||||
{
|
||||
copy.Conflicts[item.Key] = KvValue.At(remote, item.Key);
|
||||
}
|
||||
return Commit(copy, KVSyncStatus.UnknownOutcome);
|
||||
}
|
||||
var recoveringKeys = new HashSet<string>(copy.ImmediatePreservedKeys, StringComparer.Ordinal);
|
||||
foreach (var sequence in recoveredImmediate)
|
||||
{
|
||||
var source = copy.RemoteOnlyWrites[sequence];
|
||||
Rebase(copy, source.Key, KvValue.At(remote, source.Key));
|
||||
}
|
||||
foreach (var source in Sources(copy))
|
||||
{
|
||||
if (copy.OrdinaryBuffers.Contains(source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (var item in source.Values.ToArray())
|
||||
{
|
||||
var observed = KvValue.At(remote, item.Key);
|
||||
if (observed.Equals(item.Target))
|
||||
{
|
||||
source.Remove(item.Key);
|
||||
}
|
||||
else if (!observed.Equals(item.BaseValue))
|
||||
{
|
||||
copy.Conflicts[item.Key] = observed;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (var item in copy.PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
|
||||
{
|
||||
var observed = KvValue.At(remote, item.Key);
|
||||
if (!observed.Equals(item.Target) && !observed.Equals(item.BaseValue))
|
||||
{
|
||||
copy.Conflicts[item.Key] = observed;
|
||||
}
|
||||
}
|
||||
var visible = new Dictionary<string, string>(remote, StringComparer.Ordinal);
|
||||
foreach (var pair in copy.LocalOrigins.ToArray())
|
||||
{
|
||||
if (pair.Value.LocalOnly)
|
||||
{
|
||||
if (copy.LegacyLocalOnlyUnderImmediate.TryGetValue(pair.Key, out var lower))
|
||||
{
|
||||
visible[pair.Key] = lower;
|
||||
}
|
||||
else
|
||||
{
|
||||
Apply(visible, pair.Key, KvValue.At(copy.LocalData, pair.Key));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var source = copy.Unsent().Concat(copy.PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
|
||||
.Concat(copy.OrdinaryUnknownBatches.SelectMany(batch => batch.Items))
|
||||
.FirstOrDefault(item => item.Sequence == pair.Value.Sequence);
|
||||
if (source != null)
|
||||
{
|
||||
Apply(visible, pair.Key, recoveringKeys.Contains(pair.Key)
|
||||
? source.Target : KvValue.At(copy.LocalData, pair.Key));
|
||||
}
|
||||
else
|
||||
{
|
||||
copy.LocalOrigins.Remove(pair.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
copy.Baseline = remote.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
copy.HasBaseline = true;
|
||||
var reconciled = Commit(copy, copy.Conflicts.Count == 0 ? KVSyncStatus.Ready : KVSyncStatus.Conflict);
|
||||
if (reconciled.Success)
|
||||
{
|
||||
recoverImmediateView = false;
|
||||
comparisonLocal = original;
|
||||
comparisonRemote = remote.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
comparisonTimes = updatedAtUtc.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
comparisonView = visible;
|
||||
comparisonImmediateKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
return reconciled;
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> ReadView()
|
||||
{
|
||||
if (comparisonLocal == null)
|
||||
{
|
||||
return LocalView(snapshot);
|
||||
}
|
||||
var view = new Dictionary<string, string>(comparisonView, StringComparer.Ordinal);
|
||||
foreach (var pair in snapshot.LocalOrigins)
|
||||
{
|
||||
if (pair.Value.Sequence >= comparisonLocal.NextSequence)
|
||||
{
|
||||
Apply(view, pair.Key, KvValue.At(snapshot.LocalData, pair.Key));
|
||||
}
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
internal KvStateResult CompareDatas(long nowTicks)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status != KVSyncStatus.Ready || snapshot.AuthenticationInvalid || snapshot.Active != null ||
|
||||
snapshot.Unknown != null || comparisonLocal == null)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if ((comparisonLocal.OrdinaryUnknownBatches.Count != 0 && !recoveredOrdinaryUnknown) ||
|
||||
snapshot.OrdinaryUnknownBatches.Any(batch => !comparisonLocal.OrdinaryUnknownBatches.Any(initial => initial.Id == batch.Id)))
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if (nowTicks < 0 || nowTicks > DateTime.MaxValue.Ticks)
|
||||
{
|
||||
return Invalid();
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
// A new login uses the original time comparison, not the old request's outcome.
|
||||
// Retire only its initial ordinary evidence in the same commit as that comparison.
|
||||
foreach (var batch in comparisonLocal.OrdinaryUnknownBatches)
|
||||
{
|
||||
copy.OrdinaryUnknownBatches.RemoveAll(current => current.Id == batch.Id);
|
||||
foreach (var item in batch.Items)
|
||||
{
|
||||
if (copy.LocalOrigins.TryGetValue(item.Key, out var origin) && origin.Sequence == item.Sequence)
|
||||
{
|
||||
copy.LocalOrigins.Remove(item.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
var protectedKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var source in new[] { comparisonLocal, snapshot })
|
||||
{
|
||||
protectedKeys.UnionWith(source.PreparedOrdinaryBatches.SelectMany(batch => batch.Items).Select(item => item.Key));
|
||||
protectedKeys.UnionWith(source.LegacyPending.Keys);
|
||||
protectedKeys.UnionWith(source.ExplicitDeletes.Keys);
|
||||
protectedKeys.UnionWith(source.ImmediatePreservedKeys);
|
||||
protectedKeys.UnionWith(source.RemoteOnlyWrites.Values.Select(item => item.Key));
|
||||
}
|
||||
protectedKeys.UnionWith(snapshot.LocalOrigins.Where(pair => pair.Value.Sequence >= comparisonLocal.NextSequence).Select(pair => pair.Key));
|
||||
var visible = ReadView();
|
||||
foreach (var key in protectedKeys)
|
||||
{
|
||||
Apply(copy.LocalData, key, KvValue.At(visible, key));
|
||||
}
|
||||
foreach (var key in comparisonLocal.LocalData.Keys.Concat(comparisonRemote.Keys).Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (protectedKeys.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var localExists = comparisonLocal.HasBaseline && comparisonLocal.LocalData.TryGetValue(key, out _);
|
||||
comparisonLocal.LocalData.TryGetValue(key, out var localValue);
|
||||
var remoteExists = comparisonRemote.TryGetValue(key, out var remoteValue);
|
||||
var localTime = comparisonLocal.LocalUpdateTicks.TryGetValue(key, out var ticks) ? ticks : 0;
|
||||
var remoteTime = remoteExists ? comparisonTimes[key].Ticks : 0;
|
||||
if (!localExists || (remoteExists && remoteTime - localTime > TimeSpan.TicksPerSecond * 60))
|
||||
{
|
||||
copy.LocalData[key] = remoteValue;
|
||||
copy.LocalUpdateTicks[key] = remoteTime;
|
||||
foreach (var bucket in copy.OrdinaryBuffers)
|
||||
{
|
||||
if (bucket.TryGetValue(key, out var current) && comparisonLocal.OrdinaryBuffers.Any(old =>
|
||||
old.TryGetValue(key, out var item) && item.Sequence == current.Sequence))
|
||||
{
|
||||
bucket.Remove(key);
|
||||
if (copy.LocalOrigins.TryGetValue(key, out var origin) && origin.Sequence == current.Sequence)
|
||||
{
|
||||
copy.LocalOrigins.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!remoteExists || localTime - remoteTime > TimeSpan.TicksPerSecond * 60)
|
||||
{
|
||||
if (copy.NextSequence == long.MaxValue)
|
||||
{
|
||||
return Invalid();
|
||||
}
|
||||
ApplyUpdate(copy, key, localValue, true, nowTicks);
|
||||
}
|
||||
else
|
||||
{
|
||||
copy.LocalData[key] = localValue;
|
||||
copy.LocalUpdateTicks[key] = localTime;
|
||||
}
|
||||
}
|
||||
foreach (var sequence in comparisonLocal.RemoteOnlyWrites.Keys)
|
||||
{
|
||||
copy.RemoteOnlyWrites.Remove(sequence);
|
||||
}
|
||||
foreach (var key in comparisonLocal.LegacyLocalOnlyUnderImmediate.Keys)
|
||||
{
|
||||
copy.LegacyLocalOnlyUnderImmediate.Remove(key);
|
||||
}
|
||||
foreach (var key in comparisonLocal.ImmediatePreservedKeys)
|
||||
{
|
||||
if (!comparisonImmediateKeys.Contains(key))
|
||||
{
|
||||
copy.ImmediatePreservedKeys.Remove(key);
|
||||
}
|
||||
}
|
||||
var result = Commit(copy, KVSyncStatus.Ready);
|
||||
if (result.Success)
|
||||
{
|
||||
recoveredOrdinaryUnknown = false;
|
||||
ClearComparison();
|
||||
recoveredImmediate.Clear();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearComparison()
|
||||
{
|
||||
comparisonLocal = null;
|
||||
comparisonRemote = null;
|
||||
comparisonTimes = null;
|
||||
comparisonView = null;
|
||||
comparisonImmediateKeys = null;
|
||||
}
|
||||
|
||||
public KvStateResult RecordReadFailure(bool unauthorized = false)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (Terminal || snapshot.Active != null)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if (unauthorized)
|
||||
{
|
||||
var copy = snapshot.Clone();
|
||||
copy.AuthenticationInvalid = true;
|
||||
return Commit(copy, copy.Unknown == null ? KVSyncStatus.Unauthorized : KVSyncStatus.UnknownOutcome);
|
||||
}
|
||||
status = snapshot.Unknown != null ? KVSyncStatus.UnknownOutcome : KVSyncStatus.ReadFailed;
|
||||
return new KvStateResult(KVChangeError.None);
|
||||
}
|
||||
}
|
||||
|
||||
internal KvStateResult Update(string key, string value, bool remoteStore, long updateTicks)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status != KVSyncStatus.Ready || snapshot.AuthenticationInvalid)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if (!KvSnapshot.ValidKey(key) || value == null || snapshot.NextSequence == long.MaxValue ||
|
||||
(updateTicks < 0 || updateTicks > DateTime.MaxValue.Ticks))
|
||||
{
|
||||
return Invalid();
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
var sequence = ApplyUpdate(copy, key, value, remoteStore, updateTicks);
|
||||
return Commit(copy, KVSyncStatus.Ready, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
private static long ApplyUpdate(KvSnapshot copy, string key, string value, bool remoteStore, long updateTicks)
|
||||
{
|
||||
var sequence = copy.NextSequence++;
|
||||
copy.LocalData[key] = value;
|
||||
copy.ImmediatePreservedKeys.Remove(key);
|
||||
copy.LegacyLocalOnlyUnderImmediate.Remove(key);
|
||||
copy.LocalUpdateTicks[key] = updateTicks;
|
||||
var acceptedRemote = false;
|
||||
if (remoteStore)
|
||||
{
|
||||
foreach (var bucket in copy.OrdinaryBuffers)
|
||||
{
|
||||
if (!bucket.ContainsKey(key) && bucket.Count >= 10)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var baseline = bucket.TryGetValue(key, out var previous)
|
||||
? previous.BaseValue : KvValue.At(copy.Baseline, key);
|
||||
bucket[key] = new KvChange(key, sequence, baseline, KvValue.Present(value));
|
||||
copy.LegacyPending.Remove(key);
|
||||
copy.ExplicitDeletes.Remove(key);
|
||||
acceptedRemote = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
copy.LocalOrigins[key] = (!acceptedRemote, sequence);
|
||||
return sequence;
|
||||
}
|
||||
|
||||
internal KvStateResult CaptureOrdinaryBatch(out KvBatch batch)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
batch = null;
|
||||
if (status != KVSyncStatus.Ready || snapshot.AuthenticationInvalid)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if (snapshot.OrdinaryBuffers[0].Count == 0)
|
||||
{
|
||||
return new KvStateResult(KVChangeError.None);
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
var first = copy.OrdinaryBuffers[0];
|
||||
var captured = new KvBatch(Guid.NewGuid().ToString("N"), first.Values.OrderBy(item => item.Sequence), true);
|
||||
copy.PreparedOrdinaryBatches.Add(captured);
|
||||
copy.OrdinaryBuffers.RemoveAt(0);
|
||||
copy.OrdinaryBuffers.Add(new Dictionary<string, KvChange>(StringComparer.Ordinal));
|
||||
var result = Commit(copy, KVSyncStatus.Ready);
|
||||
if (result.Success)
|
||||
{
|
||||
batch = captured;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal KvStateResult BeginOrdinaryBatch(out KvBatch batch)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
batch = null;
|
||||
if (status != KVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if (snapshot.PreparedOrdinaryBatches.Count == 0)
|
||||
{
|
||||
return new KvStateResult(KVChangeError.None);
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
copy.Active = copy.PreparedOrdinaryBatches[0];
|
||||
copy.PreparedOrdinaryBatches.RemoveAt(0);
|
||||
var result = Commit(copy, KVSyncStatus.Ready);
|
||||
if (result.Success)
|
||||
{
|
||||
batch = snapshot.Active;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit requests retain their original source until the outcome is known. Ordinary buckets are never selected here.
|
||||
|
||||
internal KvStateResult BeginBatch(long throughSequence, out KvBatch batch)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
batch = null;
|
||||
if (status != KVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if (throughSequence < 0)
|
||||
{
|
||||
return Invalid();
|
||||
}
|
||||
var source = snapshot.LegacyPending.Values;
|
||||
var ordered = source.Where(item => item.Sequence <= throughSequence).OrderBy(item => item.Sequence).ToArray();
|
||||
if (ordered.Length == 0)
|
||||
{
|
||||
return new KvStateResult(KVChangeError.None);
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
copy.Active = new KvBatch(Guid.NewGuid().ToString("N"), ordered.TakeWhile(item => item.Target.Exists == ordered[0].Target.Exists).Take(10));
|
||||
var result = Commit(copy, KVSyncStatus.Ready);
|
||||
if (result.Success)
|
||||
{
|
||||
batch = snapshot.Active;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public KvStateResult ConfirmBatch(string batchId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status != KVSyncStatus.Ready || snapshot.Active == null || snapshot.Active.Id != batchId)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
foreach (var item in copy.Active.Items)
|
||||
{
|
||||
Apply(copy.Baseline, item.Key, item.Target);
|
||||
if (!copy.Active.IsOrdinary)
|
||||
{
|
||||
foreach (var source in new[] { copy.LegacyPending, copy.ExplicitDeletes })
|
||||
{
|
||||
if (source.TryGetValue(item.Key, out var current) && current.Sequence == item.Sequence)
|
||||
{
|
||||
source.Remove(item.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Rebase(copy, item.Key, item.Target);
|
||||
}
|
||||
copy.Active = null;
|
||||
return Commit(copy, KVSyncStatus.Ready);
|
||||
}
|
||||
}
|
||||
|
||||
// A definitely undispatched ordinary batch retires, as the original SDK call did; explicit source references stay.
|
||||
public KvStateResult FailBatch(string batchId, bool mayHaveCommitted, bool unauthorized = false)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (Terminal || snapshot.Active == null || snapshot.Active.Id != batchId)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
var ordinary = copy.Active.IsOrdinary;
|
||||
copy.AuthenticationInvalid |= unauthorized;
|
||||
if (mayHaveCommitted)
|
||||
{
|
||||
if (ordinary)
|
||||
{
|
||||
copy.OrdinaryUnknownBatches.Add(copy.Active);
|
||||
}
|
||||
else
|
||||
{
|
||||
copy.Unknown = copy.Active;
|
||||
}
|
||||
}
|
||||
copy.Active = null;
|
||||
var next = copy.AuthenticationInvalid ? KVSyncStatus.Unauthorized : ordinary ? KVSyncStatus.Ready : KVSyncStatus.ReadFailed;
|
||||
if (copy.Unknown != null)
|
||||
{
|
||||
next = KVSyncStatus.UnknownOutcome;
|
||||
}
|
||||
return Commit(copy, next);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Dictionary<string, KvChange>> Sources(KvSnapshot value)
|
||||
{
|
||||
return value.OrdinaryBuffers.Concat(new[] { value.LegacyPending, value.ExplicitDeletes });
|
||||
}
|
||||
private static void RemoveSources(KvSnapshot value, string key)
|
||||
{
|
||||
foreach (var source in Sources(value))
|
||||
{
|
||||
source.Remove(key);
|
||||
}
|
||||
}
|
||||
private static void Rebase(KvSnapshot value, string key, KvValue baseline)
|
||||
{
|
||||
foreach (var source in Sources(value))
|
||||
{
|
||||
if (source.TryGetValue(key, out var pending))
|
||||
{
|
||||
source[key] = pending.WithBase(baseline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal KvStateResult PrepareImmediate(IReadOnlyDictionary<string, string> data,
|
||||
out IReadOnlyList<KvChange> operations)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
operations = null;
|
||||
if (status != KVSyncStatus.Ready || snapshot.AuthenticationInvalid)
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
if (data == null || data.Count == 0 || data.Count > long.MaxValue - snapshot.NextSequence ||
|
||||
data.Any(pair => !KvSnapshot.ValidKey(pair.Key) || pair.Value == null))
|
||||
{
|
||||
return Invalid();
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
var prepared = new List<KvChange>();
|
||||
foreach (var pair in data)
|
||||
{
|
||||
copy.ImmediatePreservedKeys.Add(pair.Key);
|
||||
var operation = new KvChange(pair.Key, copy.NextSequence++,
|
||||
KvValue.At(copy.Baseline, pair.Key), KvValue.Present(pair.Value));
|
||||
copy.RemoteOnlyWrites.Add(operation.Sequence, operation);
|
||||
RemoveSources(copy, pair.Key);
|
||||
prepared.Add(operation);
|
||||
}
|
||||
var result = Commit(copy, KVSyncStatus.Ready, prepared[0].Sequence);
|
||||
if (result.Success)
|
||||
{
|
||||
operations = prepared.AsReadOnly();
|
||||
if (comparisonLocal != null)
|
||||
{
|
||||
comparisonImmediateKeys.UnionWith(data.Keys);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal KvStateResult CompleteImmediate(IReadOnlyList<KvChange> operations)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status != KVSyncStatus.Ready || snapshot.AuthenticationInvalid || snapshot.Active != null ||
|
||||
snapshot.Unknown != null || operations.Any(operation => !snapshot.RemoteOnlyWrites.ContainsKey(operation.Sequence)))
|
||||
{
|
||||
return Blocked();
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
Apply(copy.Baseline, operation.Key, operation.Target);
|
||||
Rebase(copy, operation.Key, operation.Target);
|
||||
copy.RemoteOnlyWrites.Remove(operation.Sequence);
|
||||
copy.ImmediatePreservedKeys.Add(operation.Key);
|
||||
}
|
||||
return Commit(copy, KVSyncStatus.Ready);
|
||||
}
|
||||
}
|
||||
|
||||
internal void CancelUndispatchedImmediate(IReadOnlyList<KvChange> operations)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (Terminal)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var protectedBatches = snapshot.OrdinaryUnknownBatches.Concat(new[] { snapshot.Active, snapshot.Unknown }).Where(batch => batch != null);
|
||||
var removable = operations.Where(operation => snapshot.RemoteOnlyWrites.ContainsKey(operation.Sequence) &&
|
||||
!protectedBatches.Any(batch => batch.Items.Any(item => item.Key == operation.Key && item.Sequence < operation.Sequence))).ToArray();
|
||||
if (removable.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var copy = snapshot.Clone();
|
||||
foreach (var operation in removable)
|
||||
{
|
||||
copy.RemoteOnlyWrites.Remove(operation.Sequence);
|
||||
}
|
||||
Commit(copy, status);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> LocalView(KvSnapshot source)
|
||||
{
|
||||
return new Dictionary<string, string>(source.LocalData, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private bool Terminal => status == KVSyncStatus.Closed || status == KVSyncStatus.StorageError;
|
||||
private KvStateResult Commit(KvSnapshot copy, KVSyncStatus nextStatus, long sequence = 0)
|
||||
{
|
||||
if (snapshot.Revision == long.MaxValue)
|
||||
{
|
||||
status = KVSyncStatus.StorageError;
|
||||
return new KvStateResult(KVChangeError.StorageError, sequence);
|
||||
}
|
||||
copy.Revision = snapshot.Revision + 1;
|
||||
if (!store.Save(copy, out var uncertain))
|
||||
{
|
||||
status = KVSyncStatus.StorageError;
|
||||
return new KvStateResult(KVChangeError.StorageError, sequence, uncertain);
|
||||
}
|
||||
snapshot = copy;
|
||||
status = nextStatus;
|
||||
return new KvStateResult(KVChangeError.None, sequence);
|
||||
}
|
||||
private static void Apply(Dictionary<string, string> data, string key, KvValue value)
|
||||
{
|
||||
if (value.Exists)
|
||||
{
|
||||
data[key] = value.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
data.Remove(key);
|
||||
}
|
||||
}
|
||||
private KvStateResult Blocked() => new KvStateResult(KVChangeError.Blocked);
|
||||
private KvStateResult Invalid() => new KvStateResult(KVChangeError.Validation);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status == KVSyncStatus.Closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
status = KVSyncStatus.Closed;
|
||||
ClearComparison();
|
||||
store.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Client/Assets/Scripts/Core/KV/KvSyncState.cs.meta
Normal file
11
Client/Assets/Scripts/Core/KV/KvSyncState.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0aabf3ee88684f80ba8770cbf96a9be8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user