#1541 taiwan_ohlc_range_request.requested_tickers 身分契約修復 Runbook
背景
services/stock_identity.py 的模組契約明定:資料庫只收裸 stock_code 與 canonical
listing_type,交易所後綴只能存在於對外邊界。但 taiwan.taiwan_ohlc_range_request.requested_tickers
(JSONB)長期由兩個寫入端各自違約:
services/taiwan_ohlc_range_service.py的_register_request_on_connection寫入 外部格式字串("2330.TW"),因為TaiwanOhlcRangeRequest.__post_init__會用format_external_ticker()正規化成外部格式,INSERT 再原樣持久化。services/taiwan_adjustment_factor_bootstrap.py寫入 裸碼字串("00400A"), 雖無後綴,但丟失了listing_type。
database/reconstructed_taiwan_baseline.py 的 requested_tickers_identity 稽核因此持續
回報 issue,使 reconstructed-baseline verifier 無法回報乾淨狀態(#1474 的驗收面也因此
只能以「無 sync_status contract issue」為準)。
這不是歷史殘骸:2026-09-02 12:00 UTC 之後又新增 3 列違規資料(後綴列 4 → 7), 寫入端每晚仍在產生。(2026-09-03 01:43 UTC 對 live 複測,後綴列仍是 7 列 /222 元素,數字未再變動。)
為何不能只把後綴砍掉
砍成裸碼會丟失「上市/上櫃」資訊,而該資訊在管線中只靠外部格式攜帶
(taiwan_ohlc_orchestrator.py 的 listing_types map 由傳入 ticker 解析而來,不查主檔)。
還原來源候選 taiwan_ohlc_rebuild_item 在 preparing 落地當下可能為空
(discover_daily_universe=True 會跳過 ticker item 寫入,item 要到後續 phase 才寫),
而 scheduler 的 resume 掃描涵蓋 preparing 狀態,一列解不開就整批 resume 失敗。
故採無損結構化身分:每個元素存 {"stock_code": "2330", "listing_type": "tw"}。
讀取端不需查任何表。
兩個寫入端共用的不是同一支 registry helper,而是同一個
CountryStockIdentity 驗證與同一組 {"stock_code", "listing_type"} 輸出鍵
(services/stock_identity.py):
- range service(
taiwan_ohlc_range_service.py:1811)走persist_ticker_scope(:472),它逐個 ticker 呼叫既有 registry helpernormalize_identifier_json_for_storage(:260)的{"ticker": ...}分支。 - factor bootstrap(
taiwan_adjustment_factor_bootstrap.py:654)走persist_identity_scope(:489),它不呼叫那支 helper——手上本來就是 已拆解的(stock_code, listing_type),直接建CountryStockIdentity(Country.TAIWAN, ...)再交給_identity_scope_entry(:463)。
兩條路徑之所以輸出同一種形狀:_identity_scope_entry 與 helper 的 "ticker"
分支讀的是 CountryStockIdentity 上同樣那兩個欄位,而正規化(stock_code
去空白/轉大寫、listing_type 收斂成 tw/two/None)發生在兩條路徑都會跑的
CountryStockIdentity.__post_init__。讀取端則是真的共用一支:兩個 reader
(taiwan_ohlc_range_service.py:1044、scripts/scheduler.py:1830)都經
project_ticker_scope(:514),object 元素一律委派給
project_normalized_identifier_json(:366)還原成外部 ticker。
Rehearsal 紀錄(2026-09-03,隔離副本實跑)
來源:live cortex-postgres 以 pg_dump -Fc -t taiwan.taiwan_ohlc_range_request
-t taiwan.taiwan_ohlc_rebuild_item -t taiwan.taiwan_ohlc_rebuild_run 匯出
(5,737,068 bytes,SHA-256 57f5537b201288fc30ddde88e0bace42c509026106b95e227dbb7f94c66d93e0),
還原到 throwaway pgvector/pgvector:pg15。
前置唯讀查核(對 live 直接執行):全部 5,213 個裸碼元素在各自 run 的 rebuild_item
都能解析出唯一 listing_type,0 個查無、0 個歧義 → migration 不會在 live 中止。
| 檢查項 | Before | After | 結果 |
|---|---|---|---|
| 形狀分布 | suffixed 7 列/222 元素、bare 3 列/5,213 元素、empty 17 列 | structured 10 列/5,435 元素、empty 17 列 | ✅ 無殘留字串 |
| 元素數 | — | — | ✅ 27 列逐列元素數不變 |
| 空陣列列 | 17 列 | 17 列 | ✅ md5(requested_tickers::text) 逐列不變 |
| NOTICE(第一次) | — | converted 10 row(s), 5435 element(s); 17 row(s) left untouched |
✅ |
| NOTICE(第二次) | — | converted 0 row(s), 0 element(s); 27 row(s) left untouched + 約束 already present, skipping |
✅ replay-safe |
| 約束 | 不存在 | ck_taiwan_ohlc_range_request_identity_scope |
✅ |
| 約束擋下 | — | ["2330.TW"]、["2330"]、{"2330.TW","tw"}、{"2330","TW"} 全部 violates check constraint |
✅ |
| 約束放行 | — | []、{"2330","tw"}、{"^TWII",null} |
✅ |
| 新版 verifier | — | invalid_requested_tickers = 0 |
✅ |
| 約束加固後重跑(最終版) | 同一份 dump 新副本 | converted 10 row(s), 5435 element(s); 17 row(s) left untouched + ck_… added;二次 converted 0 row(s) + already present with the current definition, skipping;27 列元素數不變、17 列空陣列 md5 不變;invalid_requested_tickers = 0 |
✅ |
| 舊約束升級路徑 | 模擬已套過舊版(寬鬆)約束的 DB | present with a stale definition, replacing it → 收緊後 [{}] 被擋 |
✅ |
| 最終版(Codex review 後收緊至 CountryStockIdentity 台灣域)重跑 | 同一份 dump 新副本 | converted 10 row(s), 5435 element(s); 17 row(s) left untouched + ck_… added;二次 converted 0 row(s) + already present with the current definition, skipping;元素數不變、17 列空陣列 md5 不變;contract = structured、invalid_requested_tickers = 0 |
✅ |
| Rollback 端到端實測 | throwaway 容器:還原 27 列 → apply 151 → 跑本 runbook 的 rollback 程序 | 逐列 md5(requested_tickers::text) 與 migration 前基準全部相符 |
✅ |
上表是該次 dump(27 列)當下的快照,不是 live 的現況,數字刻意保留原值以 存證。live 之後仍在長:2026-09-03 01:43 UTC 實測為 28 列——
empty由 17 列 變成 18 列,suffixed7 列/222 元素、bare3 列/5,213 元素則未變。拿新查詢 的結果對照上表時以此為準,兩者不一致並不代表哪一邊錯了。實際 apply 的驗收分母 一律以第 3 步當場跑出來的數字為準(該步已註明不可寫死筆數)。
收尾:rehearsal container 已 docker rm -f 清除。
正式 apply 手順
⚠ 部署順序:migration 與新 image 必須同批。舊 image 讀不懂結構化元素; 新 code 對舊資料有相容分支(後綴字串放行並告警、裸碼字串 fail-closed), 反向則無保護。
⚠ writer 不只
cortex-scheduler一個:api/routes/stocks.py的 on-demand ticker refresh(_refresh_ticker_data→scripts/ticker_refresh.py:refresh_single_ticker→refresh_taiwan_ticker_ohlc(約 76–82 行)→scripts.scheduler.sync_taiwan_ohlc→DurableTaiwanOhlcRangeLifecycle._register_request_on_connection)會在一般 使用者請求個股資料時,於 fire-and-forget background task 觸發新的 range request 寫入,同一支寫入路徑、同一張表。不停cortex-api, migration 就有第二個活躍 writer: 1. 這個 writer 可能在第 1 步pg_stat_activity讀回之後、或第 4 步 migration 取得 ACCESS EXCLUSIVE lock 之前/期間才啟動,migration 用lock_timeout = 5s(見 migration 檔案開頭),撞上時會直接canceling statement due to lock timeout失敗——可復原,但 operator 必須被告知,見第 4 步。 2. migration 之後,若cortex-api仍在跑舊 image,它仍會寫入舊的字串 shape,新約束會直接擋下、造成使用者路徑上的未處理寫入錯誤。本 repo 目前沒有把
cortex-maintenance-status這類機制接到cortex-api的維護模式(/Users/leonlin/WebstormProjects/cortex-maintenance是獨立專案的一個純狀態頁 nginx 容器,本 repo 程式碼與設定完全沒有引用它, 不會擋下或改道cortex-api的流量/寫入)。故本 runbook 唯一可行的作法是 直接停止cortex-api容器,與cortex-scheduler一起停。
1. 停止全部 writer 並 read-back
docker stop cortex-scheduler cortex-api
docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c \
"SELECT count(*) FROM pg_stat_activity
WHERE datname='cortex_investment' AND state <> 'idle'
AND query ILIKE '%taiwan_ohlc_range_request%' AND pid <> pg_backend_pid();"
預期:兩個容器皆 Exited;查詢回 0。這條查詢只能當初步訊號,不是
證明:pg_stat_activity.query 存的是該 session 最後一句語句,一個
跑過 BEGIN; INSERT INTO taiwan.taiwan_ohlc_range_request ...; SELECT 1;
後停在 idle in transaction 的 session,仍握著那一列的鎖,但 query
欄位此刻是 SELECT 1,ILIKE 篩不到它,這條查詢回 0 不代表沒有這類
session。
真正權威、不看 session 最後一句語句的查法是直接看 pg_locks:
docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c "
SELECT a.pid, a.state, a.application_name, l.mode, a.query
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.relation = 'taiwan.taiwan_ohlc_range_request'::regclass
AND a.pid <> pg_backend_pid();"
預期:無資料列。若有列且 state = 'idle in transaction',就是上面 ILIKE
查詢會漏掉的那種 session——這是它仍握鎖的直接證據,必須先確認該 session
結束(或手動 kill)才可以繼續。只要兩個容器都已停止,且這條 pg_locks
查詢也無輸出,才代表沒有其他 writer(含手動 backfill script)仍在跑。
2. Backup
docker exec cortex-postgres pg_dump -U cortex -d cortex_investment -Fc \
-t taiwan.taiwan_ohlc_range_request \
-t taiwan.taiwan_ohlc_rebuild_item \
-t taiwan.taiwan_ohlc_rebuild_run \
-f /tmp/1541-pre.dump
docker cp cortex-postgres:/tmp/1541-pre.dump ./1541-pre.dump
shasum -a 256 ./1541-pre.dump
3. Before 查核(記錄為驗收分母,不可寫死筆數)
docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c "
SELECT CASE
WHEN jsonb_array_length(requested_tickers)=0 THEN 'empty'
WHEN EXISTS (SELECT 1 FROM jsonb_array_elements(requested_tickers) v
WHERE jsonb_typeof(v)='object') THEN 'structured'
WHEN EXISTS (SELECT 1 FROM jsonb_array_elements(requested_tickers) v
WHERE v #>> '{}' ~* '\.TWO?\$') THEN 'suffixed'
ELSE 'bare' END AS shape,
count(*) rows, sum(jsonb_array_length(requested_tickers)) elems
FROM taiwan.taiwan_ohlc_range_request GROUP BY 1 ORDER BY 1;"
同時輸出逐列基準(供 after 對照):
docker exec cortex-postgres psql -U cortex -d cortex_investment -At -c \
"SELECT run_id, md5(requested_tickers::text), jsonb_array_length(requested_tickers)
FROM taiwan.taiwan_ohlc_range_request ORDER BY run_id" > 1541-before.txt
裸碼可解析性前置檢查(必跑;回傳任何非 resolvable 列即停止):
docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c "
WITH bare AS (
SELECT r.run_id, v.value #>> '{}' AS code
FROM taiwan.taiwan_ohlc_range_request r,
LATERAL jsonb_array_elements(r.requested_tickers) v
WHERE jsonb_typeof(v)='string' AND v #>> '{}' !~* '\.TWO?\$' AND v #>> '{}' NOT LIKE '^%')
SELECT (SELECT count(DISTINCT i.target_listing_type)
FROM taiwan.taiwan_ohlc_rebuild_item i
WHERE i.run_id=b.run_id AND i.scope_kind='ticker'
AND i.target_stock_code=b.code AND i.target_listing_type IS NOT NULL) AS distinct_types,
count(*) AS elements
FROM bare b GROUP BY 1 ORDER BY 1;"
預期:只有一列 distinct_types = 1。這條只判「該 run 的 evidence 解不解得出
唯一一個值」,不判解出來的值是不是 canonical 的 tw/two——後者由下一條檢查
負責,兩條都必跑,對應關係見下一節的中止條件對照表。
後綴代碼 canonicalisation 前置檢查(必跑;would_fail_constraint 非 0
即停止並回報):
docker exec cortex-postgres psql -U cortex -d cortex_investment -P pager=off -c "
WITH el AS (
SELECT r.run_id, e.value AS v
FROM taiwan.taiwan_ohlc_range_request r
CROSS JOIN LATERAL jsonb_array_elements(r.requested_tickers) AS e(value)
WHERE jsonb_typeof(r.requested_tickers) = 'array' AND jsonb_typeof(e.value) = 'string'
), projected AS (
SELECT el.run_id, el.v #>> '{}' AS raw,
CASE WHEN el.v #>> '{}' ~* '\.TWO\$' THEN regexp_replace(el.v #>> '{}', '\.TWO\$', '', 'i')
WHEN el.v #>> '{}' ~* '\.TW\$' THEN regexp_replace(el.v #>> '{}', '\.TW\$', '', 'i')
ELSE el.v #>> '{}' END AS code,
CASE WHEN el.v #>> '{}' ~* '\.TWO\$' THEN 'two'
WHEN el.v #>> '{}' ~* '\.TW\$' THEN 'tw'
WHEN left(el.v #>> '{}', 1) = '^' THEN NULL
ELSE evidence.listing_type END AS listing_type
FROM el
LEFT JOIN LATERAL (
SELECT min(i.target_listing_type) AS listing_type
FROM taiwan.taiwan_ohlc_rebuild_item i
WHERE i.run_id = el.run_id AND i.scope_kind = 'ticker'
AND i.target_stock_code = el.v #>> '{}'
AND i.target_listing_type IS NOT NULL
) AS evidence ON true
)
SELECT count(*) AS string_elements,
count(*) FILTER (
WHERE CASE WHEN listing_type IS NULL THEN code <> '^TWII'
WHEN listing_type NOT IN ('tw','two') THEN true
ELSE code !~ '^[0-9][0-9A-Z]{0,9}\$' END) AS would_fail_constraint
FROM projected;"
migration 151 的 backfill(轉換 CASE 裡 WHEN element.value #>> '{}' ~* '\.TWO$'
與 ~* '\.TW$' 那兩支,各自呼叫
regexp_replace(element.value #>> '{}', ..., '', 'i'))只用大小寫不敏感比對砍掉
.TW/.TWO 後綴,
不會把剩下的代碼本身正規化。若某個字串是 "00631l.TWO" 這種小寫代碼,
砍完後綴後會變成 stock_code = "00631l",剛好落在新約束
like_regex "^[0-9][0-9A-Z]{0,9}$" 的拒絕範圍內,migration 就會在 BEGIN
之後、COMMIT 之前直接 RAISE EXCEPTION 中止。整支 migration 是單一交易,
中止是 fail-safe(不會半套),但仍是一次可避免的中斷,應該在動手前先知道,
不要套用到一半才發現。
這條查詢投影的是「一對」欄位,判斷也是對這一對做的。 約束的判準從來不是
單看 stock_code,而是 listing_type 與 stock_code 的組合,所以前置檢查也必須
照 migration 151 的轉換 CASE 把兩個欄位都投影出來,才判得出約束會給什麼裁決:後綴
命中 .TWO/.TW 者得到 listing_type two/tw;^ 開頭者
(WHEN left(element.value #>> '{}', 1) = '^' 那一支)得到 NULL;其餘裸碼則從該 run
自己的 taiwan_ohlc_rebuild_item 解出。
但這條查詢不是 migration 151 的鏡像,那段 LEFT JOIN LATERAL 也不是 migration
evidence 查詢的同義寫法。 它是「把約束的規則,套到一對已經解出來的身分」,
而「怎麼解出來」兩邊刻意不同:這裡用 min(i.target_listing_type) 取單一值,
migration 用 array_agg(DISTINCT item.target_listing_type),並在
cardinality(listing_types) <> 1 時直接 RAISE EXCEPTION 中止(migration 檔第 142、
159–161 行)。同一個 run 內同一個代碼若解出兩個以上不同的 listing_type,min()
會靜默取字典序最小的那個,投影出一對合法身分、would_fail_constraint 回 0、
操作者照著往下套——而 migration 走到那裡會中止。這條查詢因此依賴前一條歧義前置
檢查已經先跑過:歧義由 count(DISTINCT ...) 那條攔,這條只負責約束的值域。
兩條前置檢查必須依序跑(先歧義、後 canonicalisation),且不可互相取代。 migration 151 的三條中止條件與兩條檢查的涵蓋關係:
| migration 151 中止條件 | 歧義檢查(count(DISTINCT …)) |
canonicalisation 檢查(本條) |
|---|---|---|
listing_types IS NULL(該 run 查無 evidence 列) |
✅ 冒出 distinct_types = 0 的列 |
✅ listing_type 解成 NULL 且代碼非 ^TWII → 計入 |
cardinality(listing_types) <> 1(同 run 同代碼多值) |
✅ 冒出 distinct_types ≥ 2 的列 |
❌ min() 靜默取一個值,可能回報 0 |
listing_types[1] NOT IN ('tw','two')(唯一值但非 canonical) |
❌ distinct_types = 1,看起來正常 |
✅ FILTER 第二支 listing_type NOT IN ('tw','two') → 計入 |
第三條中止條件有被涵蓋,但只有本條 canonicalisation 檢查攔得到;第二條反過來
只有歧義檢查攔得到。少跑任一條,就有一整類「會讓 migration 中途 RAISE」的資料
不會在動手前被發現。
上表在 throwaway pgvector/pgvector:pg15 上以三筆造假資料實證(同 run 同代碼
tw+two/唯一值 twse/無 evidence 列):歧義檢查回 distinct_types 0、1、2 各一列;
canonicalisation 檢查只旗標後兩筆,對歧義那筆 min() 解成 tw、不旗標;migration 的
中止述詞三筆全為 true。再把 migration 151 的約束原文套上該表做 INSERT 裁決:
{"stock_code":"1111","listing_type":"tw"} 被接受,…"twse" 與
{"stock_code":"3333","listing_type":null} 皆 violates check constraint——也就是歧義
那一類連約束本身都攔不到(它解出來的身分完全合法,只是可能解錯),唯一的網子
就是歧義前置檢查與 migration 的 cardinality 中止。容器已 docker rm -f 清除。
投影出來的那一對,再套 $check$ 區塊的兩條 jsonpath:
@.listing_type == null && @.stock_code != "^TWII"(null 只放行 ^TWII;因為
services/stock_identity.py 的 _INDEX_COUNTRIES 只把 ^TWII 對到
Country.TAIWAN,^GSPC 是美股指數,本來就不該出現在 taiwan schema),以及
@.listing_type != null && !(@.stock_code like_regex "^[0-9][0-9A-Z]{0,9}$")
(非 null 只放行裸碼 regex),外加 listing_type 本身必須是 tw/two/null。
FILTER 的三支就是這三條規則。
為什麼不能只看 stock_code 決定(即使加上「^ 開頭必須恰好是 ^TWII」
也不夠):"^TWII.TW" 這種字串會先命中後綴那一支,砍完變成
stock_code = "^TWII"、listing_type = "tw"——stock_code 看起來完全合法,但
非 null 的 listing_type 要求它必須符合裸碼 regex,^TWII 不符合,約束照樣擋。
同理,裸碼若在 rebuild_item 裡解不出 listing_type(或解出 tw/two 以外的
值),投影結果一樣過不了。這幾種都只有把兩個欄位一起看才判得出來。不要用
「寫入端不會產生這種字串」當作不查的理由:本表也會被手動修補的 UPDATE、
還原、合併寫入,而這道前置檢查存在的意義,正是要攔下寫入端沒產生、但約束會
擋下的東西。
以 2026-09-03 對 live 實測(上面這條 pair 版查詢):string_elements=5435
would_fail_constraint=0。live 目前 ^ 開頭的字串元素是 0 個,全部 5,213 個裸碼
也都解得出唯一 listing_type,所以今天各版本同值——差異只在防護力,不在讀數。
預期同樣是 0。若跑出非 0,代表存在大小寫不正規的殘留代碼、^TWII 以外的指數
符號、^ 符號被掛上後綴、或裸碼解不出 canonical listing_type,必須停止並
回報——這些元素需要人判斷(大小寫問題先正規化再套用;非台灣指數與掛錯後綴的
指數要從本表移除;解不出型別的先補 rebuild_item 證據),不可為了放行而放寬
約束,也不可盲目續套。
4. Apply
export PG_CONTAINER=cortex-postgres
# 4a. 先確認待套集合「恰好」只有 151,再動任何資料。
# runner 的 apply 會把 pending 清單「全部」依序套完
# (apply_pending_migrations.sh 的 while 迴圈),而本 runbook 的備份與
# 回滾只涵蓋 taiwan_ohlc_range_request 一張表。若清單多於一支,其餘
# migration 的變更會落在本文件的復原路徑之外——必須停下來另案處理,
# 不可在此直接 apply。
# 整段是單一 if/elif 鏈,且**每一次**呼叫都檢查離開狀態。理由:
# * 指令失敗與「回空集合」在 shell 裡長得一樣。`remaining="$(... pending)"`
# 若忽略 rc,pending 失敗時 remaining 是空字串,會被讀成「已無待套」而
# 往下執行 apply——實測以一個固定回傳 42 的假 runner 重現,舊寫法會印出
# `APPLY RAN AFTER FAILED PREFLIGHT`。故改用 `if ! var="$(...)"` 形式,
# 這個寫法會拿到命令替換的 rc(已實測)。
# * 任何一步失敗後,後面都不再有可達的 apply。
# * 刻意不用 `exit 1`——本區塊是直接貼進操作者終端執行的,
# `{ ...; exit 1; }` 是 brace group、不是 subshell,會把那個 shell 本身
# 關掉,連帶失去 PG_CONTAINER、cwd 與備份路徑,反而妨礙回報。
if ! pending="$(investment-agent/backend/scripts/apply_pending_migrations.sh pending)"; then
echo 'STOP: 4a 的 pending 查詢失敗(錯誤見上方),未執行任何 apply,請排除後重跑本區塊' >&2
elif [ "${pending}" != '151_range_request_identity_scope.sql' ]; then
printf '%s\n' "${pending}"
echo 'STOP: pending 不是恰好 151(見上方輸出),未執行 apply,請停止並回報' >&2
else
printf '%s\n' "${pending}"
# 4b. 套用
if ! investment-agent/backend/scripts/apply_pending_migrations.sh apply; then
echo 'STOP: apply 失敗,停止並回報;apply 已被呼叫過,SQL 可能已 commit,照下方「三條 post-apply STOP 的判讀」查約束是否已存在' >&2
# 4c. 確認待套已清空,且重跑為 no-op
elif ! remaining="$(investment-agent/backend/scripts/apply_pending_migrations.sh pending)"; then
echo 'STOP: 4c 的 pending 查詢失敗,無法確認狀態;不要再跑 apply,先查約束是否已存在' >&2
elif [ -n "${remaining}" ]; then
printf 'STOP: apply 後仍有 pending:%s\n' "${remaining}" >&2
else
investment-agent/backend/scripts/apply_pending_migrations.sh apply
fi
fi
預期:4a 印出唯一一行 151_range_request_identity_scope.sql(pending 的
stdout 依 runner header 契約為純檔名清單,診斷訊息走 stderr);4b 印出
migration 151: converted N row(s), M element(s); K row(s) left untouched;
4c 走 else 分支印 No pending migrations.。
本區塊一共會印出五條 STOP::4a 兩條、4b 一條、4c 兩條。 其中只有
4a 那兩條(pending 查詢失敗、以及待套集合不是恰好 151)保證該次 apply
沒有執行——這兩處守衛都在真正呼叫 apply 之前擋下,看到就停止並
回報,不要續跑。
三條 post-apply STOP 的判讀:4b 的 STOP: apply 失敗、4c 的
STOP: 4c 的 pending 查詢失敗,無法確認狀態、4c 的
STOP: apply 後仍有 pending,三條都不在上面那條保證之列,都必須另外查證,
且走的是同一條調查路徑。4b 的訊息是在 apply 已經被呼叫過之後才印的,
非 0 的返回值只說明 runner 這一趟沒有正常走完,不代表 migration SQL 沒跑。
apply_pending_migrations.sh 的 cmd_apply()(while IFS= read -r migration_file
那個迴圈;同檔的 cmd_seed_from_manifest()/cmd_seed_through() 也呼叫
record_applied,但那兩支是純補 ledger 的 seeding 指令,不是這裡說的套用路徑)
對每支 migration 是先跑
_psql_stdin -c "SET lock_timeout ..." -f - < migration.sql,成功後再用
另一條獨立指令呼叫 record_applied "${migration_file}" 把檔名寫進 ledger。若 SQL 已經
commit、但 process 在 record_applied 成功寫入前斷線,schema 上的 backfill
與 CHECK 約束都已經套上,只是 ledger 裡沒有 151 這筆紀錄——此時 pending
依然會回報 151。三條訊息各自落在這條時序的哪一段:
4bSTOP: apply 失敗:runner 當下就以非 0 退出。SQL 可能已 commit、也可能 根本沒跑(例如lock_timeout在取鎖階段就失敗,見下方 ⚠ 區塊)。4cSTOP: 4c 的 pending 查詢失敗,無法確認狀態:apply 這一趟以 0 收尾,但緊接 著那次pending查詢本身失敗,連「還有沒有待套」都不知道。不可據此推論 apply 成功或失敗,更不可以再跑一次 apply。4cSTOP: apply 後仍有 pending:runner 正常收尾、pending也查得到,但 151 仍在清單上——典型就是上面那個「SQL 已 commit、ledger 沒寫成」的情況。
三條底下都可能是「migration 其實已經真的跑過了」。用下面這條查詢分辨 (三條都適用):
docker exec cortex-postgres psql -U cortex -d cortex_investment -At -c \
"SELECT count(*) FROM pg_constraint
WHERE conrelid = 'taiwan.taiwan_ohlc_range_request'::regclass
AND conname = 'ck_taiwan_ohlc_range_request_identity_scope';"
回 0:約束不存在,SQL 真的沒跑,屬於本文件原本假設的情況,停止並回報。
回 1:約束已存在,代表 SQL 已經套用、只差 ledger 那一筆。這種情況下重跑
apply 是安全的——migration 151 是 replay-safe 設計(比對已存在約束的定義,
不變就跳過、只補 ledger),可以直接重跑 4b/4c;但重跑前應該先照第 5
步「After 查核」確認列數與形狀分布符合預期,不要只憑約束存在就假設資料
內容也正確。
⚠
canceling statement due to lock timeout處理:migration 對taiwan.taiwan_ohlc_range_request取ACCESS EXCLUSIVElock,lock_timeout = 5s(migration 檔案本身與apply_pending_migrations.sh都設)。若 apply 失敗並帶出這行錯誤,代表 第 1 步的 quiesce 不完整——有其他 session 持有該表上會與ACCESS EXCLUSIVE衝突的鎖。ACCESS EXCLUSIVE與幾乎所有鎖模式衝突, 連一個單純的SELECT(ACCESS SHARE)留在未結束的交易裡都會擋下它, 所以這裡不能反推「一定是 writer」——只能確定「還有 session 沒清乾淨」, 用第 1 步的pg_locks查詢去找出是哪個 session、什麼 mode。處置:回到 第 1 步,重新確認docker ps顯示cortex-scheduler、cortex-api皆已 停止,重新跑pg_locks查詢直到無輸出,再重跑本步驟的 apply。 不得調高lock_timeout或用其他方式繞過此錯誤——那只是把同一個 未被發現的活躍 session 問題往後拖,migration 的LOCK TABLE ... IN ACCESS EXCLUSIVE MODE是刻意設計成在有其他 session 持有衝突鎖時就是要失敗,不是要被繞過。
5. After 查核
重跑第 3 步的形狀查詢,預期只剩 structured 與 empty 兩類、無 suffixed/bare;
並比對逐列元素數與空陣列列的 md5:
docker exec cortex-postgres psql -U cortex -d cortex_investment -At -c \
"SELECT run_id, md5(requested_tickers::text), jsonb_array_length(requested_tickers)
FROM taiwan.taiwan_ohlc_range_request ORDER BY run_id" > 1541-after.txt
join -t'|' -j1 <(sort 1541-before.txt) <(sort 1541-after.txt) \
| awk -F'|' '$3!=$5 {print "ELEMENT COUNT CHANGED:",$0} $3==0 && $2!=$4 {print "EMPTY ROW CHANGED:",$0}'
預期:無輸出。
6. 重啟 cortex-scheduler 與 cortex-api(新 code 同批上線)
必要順序:quiesce 全部 writer(第 1 步)→ apply migration(第 4 步)→
cortex-scheduler與cortex-api都以新 code 重啟(本步)→ 才恢復流量(第 8 步起)。第 7 步的 verifier 要 exec 進cortex-api, 故本步必須在 verifier 之前完成,兩容器缺一都會讓 verifier 跑在舊 code 或跑不起來。
docker start cortex-scheduler cortex-api
cortex-api 在本環境是 --reload + bind mount(見
investment-agent/docker-compose.yml、docs/runbooks/1253-ohlc-history-repair.zh-TW.md
的既有說明):只要新 code 已經在 mount 進容器的路徑上(git pull/merge
已落地),重啟即會載入新 code,不需要另外 rebuild image。若部署環境的
cortex-api/cortex-scheduler 是烘焙好的 image(例如非本地 bind-mount
的環境),docker start 前必須先完成該環境對應的 image
build/pull/recreate,兩個容器都不得停留在舊 image。
預期:兩容器狀態皆為 Up;docker logs cortex-api --since 1m 與
docker logs cortex-scheduler --since 1m 皆無 CheckViolationError /
ck_taiwan_ohlc_range_request_identity_scope 相關錯誤。
7. 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'
預期(下列全部成立才算過):
| 欄位 | 預期值 | 為何能證明 151 真的跑過 |
|---|---|---|
facts.requested_tickers_contract |
"structured" |
ledger 裡有 151_range_request_identity_scope.sql 才會是這個值;沒套過是 "pre_migration_151" |
facts.identity_scope_constraint_present |
true |
pg_get_constraintdef 查得到 ck_taiwan_ohlc_range_request_identity_scope |
facts.identity_scope_constraint_definition_matches |
true |
不只看名字在不在,而是 deparse 出來的定義與 migration 151 的 $check$ 本體逐字相符 |
| issues | 不含 requested_tickers_contract_ledger_drift |
ledger 與 schema 其一有其一沒有、或約束在但定義是舊的寬鬆版本,都會冒出這個 issue |
| issues | 不含 requested_tickers_identity |
沒有違反身分契約的列 |
facts.invalid_requested_tickers |
0 |
同上,計數形式 |
⚠ 最後兩項不能單獨當通過條件:一個從未套過 migration 151 的資料庫, 走的是
REQUESTED_TICKERS_PRE_MIGRATION_151_IDENTITY_QUERY這條寬鬆稽核, 同樣會回報invalid_requested_tickers = 0且不帶requested_tickers_identityissue。真正能把「成功」與「什麼都沒發生」分開的是前四項——契約模式是structured、約束存在、定義相符、且無 ledger drift。
8. 恢復流量並觀察
第 6 步已重啟兩個容器,本步僅確認流量已恢復並開始觀察,不再另外重啟。
次日查核:
docker logs cortex-scheduler --since 24h 2>&1 | grep -c "legacy suffixed ticker scope decoded"
docker exec cortex-postgres psql -U cortex -d cortex_investment -At -c \
"SELECT count(*) FROM taiwan.taiwan_ohlc_range_request
WHERE created_at > now() - interval '24 hours'
AND EXISTS (SELECT 1 FROM jsonb_array_elements(requested_tickers) v
WHERE jsonb_typeof(v) <> 'object');"
預期:兩條都是 0(無 legacy 相容分支告警、無新的非結構化寫入)。
Rollback
為何不能直接
pg_restore --data-only回原表:migration 151 是UPDATE ... SET requested_tickers = ...,不是DELETE+INSERT,所以 每一列的 primary key(run_id)在 migration 之後仍原封不動存在於taiwan.taiwan_ohlc_range_request。對著同一張非空表跑pg_restore --data-only,COPY 會撞上既有 PK 而以 duplicate-key error 失敗。也不能改用
TRUNCATE ... CASCADE先清空再還原:taiwan.taiwan_ohlc_range_child對本表的parent_run_id/child_run_id各有一條ON DELETE RESTRICT的 FK(migration143_taiwan_ohlc_range_child_lineage.sql)。TRUNCATE本身不受RESTRICT限制,但CASCADE會把taiwan_ohlc_range_child整張表一起 砍空——而1541-pre.dump只 dump 了taiwan_ohlc_range_request/taiwan_ohlc_rebuild_item/taiwan_ohlc_rebuild_run三張表(見「2. Backup」),不含taiwan_ohlc_range_child,這些 lineage 列砍掉就回不來。下面改用「還原進 staging schema,再用
UPDATE ... FROM只覆寫 migration 151 動過的requested_tickers一欄」,不動 PK、不動其他欄位、 不觸碰taiwan_ohlc_range_child。本程序已在隔離環境對真實 dump 實測驗證(27 列、其中 10 列 migration 有轉換、17 列空陣列未動, rollback 後與 migration 前的1541-before.txt逐列 md5 全部相符;細節 見 PR #1573 review 回覆)。
1. 停止全部 writer
docker stop cortex-scheduler cortex-api
與「正式 apply 手順」第 1 步同一道 quiesce 門檻,兩個條件都成立才可繼續:
兩個容器都已 Exited,且該步驟那條 pg_locks 權威查詢無資料列。
pg_stat_activity 的 ILIKE 計數回 0 只是初步訊號、單獨不足以放行(理由與
兩條查詢的全文見該步驟)。
2. 卸除約束、清除 migration 紀錄
docker exec cortex-postgres psql -U cortex -d cortex_investment -v ON_ERROR_STOP=1 -c "
BEGIN;
ALTER TABLE taiwan.taiwan_ohlc_range_request
DROP CONSTRAINT IF EXISTS ck_taiwan_ohlc_range_request_identity_scope;
DELETE FROM public.schema_migrations
WHERE filename = '151_range_request_identity_scope.sql';
COMMIT;"
預期:ALTER TABLE / DELETE 1(或 DELETE 0 若該檔本來就沒被記錄過)/ COMMIT。
3. 把 migration 前的 dump 還原進 staging schema(不動 live 表)
docker cp ./1541-pre.dump cortex-postgres:/tmp/1541-pre.dump
docker exec cortex-postgres psql -U cortex -d cortex_investment -v ON_ERROR_STOP=1 -c "
DROP SCHEMA IF EXISTS mig151_rollback_staging CASCADE;
CREATE SCHEMA mig151_rollback_staging;
CREATE TABLE mig151_rollback_staging.taiwan_ohlc_range_request
(LIKE taiwan.taiwan_ohlc_range_request);"
docker exec cortex-postgres pg_restore --data-only --no-owner --no-privileges \
-t taiwan_ohlc_range_request -f /tmp/1541-pre-data.sql /tmp/1541-pre.dump
docker exec cortex-postgres sed -i \
-e 's/COPY taiwan\.taiwan_ohlc_range_request/COPY mig151_rollback_staging.taiwan_ohlc_range_request/' \
/tmp/1541-pre-data.sql
docker exec cortex-postgres psql -U cortex -d cortex_investment -v ON_ERROR_STOP=1 \
-f /tmp/1541-pre-data.sql
pg_restore -t只比對裸表名 tag(schema-qualified 名稱比對不到會靜默還原 0 個物件),故上面用-t taiwan_ohlc_range_request。-f(不帶-d)讓pg_restore只吐出純文字 SQL、不連 DB、不觸碰 live 表;sed把其中的COPY目標從taiwan.taiwan_ohlc_range_request改指到 staging 表,其餘SET/SELECT pg_catalog.set_config(...)之類的前導語句原樣執行即可 (對 session 設定無副作用)。LIKE ...(不加INCLUDING)只複製欄位 定義,不含 PK/FK/CHECK,staging 表不會被 migration 151 的約束卡住, 也不會意外對taiwan_ohlc_rebuild_run建 FK。
預期:CREATE TABLE;pg_restore 無輸出(純轉檔);最後一條 psql -f
印出 COPY <N>(N = 1541-pre.dump 當時的列數)。
4. 只覆寫 migration 151 動過的欄位
docker exec cortex-postgres psql -U cortex -d cortex_investment -v ON_ERROR_STOP=1 -c "
UPDATE taiwan.taiwan_ohlc_range_request AS live
SET requested_tickers = staged.requested_tickers
FROM mig151_rollback_staging.taiwan_ohlc_range_request AS staged
WHERE live.run_id = staged.run_id;"
預期:UPDATE <N>,N 與上一步 COPY <N> 相同。這是等值 join 的
UPDATE ... FROM,只有 run_id 同時存在於 live 表與 staging 表的列才會
被覆寫;1541-pre.dump 備份之後才新建的 run(run_id 不在 staging
表裡)不會被這條 UPDATE 觸碰到。
⚠ 這些備份後新建的列是 migration 已套用之後、由新 code 寫入的, 因此是結構化 shape。回退到 151 之前的 image 後,兩個 durable reader 都只接受字串陣列元素,讀到這種列會拋例外(scheduler resume discovery 與 factor-repair 載入都會炸)。所以 rollback 必須在重啟舊 binary 之前, 把這些殘留的結構化列降轉回舊格式——見下一步。
5. 降轉備份後新建的結構化列(舊 binary 讀得懂的形狀)
docker exec cortex-postgres psql -U cortex -d cortex_investment -v ON_ERROR_STOP=1 -c "
UPDATE taiwan.taiwan_ohlc_range_request AS live
SET requested_tickers = (
SELECT coalesce(jsonb_agg(
CASE element ->> 'listing_type'
WHEN 'tw' THEN to_jsonb((element ->> 'stock_code') || '.TW')
WHEN 'two' THEN to_jsonb((element ->> 'stock_code') || '.TWO')
ELSE to_jsonb(element ->> 'stock_code')
END ORDER BY ordinality), '[]'::jsonb)
FROM jsonb_array_elements(live.requested_tickers)
WITH ORDINALITY AS t(element, ordinality))
WHERE NOT EXISTS (SELECT 1 FROM mig151_rollback_staging.taiwan_ohlc_range_request AS staged
WHERE staged.run_id = live.run_id)
AND EXISTS (SELECT 1 FROM jsonb_array_elements(live.requested_tickers) AS element
WHERE jsonb_typeof(element) = 'object');"
预期:UPDATE <M>,M = 備份後新建且帶結構化 scope 的列數(可能為 0)。
此步驟只碰不在 staging 表裡、且元素為 object 的列,還原成舊 code 的外部
ticker 字串格式(^TWII 這類無 listing_type 者維持裸值)。第 2 步已先卸除
約束,故降轉後的字串形狀不會被擋。
確認再無結構化殘留:
docker exec cortex-postgres psql -U cortex -d cortex_investment -At -c \
"SELECT count(*) FROM taiwan.taiwan_ohlc_range_request
WHERE EXISTS (SELECT 1 FROM jsonb_array_elements(requested_tickers) v
WHERE jsonb_typeof(v) = 'object');"
预期:0。非 0 就不得重啟舊 binary。
6. 驗證:逐列 md5 對回 migration 前基準
docker exec cortex-postgres psql -U cortex -d cortex_investment -At -c \
"SELECT run_id, md5(requested_tickers::text), jsonb_array_length(requested_tickers)
FROM taiwan.taiwan_ohlc_range_request ORDER BY run_id" > 1541-rollback-after.txt
join -t'|' -j1 <(sort 1541-before.txt) <(sort 1541-rollback-after.txt) \
| awk -F'|' '$2!=$4 {print "MD5 MISMATCH:",$0}'
預期:對 1541-pre.dump 備份時就存在、且備份後未被其他 writer 更新過的
每一個 run_id,無輸出(md5 相符)。備份之後才新建或再更新過的
run_id 本來就不在 1541-before.txt 基準裡,join 天然不會比對它們;
它們的正確性由第 5 步的「無結構化殘留」查核負責。
7. 清理 staging、回退 image、重啟 writer
docker exec cortex-postgres psql -U cortex -d cortex_investment -c \
"DROP SCHEMA IF EXISTS mig151_rollback_staging CASCADE;"
回退 cortex-scheduler、cortex-api 兩者的 image/code 到 migration 151
之前的版本(同「正式 apply 手順」第 6 步的機制,方向相反),再
docker start cortex-scheduler cortex-api 恢復兩個 writer。