Skip to Content
核心概念遷移與診斷

遷移、表鎖與診斷

加欄、改型別、刪欄、產生大量測試資料或診斷大表,可能需要在背景掃描資料列。遷移 API 把「這張表現在是否能接受 schema 寫入」與「某一個背景工作的進度」分成兩個狀態,前端要分別追蹤。

什麼時候會出現 migration

只有三種經由 REST 欄位路由的寫入可能改在背景進行:在表格有超過 500 筆 live record 時新增欄位(自 backend 5.21.0 起;原本是 10,000 筆,operator 設定可把門檻調到 0 到 10,000 之間),以及只在表格有超過 10,000 筆 live record 時,會改變欄位 type 的 PATCH .../columns/{column_id} 與刪除欄位。新增計算欄位(link、rollup、lookup、formula)不會改走背景,一律同步回應;除了 computation_mode: "stored" 的 formula 會在請求內重新計算並把結果寫入每一筆 live 紀錄之外,不會寫入任何紀錄資料。其他欄位變更——包括 decimal 的 precision、scale 或正負號變更——一律同步執行,經由 IaC 的 column 行或 Agent 工具箱 columns_add 工具的新增也一律同步。改在背景進行的寫入會回 migration_info.background_migration: true,而工作進行期間看得到的狀態各不相同:

背景寫入回應中的 migration_id工作進行期間看得到的狀態
新增欄位migration_info.migration_id,一個 UUID欄位已在 schema 中;既有 record 會在背景以其預設值回填
型別變更沒有新型別已在 schema 中;既有 cell 會在背景轉換
刪除欄位migration_info.migration_id還沒有任何變化:欄位與其資料會保留到工作完成

型別變更會把結果存在回應從未提供的 id 下,所以只能透過下方的 table migration status 追蹤,直到 is_locked 變成 false;新增欄位則會回傳 migration_id,你可以追蹤該 id,也可以追蹤 table status(見背景新增欄位)。回填結束前,紀錄寫入會回 423。同步或背景新增都會用紀錄寫入的 coercion 處理預設值(decimal 預設值會以欄位的 scale 儲存;同步新增的 default_value_applied 回應儲存後的值,背景新增的 migration_info 則回 null),並把每筆回填紀錄的 history 歸屬於執行操作的使用者。診斷在超過 5,000 筆 live record 時改在背景進行,測試資料在要求超過 2,000 列時改在背景進行;兩者此時都會回 background_processing: true 與 migration_id。請把這些數字當成參考資訊,以回應中的旗標為準,不要把門檻寫死成 UI 邏輯。

表鎖與 423 Locked

每次 schema 寫入都會取得表層級的鎖,同步寫入也一樣,因此其他 schema 寫入、批次工作或 record 寫入不會與它交錯。這把鎖是 TTL 10 分鐘的 Redis key(expires_at 為取得時間加 600 秒),所以崩潰的程序留下的鎖會自行過期。背景的新增欄位與型別變更工作每 200 秒續約一次,所以長時間回填期間它們的 expires_at 會持續往後推。背景的刪除欄位、診斷與測試資料工作不會續約:執行超過 10 分鐘的工作會比它的鎖活得更久,因此光看 is_locked: false 並不能證明工作已經結束。

鎖被持有期間,衝突的請求會回 423 Locked,detail 為以下三種之一:

detail回傳者
Table is currently locked for migration operation: <operation>欄位新增、更新與刪除
Table is currently locked for operation: <operation>Record 建立、更新、刪除、重新排序、從垃圾桶還原與版本還原;同步批次與非同步批次的提交;發布 session 的開啟與 commit;rules 的 PUT
Table is locked for operation: <operation>診斷與測試資料

通過這項檢查、卻在搶鎖時落敗的欄位寫入,會改回 400 與 ...locked for operation: <operation> 文字。非同步批次 ticket 若在兩個 chunk 之間發現表格被鎖,會以 failed 結束,訊息為 Table became locked for a migration; bulk insert aborted(或 bulk update/bulk delete);先前已 commit 的 chunk 仍維持 commit。此時不要盲目重送原 mutation;先查 table lock 與已知 migration。

