#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_dateindex(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 列、hashec121c8165d21640bbeee92954821194與 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