Skip to Content
核心概念批次操作

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

批次介面有四種不同承諾。同步 bulk 適合互動式小批次,回應時整批已完成且具原子性;非同步 insert/update/delete 適合大量資料,先回 ticket,再逐列處理並回報部分成功。Publish session 會先把多個 chunk 隱形暫存,再非同步派送 commit,但 replace 與完整 staged dataset 仍在同一個 transaction 裡一次生效。暫存世代另提供 2–20 張表的私有追加輸入與一次同步啟用,不是取代式 publish 或部分成功 task。

檔案匯入使用的非同步 insert worker,也會在寫入任何列前依目前目標 schema 重新檢查佇列中的 XLS/XLSX 來源。舊式 .xls 不可供 decimal 欄使用;若排入佇列後目標改成 decimal,工作會以 error_code: "custom_table_import_exact_source_required" 失敗,不寫入任何列。請用 UTF-8 CSV,或以 preserve_text_cells=true 擷取的 .xlsx 文字儲存格。

先選語意,再選端點

特性同步 mixed bulk非同步 bulk tasks原子 publish session
端點POST .../records/bulkbulk-insert、bulk-update、bulk-deletepublish-sessions → chunks → commit
容量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 與 interval(match_column '<name>' is a json column and cannot be used as match_column,interval 亦同,會讓整張 ticket 失敗);這種寬鬆接受不代表其他每種欄位都能有效探測。Matching 從 record.data 讀 inline 值,所以安全選擇是 stored scalar:string、text、integer、float、decimal、boolean、date、datetime、select、user、social_client、principal。探測值會先依寫入時的儲存方式轉換,因此 "42.0" 能找到 integer 42,decimal 的 "1.2" 能找到已儲存的 "1.20"。Link 值在 relation table,rollup/lookup/formula 值則是 derived,無法透過這個 inline probe 命中。multi_select 與 attachment 是 list-valued shape,也不是安全的 match column。若不能容許重複新增,請使用有 unique 規則保護的純量欄。

不要把這份 contract 套到 agent bulk 家族(預設 direct policy 下是 custom_tables_bulk_record_actions,開啟確認時才是 custom_tables_prepare_bulk_record_actions)。該工具接受明確的 insert、update、delete 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 的前期檢查更窄:它明確拒絕 link、rollup、lookup、formula、interval、json、attachment、multi_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。即使只有 insert,endpoint 仍先要求 edit gate(403 Edit access not granted for this table.);含 insert 的批次還需要 insert 權限(403 You do not have insert permission on this table.)。kernel 依序執行的可見性檢查,對缺失或不可讀的列使用 404 {"detail":"Record not found"},own-records 拒絕只會對可讀列回 403。但這條 REST 路由對 own 與 filtered 編輯層級仍有較早的預檢:缺失目標可能回 Record '<id>' not found,own 也可能在 kernel 之前回 You can only edit/delete records you created (record '<id>').。不能宣稱 L14 的標準 body 在此路由普遍適用。可讀但不在 edit filter 範圍的列仍可回 403 This record is outside your editable scope.。

批次按request 順序、全有或全無執行;沒有新增 ordered 旗標。後面的 action 在 ACL、SCP、reference、規則與唯一性檢查時,會看到前面 action 的預期資料列與 link 變更。結構或驗證問題是 400,並指出 zero-based action(Action 1: 'data' is required for insert);predicate rule 否決是 400 Rule '<name>' violated: <reason>;transition rule 回 409 Rule '<name>' violated: transition of '<column>' from … to … is not allowed。unique 衝突回 409 {"constraint", "columns"};只有衝突資料列位於呼叫者目前可讀範圍內,且鍵的 component 沒有任何一個對呼叫者隱藏,才加入 conflicting_record_id 與 key_tuple;自後端 5.21.0 起,衝突絕不會點出隱藏欄位,被遮蔽的 body 見 unique 規則。unique_tuple 與 date_interval_exclusion 保留不含值的 typed refusal。期間圍欄 回 409 ct.scope_closed;序列拒絕使用 ct.sequence_* code。任一拒絕都會回滾全部 action,包括已先執行的 action。