<operation> 指出持有鎖的操作:add_column、update_column、delete_column、diagnosis、test_data_insertion、delete_table 與 unlink_table(刪除表格時會鎖住自己,以及每一張 link 或 rule 需要被改寫的表格),或 crossref_repair(維運修復)。有兩個值不是操作。in-progress migration (lock payload unreadable) 表示鎖存在但內容損毀;表格仍然被鎖。在狀態顯示 is_locked: false 的表格上出現 unknown,表示鎖服務本身無法使用:此時鎖檢查一律視為「未上鎖」,所以 record 寫入照常進行;但沒有任何鎖能被取得,所以每一次 schema 寫入都會被拒絕。

欄位新增、更新與刪除請求也依 API worker 進行准入。自 backend 5.21.0 起,一個 worker 同時最多執行四個(operator 可設為 1–16);遇到 worker 已滿的請求會立即被拒絕,不會排隊,回 429 與 Retry-After: 1:

{ "detail": { "code": "ct.column_operation_busy", "message": "Column operation capacity is full on this worker; retry after an operation finishes.", "concurrency_limit": 4 } }

請在延遲後重試。背景新增在回填結束前會一直佔用它的名額。欄位預覽與其他所有路由不共用這個上限。

欄位更新與轉型

PATCH .../columns/{column_id} 並不代表每個欄位都可修改。link、rollup、lookup 或 formula 欄只能修改 name 與 description;關係、彙總、lookup、pick 與 expression 都是建立時設定。欄位也不能轉成或轉離這四種計算型別。若要改設定,必須刪除並重建。

Stored 欄位轉型在遷移開始前有以下 preflight gates:

  • 轉成 select 或 multi_select 時,同一個請求必須提供完整 options 清單。
  • 必填欄必須提供新的、與新型別相容的 default_value。選填欄若沒有提供新預設值,schema 預設值會被清除,無法轉換的 cell 會退回 null。
  • 把 required 從 false 改為 true 會掃描 live rows;任何 cell 缺少或為 JSON null 都會讓更新失敗。Preflight 不會把 "" 當成空值,雖然後續 record write 會拒絕明確寫入空字串到必填欄;client 應先清理既有空字串。
  • 被 link/rollup/lookup/formula 相依引用的欄位一律不能轉型,唯一例外是 integer ⇄ float。這也會擋看似相近的 string ⇄ text。
  • 受 unique rule 涵蓋的欄位,每次轉型都會先以下方的轉換規則推算結果——包括會退回新預設值的 cell——若會有兩個 cell 碰撞,就以該 rule 的 409 unique-conflict body 拒絕,且不會改寫任何資料。
  • 舊型別專用設定會被移除,例如離開 select 會移除 options,離開 attachment 會移除附件限制。
  • 屬於 unique_tuple rule 組成欄位的欄位完全不能轉型:column type is protected by a unique_tuple rule; remove the rule first。
  • 轉成或轉離 decimal 只在表格沒有任何 record(含已刪除的)時允許;否則會以 decimal type/config changes on a table with records require an explicit data migration(轉成 decimal)或 decimal type changes on a table with records require an explicit data migration(轉離 decimal)拒絕。其他型別帶 precision、scale 與 allow_negative 一律拒絕。

變更 decimal 欄位的 precision、scale 或正負號

既有 decimal 欄位的 precision、scale 與 allow_negative 可以更新,而且這項變更絕不走背景遷移。伺服器會在表鎖下預檢所有 live 與已刪除(trash)的 cell,然後在與 schema 變更相同的 transaction 中精確改寫每個有變動的 cell——每筆都有新的 record version 與 history——因此較晚的失敗絕不會讓一半的 cell 停在新 scale:

  • 調高 scale 會補零("1.20" → "1.200");調低 scale 只能去掉值為零的小數位("1.20" → "1.2" 可以,"1.25" 不行)。
  • 任何新宣告容納不了的 cell 都會讓整個變更失敗:decimal column config is incompatible with record <id>: <reason>。已儲存的 default_value 也以同樣方式重新調整 scale(invalid decimal default_value: …)。
  • 改寫上限為 10,000 筆 record(含已刪除的);更大的表格會以 decimal config migration exceeds 10000 records; use an explicit bounded migration before changing this column 拒絕。

既有 cell 轉換矩陣

