跳轉到

#1474 sync_status Split Reconcile Runbook

背景

Migration 134(reconstructed Taiwan baseline)把每個 market 的 stock-scoped pipeline 狀態拆進 taiwan.sync_status_stock / us.sync_status_stock,並在 public.sync_status 建立新的 unique index uq_sync_status_market (market, capability, run_date)。但 dev 的 134-141 只是被attestation 核可、實際從未執行:live public.sync_status 仍停在 migration 021 的形狀 (保留 ticker 欄、unique index 是 uq_sync_status (market, capability, COALESCE(ticker,''), run_date),沒有 uq_sync_status_market)。

database/sync_status_writer.py 的 upsert 用 ON CONFLICT (market, capability, run_date),這個欄位組合在 021 形狀的表上 不是任何 constraint/index 的目標,所以每次寫入都拋 InvalidColumnReferenceError;該例外被 writer 吞掉、只記一行 WARNING Failed to write sync_status ...,因此 public.sync_status 自 2026-08-25 起 沒有任何新 row,但沒有任何告警浮出。

同時,database.reconstructed_taiwan_baseline 的 verifier 分母原本只涵蓋 taiwan.* schema,完全沒有檢查 public.sync_status 或 taiwan.sync_status_stock 的契約,所以 adoption 的 verify 通過不代表這張 表已經是 149 之後的形狀——這是本次事故未被既有驗收流程攔下的根本原因。

Migration database/migrations/149_sync_status_split_reconcile.sql (fail-closed、replay-safe)把殘留在 public.sync_status 的 legacy ticker-scoped rows 搬到對應 country 的 sync_status_stock、drop ticker 欄、建立 uq_sync_status_market,並讓 child 表的 stock_code NOT NULL + idx_sync_status_stock_date。它不會建立 us schema——us.sync_status_stock 只在 us schema 已存在的環境才會被檢查/處理。

適用前提

  • database/migrations/149_sync_status_split_reconcile.sql 已 merge 進 dev,主 repo(非 worktree)已 git pull 到含 149 的 commit。
  • 只在已核准的 dev maintenance window 執行;本 runbook 不適用 SIT(SIT 的 sync_status schema 狀態需另外查證,不可假設與 dev 相同)。
  • 執行者具備 cortex-postgres / cortex-scheduler / cortex-api 容器操作 權限。
  • Rehearsal(下一節)必須先在隔離副本跑過、記錄真實輸出,才可排正式 maintenance window。

Rehearsal 紀錄(2026-09-02,隔離副本實跑)

來源:live cortex-postgres 以 pg_dump -Fc -t public.sync_status -t taiwan.sync_status_stock -t taiwan.stocks -t public.schema_migrations -t public.schema_migration_adoptions 匯出(dump SHA-256 62dde1a9ea0255dc3707f56fd76327455a289faea15d62d44a46aa5bf47b541c,54,057 bytes), 還原到 throwaway docker run --rm --name cortex-1474-rehearsal pgvector/pgvector:pg15 (pg_restore 對 taiwan.stocks 的兩個 response-cache trigger 報 function 不存在, 與本表無關、可忽略)。Apply 走正式 runner: PG_CONTAINER=cortex-1474-rehearsal DB_USER=cortex apply_pending_migrations.sh apply。

Canonical hash 指令(before 以 ticker 欄實值、after 以 NULL::varchar 佔位, 因 live 269 列 ticker 全為 NULL,兩者等價):

SELECT md5(string_agg(row(id, market, capability, ticker, run_date, status,
                          error_message, retry_count, created_at, updated_at)::text,
                      '|' ORDER BY id))
  FROM public.sync_status;
