Skip to Content
核心概念批次操作

批次操作:mixed、非同步 tasks 與原子發布

批次介面有三種不同承諾。同步 bulk 適合互動式小批次,回應時整批已完成且具原子性;非同步 insert/update/delete 適合大量資料,先回 ticket,再逐列處理並回報部分成功。Publish session 會先把多個 chunk 隱形暫存,再非同步派送 commit,但 replace 與完整 staged dataset 仍在同一個 transaction 裡一次生效。

先選語意,再選端點

特性同步 mixed bulk非同步 bulk tasks原子 publish session
端點POST .../records/bulkbulk-insertbulk-updatebulk-deletepublish-sessionschunkscommit
容量100 actions每個 task 50,000 列/IDs0..63 slots 合計 5,000 暫存列
變更同一批可混合 insert/update/delete每個 task 只有一種動作Create-shaped rows,加上選填、受 ACL 約束的 replace 集合
提交回應實際變更結果BulkTaskResponse,只代表已受理Open/chunk 回狀態;commit 回 ticket
失敗語意任一 action 無效,整批不套用有效列繼續,逐列收集 errors完整 commit 回滾;可重試的 session 重新開啟並保留 staging
適合多選編輯、一次儲存相關變更CSV 匯入、大量更新/封存必須一次對讀者可見的部門/公司資料集

Publish session 由 moderator 擁有,只存在於 department 與 company table,並刻意拒絕受 approval 或 channel 規則治理的資料表。完整 staging、polling、retry 與 abort 協定見原子資料集發布

Upsert 屬於 REST bulk-insert,不是 agent 批次工具

非同步 REST bulk-insert payload 可以帶顯示名稱或內部欄位 ID 作為 match_column。Worker 會逐列把 match value 轉成目標欄位型別,再檢查表內存活的資料列:

Match 結果REST bulk-insert 行為
命中一筆既有存活資料列以來源列有提供的欄位對該筆做部分更新
沒有命中既有存活資料列新增一筆資料列

目前的 payload 沒有 update_columns selector;命中更新分支時,該來源列提供的所有欄位都會參與部分更新。Match value 不存在、是 null 或空字串時不會探測既有資料列,因此走新增分支。它仍遵守非同步 task 的語意:逐列錯誤從 ticket 回報,而成功的資料列可能已經 commit。

REST worker 的前期型別 gate 只拒絕 json;這種寬鬆接受不代表其他每種欄位都能有效探測。Matching 從 record.data 讀 inline 值,所以安全選擇是 stored scalar:stringtextintegerfloatbooleandatedatetimeselectusersocial_client。Link 值在 relation table,rollup/lookup/formula 值則是 derived,無法透過這個 inline probe 命中。multi_selectattachment 是 list-valued shape,也不是安全的 match column。若不能容許重複新增,請使用有 unique 規則保護的純量欄。

不要把這份 contract 套到 agent bulk 家族(預設 direct policy 下是 custom_tables_bulk_record_actions,開啟確認時才是 custom_tables_prepare_bulk_record_actions)。該工具接受明確的 insertupdatedelete actions,但沒有 match_column,也沒有 REST 型式的 upsert mode。Agent 的單筆 insert 家族(預設 direct policy 下是 custom_tables_insert_record,開啟確認時才是 custom_tables_prepare_insert_record)才有自己的 match_column upsert。

Agent 單筆 gate 刻意比 REST 的前期檢查更窄:它明確拒絕 linkrolluplookupformulajsonattachmentmulti_select,只接受上面列出的 stored scalar。探測命中多筆 live records 時也會拒絕模糊結果;REST bulk 目前則取第一筆 live match。這更說明應用 uniqueness 保護,而不是把 match probe 當成 concurrency guarantee。

同步 mixed:小批次、全有或全無

Payload 的 actions 按順序描述操作:

{ "actions": [ { "action": "insert", "data": { "品項": "鉛筆", "數量": 5 } }, { "action": "update", "record_id": "33333333-3333-4333-8333-333333333333", "data": { "數量": 3 } }, { "action": "delete", "record_id": "44444444-4444-4444-8444-444444444444" } ] }

