跳轉到

#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 helper normalize_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 列,suffixed 7 列/222 元素、bare 3 列/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。三條訊息各自落在這條時序的哪一段:

  • 4b STOP: apply 失敗:runner 當下就以非 0 退出。SQL 可能已 commit、也可能 根本沒跑(例如 lock_timeout 在取鎖階段就失敗,見下方 ⚠ 區塊)。
  • 4c STOP: 4c 的 pending 查詢失敗,無法確認狀態:apply 這一趟以 0 收尾,但緊接 著那次 pending 查詢本身失敗,連「還有沒有待套」都不知道。不可據此推論 apply 成功或失敗,更不可以再跑一次 apply。
  • 4c STOP: 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 EXCLUSIVE lock, 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_identity issue。真正能把「成功」與「什麼都沒發生」分開的是前四項——契約模式是 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(migration 143_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。