轉型不會無條件把每個值替換成新預設值。伺服器會逐一盡力轉換每筆 live record 中已存在的 cell;只有無法轉換的值才退回新的 default_value,未提供時則為 null。沒有這個 key 的 record 不會被動到,已 soft-delete 的 record 也一樣,會保留舊型別的值(與下方的 option_renames 不同)。既有 null 與 "" 會保持空值,不會被預設值填滿——但轉成或轉離 attachment、principal、user 或 social_client 時例外,此時每個已存在的 cell(包括空值)都會收到新預設值。同步與背景路徑使用同一套規則。

舊型別新型別既有 cell 結果
integer ⇄ float另一個數值型別進行數值轉換;integer→float 可能失去精度,float→integer 會先四捨五入,且必須落在 signed 64-bit 範圍。
string / textinteger / float解析 trim 後的數值文字;無效、非有限或超出範圍時退回。
integer / float / booleanstring / text使用伺服器的字串表示;布林會變成 "True" 或 "False"。
string ⇄ text另一個文字型別保留字串值。
datedatetime附加 00:00:00;這個僅遷移會產生的含秒形狀與一般 datetime 驗證衝突,見下方。
datetimedate保留空白或 T 之前的日期部分。
string / textjson把 trim 後的字串解析成 JSON,並套用 JSON byte、depth、node 與有限數字限制;無效時退回。
integer / float / booleanjson保留純量,作為合法 JSON 值。
jsonstring / text以 object key 排序後序列化;若超過新的明確 max_length 則退回。
任意型別轉成或轉離 attachment一律退回,空值也一樣;blob ID 不會跨欄位型別轉換。
任意型別轉成或轉離 principal / user / social_client一律退回,空值也一樣;schema 遷移不會重新驗證不透明的租戶身分。這包含 user ⇄ social_client 與 user ⇄ principal——沒有任何轉型會把裸 id 加上前綴變成帶標籤的 cell。
decimal任何其他型別,或任何型別轉成 decimal不轉換:表格有 record 時拒絕(見上方)。
select、multi_select 或上表未列出的任何組合另一個 stored 型別退回新預設值,未提供時為 null。

已知後端不一致 一般紀錄寫入與預設值只接受不含秒的 YYYY-MM-DD HH:mm。但 date→datetime 遷移會寫入 YYYY-MM-DD 00:00:00,所以前端可能讀到一個無法原樣寫回的含秒值。請把它視為遷移輸出,而不是新支援的輸入格式;後續寫入前先正規化到分鐘。

Select 選項改名是另一種遷移。option_renames 會改寫 live 與 soft-deleted records 的相符值、同步更新相符的 select 預設值,並重建受影響的 unique-rule entries。同一個請求不能同時使用 option_renames 與完整 options replacement。它必須是用在 select 或 multi_select 欄位上、非空的 {old: new} 物件:每個舊名稱都必須是現有選項,每個新名稱都必須是最多 64 個字元的非空字串,而且改名後的清單不能有重複。屬於 unique_tuple rule 組成欄位的欄位會拒絕它:option rename is protected by a unique_tuple rule; remove the rule first。

Table migration status:回答「表是否被鎖」

這條路徑仍是一般 scoped table path:

GET /private/module/custom_tables/{scope}/.../tables/{table_id}/migration/status

聊天室與部門版本的 {scope} 仍包含自己的 scope ID。回應重點是:

{ "table_id": "22222222-2222-4222-8222-222222222222", "is_locked": true, "lock_info": { "lock_id": "5b0c6c1e-2d4f-4a8e-9a53-6f1d2c3b4a59", "operation": "add_column", "acquired_at": "2026-07-19T02:20:00.123456+00:00", "expires_at": "2026-07-19T02:30:00.123456+00:00" }, "record_count": 15000 }

lock_info 是原樣轉出的鎖紀錄:擁有者 lock_id、operation,以及 acquired_at/expires_at(含微秒、帶 +00:00 時差的 ISO 8601 UTC 時間戳記)。表格未上鎖時為 {}。