資料列寫入可用顯示名稱或內部鍵,伺服器會轉換;共用 client 仍建議遵守欄位身分的 mapping。整個 endpoint 先要求 edit gate,即使批內只有 insert;之後每一個 insert 仍另外檢查 insert 權限。驗證、規則、row ACL 或 migration lock 任一失敗都不會留下部分變更。

成功才回實際計數:

{ "inserted": 1, "updated": 1, "deleted": 1, "message": "Bulk record operations completed successfully" }

非同步:先取得 BulkTaskResponse

三個 endpoint 分別接受 recordsupdatesrecord_ids。提交成功的共同回應是:

{ "ticket_id": "77777777-7777-4777-8777-777777777777", "message": "Bulk insert task submitted", "replayed": null }

這不是資料已完成寫入。前端要以:

GET /public/task/77777777-7777-4777-8777-777777777777

輪詢到 terminal status,呈現 progress、成功計數與每列 errors。背景工作不是 atomic:某列驗證或權限失敗,其他有效列仍可能完成;bulk-delete 做的是可復原 soft delete。

為可安全重送,提交 payload 應帶穩定的 idempotency_key。網路 timeout 時用同一個 key 重送;若後端辨識為同一任務,BulkTaskResponse.replayed 會指出 replay,而不是建立重複匯入。不要每次 retry 產生新 key。

同步批次的樂觀鎖:整批回滾

同步 actions 陣列的每個項目現在都可以帶 expected_version(整數 ≥ 1)。它action: "update" 上有效——insertdelete 帶了它,在任何動作執行前就是 422,訊息是 expected_version is only valid on update actions — an insert has no prior version and delete carries no data to guard

{ "actions": [ { "action": "update", "record_id": "33333333-3333-4333-8333-333333333333", "data": { "數量": 3 }, "expected_version": 4 }, { "action": "delete", "record_id": "44444444-4444-4444-8444-444444444444" } ] }

在這個欄位存在之前,同步 atomic 通道是整套 API 裡最後一條完全沒有版本保護的寫入路徑:BulkRecordAction 拒絕未知 key,所以送 expected_version 只會拿到 422;而批次裡一筆過期的 update 會無聲蓋掉併發的贏家,沒有任何訊號。單筆 PUT 與非同步 bulk-update item 早就有這道 guard。

輪到帶 guard 的 action 時,伺服器先 flush,對該筆資料列取 SELECT id … FOR UPDATE,refresh 後才比對版本。因為這批是 atomic,版本不符不是跳過單一項目,而是把每一個 action 都回滾的 409

{ "detail": { "error": "version_conflict", "message": "Action 1: record was modified concurrently (current version 5, expected 4); the atomic batch was rolled back — re-read and retry.", "action_index": 1, "record_id": "33333333-3333-4333-8333-333333333333", "current_version": 5, "expected_version": 4 } }

action_index 是你送出的 actions 陣列中以 0 起算的位置,UI 不必自己 diff 就能指出是哪一列。整批沒有任何東西落地——衝突之前的 action 沒有,之後的也沒有。

現在有三條通道帶同一個欄位、給三種不同的答案,挑錯通道就等於默默換掉你的錯誤處理:

通道欄位放哪裡版本不符時
PUT .../records/{record_id}body 欄位該筆資料列回 409
POST .../records/bulkactions[].expected_version,只限 update action409 version_conflict 並帶 action_index——整批回滾
POST .../records/bulk-updateupdates[].expected_version提交仍然回 200 + ticket;只有衝突的那個 item 被跳過,並記在 ticket 的 errors[]