成功才回實際計數:

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

actions 必須有 1–100 筆,未知 key 會被拒絕,而且 action 的 data 不能含 id(The 'id' field cannot be provided in data — it is auto-generated);這些都會在任何 action 執行前回 422。這條路由沒有 idempotency_key——送出它就是未知 key 的 422——也沒有 replay。Timeout 之後,重試前請先重新讀取受影響的資料列:這個批次不是完整落地就是完全沒有,對已落地的批次盲目重送會把其中的資料列新增兩次。

非同步:先取得 BulkTaskResponse

三個 endpoint 分別接受 records、updates 或 record_ids。提交成功的共同回應是:

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

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

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

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

提交時的回應

路由的權限 dependency 會先執行:bulk-insert 為 403 Insert access not granted for this table.,bulk-update 與 bulk-delete 為 403 Edit access not granted for this table.。Payload 也會驗證:records、updates 或 record_ids 必須有 1–50,000 筆,idempotency_key 最多 128 字元,match_column 最多 255 字元,未知 key 會被拒絕——這些都是 422。這些路由沒有另外的請求大小 413,也沒有速率限制。接著 handler 會檢查表格屬於路由的 scope(404 Table not found in this chatroom、department 或 company),以及沒有 migration 持有 table lock(423 Table is currently locked for operation: <operation>)。

在 write_policy 為 commands_only 的資料表上,本頁的每一條通道都會以 403 governed_context_required 被拒絕:同步批次帶 operation: "bulk"(沒有 surface),在審批預掃之前;非同步 bulk 與匯入的送出帶 surface(bulk_insert、bulk_update 或 bulk_delete),早於 Redis 保留與排入佇列,所以沒有東西被排入佇列,也不會留下 marker。Worker 會再檢查一次,ticket 以 error_code: governed_context_required 回報。Command 仍然可以寫入該資料表。

worker query parameter 用來選擇後端——legacy(在 API 程序內)、batch、sqs 或 pubsub——預設為部署的 DEFAULT_BACKGROUND_WORKER_TYPE,未設定或無效時為 legacy;trigger 執行與 command callback 也由同一個變數決定後端,它們沒有 worker 參數。提交給 batch、sqs 或 pubsub worker 時若拋出錯誤,路由會回 500(Failed to submit task to AWS Batch: <error> 或 Failed to submit task to SQS-ECS: <error>),並把 ticket 寫成 failed,error_message 為 Batch submission failed: … 或 SQS submission failed: …。供應商仍可能已經接下這個 job,所以 idempotency_key 會繼續綁定那張 failed ticket:用同一個 key 重試只會重播這次失敗,要真正重新執行必須換新 key。

輪詢 ticket

