Files
ft/CodexOutput/upserver/KVSaveRouteTests/WriteCases.cs

138 lines
12 KiB
C#

using game.PlayerDataStorage;
partial class RouteTests
{
static PlayerKVEditResult Write(PlayerKVGameSessionHandle h,string k,string v,bool remote=true)=>(PlayerKVEditResult)Call("WriteKV",h,k,v,remote);
static Task<PlayerKVFlushResult> Flush(PlayerKVGameSessionHandle h,CancellationToken t=default)=>(Task<PlayerKVFlushResult>)Call("FlushKVAsync",h,t);
static Task<PlayerKVTicketResult> Ticket(PlayerKVGameSessionHandle h,PlayerKVSyncTicket k,CancellationToken t=default)=>(Task<PlayerKVTicketResult>)Call("WaitForKVTicketAsync",h,k,t);
static TaskCompletionSource<bool> Gate()=>new(TaskCreationOptions.RunContinuationsAsynchronously);
static async Task More(){
await Run("C2-write-localonly-and-existing-pending",LocalOnly);
await Run("C2-input-and-guard-priority",Guards);
await Run("C2-real-storage-error-cleanup",Storage);
await Run("C2-conflict-and-unknown-read",NonReady);
await Run("C2-superseded-and-watermark",Watermark);
await Run("C2-response-lost-and-401",TransportFaults);
await Run("C2-cancelled-wait-does-not-cancel-drain",CancelDrain);
await Run("C2-ticket-cancellation-and-foreign-owner",TicketOwnership);
await Run("C2-stale-cross-account-and-same-account",Stale);
await Run("C2-close-versus-write-admission",CloseRace);
await Run("C2-unexpected-exception-not-disguised",Unexpected);
await Run("C3-internal-static-surface",Surface);
}
static async Task LocalOnly(){
await using var f=new Fixture();var h=await f.Open();
var remote=Write(h,"k","remote");Check(remote.Accepted&&remote.Ticket!=null&&f.Fake.Writes==0,"accepted locally only: "+remote.StateResult.Error+" uncertain="+remote.StateResult.LocalCommitMayHaveSucceeded+" cache="+f.Cache);
var local=Write(h,"k","local",false);Check(local.Accepted&&local.Ticket==null,"localonly no ticket");
ReadIs(Read(h,"k"),"None",true,"local");await Flush(h);
Check(f.Fake.Remote["101"]["k"]=="remote","localonly does not erase remote pending");ReadIs(Read(h,"k"),"None",true,"local");
Check((await Ticket(h,remote.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"original remote ticket confirmed");
Check(Write(h,"k","newRemote").Accepted,"remote after localonly");ReadIs(Read(h,"k"),"None",true,"newRemote");await Flush(h);
Check(f.Fake.Remote["101"]["k"]=="newRemote","remote clears localonly shadow");
Check(!Write(h,"k",null).Accepted&&Write(h,"","").StateResult.Error==PlayerKVChangeError.Validation,"invalid values");
Check(Write(h,"k","").Accepted,"empty string is value");await Flush(h);Check(f.Fake.Remote["101"].ContainsKey("k")&&f.Fake.Remote["101"]["k"]=="","not deleted");
}
static async Task Guards(){
Check(Write(null,"k","v").StateResult.Error==PlayerKVChangeError.Validation,"null write validation");
Check((await Flush(null)).TransportError==PlayerKVError.Validation,"null flush validation");
Check((await Ticket(null,null)).TransportError==PlayerKVError.Validation,"null wait validation");
await using var f=new Fixture();var h=await f.Open();Check(PlayerKVGameLoadGuard.TryEnter(out var g),"own guard");
try{
ReadIs(Read(h,"k"),"LoadGuardActive",null,null);
Check(Write(null,null,null).StateResult.Error==PlayerKVChangeError.Blocked,"guard before input");
var flush=await Flush(h);Check(flush.Blocked==1&&flush.Confirmed==0&&flush.Unknown==0&&flush.Superseded==0&&flush.TransportError==PlayerKVError.Cancelled,"guard flush");
Check((await Ticket(h,null)).Status==PlayerKVTicketStatus.Blocked,"guard wait");
Check(g.Violations==4&&g.Count(PlayerKVGuardPoint.ReadLocal)==1&&g.Count(PlayerKVGuardPoint.Update)==1&&g.Count(PlayerKVGuardPoint.Sync)==2,"guard exact counters");
Check(f.Fake.Writes==0&&f.Fake.Gets==1,"no network when guarded");
}finally{g.Dispose();}
Check(UnityEngine.PlayerPrefs.Calls==0&&PlayFab.PlayFabClientAPI.Calls==0,"no legacy side effects");
}
static async Task Storage(){
await using var f=new Fixture();var h=await f.Open();var temporary=Directory.GetFiles(f.Cache,"state.json",SearchOption.AllDirectories).Single()+".tmp";
Check(Path.GetFullPath(temporary).StartsWith(Path.GetFullPath(f.Cache)+Path.DirectorySeparatorChar,StringComparison.OrdinalIgnoreCase),"own temporary path");
Directory.CreateDirectory(temporary);
try{
var result=Write(h,"k","fault");Check(!result.Accepted&&result.StateResult.Error==PlayerKVChangeError.StorageError,"real IO storage failure");
Check(!result.StateResult.LocalCommitMayHaveSucceeded&&result.StateResult.AttemptSequence>0&&result.StateResult.Revision>0&&result.Ticket==null,"original storage fields");
ReadIs(Read(h,"k"),"NotReady",null,null);Check(f.Fake.Writes==0,"no storage retry/send");
}finally{Directory.Delete(temporary,false);Check(!Directory.Exists(temporary),"P3-1 no fault directory remains");}
}
static async Task NonReady(){
foreach(var unknown in new[]{false,true}){
await using var f=new Fixture();var seeded=PlayerKVSyncState.Open(f.Cache,Api,"101");Check(seeded.Success,"seed cache");
using(var state=seeded.State){state.Reconcile(new Dictionary<string,string>{{"k","base"}});state.Update("k","pending");if(unknown){state.BeginBatch(long.MaxValue,out var batch);state.FailBatch(batch.Id,true);}}
f.Fake.Remote["101"]["k"]="conflicting";var h=await f.Open();Check(h.Status==(unknown?PlayerKVSyncStatus.UnknownOutcome:PlayerKVSyncStatus.Conflict),"nonready actual state");
ReadIs(Read(h,"k"),"NotReady",null,null);Check(!Write(h,"k","blocked").Accepted,"notready blocks write");
}
}
static async Task Watermark(){
await using var f=new Fixture();var h=await f.Open();var first=Write(h,"k","v1");var second=Write(h,"k","v2");var summary=await Flush(h);
Check((await Ticket(h,first.Ticket)).Status==PlayerKVTicketStatus.Superseded,"superseded not confirmed");Check((await Ticket(h,second.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"new ticket confirmed");Check(summary.Superseded==1&&summary.Confirmed==1,"flush counts");
var entered=Gate();var release=Gate();f.Fake.Before=async(r,t)=>{if(r.RequestUri.AbsolutePath.EndsWith("Set")){entered.TrySetResult(true);await release.Task;}};
var a=Write(h,"k","before");var flush=Flush(h);await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));var b=Write(h,"k","after");release.TrySetResult(true);await flush;
Check(f.Fake.Remote["101"]["k"]=="before","old watermark excludes later edit");Check((await Ticket(h,a.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"first dispatched ticket");
await Flush(h);Check((await Ticket(h,b.Ticket)).Status==PlayerKVTicketStatus.Confirmed&&f.Fake.Remote["101"]["k"]=="after","next watermark confirms later ticket");
}
static async Task TransportFaults(){
foreach(var unauthorized in new[]{false,true}){
await using var f=new Fixture();var h=await f.Open();f.Fake.Unauthorized=unauthorized;f.Fake.DropResponse=!unauthorized;
var edit=Write(h,"k","attempt");var result=await Flush(h);var ticket=await Ticket(h,edit.Ticket);
Check(ticket.Status==PlayerKVTicketStatus.Unknown&&result.Unknown>0,"uncertain transport preserved");
Check(f.Fake.Writes==1,"no automatic resend");Check(!Read(h,"k").Success&&Read(h,"k").Found==null,"failure not default save");
if(!unauthorized)Check(f.Fake.Remote["101"]["k"]=="attempt","lost response after remote applied");
}
}
static async Task CancelDrain(){
await using var f=new Fixture();var h=await f.Open();var entered=Gate();var release=Gate();
f.Fake.Before=async(r,t)=>{if(r.RequestUri.AbsolutePath.EndsWith("Set")){entered.TrySetResult(true);await release.Task;}};
Write(h,"k","held");using var c=new CancellationTokenSource();var wait=Flush(h,c.Token);await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));c.Cancel();
var result=await wait;Check(result.TransportError==PlayerKVError.Cancelled,"cancel observer");
var close=f.Bridge.CloseAsync();try{Check(!close.IsCompleted,"close waits ignored cancellation transport");Check(!Write(h,"k","late").Accepted,"synchronous close revocation");}finally{release.TrySetResult(true);}
await close.WaitAsync(TimeSpan.FromSeconds(10));Check(f.Fake.Writes==1,"no retry from cancelled observer");
await using var f2=new Fixture();var h2=await f2.Open();var edit=Write(h2,"k","pre-cancel");using var pre=new CancellationTokenSource();pre.Cancel();
var cancelled=await Flush(h2,pre.Token);Check(cancelled.TransportError==PlayerKVError.Cancelled,"pre-cancel observer");await f2.Bridge.CloseAsync();Check(f2.Fake.Writes<=1,"pre-cancel no retry, may dispatch");
}
static async Task TicketOwnership(){
await using var f=new Fixture();var h=await f.Open();var edit=Write(h,"k","v");using var c=new CancellationTokenSource();c.Cancel();
Check((await Ticket(h,edit.Ticket,c.Token)).Status==PlayerKVTicketStatus.Cancelled&&f.Fake.Writes==0,"ticket cancellation no flush");
Check((await Ticket(h,null)).TransportError==PlayerKVError.Validation,"null ticket");
await using var other=new Fixture();var otherHandle=await other.Open();Check((await Ticket(otherHandle,edit.Ticket)).TransportError==PlayerKVError.Validation,"foreign owner same player/epoch");
await Flush(h);Check((await Ticket(h,edit.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"cancel did not erase ticket");
}
static async Task Stale(){
foreach(var nextId in new[]{"101","102"}){
await using var f=new Fixture();var a=await f.Open();var edit=Write(a,"k","A");await Flush(a);Check((await Ticket(a,edit.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"A historical confirmed");
await f.Bridge.CloseAsync();f.Current++;var b=await f.Open(nextId);var expected=(string)Read(b,"k").Value;var writes=f.Fake.Writes;var gets=f.Fake.Gets;
ReadIs(Read(a,"k"),"StaleOrClosing",null,null);Check(!Write(a,"k","bad").Accepted,"old write blocked");Check((await Flush(a)).Blocked>0,"old flush blocked");
Check((await Ticket(b,edit.Ticket)).Status==PlayerKVTicketStatus.Blocked,"A ticket not B authority");Check((await Ticket(a,edit.Ticket)).Status==PlayerKVTicketStatus.Blocked,"old handle cannot observe as current");
var businessMemory=expected;var lateRead=Read(a,"k");if(lateRead.Success)businessMemory="late mutation";
Check(businessMemory==expected,"sample callback rejects stale authority before changing shared memory");
ReadIs(Read(b,"k"),"None",true,expected);Check(f.Fake.Writes==writes&&f.Fake.Gets==gets,"B requests and cache unchanged");Check(a.OwnerInstanceId!=b.OwnerInstanceId&&a.Epoch!=b.Epoch,"new owner and epoch");
}
}
static async Task CloseRace(){
for(int i=0;i<16;i++){
await using var f=new Fixture();var h=await f.Open();var begin=Gate();var edit=Task.Run(async()=>{await begin.Task;return Write(h,"k","race");});var close=Task.Run(async()=>{await begin.Task;await f.Bridge.CloseAsync();});
var read=Task.Run(async()=>{await begin.Task;return (object)Read(h,"k");});
begin.SetResult(true);await Task.WhenAll(edit,close,read);Check(edit.Result.Accepted||edit.Result.StateResult.Error==PlayerKVChangeError.Blocked,"accepted on A or refused, no false compensation");
dynamic observed=read.Result;Check(observed.Error==PlayerKVKeyReadError.StaleOrClosing || (observed.Success && observed.Found==true && (observed.Value=="base" || observed.Value=="race")),"read racing close observes only admitted A snapshot or stale");
Check(!Write(h,"k","after").Accepted&&f.Fake.Writes==0,"late writes refused/no network");
}
}
static async Task Unexpected(){
await using var f=new Fixture();var h=await f.Open();f.ThrowCurrent=true;
try{foreach(var action in new Action[]{()=>Read(h,"k"),()=>Write(h,"k","v"),()=>Flush(h),()=>Ticket(h,null)}){
bool threw=false;try{action();}catch(InvalidOperationException e){threw=e.Message=="current-predicate-fault";}Check(threw,"unexpected fault must not become default/false success");
}}finally{f.ThrowCurrent=false;}
}
static Task Surface(){
foreach(var name in new[]{"ReadKV","WriteKV","FlushKVAsync","WaitForKVTicketAsync"}){
var m=typeof(PlayFabMgr).GetMethod(name,System.Reflection.BindingFlags.NonPublic|System.Reflection.BindingFlags.Static);
Check(m!=null&&m.IsAssembly&&m.IsStatic,"internal static ordinary method "+name);
}
Check(typeof(PlayFabMgr).Assembly.GetType("game.PlayerDataStorage.PlayerKVSaveRouteEditorValidationHost")==null,"editor host absent from service/player probe");
Check(typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic).GetValue(null)==null,"never instantiated PF component");
return Task.CompletedTask;
}
}