它適合在表設定頁決定是否 disable schema controls,也能在只收到 423、手上沒有 migration ID 時診斷目前 lock;背景的型別變更也只能靠它追蹤。表格若發生過背景新增欄位,回應還會帶最近一次的 migration_id(UUID)與 migration_status(queued、processing、completed 或 failed),而輪詢它正是中斷的新增得以復原的驅動力(見背景新增欄位)。is_locked: false 才表示可以重新整理 schema 並考慮重試使用者動作;會比鎖活得更久的工作見上方。

這條路由遵循表格的 by-id metadata 規則:對表格沒有任何權利的同公司呼叫者——以及其他任何同公司的拒絕,包括屬於其他 scope 的表格——都會得到表格不存在的答案 404 {"detail": "CustomTable Not found"},因此隱藏的表格與不存在的表格無法區分;自後端 5.21.0 起,其他公司的表格也得到同一個 404(過去是 403 Insufficient permissions. (Different Company))。record_count 是呼叫者自己的計數,會像表格詳細資料一樣受其 row policy 與 channel scope 限縮,而不是整張表的原始數字。

Migration status:回答「工作結束了沒」

背景工作用 migration_id 查詢:

GET /private/module/custom_tables/{scopeName}/migrations/{migration_id}/status

這是 id-less path:scopeName 只填 chatroom、department 或 company,後面沒有 chatroom_id、department_id 或 table_id。例如:

GET /private/module/custom_tables/department/migrations/88888888-8888-4888-8888-888888888888/status

Migration IDs 在全系統全域唯一。伺服器會由 migration ID 找到所屬的表,並套用該表的 by-id metadata 規則;只有 company 前綴的路由會額外要求表格屬於 company — chatroom/department 前綴的路由則不比對 URL 前綴,只依該表格授權。每一種拒絕——對表格沒有權利、門檻拒絕、已刪除的表格、其他 scope,甚至其他公司——都與未知 ID 一樣回 404 {"detail": "Migration not found"}。全域唯一不代表只要登入就能讀別人的進度。

刪除欄位、診斷與測試資料工作執行期間不會有任何狀態紀錄:工作結束前這條路由都回 404 Migration not found,結束後會保留結果一小時。因此 status 只會是 completed(progress 為 100)或 failed(progress 為 0),絕不會是執行中的百分比。背景新增欄位是例外:新增回應一送出,它的紀錄就已存在(見背景新增欄位)。回應提供 operation(拿得到 id 的工作為 add_column、delete_column、diagnosis 或 test_data_insertion;不提供 id 的型別變更工作則記錄為 change_type)、status、progress、total_records、migrated_records、created_at/updated_at、失敗時的 error_message,以及背景新增失敗時的 error_code。呼叫者能看到什麼,取決於他對該表的權利:

呼叫者看得到
表格管理者或 moderator(含聊天室/部門/公司管理者的層級)完整的儲存狀態
其他對該表有權利的呼叫者status、progress 與時間戳記;error_message 為 null;除非呼叫者自己對該表的 record 計數不受限制,否則 total_records 與 migrated_records 為 null

所有會產生 migration 的操作都要求 moderator,所以啟動工作的 moderator 永遠看得到每個欄位,包括 error_message;其他呼叫者只會看到 status: "failed"。只有 completed 才刷新 schema/records;failed 應停止 polling 並呈現伺服器訊息。

背景新增欄位

背景新增會回 200,帶新欄位與如下的 migration_info(其中的 id 就是要輪詢的 UUID):

{ "background_migration": true, "records_migrated": null, "estimated_records": 3120, "migration_id": "9d2b6f7e-3c41-4f0a-8a55-0b7d1e6c2a10", "default_value_applied": null, "message": "Background migration started for large dataset", "note": "Use migration status endpoint to track progress" }

Schema 變更會與一份持久的工作紀錄一起 commit,而表鎖會連同持續更新的 heartbeat 一直持有到回填結束。可用 GET .../migrations/{migration_id}/status 或表格的 migration/status 追蹤工作。與刪除或診斷工作不同,新增工作的狀態紀錄從一開始就存在:

status意義
queued新增已 commit,還沒有 worker 認領回填
processing某個 worker 持有回填並持續續租 lease
completedprogress 為 100,且 migrated_records 等於 total_records
failederror_code 說明原因