對 Custom Tables bulk ticket(ct-bulk-…),GET /public/task/{ticket_id} 即使帶有 /public 前綴,仍要求驗證身分與持久的公司 owner 綁定。Owner 綁定不存在/無效,或 ticket 屬於另一家公司時,回 404 {"detail": "Task not found"};CT 前綴 ticket 絕不退回公開讀取。從未發出的 ct-bulk-… 或 ct-publish-… id 也得到這個相同的 404;自後端 5.21.0 起,從未發出的 version-8 UUID(長度 36 字元、與 UUID v4 同形,version 位為 8;IaC apply 與匯入 extraction ticket 現在使用這種 id)也是如此。Publish 與 IaC apply ticket 只屬於發出它們的使用者,而不只屬於公司,自 5.21.0 起匯入 extraction ticket 也是。外來租戶、另一位使用者、owner 綁定不存在或已過期,以及(自 5.21.0 起)沒有或無效的憑證,因此看起來都與從未發出的 id 完全一樣,無法再用輪詢探測某個私有 ticket 是否存在。升級前以一般 UUID v4 發出的 ticket 是唯一仍可與不存在的 id 區分的情況,因為不存在的一般 UUID 會回公開的 200 與 ttl: -1:升級前的 IaC apply ticket 仍只對發出者開放,升級前的匯入 extraction ticket 沒有記錄上傳者,對所有人都被拒絕,上傳者本人也一樣。Partial-scope 整合 API key 會被拒絕;舊式 full key 例外在更前面的身分驗證處理。已授權的輪詢回 200,envelope 為 {"ttl", "body", "string", "message"}:body 是 ticket JSON,ttl 是剩餘秒數。如果工作 body 已過期但 owner 綁定仍有效,envelope 可回 ttl: -1、body: null 與 message: "The task does not exist or has been expired."。其他 ticket 家族,以及各家族誰可以輪詢,見誰可以輪詢哪種 ticket。不要分享 ticket ID。

body 欄位意義
statussubmitting(正在交給 batch/sqs/pubsub)或 processing,接著進入一個 terminal 狀態:completed、completed_with_errors、failed 或 held_for_approval
progress、total_records完成百分比,以及提交的列數
inserted_records 與 updated_records(insert;updated_records 計算 upsert 命中的列)、updated_records(update)、deleted_records(delete)實際落地的列數
current_step可讀的目前步驟,例如 Inserted 500/2000 records
execution_environment、batch_job_id、started_at提交時設定(fastapi、aws_batch 或 sqs_ecs;pubsub 也回報 sqs_ecs);worker 第一次寫入進度時會以不含它們的 body 取代(bulk-insert 的 ticket 會保留 started_at 與 submitted_at)
company_id所屬公司,提交時設定,之後每一次寫入都會保留
submitted_at、claimed_at、elapsed_ms、chunk_metricsbulk-insert ticket 的耗時,說明見下方
errors、error_count逐列錯誤:最多 100 筆,而 error_count 維持精確總數
error_message、error_code、errorfailed 時:失敗原因、insert 授權被撤銷時的 custom_table_bulk_insert_authorization_denied、伺服器終結一個尚未完成就中止的 job 時的 ct.bulk_interrupted、ct.bulk_not_started 或 ct.bulk_timeout(見下文),以及 approval gate 拒絕時的結構化 error

每次寫入進度都會把 ticket 的期限重設為 24 小時,所以 ticket 會在最後一次寫入後保留 24 小時;請在那之前讀取最終計數。

所有權另外綁定在 ct-bulk:ticket-owner:{ticket_id},不隨可變的進度 body 一起替換。進度寫入不會移除公司所有權。REST 輪詢對 CT 前綴 ticket 一律要求此綁定;MCP bulk_task_status 讀取 body 的 company_id,body 沒有時使用持久綁定。沒有可用所有權或屬於別家公司時回 not_found。MCP 的計數投影不是 REST 的逐列錯誤清單。

Bulk-insert 耗時

bulk-insert 的 ticket 還會回報時間花在哪裡,所以在動手改任何東西之前,就能判斷一個很慢的工作,是卡在排隊、卡在鎖,還是卡在資料列本身。這些欄位和上面的計數欄位一樣放在 body 裡:

{ "ticket_id": "ct-bulk-77777777-7777-4777-8777-777777777777", "status": "completed", "total_records": 1000, "inserted_records": 1000, "submitted_at": "2026-11-04T02:10:00.120000Z", "claimed_at": "2026-11-04T02:10:05.140000Z", "elapsed_ms": 10300.5, "chunk_metrics": [ { "chunk": 1, "offset": 0, "rows": 500, "elapsed_ms": 5120.4, "lock_wait_ms": 41.2, "sql_count": 4023, "sql_ms": 1890.6 }, { "chunk": 2, "offset": 500, "rows": 500, "elapsed_ms": 5080.9, "lock_wait_ms": 38.7, "sql_count": 4021, "sql_ms": 1902.3 } ] }
欄位意思
submitted_atAPI 接受這個請求的時間(UTC)。
claimed_at有 worker 領取這張 ticket 的時間(UTC)。claimed_at 減去 submitted_at 就是排隊時間;ticket 還在排隊時沒有這個欄位。
elapsed_ms從被領取起算,worker 實際花的時間,不含排隊。每完成一個 chunk 就會增加。
chunk_metrics每個 chunk 一筆,依序排列(最多 100 筆)。