檢查項 Before(149 之前) After(149 之後) 一致/預期
public.sync_status row count 269(count(ticker)=0) 269 ✅ 無 ticker rows,全部保留
public.sync_status canonical hash ec121c8165d21640bbeee92954821194(與 live 相同) ec121c8165d21640bbeee92954821194 ✅ 相等
taiwan.sync_status_stock row count 0 0 ✅ 搬入 0 列(NOTICE: moved 0 row(s))
collision report(group by market, capability, run_date having count(*)>1) 0 列 — ✅
\d public.sync_status 有 ticker 欄 是 否 ✅
\d public.sync_status unique index uq_sync_status (market, capability, COALESCE(ticker,''), run_date) uq_sync_status_market (market, capability, run_date);idx_sync_status_market_date 保留 ✅
\d taiwan.sync_status_stock stock_code nullable、無 run_date index stock_code NOT NULL、idx_sync_status_stock_date (run_date)、PK/CK/FK/uq_sync_status_stock 保留 ✅
apply_pending_migrations.sh pending 149_sync_status_split_reconcile.sql 空(0);第二次 apply 輸出 No pending migrations. ✅ replay-safe
ledger 158 列 159 列(新增 149) ✅
writer smoke ON CONFLICT (market, capability, run_date) ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification market 連寫兩次 → 1 列;stock(taiwan.sync_status_stock)連寫兩次 → 1 列;ROLLBACK ✅
verifier(本 PR 版)sync_status contract issues live(唯讀):public_sync_status_contract(legacy_ticker_column=true、legacy_indexes=[uq_sync_status]、missing_indexes=[uq_sync_status_market])、taiwan_sync_status_stock_contract(nullable_columns=[stock_code]、missing_indexes=[idx_sync_status_stock_date]) 完整 baseline+全部 migration(含 149、含 us)的 scratch DB:issues 只剩 target_migration_ledger(scratch 無 attestation)與 canonical_taiwan_data(scratch 無資料),無 sync_status 相關 issue;三表副本因 taiwan manifest 不完整會提前 blocked,不適合當 verifier 證據 ✅

以 review 修正後的最終版 149(含 index 定義斷言、public 側 sync_status_pkey/idx_sync_status_market_date 補齊、 LOCK TABLE 與刪除列數對帳)在同一份 dump 的新副本重跑:結果與上表相同(269 列、hash 相同、pending 0、 第二次 apply No pending migrations.、writer smoke 成功),\d public.sync_status 額外列出 sync_status_pkey PRIMARY KEY (id)。任一列 FAIL / UNVERIFIED 都不得排正式 maintenance window。

正式 apply 手順

1. 停止 writer 並以 read-back 證明

docker stop cortex-scheduler
docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c \
  "SELECT pid, usename, application_name, state, query
   FROM pg_stat_activity
   WHERE datname = 'cortex_investment' AND state != 'idle'
     AND query ILIKE '%sync_status%';"

预期:cortex-scheduler 容器狀態變為 Exited;pg_stat_activity 查詢 不應再回傳任何寫入 sync_status 相關表的 active query(cortex-api 的 --reload 讀取路徑可以繼續跑,本 migration 不需要 API 停機)。

2. Backup

docker exec cortex-postgres pg_dump -U cortex -d cortex_investment -Fc \
  -t public.sync_status \
  -t taiwan.sync_status_stock \
  -t public.schema_migrations \
  -t public.schema_migration_adoptions \
  -f /tmp/1474-pre.dump
docker cp cortex-postgres:/tmp/1474-pre.dump ./1474-pre.dump
shasum -a 256 ./1474-pre.dump

预期:pg_dump 無錯誤退出(exit 0);shasum 輸出一行 64 字元 hex + 檔名, 記錄該 hash 供 rollback 核對。

3. Before 查核

docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c \
  "SELECT count(*) FROM public.sync_status;"

docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c \
  "SELECT md5(string_agg(row(market, capability, run_date, status)::text, '|'
              ORDER BY market, capability, run_date))
   FROM public.sync_status;"

docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c \
  "\d public.sync_status"

预期:第一條回傳目前 row count(記錄為 BEFORE_COUNT);第二條回傳一個 32 字元 hex md5(記錄為 BEFORE_HASH);第三條的欄位清單應仍含 ticker、 unique index 名稱仍是 uq_sync_status(COALESCE 形狀)。

