Files
ft/CodexOutput/upserver/KVSyncTests/Program.cs

278 lines
21 KiB
C#

using System.Diagnostics;
using System.Reflection;
using game.PlayerDataStorage;
using Newtonsoft.Json.Linq;
if (args.Length > 0) return Child(args);
var folder = Path.GetFullPath("CodexOutput/upserver/KVSyncTests/artifacts/" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var passed = 0; var failed = 0;
void Test(string name, Action test)
{
try { test(); passed++; Console.WriteLine("PASS " + name); }
catch (Exception e) { failed++; Console.WriteLine("FAIL " + name + ": " + e.GetType().Name + " " + e.Message); }
}
Fixture New() => new Fixture(Path.Combine(folder, Guid.NewGuid().ToString("N")));
void A(bool ok, string message = "Invariant failed") { if (!ok) throw new InvalidOperationException(message); }
void Ok(PlayerKVStateResult r) => A(r.Success, "State result " + r.Error);
PlayerKVBatch Start(PlayerKVSyncState state, long limit = long.MaxValue)
{ Ok(state.BeginBatch(limit, out var batch)); A(batch != null, "No batch"); return batch; }
void Ready(Fixture f) { Ok(f.State.Reconcile(Data("k", "base"))); }
Test("Fresh cache is not ready; failed reads cannot create an empty save", () => {
using var f = New(); A(f.State.Status == PlayerKVSyncStatus.NotLoaded);
A(!f.State.Update("k", "v").Success); Ok(f.State.RecordReadFailure());
A(f.State.Status == PlayerKVSyncStatus.ReadFailed && !File.Exists(f.Main));
try { f.State.ReadLocal(); throw new Exception("Read exposed"); } catch (InvalidOperationException) { }
A(f.State.Reconcile(null).Error == PlayerKVChangeError.Validation);
Ok(f.State.Reconcile(Data())); A(f.State.Status == PlayerKVSyncStatus.Ready && f.State.ReadLocal().Count == 0);
});
Test("API normalization and account/path/environment namespaces", () => {
using var f = New();
var aliases = PlayerKVSyncState.Open(f.Root, new Uri("HTTP://EXAMPLE.TEST:80/api"), "00101");
A(aliases.Error == PlayerKVOpenError.Busy);
foreach (var (uri,id) in new[] { ("http://example.test/api/", "102"), ("http://example.test/API/", "101"), ("http://other.test/api/", "101"), ("http://example.test:8080/api/", "101") }) {
using var s = PlayerKVSyncState.Open(f.Root, new Uri(uri), id).State; A(s != null); Ok(s.Reconcile(Data()));
}
});
foreach (var id in new[] { "", "0", "-1", "../101", "1e2", "9223372036854775808", " 101" })
Test("Reject invalid ID " + id, () => A(PlayerKVSyncState.Open(folder, Fixture.Api, id).Error == PlayerKVOpenError.Validation));
foreach (var uri in new[] { "file:///tmp", "http://example.test/?x=1", "http://u:p@example.test/", "http://example.test/#part" })
Test("Reject invalid API form", () => A(PlayerKVSyncState.Open(folder, new Uri(uri), "101").Error == PlayerKVOpenError.Validation));
Test("Reject relative cache root", () => A(PlayerKVSyncState.Open("relative", Fixture.Api, "101").Error == PlayerKVOpenError.Validation));
foreach (var path in new[] { @"C:relative", @"\relative" })
Test("Reject Windows rooted but non-absolute cache path", () => A(PlayerKVSyncState.Open(path, Fixture.Api, "101").Error == PlayerKVOpenError.Validation));
Test("Real file lease excludes another process and is reusable after close", () => {
using var f = New(); A(RunChild("--lock", f.Root) == 21);
f.State.Dispose(); A(RunChild("--lock", f.Root) == 0);
f.Reopen(); A(f.State != null);
});
Test("Empty string/delete/localOnly survive disk reload with distinct meaning", () => {
using var f = New(); Ready(f); Ok(f.State.Update("empty", "")); Ok(f.State.Update("local", "private", false)); Ok(f.State.Delete("k"));
var local = f.State.ReadLocal(); A(local["empty"] == "" && local["local"] == "private" && !local.ContainsKey("k"));
f.Reopen(); A(f.State.Status == PlayerKVSyncStatus.NotLoaded); Ready(f);
A(f.State.ReadLocal()["local"] == "private" && f.State.Inspect().Pending.Count == 2);
});
Test("remoteStore=false retains prior remote intent; remote Set and Delete clear overlay", () => {
using var f = New(); Ready(f); Ok(f.State.Update("k", "v1")); Ok(f.State.Update("k", "local", false));
var b = Start(f.State); A(b.Items.Single().Target.Value == "v1"); Ok(f.State.ConfirmBatch(b.Id));
A(f.State.ReadLocal()["k"] == "local" && f.State.Inspect().Pending.Count == 0);
Ok(f.State.Update("k", "v2")); A(f.State.ReadLocal()["k"] == "v2" && f.State.Inspect().LocalOnly.Count == 0);
Ok(f.State.Update("k", "local", false)); Ok(f.State.Delete("k")); A(!f.State.ReadLocal().ContainsKey("k"));
Ok(f.State.ConfirmBatch(Start(f.State).Id)); A(!f.State.ReadLocal().ContainsKey("k"));
});
Test("v1 in flight then v2: confirm only v1 and advance v2 base", () => {
using var f = New(); Ready(f); var v1 = f.State.Update("k", "v1"); Ok(v1); var batch = Start(f.State);
var v2 = f.State.Update("k", "v2"); Ok(v2); A(v2.AttemptSequence > v1.AttemptSequence && batch.Items[0].Target.Value == "v1");
Ok(f.State.ConfirmBatch(batch.Id)); var item = f.State.Inspect().Pending["k"];
A(item.Sequence == v2.AttemptSequence && item.Target.Value == "v2" && item.BaseValue.Value == "v1");
f.Reopen(); Ok(f.State.Reconcile(Data("k", "v1"))); A(f.State.ReadLocal()["k"] == "v2");
});
Test("Set in flight then Delete; delete in flight then new Set", () => {
using var f = New(); Ready(f); Ok(f.State.Update("k", "v1")); var set = Start(f.State); Ok(f.State.Delete("k"));
Ok(f.State.ConfirmBatch(set.Id)); var del = Start(f.State); A(del.IsDelete && del.Items[0].BaseValue.Value == "v1");
Ok(f.State.Update("k", "v3")); Ok(f.State.ConfirmBatch(del.Id));
A(!f.State.Inspect().Pending["k"].BaseValue.Exists && f.State.ReadLocal()["k"] == "v3");
});
Test("101 unique keys persist and drain in batches of at most 10", () => {
using var f = New(); Ok(f.State.Reconcile(Data()));
for (var i=0;i<101;i++) Ok(f.State.Update("key"+i, "v"+i));
f.Reopen(); Ok(f.State.Reconcile(Data())); int total=0,batches=0;
while(f.State.Inspect().Pending.Count != 0) { var b=Start(f.State); A(b.Items.Count<=10);total+=b.Items.Count;batches++;Ok(f.State.ConfirmBatch(b.Id)); }
A(total==101 && batches==11 && f.State.ReadLocal().Count==101);
});
Test("Batch respects consecutive operation kind, not only matching kind", () => {
using var f=New();Ready(f);Ok(f.State.Delete("a"));Ok(f.State.Update("b","v"));Ok(f.State.Delete("c"));
var b=Start(f.State);A(b.IsDelete && b.Items.Count==1 && b.Items[0].Key=="a");Ok(f.State.ConfirmBatch(b.Id));
b=Start(f.State);A(!b.IsDelete && b.Items.Count==1 && b.Items[0].Key=="b");
});
Test("Watermark excludes newer edits and superseded unsent sequence", () => {
using var f=New();Ready(f);var old=f.State.Update("k","v1");Ok(old);Ok(f.State.Update("k","v2"));
Ok(f.State.BeginBatch(old.AttemptSequence,out var none));A(none==null);
A(Start(f.State).Items[0].Sequence>old.AttemptSequence);
});
foreach (var observed in new[] { "base", "local", "other" })
Test("Three-way pending merge " + observed, () => {
using var f=New();Ready(f);Ok(f.State.Update("k","local"));f.Reopen();Ok(f.State.Reconcile(Data("k",observed)));
A(f.State.Status==(observed=="other"?PlayerKVSyncStatus.Conflict:PlayerKVSyncStatus.Ready));
A(f.State.Inspect().Pending.Count==(observed=="local"?0:1));
if(observed=="other") { A(!f.State.Update("x","v").Success);A(!f.State.BeginBatch(long.MaxValue,out _).Success); A(f.State.Inspect().Pending["k"].BaseValue.Value=="base");Ok(f.State.Reconcile(Data("k","base")));A(f.State.Status==PlayerKVSyncStatus.Ready); }
});
Test("Missing baseline, empty string and delete use existence, not null substitution", () => {
using var f=New();Ok(f.State.Reconcile(Data()));Ok(f.State.Update("new",""));f.Reopen();Ok(f.State.Reconcile(Data()));
A(f.State.Inspect().Pending.Count==1);Ok(f.State.Reconcile(Data("new","")));A(f.State.Inspect().Pending.Count==0);
Ok(f.State.Delete("new"));f.Reopen();Ok(f.State.Reconcile(Data()));A(f.State.Inspect().Pending.Count==0);
});
Test("Mixed merge conflict blocks entire instance while preserving local intention", () => {
using var f=New();Ok(f.State.Reconcile(Data("a","base","b","base")));Ok(f.State.Update("a","A"));Ok(f.State.Update("b","B"));
f.Reopen();Ok(f.State.Reconcile(Data("a","A","b","other")));A(f.State.Status==PlayerKVSyncStatus.Conflict);
A(f.State.Inspect().Pending.Count==1 && f.State.Inspect().ConflictObservations.ContainsKey("b"));A(!f.State.BeginBatch(long.MaxValue,out _).Success);
});
Test("JSON value equality is byte-string equality", () => {
using var f=New();Ok(f.State.Reconcile(Data("k","{}")));Ok(f.State.Update("k","{\"v\":1}"));f.Reopen();
Ok(f.State.Reconcile(Data("k","{ \"v\": 1 }")));A(f.State.Status==PlayerKVSyncStatus.Conflict);
});
foreach(var observation in new[] { "base", "v1", "other" })
Test("Unknown write survives reopen/readback without unlocking " + observation, () => {
using var f=New();Ready(f);Ok(f.State.Update("k","v1"));var b=Start(f.State);Ok(f.State.Update("k","v2"));
Ok(f.State.FailBatch(b.Id,true,true));A(f.State.Status==PlayerKVSyncStatus.UnknownOutcome && f.State.Inspect().AuthenticationInvalid);
f.Reopen();Ok(f.State.Reconcile(Data("k",observation)));A(f.State.Status==PlayerKVSyncStatus.UnknownOutcome);
A(f.State.Inspect().UnknownBatch.Items[0].Target.Value=="v1" && f.State.Inspect().Pending["k"].Target.Value=="v2");
A(!f.State.BeginBatch(long.MaxValue,out _).Success && !f.State.Update("k","v3").Success);f.Reopen();A(f.State.Status==PlayerKVSyncStatus.UnknownOutcome);
});
Test("Process exit with write-ahead batch recovers as unknown", () => {
using var f=New();Ready(f);f.State.Dispose();A(RunChild("--active",f.Root)==0);f.Reopen();
A(f.State.Status==PlayerKVSyncStatus.UnknownOutcome && f.State.Inspect().UnknownBatch!=null);
});
Test("Positive no-dispatch evidence retains pending and permits fresh read retry", () => {
using var f=New();Ready(f);Ok(f.State.Update("k","v1"));var b=Start(f.State);Ok(f.State.FailBatch(b.Id,false));
A(f.State.Status==PlayerKVSyncStatus.ReadFailed && f.State.Inspect().Pending.Count==1 && f.State.Inspect().UnknownBatch==null);
Ok(f.State.Reconcile(Data("k","base")));A(f.State.Status==PlayerKVSyncStatus.Ready);
});
Test("Unauthorized stops same instance; new instance requires new successful read", () => {
using var f=New();Ready(f);Ok(f.State.RecordReadFailure(true));A(f.State.Status==PlayerKVSyncStatus.Unauthorized);
A(!f.State.Reconcile(Data()).Success);f.Reopen();A(f.State.Status==PlayerKVSyncStatus.NotLoaded);Ready(f);
});
foreach(var stage in Enum.GetValues<KVCacheWriteStage>())
Test("Edit disk fault " + stage, () => {
using var f=New();Ready(f);var before=f.State.Inspect();f.Arm(stage);var r=f.State.Update("k","new");
A(!r.Success && r.Error==PlayerKVChangeError.StorageError && r.AttemptSequence==before.NextSequence && r.Revision==before.Revision+1);
A(r.LocalCommitMayHaveSucceeded==(stage==KVCacheWriteStage.AfterReplace));
A(f.State.Inspect().Revision==before.Revision && f.State.Inspect().Pending.Count==0 && f.State.Status==PlayerKVSyncStatus.StorageError);
A(!f.State.Update("later","no").Success);f.Disarm();f.Reopen();
A(f.State.Inspect().Pending.Count==(stage==KVCacheWriteStage.AfterReplace?1:0));
});
Test("BeginBatch disk failure never exposes sendable batch", () => {
using var f=New();Ready(f);Ok(f.State.Update("k","v1"));f.Arm(KVCacheWriteStage.AfterReplace);
var r=f.State.BeginBatch(long.MaxValue,out var batch);A(!r.Success && batch==null && r.LocalCommitMayHaveSucceeded);
f.Disarm();f.Reopen();A(f.State.Status==PlayerKVSyncStatus.UnknownOutcome);
});
foreach(var stage in new[] { KVCacheWriteStage.BeforeReplace, KVCacheWriteStage.AfterReplace })
Test("HTTP-confirmation disk failure " + stage, () => {
using var f=New();Ready(f);Ok(f.State.Update("k","v1"));var batch=Start(f.State);f.Arm(stage);
var r=f.State.ConfirmBatch(batch.Id);A(!r.Success && f.State.Inspect().ActiveBatch!=null);f.Disarm();f.Reopen();
A(stage==KVCacheWriteStage.AfterReplace ? f.State.Inspect().Pending.Count==0 && f.State.Status==PlayerKVSyncStatus.NotLoaded : f.State.Status==PlayerKVSyncStatus.UnknownOutcome);
});
foreach(var stage in Enum.GetValues<KVCacheWriteStage>().Where(s=>s!=KVCacheWriteStage.BeforeWrite))
Test("Process termination at disk boundary " + stage, () => {
using var f=New();Ready(f);f.State.Dispose();A(RunChild("--crash",f.Root,stage.ToString())==17);f.Reopen();
A(f.State.Inspect().Pending.Count==(stage==KVCacheWriteStage.AfterReplace?1:0));
});
Test("Main corruption never falls back to older valid backup", () => {
using var f=New();Ready(f);Ok(f.State.Update("k","new"));f.State.Dispose();A(File.Exists(f.Main+".bak"));
File.WriteAllText(f.Main,"broken");A(f.TryReopen().Error==PlayerKVOpenError.RecoveryRequired);A(File.ReadAllText(f.Main)=="broken");
});
Test("Valid main wins over broken backup and stale temporary file", () => {
using var f=New();Ready(f);Ok(f.State.Update("k","new"));f.State.Dispose();File.WriteAllText(f.Main+".bak","broken");File.WriteAllText(f.Main+".tmp","broken");
f.Reopen();A(f.State.Inspect().Pending["k"].Target.Value=="new");
});
foreach(var suffix in new[] { ".bak", ".tmp" })
Test("Missing main with orphan " + suffix + " requires recovery", () => {
using var f=New();f.State.Dispose();File.WriteAllText(f.Main+suffix,"orphan");A(f.TryReopen().Error==PlayerKVOpenError.RecoveryRequired);
});
var mutations = new (string,Action<JObject>)[] {
("future format", j=>j["formatVersion"]=2), ("wrong account",j=>j["playerId"]="102"), ("wrong API",j=>j["apiBaseUri"]="http://other.test/"),
("missing field",j=>j.Remove("pending")), ("bad bool",j=>j["hasBaseline"]="true"), ("bad revision",j=>j["revision"]=-1),
("bad sequence",j=>j["nextSequence"]=0), ("pending null",j=>j["pending"]=null), ("null value",j=>j["baseline"]["k"]=null),
("bad key",j=>j["baseline"][new string('x',129)]="v"), ("duplicate sequence",j=>j["pending"][1]["sequence"]=j["pending"][0]["sequence"]!.DeepClone()),
("sequence beyond counter",j=>j["pending"][0]["sequence"]=j["nextSequence"]!.DeepClone()),
("impossible missing value",j=>j["pending"][0]["target"]["exists"]=false), ("no baseline with pending",j=>j["hasBaseline"]=false),
("invalid conflict",j=>j["conflictObservations"]["absent"]=JObject.Parse("{\"exists\":false,\"value\":null}"))
};
foreach(var (name,mutate) in mutations)
Test("Reject damaged cache " + name, () => {
using var f=New();Ready(f);Ok(f.State.Update("a","1"));Ok(f.State.Update("b","2"));f.State.Dispose();var j=JObject.Parse(File.ReadAllText(f.Main));mutate(j);File.WriteAllText(f.Main,j.ToString());
A(f.TryReopen().Error==PlayerKVOpenError.RecoveryRequired);
});
foreach(var text in new[] { "{}", "[]", "{\"formatVersion\":1,\"formatVersion\":1}", "{\"revision\":92233720368547758081234}" })
Test("Reject malformed snapshot shape", () => { using var f=New();f.State.Dispose();File.WriteAllText(f.Main,text);A(f.TryReopen().Error==PlayerKVOpenError.RecoveryRequired); });
var batchMutations = new (string,Action<JObject>)[] {
("empty batch",j=>j["activeBatch"]["items"]=new JArray()),
("missing pending",j=>j["pending"]=new JArray()),
("target mismatch",j=>j["activeBatch"]["items"][0]["target"]["value"]="different"),
("base mismatch",j=>j["activeBatch"]["items"][0]["base"]["value"]="different"),
("bad batch id",j=>j["activeBatch"]["id"]="not-a-batch"),
("both active and unknown",j=>j["unknownBatch"]=j["activeBatch"]!.DeepClone()),
("duplicate active key",j=>((JArray)j["activeBatch"]["items"]).Add(j["activeBatch"]["items"][0]!.DeepClone())),
("oversized counter",j=>j["nextSequence"]=JToken.Parse("92233720368547758081234"))
};
foreach(var (name,mutate) in batchMutations)
Test("Reject inconsistent write-ahead record " + name, () => {
using var f=New();Ready(f);Ok(f.State.Update("k","new"));Start(f.State);f.State.Dispose();
var j=JObject.Parse(File.ReadAllText(f.Main));mutate(j);File.WriteAllText(f.Main,j.ToString());
A(f.TryReopen().Error==PlayerKVOpenError.RecoveryRequired);
});
Test("Oversized disk snapshot is rejected before parsing", () => {
using var f=New();f.State.Dispose();using(var stream=File.Create(f.Main))stream.SetLength(64L*1024*1024+1);
A(f.TryReopen().Error==PlayerKVOpenError.RecoveryRequired);
});
Test("Concurrent edits receive distinct durable sequences without lost keys", () => {
using var f=New();Ok(f.State.Reconcile(Data()));
var results=new System.Collections.Concurrent.ConcurrentBag<PlayerKVStateResult>();
Parallel.For(0,24,i=>results.Add(f.State.Update("parallel"+i,"v"+i)));
A(results.All(r=>r.Success) && results.Select(r=>r.AttemptSequence).Distinct().Count()==24 && f.State.Inspect().Pending.Count==24);
f.Reopen();A(f.State.Inspect().Pending.Count==24);
});
Test("Sequence overflow explicitly refuses edit without corrupting old state", () => {
using var f=New();Ready(f);f.State.Dispose();var j=JObject.Parse(File.ReadAllText(f.Main));j["nextSequence"]=long.MaxValue;File.WriteAllText(f.Main,j.ToString());
f.Reopen();Ready(f);A(f.State.Update("k","new").Error==PlayerKVChangeError.Validation && f.State.Inspect().Pending.Count==0);
});
Test("Revision overflow blocks persistence without clearing pending", () => {
using var f=New();Ready(f);Ok(f.State.Update("k","new"));f.State.Dispose();var j=JObject.Parse(File.ReadAllText(f.Main));j["revision"]=long.MaxValue;File.WriteAllText(f.Main,j.ToString());
f.Reopen();A(!f.State.Reconcile(Data("k","base")).Success && f.State.Inspect().Pending.Count==1 && f.State.Status==PlayerKVSyncStatus.StorageError);
});
Test("Invalid keys/values do not advance revision or touch disk", () => {
using var f=New();Ready(f);var revision=f.State.Inspect().Revision;
foreach(var key in new[] { "", null, new string('k',129) }) A(f.State.Update(key,"v").Error==PlayerKVChangeError.Validation);
A(f.State.Update("k",null).Error==PlayerKVChangeError.Validation && f.State.Inspect().Revision==revision);
Ok(f.State.Update(new string('k',128),""));
});
Test("Closed state cannot edit, acknowledge or release another owner's lease", () => {
using var f=New();Ready(f);Ok(f.State.Update("k","new"));var batch=Start(f.State);var old=f.State;old.Dispose();f.Reopen();old.Dispose();
A(!old.ConfirmBatch(batch.Id).Success && !old.Update("k","later").Success);
A(PlayerKVSyncState.Open(f.Root,Fixture.Api,"101").Error==PlayerKVOpenError.Busy && f.State.Status==PlayerKVSyncStatus.UnknownOutcome);
});
Test("Diagnostics/local data and batch items are immutable snapshots", () => {
using var f=New();Ready(f);Ok(f.State.Update("k","v1"));var view=f.State.Inspect();var batch=Start(f.State);
Ok(f.State.Update("k","v2"));A(view.Pending["k"].Target.Value=="v1" && batch.Items[0].Target.Value=="v1");
try { ((IDictionary<string,string>)f.State.ReadLocal()).Add("x","y");throw new Exception("Mutable"); } catch(NotSupportedException) { }
});
Console.WriteLine($"Passed {passed}; Failed {failed}. Synthetic fixtures only: {Path.GetFileName(folder)}");
return failed==0?0:1;
static Dictionary<string,string> Data(params string[] pairs)
{ var d=new Dictionary<string,string>(StringComparer.Ordinal);for(int i=0;i<pairs.Length;i+=2)d.Add(pairs[i],pairs[i+1]);return d; }
static int RunChild(params string[] args)
{
var start=new ProcessStartInfo("dotnet") { UseShellExecute=false,CreateNoWindow=true,RedirectStandardOutput=true,RedirectStandardError=true };
start.ArgumentList.Add(Assembly.GetExecutingAssembly().Location);foreach(var arg in args)start.ArgumentList.Add(arg);
using var process=Process.Start(start);
if(!process.WaitForExit(10000)){process.Kill();throw new TimeoutException("Child process");}
return process.ExitCode;
}
static int Child(string[] args)
{
bool armed=false;
var result=PlayerKVSyncState.Open(args[1],Fixture.Api,"101",stage=> { if(armed && args[0]=="--crash" && stage.ToString()==args[2])Environment.Exit(17); });
if(args[0]=="--lock") { result.State?.Dispose();return result.Error==PlayerKVOpenError.Busy?21:result.Success?0:22; }
if(!result.Success)return 23;
var state=result.State;if(!state.Reconcile(Data("k","base")).Success)return 24;
armed=true;if(!state.Update("k","child").Success)return 25;
if(args[0]=="--active"){if(!state.BeginBatch(long.MaxValue,out var batch).Success||batch==null)return 26;Environment.Exit(0);}
return 27;
}
sealed class Fixture : IDisposable
{
internal static readonly Uri Api=new Uri("http://example.test/api/");
public string Root { get; }
public string Main => Path.Combine(new KVIdentity(Root,Api,"101").DirectoryPath,"state.json");
public PlayerKVSyncState State { get; private set; }
KVCacheWriteStage? failAt;
public Fixture(string root){Root=root;Reopen();}
public void Arm(KVCacheWriteStage stage)=>failAt=stage;
public void Disarm()=>failAt=null;
public PlayerKVOpenResult TryReopen(){State?.Dispose();var r=PlayerKVSyncState.Open(Root,Api,"101",stage=>{if(stage==failAt)throw new IOException("Injected disk failure");});State=r.State;return r;}
public void Reopen(){var r=TryReopen();if(!r.Success)throw new InvalidOperationException("Open "+r.Error);}
public void Dispose()=>State?.Dispose();
}