Plan 與 Apply
Plan 與 apply 使用同一份 JSONL,但職責不同:plan 只讀並產生可審查差異;apply 重新計算同一差異,核對已審查的 hash 後才執行。可靠的紀律不是「先試 apply」,而是「每一次 apply 都源自剛審查的 plan」。
另行合併的 scope package lifecycle使用已取得的 manifest/input identity 與 retained run,不取代本頁 JSONL 端點或 plan_hash 流程。
兩個端點,一套紀律
POST .../tables/iac/plan 回傳 actions 與 plan_hash,不寫入 resource 或 IaC state。POST .../tables/iac/apply 接受相同 NDJSON,並以 plan_hash query parameter 綁定審查版本。API 為相容性把該 query 標成 optional,但本章的標準流程一律提供;不帶 hash 就失去 plan-stale 保護。端點與完整 request/response schema 可查閱 plan 參考與 apply 參考。
Plan 與 apply 刻意接收原始 JSONL,所以它們的 OpenAPI request body 無法呈現有型別的 line union。生成或驗證文件前,請在同一個 chatroom、department 或 company scope 呼叫經驗證的 GET .../tables/iac/contract。回應包含 version、kinds、resource_types 與一條代表性的 line;response schema 才是權威的 discriminated IacLine union。見 iac.contract。
Diff 類別
| Action | 意義 | 審查重點 |
|---|---|---|
create | 沒有 state row 或可唯一辨認並採用的既有資源,將建立新 resource。ref 的 state row 指向已不存在的資源時也會規劃 create:apply 會重建它並改指 state row | 名稱、spec 與依賴順序 |
adopt | ref 將接管可唯一辨認的既有 resource(見 Adoption) | 候選是否正確;採用後哪些欄位成為 IaC-owned |
move | renamed_from 把既有 state/ref 移到新 ref。兩個 ref 都沒有 state row,或兩者都有時,該行改帶 diff 階段錯誤(見 claim 與搬移錯誤);只有新 ref 有 state row 時,代表搬移早已完成,該行直接以新 ref 規劃 | 新舊 ref 是否代表同一資源 |
update | live 值與宣告 owned fields 不同 | changes[].from → changes[].to,以及 drifted_fields |
noop | resource 已符合宣告 | 是否真的是預期的收斂狀態 |
delete | 明確 state: "absent" 將移除 live resource。absent 的 column 行若其欄位已不在表格 schema 中(先前的刪除被中斷),同樣規劃 delete,並附 warning column already removed from the schema; apply finishes its cell cleanup;在 company 與 department 文件中,apply 會完成這項清理 | 依賴、資料影響與復原方案 |
orphaned | 本文件沒有任何一行宣告的 state row,其指向的資源在 live 已無法解析;只回報一次,line 為 0。每次 apply 結束時都會清掉這類 row,不需要處理。absent 的 column 行若其 state row 指向已不存在、或已被同一 ref 下另一張表格取代的表格,也會規劃 orphaned,且 line 是該行自己的行號:原表格的 cell 與 state row 都保留,warning 會說明須由營運人員先清理或 purge 原表格 | 若資源遺失是誤操作,請把它恢復 |
insert | record natural key 尚不存在 | key 與資料值是否正確 |
skip | record 已存在,或 drift 依策略保留 | on_drift 是否刻意選擇 |
Plan 以文件順序建立主要 action;record 在資源 refs 可解析後處理。回傳的 actions 依行號排序,所以 line: 0 的 orphaned(沒有任何一行宣告的 state row)排在最前面;record action 的 ref 是 {table}.record:{line}。summary 依類型計算每一個規劃的 action,而 apply 回應的 applied summary 只計算實際套用成功的行。Action 可能有 warning 或 error,必須逐項審查,不能只看 HTTP status。
Adoption
Adopt 需要一個尚未被 claim 的唯一 live 對象,而怎樣才算「符合」依 kind 而定:
| Kind | 何時 adopt |
|---|---|
table | Scope 中恰好一張 live 表格名為 spec.name;多張時為 diff 階段的 ambiguous adoption: {n} live tables named '{name}' |
column | Live 表格有一個顯示名稱為 spec.name 的欄位 |
rule、trigger | 表格上某個未受管理的 live rule 或 trigger 與宣告在結構上相同(trigger 以遮罩後的形式比較) |
view | 表格上恰好一個共用 view 名為 spec.name;多個時為 ambiguous view adoption: {n} shared views named '{name}',私人 view 永遠不會被 adopt |
command | Scope 中恰好一個 live command 名為 spec.name,且其 spec 與宣告相同——否則 validate 階段錯誤會擋下整份文件 |
grant、insight_selection 與 client_access 行永遠不會規劃 adopt:主體已有 live grant、聊天室已有 selection,或表格已有 client-access 設定時,該行會對那一列 live 資料規劃 update 或 noop,並在 apply 時接管。public_read 完全沒有 adoption。
Apply 時,table、column 與 command 的 adoption 只會寫入 state,其他什麼都不做:adopt action 的 changes 一項都不會寫入。Command 只有在已經一致時才會被採用,所以立即收斂;與宣告不同的 table 或 column,下一次 plan 會規劃 update,第二次 apply 才寫入差異。Rule、trigger 或 view 的 adoption 目前完全不會收斂:executor 會尋找 adoption 還沒有的 state row,所以該行以 {rule|trigger} '{table}.{ref}': adopt with no resolvable native id 或 view '{table}.{ref}': adopt with no resolvable state row 失敗、不寫入 state,下一次 plan 又會規劃 adopt。要讓這類資源受 IaC 管理,請先透過 REST 刪除 live 的那一份,讓該行規劃 create。
破壞性變更
刪除從不由「文件裡沒寫」推導。只有 state: "absent" 會規劃 delete;table 是唯一允許 absent 行仍帶 spec 的 kind,用於先採用既有表格再刪除。Column、rule、trigger、view、grant、command 與 public_read 的 absent 行都不能帶 spec——這七種 kind 觸發的是完全相同的訊息。Table 刪除是 soft delete,但仍可能牽動欄位、資料、規則與外部流程,因此要把依賴 warning 當成 release gate。
state 存在於九種 kind:table、column、rule、trigger、view、grant、command、insight_selection 與 public_read。header、record 與 client_access 沒有。不要用這三種表達資源刪除;grant 的 state: "absent" 才是撤銷授權的明確方式。
delete 的效果依 kind 而不同:table 會移到垃圾桶,可透過 REST 還原;column 會連同資料一起刪除;rule、trigger 或 view 會被移除;grant 會被撤銷;insight selection 會被移除;public_read token 會被撤銷(見下方);command 會被軟刪除。
有兩種 absent 生命週期不是一般意義上的刪除:
public_read的 absent 行是撤銷 token。Plan action 仍顯示delete,但 apply 只設定revoked = true,永不硬刪除該列。燒掉的 capability URL 永遠是死的,之後的present行會發行 URL 全新的 token,而不是讓舊的復活。command的 absent 行會軟刪除受管理的 command。若該 command 有進行中的 staged execution,這一行會被擋住,對該 command 的update也一樣:兩者都會以command '{ref}' has pending staged executions讓該行失敗。
Drift 與欄位擁有權
IaC 使用三方比較:本次 desired spec、state 裡的 last-applied spec,以及目前 live resource。只有曾由宣告提供、且本次仍提供的欄位屬於 IaC 管理範圍;省略欄位不等於重設預設值,而是保留 live 值並放棄該欄位的宣告擁有權。
如果 managed field 在上次 apply 後被手動修改,plan 會把它列入 drifted_fields 並以 desired value 規劃 update。先判斷 live 修改是意外 drift,還是應先 export/編輯文件納入的新意圖。
會填入 drifted_fields 的是 table、column、grant 與 command 行,現在也包括類型為 unique、unique_tuple、same_dimension_reference 與 date_interval_exclusion 的 rule 行。其他所有 kind——view、trigger、client_access、public_read 或 insight_selection 行,以及其他類型的 rule——仍會規劃回到宣告值的 update,但 drifted_fields 維持空白,請直接審查它的 changes。
Record Drift
Record 以表格 natural key 比對,不寫入 IaC state。on_drift 控制 key 已存在但其他 declared cells 不同時的行為:
"skip"(預設)保留 live row,action 為skip,並警告declared cells drifted; on_drift=skip leaves the live row unchanged。"update"規劃更新 declared cells;未宣告欄位不受影響。
只比較純量 cell。Link cell 永遠不算 drift:既有資料列的 link cell 只有在 on_drift: "update" 下、且解析出的目標與 live 不同時才會改寫。
Natural key 來自 table spec.key、可解析的既有表格狀態,或在兩者都沒有指定時,取表格第一個宣告、非計算且可當 key 的欄位;此時 plan 會警告 table relies on the positional-default key column for record matching; set key explicitly。Key 不能是 boolean、json 或 interval 欄位。Principal(principal/user/social_client)欄位可以當 key,因為 principal cell 就是一個純量字串。
decimal 的 key 與 cell 以正規化後的儲存形式比對:每個宣告值會先依欄位的 scale 正規化,所以 "1.2" 會對上已儲存的 "1.20",而不是再插入一列。欄位無法容納的值——JSON 數字、小數位數多於 scale、位數過多,或在 allow_negative: false 欄位上的負值——會在該 record 行產生 validate-phase 錯誤(decimal column '<key>': …),plan 因此不可套用。
唯寫的 Trigger Secret
Trigger webhook 與 api_call 的憑證在每個 IaC 回應中都是唯寫的。當 trigger 行的 actions 有差異時,其 changes 項目兩側的每個 secret slot 都顯示為 "__SECRET__":url 的 userinfo、每個有值的 query 參數、每個 header 值,以及除了精確 $row.<column> 參照以外的每個 api_call body 字面值。同一項目還帶有 secrets 物件,其 from 與 to 清單以 action_index、location(url_userinfo、url_query、header 或 body)、name、occurrence、path 與 has_value 定位每個佔位符,絕不含值、hash 或前綴。Scheme、host、port、path、header 名稱、query key 與 body key 仍然可見。
Trigger 行保留或變更已儲存的憑證時,規則與 REST triggers PUT 相同:slot 放佔位符或省略,會保留這一行 IaC 受管 trigger 的儲存值;新值會取代它;null 會清除它。無法綁定的保留是 validate-phase 錯誤,code 為 trigger_secret_keep_refused,因此什麼都不會套用。常見原因包括:在規劃為 create 的行上使用佔位符(新的 scope,或沒有 IaC 受管、也沒有可採用的 live trigger 能提供儲存值)、沒有 id 的 action 可能對應多個已儲存 action、action 類型改變,或 url 的 scheme、host、port 或 Host header 改變。把 action 指向新目的地,就必須重新輸入它的憑證。
已審查 Hash 與 409
Apply 會用目前 live system 重新 plan。如果重新計算的 hash 與 plan_hash query parameter 不同,伺服器不執行任何 action,回傳:
{
"detail": {
"code": "plan_stale",
"message": "The live system changed since this plan was computed; re-plan and retry.",
"plan_hash": "<recomputed hash>"
}
}這個 409 是 optimistic concurrency contract。回應中的 hash 只能指出伺服器已有新 plan;仍須重新取得完整 plan 並審查差異,不能直接拿新 hash 重送。
plan_hash 折入的東西比「文件 + table 指紋 + grant 指紋 + 觸及的 state 列」更多。其中文件部分是每一個已解析行的正規形式(key 排序、無空白、補上預設值),而不是原始位元組,因此重新排版文件不會改變 hash。它還折入 view、public_read 與 command 的 live 指紋、grant principal 與 insight_selection.chatroom 中每一個可攜身分 token 的解析結果(table moderators、record cell、trigger when 值與 invoke_command inputs,以及 link 自然鍵裡的 token 走同樣的解析,但不會綁定),以及每一行的 before-fingerprint。當文件宣告了 decimal 欄位,或 unique、unique_tuple、same_dimension_reference、date_interval_exclusion rule,或被觸及的 live 表格本身已有這些設定時,每張被觸及表格的 write-contract digest 也會折入,因為以秒為單位的 updated_at 分不出同一秒內的兩次 schema 修改。因此 hash 變 stale 的理由比以前更多——別人在別處改了一個 public-read token 或一個 command 就足夠。
這個 hash 也不再是該內容的單純 SHA-256。指紋包含 trigger 設定與 command callback secret 的 digest,所以伺服器現在回傳以自身金鑰(部署的 JWT_SECRET_KEY)計算的 HMAC-SHA-256。它仍是 64 個十六進位字元,但 client 無法自行重算或預測:請把它當成 plan 回傳的不透明值。輪替該金鑰會讓所有尚未使用的 hash 全部 stale——apply 會回 409 plan_stale——而沒有這把金鑰的伺服器根本無法計算 plan(500)。
這個擴大也讓「釘住 hash」更值得多打一次 API。不帶 plan_hash 時,grant 與 insight selection 的身分 token 會綁到 apply 當下持有該名稱的人;帶了它,審查與 apply 之間的改名會重新蓋章,該次 apply 會被拒絕。
升級備註(backend 5.22.0):plan 的指紋不再包含資料表 settings 裡由伺服器擁有的座標索引狀態。因此在升級前為「涉及含座標欄位之資料表」的文件算出的 plan_hash,會在升級時失效一次:apply 會以 409 plan_stale 拒絕舊的 hash(沒有索引狀態的資料表,hash 不變),請重新 plan 並審查新的 plan。不涉及這類資料表的 plan 不受影響。和提案、世代不同的是,這類資料表上尚未套用的 plan 在其索引回填期間仍會失效,因為指紋還涵蓋資料表的 updated_at,而索引狀態的每一次寫入都會改變它。
具型別的 Apply 與並行訊號
不要把文件層級守衛與兩種具型別的 per-line 訊號混為一談:
| 訊號 | 形狀 | 意義 |
|---|---|---|
409 {"code": "plan_stale"} | 文件層級的 HTTP status | 釘住的 plan_hash 不再相符。完全沒有執行 |
IacLineError.code == "iac_plan_stale" | 200 apply 回應內的 per-line 錯誤 | command 或 trigger 的 before-fingerprint 在規劃與該行執行之間變動。該行回滾;apply 繼續 |
IacLineError.code == "retryable_lock_conflict" | 200 apply 回應內的 per-line 錯誤 | 該行遇到 MySQL 1205 或 1213。該行回滾;apply 繼續 |
iac_plan_stale 涵蓋 command 的 resource、state、tag 或 dependency digest,以及 trigger 行的 before-fingerprint;對應 detail 分別是 command changed after planning; run plan again 與 trigger changed after planning; run plan again。因此,規劃與該行執行之間的外部 trigger drift 可能呈現為 per-line iac_plan_stale。
有一種同次 apply 內的精確變動不算 drift。前一個成功的 column、rule 或 trigger 行可能透過 server-owned dependency maintenance 刷新相依 command 並推進 dependency digest,不限 DSL version;後面的 command 行若看到的是已記錄的精確 digest 推進,executor 會接受,不產生假的 iac_plan_stale。同樣地,前一個 command 行的 system tag 同步推進了某 command 的 tag digest,或同一張表格前一個 trigger 行在本次 apply 推進了該表的 trigger digest,也都會被接受。未記錄或外部造成的推進仍 fail closed。
IacLineError 還可能出現另外九個封閉代碼。前四個在 plan 時以 validate-phase 錯誤提出,所以 plan 不可套用,apply 會回 422 且完全不執行;其餘五個只會在 apply 時提出:
invalid_moderator_target——table行的moderators指到不存在、已刪除、屬於其他公司或是服務帳號的使用者,或$user:token 無法解析。Apply 會用同一規則重新檢查;若規劃後使用者集合有變,該行會帶這個代碼。trigger_secret_keep_refused——trigger行想保留一個無法綁定的唯寫 secret;見唯寫的 trigger secret。governed_context_required——record行指向的資料表,其有效的write_policy是commands_only(文件自己的table行若宣告了這個設定就以它為準,否則以 live 資料表為準)。Apply 會再檢查一次,並在該行回報record '<table>' line <n>: governed_context_required — …、不帶code;見 action policy。command_output_reserved——command行的definition.outputs宣告了名為approval_provenance的 output,這個名稱保留給 kernel 擁有的 provenance(detail 為approval_provenance is reserved for kernel-owned provenance)。先前已儲存的 command 仍會繼續執行,但任何宣告了這個 output 的 presentcommand行,即使內容未變或是 export 產生的,plan 都會失敗;請替該 output 改名。見 approval provenance。effect_identity_locked——command行變更或移除了已記錄過業務事件的 command 的effect_identity。它是200apply 回應內的 per-line 錯誤:該行 rollback、其他行仍會套用,重試無法修復;見 Command line 中的 effect identity。date_interval_exclusion_invalid_period、date_interval_exclusion_overlap與date_interval_exclusion_retry——record行(或套在既有資料列上的rule行)違反了date_interval_exclusionrule。它們是帶固定、不含值的 detail 的 per-line 錯誤;date_interval_exclusion_retry不會寫入任何東西,重新 plan 並再 apply 一次即可。approval_staging_unavailable——會被require_approvalrule 攔下的延後record行無法暫存,所以什麼都不會暫存、該行被拒絕;等 link 目標存在後再 apply,或拆開 link 迴圈(先寫資料列、再寫 link)。見原子行與延後群組。
retryable_lock_conflict 是 MySQL 1205 lock-wait timeout 或 1213 deadlock 的穩定分類。其 detail 會淨化成 Concurrent write conflict (lock); please retry the request.;回應絕不包含原始資料庫文字、SQL 或參數。由於更早或更後面的行可能已套用,遇到任一具型別的 per-line 訊號後,應先讀取目前狀態,再執行全新的 plan/審查/apply;不要盲目重送舊 hash。其他舊有 apply diagnostics 的 code 不設值。
Apply 執行
Parse/validate error 會在執行前讓整份文件失敗。通過 gate 後,apply 依文件順序 per-line best-effort 執行,plan action 帶有錯誤(包括 diff 階段錯誤)的行會被略過並標為 applied: false;某一行失敗會回滾自己的 transaction、記錄 phase: "apply",然後繼續,但因驗證與日期期間 rule 以外的原因被拒絕的基數為 many 的 link cell 除外:它會在先前各行已 commit 之後,以該次寫入自己的 HTTP 錯誤讓整個 request 失敗;先前成功的行不會整份回滾。Column/rule 行使用一般 dependency refresh:成功 recompile 與 business mutation 共用 commit;非資料庫 refresh failure 則略過並讓 command 維持 stale,不 veto 該行。Trigger 行更強:trigger、schema history、trigger IaC state 與所有 selected refreshed command definitions 構成同一 rollback bundle,refresh failure 會 veto 該 trigger 行。Column/rule/trigger/view 的 free-dict spec 還會在持久化路徑經過完整 CRUD validator,因此 clean plan 後仍可能出現 per-line apply error。Apply 回應必須逐 result 檢查,修正後重新 export/plan,不能只看 HTTP status。
每一行都在文件中輪到它時執行,record 行也一樣:record 的純量 cell 與基數為 one 的 link cell 會在它自己那一行一起寫入,所以請像 export 一樣,把 record 宣告在它要填的表格與欄位之後。最後一行之後,apply 依序執行:延後的 record 群組(基數為 one 的 link 在它自己那一行還無法解析的行,通常是因為它指向後面某一行才建立的資料列)、所有 record 行中基數為 many 的 link cell、因文件後方才宣告的 command 或 materialize_slots 表格而暫緩的 trigger 行、因後方才宣告的 view 而暫緩的 public_read 行、孤兒回收、system tag 同步,以及重寫仍帶有憑證的舊 trigger state 列。大量 record 可能由非同步工作完成;以 apply 回應與後續 plan/state 查詢確認最終結果,不要把 request 已接受等同所有變更已收斂。
Ticketed Apply
當 record-line count 超過 CUSTOM_TABLE_IAC_SYNC_RECORD_LIMIT(預設 200,以整份文件計算)、文件包含重量級欄位變更,或(自後端 5.22.0 起)它的 plan 建立、更新、刪除或搬移超過 300 個資源(CUSTOM_TABLE_IAC_SYNC_CHANGE_LIMIT 的預設值)時,apply 仍會在任何 dispatch 之前完成 parse、validation、plan 計算,並檢查 supplied plan_hash。所以 request-time parse 400、validation 422 或 hash mismatch 409 絕不會回 ticket;只有通過這層 preflight 的文件才會被暫存、dispatch,並回 202 {"message": "IaC apply submitted for background processing", "ticket_id"}。即使你沒有帶 hash,worker 也會釘住 dispatch 當下計算的 plan hash,所以 worker 執行前系統若有漂移,會以 plan_stale 結束。
因此 apply 有兩種成功回應,client 兩種都必須處理。200 表示文件已在 request 內套用完畢:請讀它逐行的 results 與 errors。202 表示文件已被接受為 ticket,request 內沒有執行任何東西:請輪詢 ticket,像讀同步回應一樣讀 body.result,再 plan 一次確認已收斂。會得到哪一種,取決於文件本身、它所對照的 live 系統(同一份文件第一次 apply 可能回 202,重新套用時則可能回 200),以及部署自行設定、且沒有任何回應會告知的上限;只把 200 當成功的 client,一旦文件越過其中一個上限就會出問題。
idempotency_key 只在這條路徑有作用;同步 apply 會忽略它,只是已存在的 ticket 會先被 replay(見下)。它的範圍是 scope 與執行的使用者,有效 24 小時。自 backend 5.21.0 起,它還會綁定到第一次使用時的完整文件位元組(原始 request body,或 multipart 上傳的 file part,所以換了 multipart boundary 也不影響):同一份文件再送一次會回 202,帶原本的 ticket_id 與訊息 Bulk iac-apply already submitted (idempotent replay);伺服器在 plan 之前就會找這個 replay,所以第一次嘗試已改動系統的請求被重試時,會拿回原 ticket,而不是 409 plan_stale。已綁定的 key 若搭配不同的文件,會在它即將被 dispatch 時被拒絕,回 409 {"detail": {"code": "ct.iac_idempotency_key_reused", "message": "This idempotency_key was already used with a different IaC document."}}。同一份文件的每次 replay 都會續期這個綁定,所以它可能比 key 最初的 24 小時更久。落入同步 apply 的請求則直接忽略這個 key。這個 key 與 bulk 通道共用保留機制,所以並行的重複請求也可能得到 409 A bulk iac-apply with this idempotency_key is already in progress(附 Retry-After: 1)、409 A bulk iac-apply with this idempotency_key was submitted during a deployment transition; retry after the replay window,或 503 Bulk iac-apply idempotency state is temporarily unavailable; retry with the same key。即將被 dispatch、而 key 已綁定到某張 ticket 的請求,若伺服器無法檢查這個綁定,也可能得到 503 {"detail": "IaC idempotency lookup unavailable; retry later."};此時沒有 dispatch 任何東西,請稍後用同一個 key 重試。
worker 設為 batch、sqs 或 pubsub(預設為部署的 DEFAULT_BACKGROUND_WORKER_TYPE)時,提交失敗會回 500 {"detail": {"code": "ct.iac_dispatch_failed", "message": "The background apply could not be submitted. Retry later."}};provider 的錯誤文字只會寫進伺服器日誌。該 ticket 會顯示 status: "failed",同一個物件放在 body.error,訊息也放在 error_message。Provider 仍可能已接受該工作,所以 idempotency_key 會繼續綁在這張失敗的 ticket 上:用同一個 key 重試只會 replay 它,要真正重新執行需要新的 key。
請以送出 apply 的那位使用者的身分輪詢 GET /public/task/{ticket_id}。Poll endpoint 本身固定回 HTTP 200;IaC 狀態嵌在 body 下,最上層沒有 status:
{
"ttl": 86399,
"body": {
"ticket_id": "11111111-1111-8111-8111-111111111111",
"status": "finished",
"updated_at": "2026-08-18T02:30:00+00:00",
"result": { "applied": { "create": 0, "adopt": 0, "move": 0, "update": 0, "noop": 0, "delete": 0, "orphaned": 0, "insert": 0, "skip": 0 }, "results": [], "errors": [] },
"private": true,
"operation": "custom-table-iac-apply"
},
"string": null,
"message": "The task has been found, and it is in JSON format."
}body.status 會是 submitting(已接受,尚無 worker 認領)、processing(已有 worker 認領並正在 apply;body.current_step 為 Applying the IaC document...)、finished 或 failed。finished 把 IacApplyResponse 放在 body.result;failed 則帶 body.error,內含 code 與 message:
body.error.code | body.error.message | 另外帶有 |
|---|---|---|
scope_unavailable | The target scope no longer exists or is unavailable. | |
doc_unavailable | the uploaded document could not be retrieved | |
parse_error | line {n}: {detail} | |
plan_stale | The live system changed since this plan was computed; re-plan and retry. | plan_hash(重算後的) |
validate_error | The recomputed plan carries validate-phase errors; nothing was executed. | errors(逐行清單) |
internal_error | internal error; see server logs | |
iac_permission_denied | IaC live authorization or dependency validation denied execution. | |
ct.iac_apply_not_started | The IaC apply was not started before its queue deadline. Nothing was applied; re-apply. | |
ct.iac_apply_interrupted | The IaC apply stopped before it reported a result (its worker was interrupted). Some changes may already be applied: re-plan, review and re-apply. | |
ct.iac_apply_timeout | The IaC apply exceeded its attempt deadline. Some changes may already be applied; re-plan, review and re-apply. | |
ct.iac_dispatch_failed | The background apply could not be submitted. Retry later. | body.error_message,內容相同 |
ct.iac_legacy_backfill_busy | No IaC slot became available for LEGACY apply; retry later. | body.error_message 與 body.http_status: 429 |
Worker 一定會重新下載、parse 與 plan 暫存文件,所以 parse_error 與 validate_error 描述的是拿到 202 之後的防禦性 recheck,不是原始 request-time 400 或 422 的另一種 ticket 形式。Worker 執行期間若 live 授權被拒(例如發出者在 dispatch 後失去存取權),ticket 會以 failed 結束,error.code 為 iac_permission_denied(403 以外的拒絕,不只 422,都是 validate_error,訊息相同),先前已套用的行維持已套用。ct.iac_* 代碼的說明見重量級欄位變更與 Ticket 沒有結果就結束。
Ticket 屬於送出 apply 的使用者。自 backend 5.21.0 起,ticket id 是 version-8 UUID(36 個字元,外形與一般 UUID 相同),而 poll 會拒絕其他所有呼叫者:不同的使用者、未驗證的請求,以及不存在的 id,得到的都是完全相同的 404 {"detail": "Task not found"},所以拒絕回應不會透露 ticket 是否存在。Ticket body 在最後一次寫入後保留 24 小時;body 已過期但發出者的擁有權紀錄仍在時,發出者的 poll 會回 HTTP 200,內容是 ttl: -1、body: null 與 message: "The task does not exist or has been expired.";之後則同樣是 404。仍請把 ticket id 當作機密,不要記錄或分享。private ticket 的一般規則見輪詢 ticket。
不是每個 apply 錯誤都對應得到某一行。IacLineError.line 是 ge=0,其中 0 代表「與任何文件行無關」——apply 結束時的 tag 同步與孤兒回收都以這種形式回報,kind 為 "table"、ref 為 header 的 system(最後重寫舊 trigger state 列失敗時,會再加一則 trigger state rewrite failed (<exception type>),kind 為 "trigger"):
tag sync failed: <exception>
orphan prune failed: <exception>Apply 的錯誤 detail 也刻意做過衛生處理,因為原始例外字串可能夾帶明文密語、trigger 憑證或未經淨化的 integrity error。任何資料庫 driver 錯誤,lock 衝突會變成 Concurrent write conflict (lock); please retry the request.,其餘變成 Database operation failed.。Pydantic 失敗會變成 invalid configuration (N field error(s))、client_access 的例外變成 client_access configuration is invalid ({ExcType})、command 的例外變成 command configuration is invalid ({ExcType});大括號代表實際代入的 exception class 名稱。真正的原因不在回應裡,要去伺服器日誌找。
若有未預期的例外從整個 plan 或 apply 拋出,回應只有 500 {"detail": "Internal Server Error"}。伺服器會回滾進行中的交易(apply 已 commit 的行仍保持已套用),日誌只記錄例外型別與資料庫錯誤代碼、絕不記錄例外文字,因為文件可能帶有唯寫的 trigger secret,而 driver 錯誤會把它原樣帶出。
重量級欄位變更
自 backend 5.21.0 起,必須改寫大型表格既有資料列的欄位變更,不再於 request 內執行。只要文件中任何一行 column 屬於下列變更,而且它的表格已存在、live 記錄超過 10,000 筆(預設值;剛好 10,000 筆不會觸發這條規則,部署可調整門檻),整份文件就會改走上述 ticketed 路徑,request 回 202:
create:會把新欄位回填到每一列;delete:會從每一列移除該欄位。這裡的計數包含 soft-delete 的記錄,因為刪除欄位也會改寫它們;update(或同時改動欄位的move)變更type、options、required、max_length、precision、scale、allow_negative或座標欄位(coordinate_system、axes、length_unit、capture);- 在有
computation_mode: "stored"公式欄位的表格上,update其他會影響資料的欄位(例如default_value),因為 stored 值需要重新計算。
不會計入的情況:同一份文件建立的表格、noop、adopt 與只改名的 move,以及只變更 name、description 或 unit。record-line 門檻與變更上限仍各自獨立判斷。Parse、validation 與 plan_hash 檢查仍先在 request 內執行,所以被拒絕的文件依然回 400、409 或 422,絕不會回 ticket。收到 202 後,請輪詢 ticket,像讀同步回應一樣讀 body.result,再 plan 一次確認已收斂。
部署的預設 worker 不是 legacy 時,對這類文件明確指定 worker=legacy 會在暫存任何東西之前就被拒絕:400 {"detail": {"code": "ct.iac_legacy_backfill_unavailable", "message": "LEGACY backfill is available only when LEGACY is the default worker."}}。請不要帶 worker,或使用部署的預設值。預設為 legacy 的部署會在 API 行程內、以專用且數量有限的 IaC slot 執行回填。若短時間內(預設 30 秒,最長 60 秒)沒有 slot 空出,ticket 會以 failed 結束,error.code 為 ct.iac_legacy_backfill_busy 並帶 http_status: 429;此時沒有套用任何變更,idempotency_key 也已釋放,可以稍後原樣重送。自後端 5.22.0 起,變更量大的 plan走同一條 worker=legacy 路徑,只是拒絕訊息不同。
變更量大的 Plan
自後端 5.22.0 起,改動大量資源的 plan 同樣不在 request 內執行。只要 plan 建立、更新、刪除或搬移超過 300 個資源(預設值;剛好 300 個不會觸發這條規則,部署可用 CUSTOM_TABLE_IAC_SYNC_CHANGE_LIMIT 調整上限),整份文件就會改走上述 ticketed 路徑,request 回 202。5.22.0 之前,只有另外兩條規則會把 apply 導向 ticket,不論 plan 改動多少資源。
計入的是 request 已經算好的那份 plan:
- 計入: 除
record以外所有行類型的create、update、delete與moveaction。 - 不計入:
noop、adopt與orphanedaction,以及所有recordaction(record 行另有 record-line 上限)。所以就這條規則而言,重新套用一份未變更的文件,不論有多少行都仍走同步,對大文件做小幅修改也一樣:計算的是變更數,不是行數。record-line 規則仍各自判斷:record line 超過CUSTOM_TABLE_IAC_SYNC_RECORD_LIMIT(預設 200)的文件,即使沒有任何變更也會回202。
Plan 的 summary 會計入每一個 action,包含 record action(record 行可以規劃 update),所以它的數字可能比這個計數高。沒有任何回應會告訴你這個上限,所以請依回傳的狀態碼分流,而不是事先預測。
三條路由規則彼此獨立,任何一條都會把整份文件送進 ticket:record line 超過 CUSTOM_TABLE_IAC_SYNC_RECORD_LIMIT、重量級欄位變更,或變更數超過 CUSTOM_TABLE_IAC_SYNC_CHANGE_LIMIT。每個上限各自比較,剛好等於上限不會觸發該規則。Request 階段的檢查沒有變,而且在路由之前執行:parse 400、授權 403、plan_hash 409 與 validation 422 都絕不會回 ticket。
變更量大的 plan 走與重量級欄位變更相同的重型通道。部署的預設 worker 不是 legacy 時,明確指定 worker=legacy 會在暫存任何東西之前就被拒絕,代碼相同、訊息不同:400 {"detail": {"code": "ct.iac_legacy_backfill_unavailable", "message": "LEGACY apply of a plan with more changes than the synchronous limit is available only when LEGACY is the default worker."}}。重量級欄位變更的訊息 LEGACY backfill is available only when LEGACY is the default worker. 保留給不屬於變更量大的文件;兩者皆是的文件會得到變更量大的訊息。即使文件同時有超過 200 條 record line,這個拒絕仍然適用。請不要帶 worker,或使用部署的預設值。預設為 legacy 的部署上,變更量大的 apply 會在同一個專用 slot 池等待空位,等待時間與 ct.iac_legacy_backfill_busy 的結果都相同。追蹤 ticket 的方式見 Ticketed Apply,之後再 plan 一次確認已收斂。
Ticket 沒有結果就結束
自 backend 5.21.0 起,worker 消失時 ticket 不會永遠停在 submitting 或 processing。伺服器不會在背景掃描 ticket:只有發出者 poll 它、或 queue 重新投遞 processing ticket 的工作時,才會把它結束為 failed(發現自己已超過期限的 worker 會自行寫入 timeout 代碼)。ct.iac_apply_interrupted 的第一次 poll 發現 worker 的 heartbeat 已過期時,只會記下這次觀察;至少三個 lease 週期(預設 3 分鐘)之後的另一次 poll 才會結束該 ticket,所以請持續 poll。Queue 投遞仍是 submitting 的 ticket 時不會結束它:worker 會認領該工作並執行 apply。Ticket 會以下列其中一個代碼結束:
body.error.code | 結束的情況 | 該怎麼做 |
|---|---|---|
ct.iac_apply_not_started | submitting 的 ticket,(預設)一小時內沒有任何 worker 認領 | 沒有套用任何變更:再 apply 一次;若你送過 idempotency_key,請改用新的 key(或不帶 key) |
ct.iac_apply_interrupted | processing 的 ticket,其 worker 已有數分鐘沒有回報(被 kill、被驅逐) | 部分行可能已套用:重新 plan、審查,再 apply;若你送過 idempotency_key,請改用新的 key(或不帶 key) |
ct.iac_apply_timeout | processing 的 attempt 超過期限,最長兩小時 | 與 interrupted 相同 |
重新投遞的工作絕不會接手 processing 的 ticket,做到一半的 apply 也不會自行重跑。已套用的行維持已套用,所以請把同一份文件再 plan 一次:已套用的部分規劃為 noop(record 為 skip),其餘如前,過期的 plan_hash 仍會以 plan_stale 被拒絕。暫存的文件會在 ticket 結束時刪除,ticket 仍只屬於發出者。
只有一種情況下,apply 的結果可以取代 timeout。自後端 5.22.0 起,apply 若在 attempt 期限之前就已回傳,它的 finished 結果會取代搶先寫入的 ct.iac_apply_timeout(可能是 worker 自己的 heartbeat、某次 status poll,或重新投遞的工作寫的;它是在 apply 回傳之後、結果寫入之前的那一瞬間寫入的),所以原本讀到 failed 加 timeout 的 ticket,之後的 poll 可能讀到 finished。超過期限才回傳的 apply 絕不會取代 timeout,因為輪詢的一方可能已經讀過它;其他所有最終狀態也都維持原樣。5.22.0 之前的結果取決於 timeout 是誰寫的,而不是 apply 何時回傳:晚回傳的結果會取代 worker 自己的 heartbeat 寫的 timeout,準時回傳的結果卻從不取代 status poll 或重新投遞的工作寫的 timeout。無論哪一種,重新 plan 都是穩妥的確認方式。
已結束的 ticket 不會釋放它的 idempotency_key;除了以 ct.iac_legacy_backfill_busy 結束的 ticket 之外,任何其他 failed 的 ticket 也都不會。自第一次提交起 24 小時內,同一份文件搭配同一個 key 會回 202 與 Bulk iac-apply already submitted (idempotent replay),交還同一張失敗的 ticket,而不是再 apply 一次;而不同的文件搭配該 key,一旦會被 dispatch 成 ticket,就會得到 409 ct.iac_idempotency_key_reused;同一份文件的 replay 會續期這個文件綁定,所以該 409 可能持續超過最初的 24 小時。所以重新 plan 並審查之後,請用新的 key(或不帶 key)apply。
Chatroom 在 apply 中途消失
對 chatroom scope 文件,apply 會在每次 mutation 與最後的孤兒回收前重新鎖定 live chatroom root。若該房間在 validation 後被 soft-delete,目前 transaction 會回滾,request 回應:
404 Chatroom not found不要把這項行為推廣成 department 與 company IaC 的整份文件 mutation fence。command 行會取得自己的三種 scope command-root lock,scope 已不在時回 404 Department not found 或 404 Company not found;但一般的 per-mutation root re-lock 只套用在 chatroom 文件。在 route 層,不存在的聊天室回 404 Chatroom Not found,不在你公司的部門回 404 Department not found;company route 沒有 scope 404。
Request 時間預算
自 backend 5.21.0 起,plan、state 與 export 都在伺服器端的時間預算內執行,apply 在開始改動任何東西之前也一樣:預算涵蓋 parse、plan、plan_hash 檢查,以及選擇同步或 ticketed apply。預設為 120 秒,是部署設定,client 無法調高;request 等待 API worker 少數 IaC 執行 slot 的時間也計入預算。預算用完時 request 回 422,而且沒有寫入任何東西:
{
"detail": [
{
"line": 0,
"kind": "header",
"ref": "",
"phase": "validate",
"code": "ct.iac_time_budget_exceeded",
"reason": null,
"column_id": null,
"detail": "IaC request exceeded its server time budget."
}
]
}回應 body 與 validate 階段錯誤是同一種 list 形狀,所以請以 code 判斷。它不是 429,也沒有獨立的排隊代碼。請在系統較不忙時重試,或改送較小的文件。同步 apply 一旦開始執行各行,預算就不再適用,request 不會被它中途切斷。Request 開始執行時也會再檢查一次存取權:在等待 slot 的期間失去 scope 存取權的呼叫者,會得到與全新 request 完全相同的 scope 一般 403,而且不會寫入任何東西。
審查清單
- JSONL 與 plan 來自同一 commit,而且
plan_hash仍與用目前 live state 重算的 plan 相符;hash 本身沒有 TTL。 - 每個
adopt、move、delete與 driftedupdate都有明確理由。 - Table/column 的跨 ref、filtered grant 的 policy,以及 record key 都解析到預期資源。
- 沒有忽略 action-level error 或 warning。
- Apply 後已排定 re-plan,預期 resource
noop、recordskip。
動手試試
在 IaC 工作台載入一份 export,先改 description 觀察 update,再把 live 值手動變更後重新 plan,辨認 drifted_fields。全程使用測試 scope,直到你能解釋每個 action 才 apply。