4. Apply migration 149

PG_CONTAINER=cortex-postgres investment-agent/backend/scripts/apply_pending_migrations.sh apply
investment-agent/backend/scripts/apply_pending_migrations.sh pending

runner 以 lock_timeout=5s 執行(apply_pending_migrations.sh live 預設);149 會對 public.sync_status 與 {schema}.sync_status_stock 取 ACCESS EXCLUSIVE lock,這兩張表在 /api/sync-status 讀取路徑上。若 5 秒內有 API 查詢持鎖,migration 會以 canceling statement due to lock timeout 整筆 rollback(fail-closed、ledger 不記錄)——這是預期行為,直接重跑 apply 即可,不需人工修復。

预期:第一條輸出顯示只套用了 149_sync_status_split_reconcile.sql(不應有 其他 pending 檔案一併套用,否則代表 dev 落後的範圍比預期大,須先停下核對 pending 清單);第二條 pending 應為空輸出(0 pending)。

PG_CONTAINER=cortex-postgres investment-agent/backend/scripts/apply_pending_migrations.sh apply

预期:第二次 apply 為 no-op(沒有任何檔案被記錄進 ledger,exit 0),確認 149 replay-safe。

若 apply 以 migration 149 found unroutable legacy sync_status tickers: (id=…, ticker=…) 或 … colliding on one destination natural key … 失敗:這是 fail-closed 設計,整筆已 rollback、ledger 未記錄。 live dev 不會發生(rehearsal 記錄 count(ticker)=0);其他環境(如 SIT)若出現,須先把該 ticker 對應的 taiwan.stocks 列補齊(可路由)或經 operator 核准後把該 legacy 列另存到備份表再刪除,不得修改 149 放寬條件。

5. After 查核

docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c \
  "SELECT count(*) FROM public.sync_status;"

docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c \
  "SELECT md5(string_agg(row(market, capability, run_date, status)::text, '|'
              ORDER BY market, capability, run_date))
   FROM public.sync_status;"

docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c \
  "\d public.sync_status"

docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c \
  "\d taiwan.sync_status_stock"

预期:

  • 第一條回傳的 count 等於或小於 BEFORE_COUNT(legacy ticker-scoped rows 已搬到 taiwan.sync_status_stock / us.sync_status_stock,剩下的 market-scoped rows 未被 149 更動)。
  • 第二條的 hash 必須等於 BEFORE_HASH——149 對未搬移列不得做任何 UPDATE, 只搬移/刪除 ticker-scoped 列、drop 欄位、建索引。
  • 第三條的欄位清單不再含 ticker;unique index 名稱是 uq_sync_status_market,欄位為 (market, capability, run_date)。
  • 第四條顯示 stock_code 為 NOT NULL,且存在 idx_sync_status_stock_date index(run_date 上)。

6. Verifier

docker exec cortex-api sh -c 'cd /app && python -m database.reconstructed_taiwan_baseline \
  --database-url "$DATABASE_URL" --expected-database-name cortex_investment verify'

预期:輸出的 issues 清單不含 public_sync_status_contract 或 taiwan_sync_status_stock_contract(若 us schema 在此環境不存在, us_sync_status_stock_contract 本就不會出現)。清單仍可能含既有、與本次 無關的 requested_tickers_identity(獨立追蹤中的 follow-up,非本 runbook 驗收面)——驗收標準是「無 sync_status 相關 contract issue」,不是 status: adopted 或空清單。 另外 facts 中 attestation_schema_evidence_current 會是 false:attestation 存的是 adoption 當下(migration 141) 的 evidence hash 快照,142–149 之後必然不同,這是 audit fact 而非 gate(見 adoption runbook §1.1)。

7. Writer smoke(不留痕跡)

docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c "
BEGIN;
INSERT INTO public.sync_status (market, capability, run_date, status, error_message)
VALUES ('taiwan', 'chip_snapshot', CURRENT_DATE, 'success', NULL)
ON CONFLICT (market, capability, run_date)
DO UPDATE SET status = EXCLUDED.status, updated_at = now();
ROLLBACK;
"

预期:交易內的 INSERT ... ON CONFLICT 不報錯(不再是 InvalidColumnReferenceError);ROLLBACK 之後 public.sync_status 不留任何新資料(第 5 步查回的 row count/hash 仍然有效,不需重跑)。

8. 恢復 scheduler 並驗收 24 小時後行為

docker start cortex-scheduler

次日或下個排程 run 之後:

docker logs cortex-scheduler --since 24h | grep "Failed to write sync_status"

预期:無輸出(不再出現該 WARNING)。

curl -s https://<host>/api/sync-status | jq '.'

预期:回傳的 matrix 含最新 run_date 的成功 row(實際欄位形狀以 api/routes/sync_status.py 的 SyncStatusMatrixResponse 為準)。

Rollback

⚠ 不可用「DROP TABLE 後 pg_restore -t public.sync_status」:pg_restore -t 只比對裸表名 tag(schema-qualified 名稱比對不到,靜默還原 0 個物件並 exit 0),且 DROP TABLE public.sync_status 在有 us.sync_status_stock(共用 sync_status_id_seq)的 DB 會直接失敗。 下列手順已於 2026-09-02 在隔離副本實測(apply 149 → rollback → 269 列、hash ec121c8165d21640bbeee92954821194 與 before 相同、形狀回到 021、ledger 149 列移除、sequence 不變)。

若第 5–7 步任一項未通過,或 8 之後仍看到寫入失敗:

docker stop cortex-scheduler

A. 反向 DDL + 清空 + 移除 ledger(單一 transaction)

docker exec cortex-postgres psql -U cortex -d cortex_investment -v ON_ERROR_STOP=1 -c "
BEGIN;
DROP INDEX IF EXISTS public.uq_sync_status_market;
ALTER TABLE public.sync_status ADD COLUMN IF NOT EXISTS ticker VARCHAR(20);
CREATE UNIQUE INDEX IF NOT EXISTS uq_sync_status
    ON public.sync_status (market, capability, COALESCE(ticker, ''), run_date);
ALTER TABLE taiwan.sync_status_stock ALTER COLUMN stock_code DROP NOT NULL;
DROP INDEX IF EXISTS taiwan.idx_sync_status_stock_date;
TRUNCATE public.sync_status, taiwan.sync_status_stock;
DELETE FROM public.schema_migrations
 WHERE filename = '149_sync_status_split_reconcile.sql';
COMMIT;
"

预期:COMMIT 無錯誤。只反轉 149 新建的物件(uq_sync_status_market、idx_sync_status_stock_date、 NOT NULL、idx_sync_status_market_date 若為 149 所建);PK/CK/FK/uq_sync_status_stock 在 live 本就存在,不動。 若環境有 us.sync_status_stock,對它重複 DROP NOT NULL / DROP INDEX 兩句並一併 TRUNCATE。

B. 以第 2 步的 backup 做 data-only 還原(裸表名 tag)

docker cp ./1474-pre.dump cortex-postgres:/tmp/1474-pre.dump
docker exec cortex-postgres pg_restore -U cortex -d cortex_investment --data-only \
  -t sync_status -t sync_status_stock /tmp/1474-pre.dump
echo restore-exit=$?

预期:restore-exit=0、無 pg_restore: error。(dump 的 COPY 帶欄位清單,ticker 欄位位置不同不影響。)

C. 復驗

以第 3 步的指令重查:row count 必須 = BEFORE_COUNT、hash 必須 = BEFORE_HASH;\d public.sync_status 應再次含 ticker 與 uq_sync_status;SELECT count(*) FROM schema_migrations WHERE filename LIKE '149%' 為 0; SELECT last_value FROM public.sync_status_id_seq 不變。schema_migration_adoptions 的 attestation 列不動。 全部相符後:

docker start cortex-scheduler