total_records 是新增被接受時取得的 live 紀錄數;工作完成前 progress 與 migrated_records 都維持 0。所有能讀取該狀態的呼叫者都看得到 error_code,而 error_message 仍只限表格的管理者與 moderator。紀錄在最後一次更新後保留 24 小時。新增處於 queued 或 processing 時,每次 table status 輪詢都會從持久的工作紀錄重建遺失的紀錄;已結束的新增則只在最後一次更新後 24 小時內重建。新增當下含超過 10,000 筆 live 紀錄的表,新增座標欄完成後,其座標索引仍維持待處理。

error_code原因狀態輪詢是否重試
ct.column_add_interrupted執行回填的 worker 停止回報超過數個 lease 週期(預設每個 60 秒);自後端 5.22.0 起,也包含因資料庫鎖衝突而結束的嘗試(見下)是
ct.column_add_not_started狀態 schema 有宣告,但欄位新增不會產生它:尚未被任何 worker 認領的 queued 工作,會在表鎖解除後由下一次輪詢直接啟動是
ct.column_add_timeout這次嘗試執行超過兩小時是
ct.column_add_failed回填丟出的錯誤不是鎖衝突、第三次嘗試仍以鎖衝突結束(見下),或欄位在重新啟動前已被變更或刪除否,為最終狀態

背景回填是 schema 重寫:它為每筆紀錄設定欄位 key 並寫入一筆 history,不會對這些紀錄執行紀錄寫入規則或 trigger,所以原本就違反規則的紀錄不再讓新增失敗。仍維持同步的新增(預設最多 500 筆 live 紀錄)則讓每筆紀錄走一般的紀錄寫入路徑。

若執行回填的 API worker 遺失,復原由輪詢驅動,而且在表仍被鎖定時什麼都不做,所以第一步是表鎖依自己的 TTL(10 分鐘)失效。之後,經授權的 migration/status 輪詢(table 或 id)先記錄這次嘗試已停止回報;至少三個 lease 週期(預設 3 分鐘)之後的第二次輪詢,會把該嘗試結束為 ct.column_add_interrupted,並在同一次呼叫中重新排入佇列,所以你接著讀到的狀態通常又是 queued。因此,顯示 is_locked: false 但新增仍是 queued 或 processing,表示它尚未在執行:請持續輪詢。重新啟動只會填補仍缺該欄的 cell,所以期間寫入的值(包含明確的 null)都會保留,而被中斷的重啟會由之後的輪詢接續。沒有人輪詢時,沒有任何機制會重啟工作。不要重送新增請求:欄位已經存在。伺服器把工作保存在資料表 settings 中,並在 settings 回應與 IaC 匯出中省略它;帶有它的 settings payload 會因屬於伺服器自有狀態而被拒絕。

資料庫鎖衝突不再讓欄位新增永久失敗。 自後端 5.22.0 起,回填語句被 InnoDB 選為死結犧牲者(MySQL 1213)時會重跑:先 rollback、短暫退避後再執行,同一語句最多共執行三次。這樣做是安全的,因為它只填補仍缺該欄的 cell。鎖等待逾時(1205),或第三次執行仍發生的死結,不會在 worker 內再重試:該次嘗試會結束為 ct.column_add_interrupted(它的 error_message 只有管理者與 moderator 讀得到,內容為 Column backfill hit a database lock conflict; the next authorized status poll resumes only missing cells.),並釋放表鎖。下一次經授權的 migration/status 輪詢(table 或 id)會在回應之前把工作重新排入佇列,所以你接著讀到的狀態通常又是 queued(只有輪詢無法重新排入時,例如表仍被鎖定,才會讀到這筆被中斷的紀錄與其訊息)。之後由輪詢再次啟動執行;每個 lease 週期(預設 60 秒)最多排程執行一次,所以太快的輪詢只會看到 queued,之後的輪詢才會啟動它。期間寫入的值(包含明確的 null)都會保留,與 worker 遺失後的重啟相同。工作後段有兩個步驟發生鎖衝突時,也以同樣方式結束該次嘗試:在至多含 10,000 筆 live 紀錄的表上為座標欄建立座標索引,以及把工作標記為 completed 的那次寫入。這兩個步驟不會在 worker 內重跑,所以連死結也直接讓該次嘗試結束為 ct.column_add_interrupted。因鎖衝突而結束的嘗試以每個工作為單位計數,第三次會把工作結束為 failed 與 ct.column_add_failed,所以工作不可能無限重試。5.22.0 之前,任一種鎖衝突的第一次發生就把工作結束為 ct.column_add_failed,之後沒有任何輪詢會重試。