每筆 chunk_metrics 描述一個最多 500 列的 chunk:

欄位意思
chunkchunk 編號,從 1 開始。
offset這個 chunk 第一列在送出清單中的位置,從 0 算起。
rows這個 chunk 嘗試處理的輸入列數,包含被拒絕的列。
elapsed_ms這個 chunk 從開始到 commit 或 rollback 的時間,不含更新 ticket 所花的時間。
lock_wait_ms取得資料表與上層資料表的鎖(guard)所花的時間,包含準備與重新整理這些鎖所需的查詢。它不是單純的資料庫鎖等待,而且和 sql_ms 有重疊:不要把兩者相加。
sql_count這個 chunk 期間嘗試執行的 SQL 敘述數。一次用 executemany 送出的一批資料列只算一次。
sql_ms花在 SQL 上的時間,包含失敗的敘述與資料庫的鎖等待。

怎麼看:排隊時間很長,代表工作在等 worker,不是資料列慢。某個 chunk 的 elapsed_ms 遠大於它的 sql_ms,代表時間花在應用程式端而不是資料庫。每個 chunk 的 lock_wait_ms 都很高,可能表示這張表或它的上層資料表有競爭,例如同一張表上正好有另一個大型的寫入。只有 bulk-insert 的 ticket 有這些耗時欄位。舊版伺服器寫的 ticket 沒有任何一個欄位,還在排隊的 ticket 只有 submitted_at;其他欄位會隨 worker 寫入而出現。

誰可以輪詢哪種 ticket

GET /public/task/{ticket_id} 是 /public 路由,但它對呼叫者的要求取決於 ticket。伺服器拒絕的每一次輪詢,不論原因,都是同一個 404 {"detail": "Task not found"}。

Ticket誰可以輪詢
ct-bulk-…:bulk insert、update 與 delete ticket,包含檔案匯入的寫入 ticket擁有它的公司任何已登入使用者
ct-publish-…:publish session 的 commit ticketcommit 該 session 的使用者
version-8 UUID:IaC apply ticket 與匯入 extraction ticket啟動該工作的使用者
Custom Tables 內部儲存 key:匯入 session、資料表鎖、schema migration 與診斷結果、清除與預覽 ticket、owner 綁定、冪等結果、速率計數器及類似的內部狀態沒有人,不論憑證
不是 Custom Tables ticket、也沒有私有 owner 的 task任何人:id 本身就是 capability,不存在的 id 回 200,ttl: -1、body: null

憑證是一般的 Authorization: Bearer JWT(上文提到的舊式 full API key 例外在這裡同樣適用)。缺少、無效或過期的 JWT、partial-scope 整合 API key、其他公司的使用者,以及(對僅限發出者的列而言)同公司的其他使用者,都會得到上述 404,前三種形狀的未知 id 也一樣。自後端 5.21.0 起,輪詢只有在上表的 Custom Tables 列,以及少數僅限發出者的其他平台 task 才要求憑證;後端 5.20.1 對每一次 task 輪詢都做驗證。

工作中止、尚未完成時