同步通道有三個邊界:

  • 同一批裡碰同一筆資料列兩次會 fail closed。 guard 比對的是輪到該 action 時資料列的當下版本,所以前一個更新同一筆的 action 已經把版本推高了。要對同一筆做多處編輯,就合併成一個 action,或只在第一個 action 上設 expected_version
  • 簽核閘門下回的是另一個 409。require_approval 規則命中這一批時,guard 會一路帶進 staging,並在 staging lock 下複驗——但 atomic 呼叫端拿到的是 409 {"error": "record_changed_during_staging", "message": "a target record changed while the batch was being staged; nothing was staged or executed — retry", "record_ids": [...]},沒有 action_index,也沒有版本數字。這條路由上不要只認 version_conflict
  • 不帶這個欄位就維持 last-writer-wins,行為不變。agent tool custom_tables_bulk_record_actions 完全沒有開放這個欄位(而且上限是 50 個 action,不是 REST 的 100)。

非同步通道的樂觀鎖:跳過該項目

每個 bulk-update item 可以帶 expected_version(整數 ≥ 1),語意鏡射單筆 PUT

{ "updates": [ { "record_id": "33333333-3333-4333-8333-333333333333", "data": { "數量": 8 }, "expected_version": 4 } ], "idempotency_key": "update-demo-002" }

worker 在該筆資料列的 row lock 下比對版本。不符的 item 會被跳過——而因為提交當下已經回了 200 + ticket,衝突只會出現在輪詢的 ticket 裡:terminal status: "completed_with_errors"、精確的 error_count,以及這種形狀的 errors[]

{ "error": "version_conflict", "expected_version": 4, "current_version": 6 }

逐筆錯誤明細上限 100 筆;超過之後 error_count 仍然精確。三個要抓穩的邊界:

  • 非同步通道在結構上不可能回 per-item 的 HTTP 409。如果你的 UI 期待單筆 PUT 的衝突狀態碼,請改成讀 ticket。
  • 沒帶 expected_version 的 item 維持 last-writer-wins,行為不變。bulk-delete 不收這個欄位。
  • 簽核閘門下,版本衝突在 staging 時就檢查,衝突的 item 不會被 stage——HELD 批次在 apply 時洗不掉這道 guard。通過的資料列從那之後由簽核通道自己的版本複驗(staged 的 snapshot_version,apply 時再驗一次)接手保護;見審批總覽

未知 key 現在是 422

四個 bulk payload(BulkRecordAction 與混合 payload、bulk-insertbulk-update 的 item 與 payload、bulk-delete)全部以 422 拒絕未知 key,不再無聲丟掉。這殺掉的失敗模式是:打錯字的 expectedversion 以前會回 200,你以為有的保護其實不存在。同一波 forbid 收斂也涵蓋 aggregate 請求、search body、saved view 的建立與更新、export、欄位建立與更新——見查詢

批次寫入 link 值時,target ids 會在單一寫入 chokepoint 驗證——而「你不能參照那一列」現在會歸因,不再是統一答案。目標存在、通過你的 row ACL、只被你 acting room 的 channel scope 擋住時,會回它自己的 denial;目標真的不存在、已被 soft delete,或被 ACL 藏起來時,仍然回刻意統一的「找不到」,因為把這幾種分開就等於讓寫入變成 existence oracle。

三條 bulk 通道的答案不同,而且差異是結構性的:

  • 同步 POST .../records/bulk 統一失敗在這條路由上一直是 400、body 是字串 "linked record not found"——從來不是 404,因為這條路由的 handler 把底層的 ValueError 映成 400。被歸因的那一類現在是 403,而且 detail物件,不是字串:

    { "detail": { "error": "scp_out_of_scope", "message": "link target(s) mv-2: the link target exists but is outside your channel scope", "table_id": "22222222-2222-4222-8222-222222222222" } }

    errorscp_out_of_scopescp_scope_undeclared(你的房間在目標資料表上沒有宣告 channel scope)或 scp_rule_dangling(目標資料表上的 channel rule 已經無法解析)三者之一。請以 detail.error 分支,不要以狀態碼分支;並且要預期這條路由的 detail 可能是字串也可能是物件。

  • 非同步 POST .../records/bulk-update Denial 只會以 ticket errors[] 的逐列項目出現,永遠不是 HTTP 狀態碼。變的是那個字串:它以前是 framework 的 repr、把狀態碼黏在前面的 "403: {'error': 'scp_out_of_scope', ...}",現在只剩 detail payload 本身,"{'error': 'scp_out_of_scope', 'message': \"link target(s) mv-2: the link target exists but is outside your channel scope\", 'table_id': 't-mv'}"。原本會剝掉開頭 403: 的 client 必須改。它仍然是 errors[] 裡的 Python repr 字串,不是結構化物件——請用 scp_out_of_scope 之類的子字串比對,不要嘗試把它當 JSON 解析。

  • 非同步 POST .../records/bulk-insert 沒有變。 它的 insert 路徑根本沒有經過歸因 chokepoint,所以被 scope 藏起來的目標仍然落在原本那個逐列 errors: ["link target records not found or not accessible"]。不要告訴呼叫端 bulk-insert 會回 403

