Files
ft/CodexOutput/upserver/B3BatchMigration/M2/CP1-source-diff.patch
Liubing\LB 6813da149a feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。
保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。

验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。
包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
2026-09-30 19:19:05 +08:00

193 lines
13 KiB
Diff

diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs
index 2ce900a10..dc6fdc43b 100644
--- a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs
+++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs
@@ -6,7 +6,7 @@ namespace game.PlayerDataStorage
{
internal enum PlayerKVGameLoadError
{ None, BackendNotEnabled, BackendUnsupported, Input, Prepare, Schema, Apply, Stale, SideEffect, GateBusy, PreviousClosePending, PreviousCloseFailed, ClosePending, CloseFailed }
- internal enum PlayerKVGameLoadState { Rejected, LoadedForValidation }
+ internal enum PlayerKVGameLoadState { Rejected, LoadedForValidation, GameLoaded }
// Preserve stage evidence without retaining a successful Prepare payload on a later load failure.
internal sealed class PlayerKVPrepareEvidence
{
@@ -27,7 +27,7 @@ namespace game.PlayerDataStorage
}
internal sealed class PlayerKVGameLoadResult
{
- internal PlayerKVGameLoadState State => Error==PlayerKVGameLoadError.None ? PlayerKVGameLoadState.LoadedForValidation : PlayerKVGameLoadState.Rejected;
+ internal PlayerKVGameLoadState State { get; }
internal PlayerKVGameLoadError Error { get; }
internal PlayerKVGameLoadError OriginalError { get; }
internal bool CleanupFailed => Error==PlayerKVGameLoadError.CloseFailed;
@@ -37,8 +37,9 @@ namespace game.PlayerDataStorage
internal int ModuleCount { get; }
internal int IntentCount { get; }
internal PlayerKVGameLoadResult(PlayerKVGameLoadError error,long epoch,PlayerKVGameLoadError? original=null,
- PlayerKVSchemaVerdict? verdict=null,PlayerKVLoginPrepareResult prepare=null,int modules=0,int intents=0)
- { Error=error; OriginalError=original ?? error; SchemaVerdict=verdict; Prepare=prepare==null ? null : new PlayerKVPrepareEvidence(prepare); Epoch=epoch; ModuleCount=modules; IntentCount=intents; }
+ PlayerKVSchemaVerdict? verdict=null,PlayerKVLoginPrepareResult prepare=null,int modules=0,int intents=0,bool forGame=false)
+ { Error=error; OriginalError=original ?? error; SchemaVerdict=verdict; Prepare=prepare==null ? null : new PlayerKVPrepareEvidence(prepare); Epoch=epoch; ModuleCount=modules; IntentCount=intents;
+ State=error!=PlayerKVGameLoadError.None ? PlayerKVGameLoadState.Rejected : forGame ? PlayerKVGameLoadState.GameLoaded : PlayerKVGameLoadState.LoadedForValidation; }
}
internal sealed class PlayerKVGameLoadCoordinator
{
@@ -61,6 +62,7 @@ namespace game.PlayerDataStorage
Action clearAfterApply;
long epoch;
bool loaded;
+ PlayerKVGameSessionHandle gameHandle;
internal bool LoadedForValidation => loaded && context!=null && context.IsCurrent && guard!=null && guard.IsCurrent && guard.Violations==0;
internal int QuitObserveMs => capability?.QuitObserveMs ?? 5000;
internal PlayerKVGameLoadCoordinator(PlayerKVGameLoadTransaction transaction,Func<bool> unstarted,Action notify,Func<int> intentCount,
@@ -72,7 +74,29 @@ namespace game.PlayerDataStorage
this.unbind=unbind ?? throw new ArgumentNullException(nameof(unbind)); this.registry=registry ?? PlayerKVShutdownRegistry.Process;
mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current;
}
- internal async Task<PlayerKVGameLoadResult> LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
+ internal Task<PlayerKVGameLoadResult> LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
+ => LoadCoreAsync(id,next,token,identityCurrent,false);
+ internal Task<PlayerKVGameLoadResult> LoadForGameAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
+ => LoadCoreAsync(id,next,token,identityCurrent,true);
+ internal bool TryGetCurrentGameHandle(long expectedEpoch,out PlayerKVGameSessionHandle handle)
+ {
+ handle=null;
+ PlayerKVLoginContext owner; PlayerKVGameSessionHandle published;
+ lock(closeGate)
+ {
+ if(closing!=null || epoch!=expectedEpoch || gameHandle==null) return false;
+ owner=context; published=gameHandle;
+ }
+ // Never call the external identity predicate while holding a publication monitor.
+ if(!owner.TryAcquireGameHandle(expectedEpoch,out var original) || !ReferenceEquals(original,published)) return false;
+ lock(closeGate)
+ {
+ if(closing!=null || epoch!=expectedEpoch || !ReferenceEquals(context,owner) || !ReferenceEquals(gameHandle,published)) return false;
+ handle=published;
+ return true;
+ }
+ }
+ async Task<PlayerKVGameLoadResult> LoadCoreAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token,Func<bool> identityCurrent,bool forGame)
{
if(next==null || Thread.CurrentThread.ManagedThreadId!=mainThread || string.IsNullOrEmpty(id))
return Result(PlayerKVGameLoadError.Input);
@@ -89,9 +113,13 @@ namespace game.PlayerDataStorage
if(token.IsCancellationRequested || !unstarted() || transaction.Poisoned) return Result(PlayerKVGameLoadError.Input);
if(!PlayerKVGameLoadGuard.TryEnter(out var enteredGuard)) return Result(PlayerKVGameLoadError.GateBusy);
lifetimeRegistration.Dispose();
- guard=enteredGuard; capability=next; closing=null; loaded=false;
- attempt=Interlocked.Increment(ref epoch);
- context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent()));
+ lock(closeGate)
+ {
+ guard=enteredGuard; capability=next; closing=null; loaded=false; gameHandle=null;
+ attempt=Interlocked.Increment(ref epoch);
+ context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent()));
+ }
+ var owner=context;
lifetimeRegistration=token.Register(()=> { _=Revoke(attempt); });
var work=context.PrepareAsync(token);
var cancelled=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -113,9 +141,26 @@ namespace game.PlayerDataStorage
finally { var pending=clearAfterApply; clearAfterApply=null; pending?.Invoke(); }
if(applied!=PlayerKVApplyError.None) return await Reject(applied==PlayerKVApplyError.Stale ? PlayerKVGameLoadError.Stale :
applied==PlayerKVApplyError.SideEffect ? PlayerKVGameLoadError.SideEffect : PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict);
- if(token.IsCancellationRequested || !context.IsCurrent || guard.Violations!=0) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
- loaded=true;
- return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None,attempt,verdict:verdict,modules:31,intents:intentCount());
+ if(token.IsCancellationRequested || !owner.IsCurrent || enteredGuard.Violations!=0) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
+ var intents=intentCount();
+ PlayerKVGameSessionHandle original=null;
+ if(forGame && !owner.TryAcquireGameHandle(attempt,out original)) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
+ bool published=false;
+ lock(closeGate)
+ {
+ if(closing==null && epoch==attempt && !token.IsCancellationRequested && ReferenceEquals(context,owner) && enteredGuard.IsCurrent && enteredGuard.Violations==0)
+ {
+ if(forGame)
+ {
+ enteredGuard.Dispose();
+ // Dispose serializes with Reject; its final count cannot change afterwards.
+ if(enteredGuard.Violations==0) { gameHandle=original; published=true; }
+ }
+ else { loaded=true; published=true; }
+ }
+ }
+ if(!published) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
+ return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None,attempt,verdict:verdict,modules:31,intents:intents,forGame:forGame);
}
catch { return await Reject(PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict); }
finally { loginGate.Release(); }
@@ -148,7 +193,7 @@ namespace game.PlayerDataStorage
{
if(expectedEpoch.HasValue && expectedEpoch.Value!=Interlocked.Read(ref epoch)) return Task.CompletedTask;
if(closing!=null) return closing;
- loaded=false; Interlocked.Increment(ref epoch);
+ loaded=false; gameHandle=null; Interlocked.Increment(ref epoch);
if(context==null && guard==null) return Task.CompletedTask;
var completion=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
closing=completion.Task;
diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs
index a1bb78c83..8b779f45c 100644
--- a/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs
+++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs
@@ -58,6 +58,21 @@ namespace game.PlayerDataStorage
internal PlayerKVAccountDescriptor AccountDescriptor =>
Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null;
+ // Only the coordinator may publish this original handle after Apply and Guard release.
+ internal bool TryAcquireGameHandle(long expectedEpoch, out PlayerKVGameSessionHandle gameHandle)
+ {
+ gameHandle = null;
+ // IsCurrent can invoke external code and synchronously reenter CloseAsync.
+ if (expectedEpoch != epoch || !IsCurrent) return false;
+ lock (gate)
+ {
+ if (closing != null || revoked != 0 || prepared != 1 || handle == null || handle.Epoch != expectedEpoch)
+ return false;
+ gameHandle = handle;
+ return true;
+ }
+ }
+
// All issuance and Close admission share gate. The editor factory may not block on Close.
internal bool TryAcquireAccountLease(PlayerKVControlledLoadCapability capability,
out PlayerKVAccountLease lease)
diff --git a/CodexOutput/upserver/KVGameLoadTests/CoordinatorTests.cs b/CodexOutput/upserver/KVGameLoadTests/CoordinatorTests.cs
index 46efb0c55..d53aa274c 100644
--- a/CodexOutput/upserver/KVGameLoadTests/CoordinatorTests.cs
+++ b/CodexOutput/upserver/KVGameLoadTests/CoordinatorTests.cs
@@ -59,6 +59,19 @@ static partial class Tests
}
static async Task RunCoordinatorTests()
{
+ await Run("M2-CP1-game-load-releases-guard-after-notification",()=>OnMain(async()=>
+ {
+ var f=new LoadFixture();
+ try
+ {
+ var result=await f.Coordinator.LoadForGameAsync("101",f.Capability());
+ Check(result.Error==PlayerKVGameLoadError.None && f.Notify==1,"complete apply and notification");
+ Check(!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.ReadLocal),"game success must release its load guard");
+ Check(result.State.ToString()=="GameLoaded" && !f.Coordinator.LoadedForValidation,"game success differs from validation");
+ }
+ finally { await f.Coordinator.Revoke(); }
+ }));
+ await RunGameHandoffTests();
await Run("coordinator-success-reload-revoke-and-one-auth",()=>OnMain(async()=>
{
var f=new LoadFixture();
diff --git a/CodexOutput/upserver/KVGameLoadTests/KVGameLoadTests.csproj b/CodexOutput/upserver/KVGameLoadTests/KVGameLoadTests.csproj
index 423e444ba..eaf11bc49 100644
--- a/CodexOutput/upserver/KVGameLoadTests/KVGameLoadTests.csproj
+++ b/CodexOutput/upserver/KVGameLoadTests/KVGameLoadTests.csproj
@@ -5,6 +5,8 @@
<RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
</PropertyGroup>
<ItemGroup>
+ <Compile Include="../../../Client/Assets/Scripts/Core/KVMgr.cs" Link="Production/KVMgr.cs" />
+ <Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVKeyReadResult.cs" Link="Production/PlayerKVKeyReadResult.cs" />
<Compile Remove="ServiceHost/**/*.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs" Link="Production/PlayerKVInitializationIntents.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs" Link="Production/PlayerKVGameLoadTransaction.cs" />