bulk job 會替它的 ticket 續租一個短 lease(預設 60 秒),並在每個 500 列分段 commit 之前先寫入 checkpoint。自後端 5.21.0 起,當執行該 job 的 worker 消失(legacy 是被回收或重啟的 API 程序,佇列 worker 則是被停止的 pod 或 task),伺服器會把 ticket 終結為 failed 並附上型別化的代碼。這發生在 ticket 被輪詢的時候:每一次已授權的 GET /public/task/{ticket_id} 都會檢查尚未進入最終狀態的 ticket,沒有背景掃描替你處理,所以請持續輪詢。MCP 的 bulk_task_status 工具只讀取儲存的 ticket,不會終結過期的 ticket。

error_code時機計數器
ct.bulk_interruptedlease 不再被續租。已死亡的 worker 預設在最後一次續租後大約六分鐘被終結,前提是輪詢持續進行已 commit 的列數,包含停止前剛 commit 的分段
ct.bulk_not_started沒有 worker 在啟動期限內(預設一小時)認領 ticket;沒有寫入任何列0
ct.bulk_timeout嘗試開始已超過時間上限(預設兩小時,外加短暫寬限)仍未完成。不論 worker 是否還在執行,一次輪詢就會終結它;仍存活的 worker 會在下一次檢查時自行停止已 commit 的列數
{ "ticket_id": "ct-bulk-77777777-7777-4777-8777-777777777777", "status": "failed", "progress": 50, "total_records": 2000, "inserted_records": 1000, "updated_records": 0, "current_step": "Inserted 1000/2000 records", "updated_at": "2026-10-08T03:30:00+00:00", "company_id": "11111111-1111-4111-8111-111111111111", "error_code": "ct.bulk_interrupted", "error_message": "The job stopped before reporting completion. Committed rows remain. Retry the same idempotency key to retrieve this ticket, not to resume it. Inspect the committed rows; submit only the remaining work with a new key." }

計數器是該通道自己的(inserted_records、updated_records 或 deleted_records)。ct.bulk_timeout 的訊息以 The job exceeded its time limit and was stopped. 開頭,ct.bulk_not_started 的訊息則以 The job never started: no worker claimed it before the start deadline, so no rows were written. 開頭。停止之前已 commit 的列維持已 commit:沒有 rollback,也不會自動續跑。請對照計數器與你自己的業務識別碼來核對,因為單看數量無法知道哪些列已落地,然後只用新的 idempotency_key 提交剩下的工作。舊 key 會繼續綁定在那張失敗的 ticket 上,直到原始提交後 24 小時,所以重用它只會重播那張 ticket(replayed: true),不會再次 insert,也不會續跑;而用新 key 重送整份原始請求會讓已落地的列重複(見冪等重試)。背景新增欄位不在這裡輪詢,它透過資料表 migration 狀態回報。IaC apply ticket 也以同樣方式結束,使用它們自己的 ct.iac_* 代碼(見 Plan 與 Apply)。

分段 commit 與逐列錯誤