Diagnosis:找 schema、資料與效能問題

診斷從一般 scoped table route 啟動:

POST .../tables/{table_id}/diagnosis

它檢查有效/無效資料列、validation errors、schema issues、data-integrity issues 與 performance recommendations。報告會內嵌每筆 schema 無效資料列的原始 record_data,所以每個 scope 都需要 table-moderator 權利:明確的 table moderator、聊天室建立者,或該表所屬部門或公司的管理者。讀取、record 編輯與分享授權都不會給予這項權利;沒有這項權利的呼叫者會得到 403;自後端 5.21.0 起,其他公司的表格與不存在的表格 id 一樣回 404 {"detail": "CustomTable Not found"}。公司路由以前只要求一般 table access,現在不再如此。

小表可能同步回 diagnosis_results。超過背景門檻時回 background_processing: true 與 migration ID;先輪詢一般 migration status,完成後再取:

GET /private/module/custom_tables/{scopeName}/migrations/{migration_id}/diagnosis-results

Diagnosis results 路徑與 status 一樣沒有 scope ID/table ID,報告會保留一小時。同步診斷也會回 migration_id 並儲存報告,所以之後同一條路由也能再取一次。報告帶著同樣的原始資料列,所以這條路由在三個 scope 都需要對 migration 所屬表格的 table-moderator 權利(否則 403);診斷未完成、未知或已過期時回 404 {"detail": "Diagnosis results not found"}。自後端 5.21.0 起,其他公司的 migration id、解析不到所屬表格的 migration,以及公司路由上不屬於 company scope 或已在垃圾桶的表格,也都得到同一個 404。在部門與聊天室路由上,同公司的 migration 若所屬表格已在垃圾桶,則改為回 404 CustomTable Not found;而房間已刪除、但表格本身仍在的聊天室表格,則回 404 Chatroom Not found。租戶檢查發生在 moderator 閘門之前,所以外來 id 不會走到 403(這條路由不再使用 Migration not found)。失敗的診斷不是 404:會回 200,其中 diagnosis_results: null、message: "Diagnosis failed: <error>"。超過 5,000 筆 record 的表格會在背景診斷。若啟動時表已被其他遷移鎖住,會回 423,不會排入另一個互相衝突的診斷。

建議的前端狀態機

  1. Schema/diagnosis 回同步結果:立即刷新並結束。
  2. 回 migration_id:保存 ID、scope name、table ID 與 operation,顯示處理中狀態並輪詢 job status;只要表格仍被鎖、或工作仍可能在執行,就把 404 Migration not found 視為「仍在執行」。
  3. 回 background_migration: true 卻沒有 ID(型別變更),或只收到 423:輪詢 table migration status,顯示 lock_info.operation,等 is_locked 變成 false 後再重新抓 schema。背景新增確實會回 ID(步驟 2),而且它的狀態紀錄立即存在;請持續輪詢,因為中斷的新增只會由輪詢重新啟動。
  4. Job completed:若是 diagnosis,再取 diagnosis-results;否則重新抓 table/schema。
  5. Job failed:停止重試,顯示 error_message(只有管理者與 moderator 看得到;其他呼叫者為 null),保留診斷資訊供支援使用。例外是 error_code 為 ct.column_add_interrupted 或 ct.column_add_timeout 的欄位新增:請持續輪詢,因為記錄 worker 遺失或逾時的那次輪詢會在同一次呼叫中重新啟動它,而鎖衝突(由工作自己記錄)之後則由下一次輪詢重新啟動;只有 ct.column_add_failed 是最終狀態。回 429 ct.column_operation_busy 的欄位寫入並未開始:請在 Retry-After 之後重試。

不要只把 polling state 放在單一 React component;重新整理後應能從保存的 migration ID 恢復,或至少用 table lock endpoint 誠實顯示維護中。

所有狀態 response 與各 scope auth 見生命週期參考。欄位變更如何產生 migration 見欄位型別;可在 API Playground檢查 id-less URL 展開結果。

Last updated on