讀取失敗 ticket 的 error_message

Terminal failed 的 ticket 會帶 error_message,而這個欄位不再洩漏失敗的 SQL 敘述或它的繫結參數——它以前會把 driver 完整的 [SQL: …] [parameters: …] 字串直接交給輪詢中的 client。對輪詢端有意義的是兩個已淨化的訊息:

  • Concurrent write conflict (lock); please retry the request. — 短暫的 row lock 衝突。提交本身沒有問題,剩下的工作值得重跑。
  • Database operation failed. — 其他所有 driver 錯誤。細節留在伺服器日誌,client 端沒有東西可以解析。

Warning 不要用同一個 idempotency_key 重送來重跑一個失敗的 ticket。key 一旦綁定了 ticket,重送就是原樣 replay 那張 ticket——你拿回的是同一個 terminal 失敗加上 replayed: true,不是一次新的嘗試。真正要重跑必須換一個新 key。

因為每個 chunk 各自 commit,失敗 ticket 的成功計數回報的是實際落地的數量,而不是 0。請以它們對帳,只重送沒有落地的列;無條件整批重送會讓已 commit 的那些變成重複資料。重試的完整約定見併發與 lock 衝突

Approval gate 的差異

require_approval 命中時,兩種 bulk 都以「整批」為審批單位,且核准前沒有任何 action 套用:

  • 同步 mixed:提交直接回 409 approval_required,包含 process_idstaged_change_id。這不是 atomic rollback 後的普通錯誤,而是整批已被 staged。
  • 非同步 task:HTTP 提交仍先回 ticket。Worker 檢查規則後,ticket 進入 terminal status: "held_for_approval",並帶 process_idstaged_change_id;沒有任何列先執行。

因此非同步 UI 不能只認 completedfailedheld_for_approval 要顯示「等待審批」並連到 review process;核准或拒絕後再刷新資料,不要另開一個新 bulk task。詳見審批總覽

實作檢查表

  1. 需要原子性就拆成最多 100 個同步 actions;需要吞吐量才選 async。
  2. Async 提交回應只建立進度畫面,實際結果以 ticket 為準。
  3. 同步錯誤標示 action index;async errors 保留 row index/record ID,讓使用者只重試失敗列。
  4. 同一個邏輯提交的重送重用 idempotency_key(timeout、回應不明時);要重跑終局失敗的 ticket 則必須換新 key(見「讀取失敗 ticket 的 error_message」)。
  5. 同時處理 approval_requiredheld_for_approval,不要把等待審批顯示成失敗。
  6. bulk-update item 帶了 expected_version 時,把 terminal 的 completed_with_errors 當成部分成功:掃 errors[] 裡的 version_conflict、重新讀取那些資料列拿到最新 version,只重送衝突列——並換一個新的 idempotency_key
  7. 同步 actions 帶 expected_version 時,要處理兩種 409version_conflict(重新讀 record_id 指出的那一筆、重建整批、整批重送),以及簽核閘門下的 record_changed_during_staging(沒有 stage、也沒有執行——重試即可)。兩種都要假設沒有任何東西落地。

完整 request models 與 worker 選項見批次參考;可用 API Playground先驗證小批次,再把大量流程接上 ticket polling。

Last updated on