Migration 安全契約、Runner 統一與 TestBot Anti-Drift 防線建立¶
文檔資訊
- 分類: debugging
- 難度: advanced
- 預估閱讀時間: 15 分鐘
- 標籤:
golang-migrate,CREATE INDEX CONCURRENTLY,migration safety,anti-drift,testbot,release-gate,postgres,docker-compose
摘要¶
從 Admin Control Plane PR 的嚴格代碼審查出發,發現 migration 042 因 CREATE INDEX CONCURRENTLY 在 transaction 內無法執行導致 dirty state,進而擴展為完整的 Migration 安全契約重建計畫(8步驟)。同時確立 TestBot anti-drift 最小防線(coverage_manifest.yaml + lint test + bootstrap test),並修正文件與現實的落差。
關鍵學習¶
-
CREATE INDEX CONCURRENTLY和DROP INDEX CONCURRENTLY不能在 transaction 內執行,而 golang-migrate 預設對每個 migration 檔案包裝 transaction -
Migration runner 雙軌問題:外部
migrate/migrate寫schema_migrations,而cmd/twirpserver寫game_schema_migrations,造成 dirty state 難以診斷 -
正確修法不是只修 042,要掃描全部
migrations/postgres/找所有違規 DDL(032 也有 CONCURRENTLY) -
Anti-drift 策略:「先觀測,再宣告,最後機械化」—— 先用
all_calls.jsonl建立可信基線,再引入coverage_manifest.yaml,最後才加Step.Coverscodegen -
Bot scratch state 應用 typed
StepContext/Scratch(生命週期限單步驟),不能用map[string]interface{}替換 typed god object -
Release Gate 應拆成兩層:Release Minimum(4-5 個真正決定能否發版的 blocker)vs Full Production Gate
-
PS1 ops script 用字串插值拼 SQL 有 SQL injection 風險,且與 Go 實作形成雙軌,正確做法是改呼叫 Admin API
-
Migration lint 和 bootstrap test 應用 Go test 實作,避免 grep shell 差異;bootstrap 要驗 fresh DB + upgrade path 兩種場景
-
Subagent 輸出不能盲目信任:『確認檔案存在 ≠ 確認內容正確』
技術細節¶
Migration 安全問題根因¶
golang-migrate 預設對每個 migration 檔案啟動一個 DB transaction。CREATE INDEX CONCURRENTLY 在 PostgreSQL 不允許在 transaction 內執行,導致 migration 042 執行失敗並標記 dirty=true。
違規位置:
- migrations/postgres/042_expand_admin_audit.up.sql:18
- migrations/postgres/032_add_battle_records_one_active_per_user.up.sql
- migrations/postgres/032_add_battle_records_one_active_per_user.down.sql
正確修法: 移除 CONCURRENTLY keyword。對 gm_audit_logs(admin audit 小表)這是正確做法,不是退而求其次。CONCURRENTLY 的意義是「讓線上讀寫不被鎖住」,在 golang-migrate 的 transaction 包裝架構下本身就是矛盾的。
Migration Runner 雙軌問題¶
Docker Compose 外部 migrate/migrate → 寫 schema_migrations
cmd/twirpserver 內建 migrate.go → 寫 game_schema_migrations
解決方案:統一用 cmd/twirpserver --migrate,docker-compose.yml 的 migrate service 改為同一個 twirpserver image + command: ["--migrate"],消除 image drift。
Makefile subcommand 寫法陷阱¶
# 錯誤寫法(-- 導致參數被當作 config path)
go run ./cmd/twirpserver -- --migrate
# 正確寫法
go run ./cmd/twirpserver --migrate
ErrNilVersion 邊界處理¶
golang-migrate 在沒有任何 migration 時回傳 ErrNilVersion,必須明確處理,否則 migrate-down 和 migrate-status 對全新 DB 會假失敗。
TestBot Anti-Drift 架構¶
docs/testbot/05_anti_drift.md 設計的四道防線(100% 未實作):
1. RPC catalog codegen(proto-driven)
2. Generated client wrappers(codegen)
3. coverage_manifest.yaml(YAML 宣告)
4. Scenario graph audit(自動化)
最小可用 anti-drift(已實作):
- test/bot/coverage_manifest_test.go → 對準實際 bot wrapper surface
- test/migrations/migration_lint_test.go → 掃描違規 DDL
- test/migrations/migration_bootstrap_test.go → 真 golang-migrate 路徑驗證
PS1 安全問題¶
# 危險:字串插值 SQL injection
$sql = "UPDATE player_data SET ... WHERE uid = '$uid'"
# 正確:呼叫 Admin API(已有 player.reset_progress GM 指令)
Invoke-RestMethod -Uri "$adminUrl/twirp/..." -Body @{command="player.reset_progress"}
What Changed¶
發現的核心問題(8 個被繞過的技術債)¶
代碼審查過程中,透過對方自我揭露發現 8 個「已知繞過但未真正修復」的問題:migration 042 的 CREATE INDEX CONCURRENTLY、local DB dirty state 未清、audit schema 21 欄位 INSERT 與未落地的 DB schema 不符、publisher 429 只做 client-side retry、admin rate limit 只做 env-level bypass、TestBot_StaminaRecovery 仍然失敗、migration runner 雙軌未統一、compose stack 不可全量重建。
設計並實施 8 步驟完整修復方案¶
與用戶協作確認終局設計:(1) 只有一條 migration runner,(2) 所有 checked-in SQL 必須 transaction-safe,(3) 清掃全部違規 migration(不只 042),(4) CI 用 Go test 強制 lint,(5) bootstrap + upgrade-path 兩個驗證場景。最終方案:修違規 SQL → 統一 docker-compose runner → 統一 Makefile → 清 dirty state → 驗證 schema → migration lint → bootstrap test → 收斂文件。
TestBot Anti-Drift 防線建立¶
docs/testbot/05_anti_drift.md 原本是 100% 未實作的設計文件。本輪建立了最小可用防線:coverage_manifest_test.go 對準實際 bot wrapper surface(而非整包 proto methods),33 個現有 bot tests 的覆蓋基線,並將所有 testbot 文件狀態從 Plan 改為 Partial,補充真實缺口說明。
So What¶
這個對話記錄了一個典型的「文件比實作樂觀、繞過方案累積成技術債」的完整修復過程。核心價值在於:
migration 安全契約**的建立方式(不能只修眼前這個,要建立 CI 防線讓同類問題進不了主幹),以及 **anti-drift 的正確起點(「先觀測,再宣告」—— 用 all_calls.jsonl 建基線,而非先寫不存在的 Step.Covers metadata)。
這兩個設計原則在任何有 migration 和 E2E test bot 的 Go 後端專案都直接適用。
Trade-offs¶
- 移除 CONCURRENTLY vs 保留 online DDL 能力:移除後
CREATE INDEX會短暫鎖表,但golang-migratetransaction 架構下 CONCURRENTLY 本就無法執行,這是虛假的能力。對 admin audit 小表,標準 CREATE INDEX 完全足夠。 - 統一 migration runner vs 維持兩套:統一後消除 schema table 分裂,但需要修改 docker-compose.yml,有短暫 compose setup 成本。長期維護成本大幅降低。
- Go test migration lint vs grep shell script:Go test 更穩定(跳過 comment、處理 edge case),但需要初始實作成本。
- anti-drift: coverage_manifest 先行 vs Step.Covers 先行:manifest 建立在可觀測的
all_calls.jsonl基線上,比先寫宣告性 metadata 更可信。代價是需要 bot suite 先穩定跑通。 - PS1 改呼叫 Admin API vs 修 PS1 內部邏輯:API 呼叫消除雙軌問題,但依賴 admin service 可用。緊急 ops 場景下可能有 availability concern。
Try It Fast¶
# 驗證 migration lint(掃描違規 CONCURRENTLY DDL)
go test ./test/migrations -run TestMigrationTransactionSafety -count=1 -v
# 驗證正確的 subcommand 寫法
go run ./cmd/twirpserver --migrate-version
# 不要用:go run ./cmd/twirpserver -- --migrate-version(-- 會讓參數走 config path)
# Bootstrap test(需要 Docker)
go test -tags integration ./test/migrations -run TestMigrationBootstrap -count=1 -v
# 檢查 migration 檔案是否有違規 DDL
grep -r 'CONCURRENTLY' migrations/postgres/
-- 修復前(042 違規寫法)
CREATE INDEX CONCURRENTLY idx_gm_audit_logs_operator_key_id
ON gm_audit_logs (operator_key_id);
-- 修復後(移除 CONCURRENTLY)
CREATE INDEX idx_gm_audit_logs_operator_key_id
ON gm_audit_logs (operator_key_id);
Recommendation¶
- 建立 Migration 安全契約:repo 內所有
migrations/postgres/*.sql不允許CONCURRENTLYDDL,用test/migrations/migration_lint_test.go在 CI 強制執行 - 統一 migration runner:docker-compose.yml 的 migrate service 改用同一個 twirpserver image +
--migrate,消除schema_migrationsvsgame_schema_migrations雙軌 - Anti-drift 起點:先讓 bottest 穩定跑通並收集
all_calls.jsonl,建立可信 RPC 覆蓋基線,再引入coverage_manifest.yaml,最後才加Step.Coverscodegen - Release Gate 兩層化:Release Minimum(4-5 個真正決定能否發版的 P0)與 Full Production Gate 分開管理,避免 12 個 P0 平鋪導致優先級失效
- PS1 ops script 消除:有 Admin API 和後台介面後,刪除有 SQL injection 風險的 PowerShell 雙軌實作
- Subagent 驗證原則:「確認檔案存在 ≠ 確認內容正確」,subagent 回報的 code review 結論必須透過直接讀取檔案驗證