Bulk insert/update 在處理資料列前,會持有 parent-table lock 並重新載入規則 context。因此排隊工作會遵守開始後才提交的期間、筆數與區間規則,不會一直沿用最初的舊快照(#1793)。這不會讓非同步工作變成原子操作。

Worker 一次處理並 commit 500 列,每段結束後改寫 ticket,所以後面的失敗絕不會撤銷前面的段落。每段開始前它都會重新檢查 table lock:執行途中若有 migration 鎖住表格,ticket 會以 failed 結束,訊息為 Table became locked for a migration; bulk insert aborted(或 bulk update/bulk delete),在此之前 commit 的段落仍維持 commit。

在這些通道上,rule 違規——包括 unique 與 count_limit——與權限拒絕都是逐列錯誤,絕不會讓整個批次失敗。各通道的錯誤項目形狀不同:

通道項目拒絕訊息
bulk-insert{"index", "errors"}This record is outside your editable scope(edit filter 不接受新的列)
bulk-update{"index", "record_id", "errors"}Record not found(不存在,或被你的讀取政策隱藏——兩者無法區分)、Permission denied: you can only edit your own records、This record is outside your editable scope、This edit would move the record outside your editable scope
bulk-delete{"record_id", "errors"},沒有 indexRecord not found、Permission denied: you can only delete your own records、This record is outside your editable scope

index 是提交清單中的 zero-based 位置。bulk-update 的 version_conflict 項目見下方。

排隊中的 insert 在執行時重新授權

被接受的 bulk-insert 記下的是「誰提出的」,而不是一份權限。Worker 開始處理由使用者發起的 task 時,會在新的 session 中、於任何核准 staging 或資料列處理之前,重新執行與 REST route 相同的成員資格 → 有效權限 → insert 閘門。若使用者在這段期間失去表格、聊天室或部門成員資格,或帳號,ticket 會立即以下面這個輪詢 body 結束:

{ "ticket_id": "ct-bulk-77777777-7777-4777-8777-777777777777", "status": "failed", "progress": 0, "total_records": 2, "inserted_records": 0, "updated_records": 0, "current_step": "failed", "updated_at": "2026-09-29T02:20:00+00:00", "error_message": "Insert authorization is no longer available.", "error_code": "custom_table_bulk_insert_authorization_denied", "company_id": "11111111-1111-4111-8111-111111111111" }

不會寫入任何資料列。通過重新檢查時,也會依當下的授權重新推導該使用者的隱藏欄位與 edit filter,所以派送後才隱藏的欄位或收緊的 filter,同樣適用於排隊中的資料列。檔案匯入也透過同一個 task 派送,因此一樣受此檢查。請依 error_code 分支;不要重送同一份提交,在恢復權限之前答案都不會改變。

排隊中的 update 與 delete 沿用提交時的權限

只有 bulk-insert 會在執行時重新檢查存取權。bulk-update 與 bulk-delete 會以提交時擷取的權限執行:只能編輯自己資料的限制、edit filter、把隱藏列回報成 Record not found 的讀取政策,以及隱藏欄位,都會凍結在 task 中,因此派送後才撤銷的授權無法攔下排隊中的 update 或 delete。

冪等重試

為可安全重送,提交時請帶穩定的 idempotency_key(最多 128 字元;空字串視同省略)。這個 key 的範圍是表格、操作(insert、update 或 delete)與執行的使用者,有效 24 小時。用同一個 key 重試會回 200 {"message": "Bulk insert already submitted (idempotent replay)", "ticket_id": "<original>", "replayed": true},而且完全不讀新的 body——重用 key 時換了 payload 也會被忽略,重播的是原本的 ticket——所以請輪詢那張 ticket 取得結果。不要每次 retry 產生新 key。

原始請求仍在派送時到達的重複請求,會短暫輪詢約兩秒後拿到原本的 ticket。若之後原始請求仍在處理中,會回 409 A bulk insert with this idempotency_key is already in progress 並附 Retry-After: 1;若原始請求的保留在等待期間被釋放或過期,這個重複請求會接手該 key 並派送自己的批次。部署期間由舊版伺服器保留的 key 會以 409 A bulk insert with this idempotency_key was submitted during a deployment transition; retry after the replay window 拒絕。冪等狀態儲存在已看到或已嘗試保留之後失敗時,路由會回 503 Bulk insert idempotency state is temporarily unavailable; retry with the same key 並附 Retry-After: 1;若第一次探測時儲存就已無法連線,批次會在沒有 replay 保護的情況下派送。(update 與 delete 的訊息相同,只是換成各自的操作名稱。)

在有 require_approval rule 的表格上,只要被 hold 的批次其 staged change 仍是 pending 或 applying,資料庫檢查就會持續去重,即使 24 小時的 key 已過期或儲存失敗也一樣。這種重送會拿到新的 ticket_id,它會立即顯示 held_for_approval,並帶相同的 staged_change_id 與 process_id。

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

同步 actions 陣列的每個項目現在都可以帶 expected_version(整數 ≥ 1)。它只在 action: "update" 上有效——insert 或 delete 帶了它,在任何動作執行前就是 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[]:

{ "index": 0, "record_id": "33333333-3333-4333-8333-333333333333", "error": "version_conflict", "current_version": 6, "expected_version": 4, "errors": ["Record was modified concurrently (current version 6, expected 4); re-read and retry."] }

逐筆錯誤明細上限 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 時再驗一次)接手保護;見審批總覽。

