Grant Pipeline 重建:MutationPlan 架構設計與 Pool Resource Routing 修正¶
文檔資訊
- 分類: architecture
- 難度: advanced
- 預估閱讀時間: 12 分鐘
- 標籤:
grant-pipeline,mutation-plan,plan-context,reward-routing,stamina,pool-resource,go,game-backend,production-grade,architecture-debt
摘要¶
針對 Pool Resource Routing 的 4 個 Findings 展開完整架構重建討論,最終定案 14 點 production-grade spec,核心是引入 PlanContext、MutationPlan(純函數 planner)、ExecuteMutationPlanTx(唯一寫入入口)、executor-authored MutationSummary、DisplayReward(從 plan 投影)的五層分離架構,同時修正 CAS retry non-determinism、data_version ownership、public API pool bypass 等問題。
關鍵學習¶
-
MutationSummary 必須由 executor 根據「實際提交的 mutation」產生,不能從 plan 推導——前者是 DB 實際改了什麼,後者是玩家看到什麼,兩者不能混用
-
PlanContext 要同時凍結 Tables + NowMs + RNGSeed,光凍結 gamedata 還會讓 time-limited item 的 ExpireAt 在 retry 間漂移
-
CAS retry 不能重 roll random:隨機流程必須在 retry 外先 plan 完整 reward graph,planner 應是純函數(P0)
-
Pool Resource 目前只支援 Stamina,schema 也只有 stamina 欄位,不要假裝 generic pool 已存在,文件/命名/註解要全部收斂到這個事實
-
auto-open 必須在 planning phase 展開完,executor 只負責寫 DB,不做遞迴——這同時解掉 nested reward 丟失、recursion 無 guard、response 組裝不一致
-
data_version/changelog 只能由最外層 use case bump 一次,executor 不 bump version,只回 MutationSummary
-
AffectedDomains 要用 typed enum/bitset,不要自由字串,否則順序、重複、拼字都會變成新債
-
ProjectDisplayRewards(plan) 合理,BuildMutationSummary(plan) 不合理——這是整份 spec 最關鍵的分界
-
容量模型要算「淨新增槽位」:useItem 的 source item 被消耗可能釋放槽位,要一起計入 ConsumeSpec
-
不要做雙軌過渡:舊的 handleRewardResources 等只能變成薄 wrapper 呼叫新 planner/executor,不能留半套舊邏輯
技術細節¶
核心問題(原始 Findings)¶
高優先級:RewardEntry.ID contract 不一致
proto明寫RewardEntry.Id對 currency 應是currency_type(player_types.pb.go:1939)RewardResources路徑回的是ResourcesType(item_effects.go:225)DropGroup Currency路徑回的是ResourcesDefinitionConfig.ID(item_effects.go:389)- 同一種 Gold/Stamina 在不同 path 對 client 呈現不同 ID,
mergeRewards也不會合併(item_effects.go:665)
中:Pool Resource Routing 實際只對 Stamina 成立
- 所有分派都硬寫成
AddPoolStamina(item_effects.go:220,adapters_reward.go:60) - 底層 storage 只有 stamina 欄位(
player_repository.go:90,player_repository_postgres.go:609)
中:GrantItems auto-open 路徑漏測
GrantItems會先把BooleanAutoOpen=true的 item 直接 dispatch(player_inventory_service.go:1213)autoApplyItem進到同一套RewardResources/DropGroup邏輯(item_effects.go:522)- 這條路徑會被 reward pipeline 和 dungeon item grant 間接打到
定案架構(14 點 Spec)¶
1. PlanContext
2. MutationPlan(原 GrantPlan,改名因同時含 consume + grant)
- 只有 canonical GrantSpec,全部用 config ClassID
- ConsumeSpec 也在此,含 InstanceID/ItemID/Quantity
3. Pure Planner
- PlanItemEffects(ctx PlanContext, ...) -> MutationPlan
- 展開 RewardResources、DropGroup、RandomBox、auto-open
- 加 maxAutoOpenDepth + cycle detection(A→B→A 型配置錯誤 fail-closed)
- auto-open 禁止 interactive effect(遇到就 fail-closed,不默默選 index 0)
4. ExecuteMutationPlanTx(唯一寫入入口) - 集中做:ValidateGrantSpec、ResourcesDefinitionConfig 批次 lookup、IsKnownResourcesType、IsPoolResource、pool/simple routing、item batch upsert - 回傳 MutationSummary(executor 根據「實際提交的 mutation」產生)
5. MutationSummary(executor-authored)
type MutationSummary struct {
DeletedItemInstanceIDs []string
GrantedItems []GrantedItemResult // 含 instanceID/isNew/merged
SimpleResourceDeltas map[ResourceType]int64
PoolDeltas map[ResourceType]int64
AffectedDomains DomainBitset // typed enum,不是 []string
}
6. DisplayReward:從 MutationPlan 投影,不從 MutationSummary 推導
7–8. Distributor/RewardGranter 改回 MutationSummary;最外層 use case 負責單次 data_version/changelog
9. 封死 public API 旁路
- AddCurrencyAmount、DeductCurrencyAmount、GetCurrencyBalance、AdminSetResource 對 pool type 直接報錯
- 同時 honest rename:AddCurrencyAmount 改名,明確只適用 simple-balance
10. Stamina-only honest labeling
- GrantPoolResourceTx 保留 dispatcher 形狀,但文件明寫目前只實作 stamina
- 若未來要真 generic pool:新增 player_pool_resources(user_id, resource_type, amount, max_amount, recover_at, ...) table,schema migration 必須做
11. 淨槽位容量模型
- 容量檢查要算「淨新增槽位」:freed = consumed non-stackable items
- ConsumeSpec 需攜帶「是否會釋放槽位」資訊給 executor/capacity checker
12. 禁止雙軌過渡
- 舊的 handleRewardResources、grantCurrencyRewards、grantItemRewards、playerRewardGranter 只能成為薄 wrapper
13. Deterministic 測試矩陣 - version conflict retry 不改掉落 - auto-open nested reward 不丟失 - all-auto-open 仍會 bump version - pool bypass API 會 loud failure
14. Repo contracts 要回傳實際 mutation 結果
- 目前 item_effects.go:516 只回 error,正式版要回 GrantedItemResult
文檔審查發現的 P0/P1 硬傷¶
- P0:MutationPlan 定義與 planner 產生邏輯互相打架(誰組最終 plan 不明確)
- P0:
ProjectDisplayRewards把 Stamina 投影成 "resource",與現有測試/client contract 不一致 - P0:
planItemMetadata用了不存在的EffectiveTimeLimitInMinutes欄位,與現有ItemLimitedTime + CalcExpireAtMs(...)不相容 - P1:
validateNetCapacity用len(summary.DeletedItemInstanceIDs)扣槽位,但 stackable consume 不釋放槽位,高估可用空間 - P1:
GrantItemsdata-version bump 範例未使用BumpDataVersionAtomic(),在 contention 下引入可避免的 version conflict
What Changed¶
這段對話從「4 個 Findings」出發,經過多輪架構迭代,最終定案一套完整的 Grant Pipeline 重建設計。
核心架構轉變:從 RewardResult 兼「grant 輸入」和「client 輸出」雙重職責(split-brain 根因),轉變為 MutationPlan(canonical grant spec)+ MutationSummary(executor 實際產生)+ DisplayReward(從 plan 投影)三層明確分離。
關鍵設計決策:引入 PlanContext 凍結 Tables + NowMs + RNGSeed,讓 planner 成為純函數,解決 CAS retry 重 roll random 的 P0 問題;auto-open 展開移到 planning phase,executor 只負責寫 DB,徹底消滅 nested reward 丟失和 recursion 無 guard 問題。
誠實邊界劃定:明確宣告 Pool Resource 目前只支援 Stamina,schema 和 API 全部收斂到這個事實,不假裝 generic 已存在——若要真正 generic 必須做 schema migration。
So What¶
這個設計解決的不只是一個 bug,而是一整套架構債(item effect/reward pipeline 沒有統一 grant pipeline)和領域債(DB 和 public API 只支援 Stamina 卻宣稱 generic)。
如果只 patch 個別問題,任何修法都只是把 bug 換位置。這份設計確立了明確的邊界:planner 是純函數、executor 是唯一寫入入口、MutationSummary 只能由 executor 產生、data_version 只能在最外層 bump 一次。這些規則一旦落地,未來加新 reward 類型、新 pool resource、新 use case 都有清楚的 contract 可依循。
文檔審查發現的 P0/P1 硬傷也提醒:設計文檔在落地前必須對照實際 codebase 逐行驗證,尤其是不存在的 field、與現有 contract 不一致的型別轉換邏輯。
Trade-offs¶
- 誠實版 vs 平台版:目前選擇「誠實版」(承認 stamina-only),避免假裝 generic 造成更多混淆;若產品 roadmap 真的有 PvpTicket/BossTicket,不做 schema migration 就不算正式版
- planner 純函數 vs CAS retry 兼容:選擇在 retry 外先 plan 完整 reward graph(而非 retry 內共用 seed),犧牲一點彈性換取可測試的純函數設計
- MutationSummary from executor vs from plan:執行後才能得知真實 instance-level delta,代價是 repo contract 必須一起改(目前 item_effects.go:516 只回 error)
- 禁止雙軌過渡:舊 handler 只能成為薄 wrapper,不能留半套舊邏輯——犧牲過渡期的靈活度,換取不讓技術債原樣回來
- AffectedDomains typed enum:比 []string 嚴格但改動範圍更大,長期可維護性明顯更好
Try It Fast¶
// PlanContext - 在 retry 外凍結,讓 planner 成為純函數
type PlanContext struct {
Tables GameDataTables
NowMs int64
RNGSeed int64
MaxAutoOpenDepth int
}
// MutationPlan - canonical plan,全部用 config ClassID
type MutationPlan struct {
Grants []GrantSpec
Consumes []ConsumeSpec
}
// GrantSpec - 統一 ID contract
type GrantSpec struct {
Type GrantType // Item / Resource / Hero / ...
ClassID string // 一律用 config ID,不用 ResourcesType
Quantity int64
}
// 正確的流程分離
func UseItem(ctx context.Context, ...) (*Response, error) {
// 1. 在 retry 外 plan(純函數,含 RNGSeed)
planCtx := PlanContext{Tables: tables, NowMs: now, RNGSeed: seed}
plan, err := planner.PlanItemEffects(planCtx, item, ...)
// 2. retry 只負責執行,不重 plan
var summary MutationSummary
err = runWithVersionRetry(func(tx Tx) error {
summary, err = executor.ExecuteMutationPlanTx(tx, userID, plan)
return err
})
// 3. display 從 plan 投影(不從 summary)
display := projector.ProjectDisplayRewards(plan)
// 4. 最外層 bump data_version
_ = repo.BumpDataVersionAtomic(ctx, userID, summary.AffectedDomains)
return buildResponse(display, summary), nil
}
Recommendation¶
- 落地順序嚴格遵守:PlanContext/MutationPlan 型別 → pure planner → ExecuteMutationPlanTx → Distributor 回 MutationSummary → 舊 handler 改薄 wrapper → 移除 RewardResult 雙重職責 → public API guard + rename → schema migration(如需真 generic)
- 文檔落地前必須對照 codebase 驗證:重點檢查(a)新引入的 field/method 是否真實存在,(b)Stamina 的 client-facing type 在 DisplayReward 投影時是否保持
currency而非誤投成resource,(c)expiration 邏輯是否對齊現有ItemLimitedTime + CalcExpireAtMs - MutationSummary 的分界是最高優先守則:永遠從 executor 產生,不要從 plan 推導——這是防止整個系統再次 split-brain 的核心邊界
- ConsumeSpec 要帶 isNonStackable flag:讓 validateNetCapacity 能正確算淨槽位,避免 stackable consume 高估可用空間
- GrantItems 全 auto-open 不能 early return:即使全部都是 auto-open 也必須走 data_version/changelog,使用
BumpDataVersionAtomic()而非手動 GetCurrentDataVersion + UpdateDataVersion - AffectedDomains 在型別定義階段就要用 typed bitset,不要事後再改