Decimal cell 是字串

在每條批次通道中,decimal cell 都要寫成 JSON 字串,例如 "12.50";JSON 數字會被拒絕。值會依欄位的 precision、scale 與 allow_negative 驗證,並以正規形式儲存,因此 scale 為 2 時 "12.5" 會存成 "12.50"。欄位無法容納的值,在非同步通道中會讓該列失敗,在同步批次中會讓整批失敗,錯誤會點名該欄(… must be a valid decimal string: <reason>)。

未知 key 現在是 422

四個 bulk payload(BulkRecordAction 與混合 payload、bulk-insert、bulk-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" } }

    error 是 scp_out_of_scope、scp_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 的差異

若較早的依序 action 讓後面的 action 從直接執行轉為需要核准 hold,同步 bulk 會拒絕並回 409 {"detail": {"error": "approval_routing_changed", "message": "An ordered action enters an approval hold; split the batch and retry."}}。不會套用或暫存任何 action;請拆開批次並重新讀取,不要把這個拒絕當作成功的核准請求。

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

  • 同步 mixed:提交直接回 409 approval_required,包含 process_id 與 staged_change_id。這不是 atomic rollback 後的普通錯誤,而是整批已被 staged。若某個目標資料列已被另一個待審的 staged change 占住,則改回 409 {"error": "approval_pending", "process_id", "staged_change_id"},且沒有 stage 任何東西。
  • 非同步 task:HTTP 提交仍先回 ticket。Worker 檢查規則後,ticket 進入 terminal status: "held_for_approval",並帶 process_id/staged_change_id;沒有任何列先執行。其他 gate 結果同樣會在不執行任何列的情況下結束 ticket:與已在等待審批的資料列重疊的批次會以 completed_with_errors 結束,並把重疊列列為逐列錯誤;超過 1,000 列的受控批次會以 failed 結束,帶 error: {"error": "gated_batch_too_large", "limit": 1000} 與 error_message gated batch exceeds the approval row limit (1000); split it or narrow the rule;staged-changes 上限已滿時則以 failed 結束,帶 error: {"error": "staged_cap_exceeded", "scope", "limit"},而不是同步通道的 429。

因此非同步 UI 不能只認 completed/failed。held_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_required 與 held_for_approval,不要把等待審批顯示成失敗。
  6. bulk-update item 帶了 expected_version 時,把 terminal 的 completed_with_errors 當成部分成功:掃 errors[] 裡的 version_conflict、重新讀取那些資料列拿到最新 version,只重送衝突列——並換一個新的 idempotency_key。
  7. 同步 actions 帶 expected_version 時,要處理兩種 409:version_conflict(重新讀 record_id 指出的那一筆、重建整批、整批重送),以及簽核閘門下的 record_changed_during_staging(沒有 stage、也沒有執行——重試即可)。兩種都要假設沒有任何東西落地。
  8. failed 的 bulk-insert ticket 若帶 error_code: "custom_table_bulk_insert_authorization_denied",要當成權限問題而不是暫時性失敗:什麼都沒有寫入,在使用者恢復 insert 權限之前,重送也會以同樣方式失敗。
  9. 持續輪詢一直停在 processing 的 ticket:worker 消失的 job 只有輪詢才能終結。遇到 ct.bulk_interrupted 或 ct.bulk_timeout,先核對已 commit 的列,只用新的 idempotency_key 提交剩下的;遇到 ct.bulk_not_started,則什麼都沒有寫入。

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

Last updated on