背景任務
三條耐久 outbox 通道共用 edge_worker_outbox 表(operation = deploy / artifact_cleanup / control_audit)。只有部署任務走背景任務 transport;兩個調節器(加上部署回收重發)在每個 FastAPI API replica 內以硬編碼的 60 秒 asyncio 迴圈執行,每條通道各自 try/except、批次上限 50、FOR UPDATE SKIP LOCKED。沒有 cron,也沒有任何環境變數能調節奏、批次大小、租約 TTL 或退避階梯 — 全是程式碼常數。第二條同樣寫死的迴圈,也就是讓已刪除 Worker 退役的 15 秒 host 調解,在每個 replica 內與它並行(見下方 Host 調解迴圈)。
部署任務、回收掃描與產物儲存都經由 EDGE_WORKERS_PROVIDER 選定的 provider 執行(預設 cloudflare;裝了 Edge Workers 附加元件的 kit 上為 onprem,見環境變數)。本頁描述雲端 provider,並標出地端的差異。
Provider 部署範圍。 Staging 與 production 不共用各自的 transport 或調節器。每次寫入 outbox(enqueue_deploy、enqueue_control_audit、create_artifact_cleanup_intent)都會蓋上 dispatch_namespace。在雲端,部署列或控制列取用 Worker 所記錄之服務位置的 namespace;Worker 的首次部署與每一筆產物清理意圖,則以該環境的 EDGE_WORKERS_CLOUDFLARE_DISPATCH_NAMESPACE 為預設值;地端蓋的是 onprem:<12 hex>。四個回收認領掃描(claim_recoverable_deployments、claim_recoverable_control_audits、claim_control_receipt_acks、claim_artifact_cleanup_intents)只取其環境可以執行的列:雲端是共用位置加上該環境自己的舊有位置(另一個環境之舊有位置的列留給那個環境,舊有 "" 的列不再被認領),地端則只取自己的 onprem:<12 hex> 範圍。雲端沒有可用的 namespace 設定時,什麼都不認領。部署投遞若送到一個不可以執行該列的環境,會直接返回而不碰那一列,留給它的擁有者。結算與部署准入圍欄刻意維持全域;依精確 id 的部署租約則和掃描一樣受範圍限制(雲端上,落在該環境可認領位置之外的列,或其所記錄的位置不是該環境會執行的位置,不會取得租約:wrong_scope)。第五個掃描 claim_deferred_deployments 只存在於地端(見下)。
部署任務
進入點 run_edge_worker_deployment(outbox_id, company_id, department_id, message_id) — 恰好四個非空純量參數,入口重新驗證。
| 常數 | 值 |
|---|---|
| Redis 投遞認領 TTL/心跳 | 30 秒/10 秒(key edge-worker:delivery:v2:{message_id}) |
| DB 租約 TTL/心跳 | 90 秒/30 秒 |
| 部署嘗試上限 | 2(每次取得租約就遞增 attempts) |
| 部署狀態 Redis TTL | 7 天(key edge-worker:deployment:v2:{company}:{dept}:{ticket}) |
| 投遞完成標記 TTL | 15 天 |
| 重試退避 | 無 — release_for_retry(0) → next_attempt_at = NULL |
| Release 就緒預算 | 600 秒輪詢截止點(後端 5.22.0 之前為 60 秒;截止點之後不再啟動探測,每次探測在 40 秒或到達截止點時被中斷),退避 1 → 2 → 4 → 5 秒(上限),斷言 subject system:edge-worker-deploy,失敗代碼 worker_readiness_timeout |
階段:參數重驗 → Redis client(失敗可容忍)→ 諮詢性投遞認領(owner-fenced Lua;終態 DB 列直接短路;蓋在非終態 DB 狀態上的過期 done 標記會被刪除重認領)→ provider 解析(edge_workers_provider();設定錯誤會被記成 provider invalid)→ DB 租約迴圈(lease_seconds=90;處置 acquired/terminal/inflight/not_due/wrong_scope;同 owner 回收只限同一 AWS_BATCH_JOB_ID 的第 ≥2 次嘗試;僅地端:執行閘門關閉時,在取得租約前丟出 DeploymentRetryLater("Edge Workers execution is fenced"),provider 無法建構則丟出 DeploymentRetryLater("Edge Worker provider is unavailable"))→ provider 無效時結算票證 為 failed(調解模式為 uncertain),edge_workers_provider_invalid → 僅雲端、非調解模式:department_deleted 檢查(部門資料列不存在或已軟刪除時丟出 ProviderTerminalError("department_deleted");它在取得租約之後、讀取任何產物或寫入 deploying 圍欄之前執行,所以什麼都不會上傳 — 上傳會讓已刪除部門的 Worker 復活)→ 快照列(鎖序 outbox → worker → version;state=="deploying" ⇒ GET-only 調解模式 + 重新暫停)→ 完整性驗證(manifest 完整性;module tuple;7 個摘要;回執鍵集合 {builderPolicy, bundleDigest, dependencyDigest, lockDigest, manifestDigest, projectDigest, releaseGeneration, receiptVersion, sbom, sbomDigest, staticData, staticDataDigest} 且 receiptVersion="1";重算 release generation;R2 讀回 project ≤2 MiB、bundle ≤5 MiB 並驗 SHA-256)→ deploying 提交 + worker.status="paused"(同交易;提交確認遺失時會重讀)→ 舊版部署的機密清除(見下)→ provider 上傳(雲端為 Cloudflare PUT)→ 雙端回讀(settings + Worker Details)→ 就緒關卡:對上傳的 release generation 送出唯讀的 access_policy.get 控制指令,在 600 秒輪詢截止點下重試(探測拋出例外視為未就緒;截止點之後不再啟動探測,每次探測在 40 秒或到達截止點時被中斷,且每次探測之前與之後都會重新驗證 DB 租約)。收到回應導向 completed;截止點後的探測仍失敗則導向 uncertain worker_readiness_timeout。每一種 completed 結算 — 全新上傳、模糊上傳後的回讀、GET-only 調解 — 都經過這道關卡(見版本與部署)。
舊版部署的機密清除。 在 deploying 圍欄提交之後、上傳之前:若部署的目標 release 是以早於 department-worker-project-v2.6 的 builder 政策建置(沒有 Worker 機密),而 Worker 目前 deployed 的 release 為 v2.6 或更新,任務會先透過目前的 runtime 清除所有 Worker 機密(v2.7 及更新用 secrets.purge,v2.6 逐名稱 secrets.delete,每個都有回執確認),並寫入稽核列 secrets.purged_before_legacy_deploy(resource_type 為 edge_worker,系統 actor)。沒有任何機密存在檢查或 manifest 宣告會擋住部署:降版一律清除先前 release 可能持有的機密。若 Worker 或其目前 release 已變動,或清除未獲確認,任務以 failed artifact_integrity_failed 結束;閘道錯誤則以 failed deployment_internal_error 結束(尚未觸及 provider)。能力對照表見提交管線。
例外階梯(provider 中立的類別;Cloudflare 的錯誤是它們的子類別,所以每個雲端代碼與結果都不變):
| 例外 | 結果 |
|---|---|
DeploymentRetryLater | 什麼都不結算、重新丟出(transport nack;DB 列維持可回收) |
DeploymentIntegrityError(artifact_integrity_failed) | 調解模式 → uncertain,否則終態 failed |
ProviderTerminalError — Cloudflare 的終態錯誤 cloudflare_upload_rejected、cloudflare_reconcile_rejected、cloudflare_config_invalid、script_name_invalid、bundle_digest_invalid、compatibility_date_invalid | 同樣的分割 |
ProviderTerminalError("department_deleted")(雲端;在任何上傳之前丟出) | failed department_deleted:outbox failed,版本 state="failed" 且 error_message 為 deployment failed,Worker 狀態還原為 active(若有 current 版本)否則 draft,稽核 deployment.failed 且 reason_code 為 department_deleted |
provider 設定無效(EdgeWorkerProviderConfigError) | failed(調解模式為 uncertain)edge_workers_provider_invalid |
ProviderTerminalError edge_host_capacity_exhausted(地端) | 同樣的分割;唯一把 provider 指引文字當作版本 error_message 的終態代碼(The host's Worker capacity is reached; re-run setup with a larger --edge-workers-max)— 其他終態訊息一律維持 deployment failed |
ProviderUncertainError(Cloudflare:cloudflare_upload_uncertain = 逾時/3xx/408/409/5xx/傳輸;cloudflare_response_invalid) | 回讀:吻合 → 就緒關卡 → completed(或 uncertain worker_readiness_timeout);不吻合 → uncertain provider_bundle_mismatch;讀不到 → uncertain |
ProviderRetryableError / EdgeWorkerArtifactError / DeploymentRetryableInternalError | 重試處理:429(ProviderRetryableRejectionError)撤回 deploying 圍欄允許第二次 PUT;耗盡+有圍欄 → uncertain deployment_reconciliation_unavailable;耗盡無圍欄 → failed retry_exhausted(LEGACY 下為 retry_transport_unavailable);否則回到 pending(有圍欄的重試維持 GET-only) |
ProviderDeferredError(edge_host_fenced;地端,已證實無害的備份圍欄) | 不算失敗:版本回到 deploy_pending,error_code 為 edge_host_fenced、error_message 為 activation deferred by backup fence,Worker 狀態還原(active/draft),outbox 列不延遲地回到 pending,而且不計入該次嘗試(attempts 遞減);票證顯示 pending、error_code: "edge_host_fenced" |
裸 Exception | provider 可能已被觸及 → uncertain provider_settlement_lost,否則 failed deployment_internal_error |
finally:停兩個心跳;只有耐久 DB 狀態為終態才寫 done 標記,否則釋放 owner token。
產物清理調節器
調解提交時在任何 R2 寫入之前建立的 artifact_cleanup 意圖(dedupe key artifact-cleanup:{artifact_id};payload 恰好兩項 {kind: project|bundle, object_key, digest};digest 為 64-hex)。
| 參數 | 值 |
|---|---|
| 首次嘗試前的意圖寬限 | 900 秒(夾 30–3600) |
| 租約 | 300 秒(夾 30–3600),token artifact-cleanup:{uuid4} |
| 批次 | 每輪 50,硬上限 100 |
| 失敗退避 | min(3600, 15·2^min(attempts−1, 8)) → 15、30、60、120、240、480、960、1920、3600 秒 |
每筆認領:payload 無效 → failed cleanup_payload_invalid。引用中的 key 保留:每個 object key 對同租戶 edge_worker_versions 檢查;兩個 key 都被引用時該列以 cancelled 結算並指向擁有版本、什麼都不刪(即使損毀讓兩個 key 指向不同版本也 fail-closed);只有一個被引用時只刪孤兒。刪除是冪等的(刪不存在的 key 也成功)且在第一個失敗項就停 — 整組重試。結算受租約 token 圍欄;R2 store 建構失敗會把整批結算為失敗(每列各燒一次 attempts)。收養路徑:提交路由的版本提交交易原子性取消意圖(adopt_locked_artifact_cleanup 重驗租戶與四組 key/digest);未提交成功的提交改呼叫 make_artifact_cleanup_due(預設代碼 submission_not_committed)。
控制面稽核調節器
對照 Durable Object 回執(control.receipt.get)調解存取政策、執行期設定、操作設定、Worker 機密與服務帳號憑證的變更 — 永不重放變更。outbox 列 id 兼任確定性稽核列 id,所以重放冪等("audit id is already bound to different facts" 防漂移)。
| 參數 | 值 |
|---|---|
| 請求路徑寬限 | 300 秒;請求路徑延遲重試 15 秒(provider_outcome_unknown),revision_conflict 終態 |
| 租約 | 120 秒,token control-audit:{uuid4} |
| 批次 | 50 |
| 回執驗證嘗試 | 3 次,之後 uncertain control_receipt_invalid |
| 重試退避 | 同 15 秒 → 3600 秒階梯 |
共有十二個指令 operation 被稽核;5.21.0 新增的七個如下:
指令 operation | 稽核 action(no-op 動作) | resource_type |
|---|---|---|
operation_settings.replace | operation_settings.replaced(指令帶有 liveReleaseGeneration 時為 operation_settings.staged) | edge_worker_operation_settings |
operation_settings.api_key.rotate | operation_settings.api_key.rotated | edge_worker_operation_settings |
operation_settings.api_key.revoke | operation_settings.api_key.revoked(operation_settings.api_key.revoke_noop) | edge_worker_operation_settings |
secrets.put | secrets.put | edge_worker_secret |
secrets.delete | secrets.deleted(secrets.delete_noop) | edge_worker_secret |
service_accounts.credentials.put | service_accounts.credentials.put | edge_worker_service_account |
service_accounts.credentials.delete | service_accounts.credentials.deleted(service_accounts.credentials.delete_noop) | edge_worker_service_account |
其餘五個(access_policy.replace、access_policy.api_key.rotate、access_policy.api_key.revoke、runtime_config.replace、runtime_config.delete)不變。每一種的 resource_id 都是 Worker id,而稽核 digest 絕不包含機密值、金鑰或密碼(見稽核與控制面稽核)。
新 operation 的回執規則(疊加在下方的嚴格外層檢查之上):結果鍵集合對兩個 API 金鑰撤銷恰為 {conflict, revoked, current},對 secrets.delete 與 service_accounts.credentials.delete 恰為 {conflict, deleted, current},其餘為 {conflict, current},且 conflict 為 false。operation_settings.* 要求 current.state == "reconciled" 且 current.revision == baseRevision + 1(什麼都沒撤銷的撤銷為 + 0);replace 另外要求 current.releaseGeneration 等於指令的值。secrets.put 要求 current 鍵恰為 {name, sha256, updated_at},且名稱與 valueDigest 同指令;secrets.delete 為 {name, deleted, updated_at};服務帳號 put 為 {serviceAccountId, username, passwordDigest, updated_at} 且與指令相同;服務帳號 delete 為 {serviceAccountId, deleted, updated_at}。回執說 deleted: false 的刪除記為 no-op 動作。updated_at 必須是有效時間戳。
已退役的 Worker。 讀取回執之前,兩個輪次都先問該 Worker 是否已退役:它的部門已軟刪除且存在成功的 department.worker_retired 稽核,或它的公司已被硬刪除(沒有任何公司有該 id 的部門資料列,也沒有 Worker 資料列)。然後在不呼叫 provider 的情況下:復原輪取消 outbox 列(cancelled,last_error_code 為 worker_retired,last_error 為 Worker retired before its control receipt was read,不寫稽核列);確認輪停止重試已完成的列(next_attempt_at 清空,last_error_code 為 worker_retired,last_error 為 provider receipt retired with its Worker)。兩者都記錄 INFO Edge Worker control receipt settled: its Worker is retired,從不重試也不報錯。見退役。
復原輪:認領 → 載入控制目標(現任 worker 必須與認領的 worker_id/script_name 吻合且有 deployed 的 current 版本;release generation 從當下已部署版本重算)→ control.receipt.get 帶 {mutationId: outbox_id, commandDigest} → 嚴格回執驗證(精確外層鍵集合;policy 鍵恰為 {api_key_configured, api_key_last_four, ip_allowlist, mode, revision, updated_at};revision 必須是 base+1(no-op 撤銷為 base+0);輪替必須顯示指令的 lastFour;runtime-config 的 valueJson 必須正規且雜湊等於它的 sha256)→ 結算:找到且有效 → completed(+稽核列);找不到 → failed control_receipt_not_found;無法使用 → 重試 control_receipt_unavailable;不帶已記錄服務位置的回執列(在後端 5.22.0 之前排入的每一列,以及屬於由舊有位置服務之 Worker 的列),若其 namespace 不是 Worker 目前服務位置的 namespace,或其目前位置無法解析 → failed legacy_serving_target_unavailable(已記錄的位置之後有變動,則視為無法使用而重試)。
確認輪:認領 next_attempt_at 到期的 completed 列(該欄位在 completed 列上兼任工作佇列與秒精度 CAS 標記),送 control.receipt.ack,必須恰好回 {"acknowledged": true};失敗按同一退避階梯重排(control_receipt_ack_failed);Worker 已退役的列到此為止(見上);不帶已記錄服務位置、且無法對應到 Worker 目前服務位置的回執列也一樣(next_attempt_at 清空、last_error_code 為 legacy_serving_target_unavailable,計為 failed)。
入列時的指令驗證很嚴(每個 operation 有精確欄位集合;baseRevision 是真 int 0…2^53−1,但四個無修訂版的 operation secrets.put、secrets.delete、service_accounts.credentials.put、service_accounts.credentials.delete 不帶它;valueJson ≤ 65 536 UTF-8 位元組且解析為物件;明文 API 金鑰、Worker 機密值與服務帳號密碼或 salt 進不了 outbox — 進去的只有摘要;script_name 必須符合 ^dw-[0-9a-f]{48}$)。
Transport 與 Pub/Sub consumer
DEFAULT_BACKGROUND_WORKER_TYPE ∈ legacy(預設 — FastAPI BackgroundTasks,行程內)/pubsub(GCP 生產)/sqs/batch。部署任務一律以 job_id = message_id 派送。
- consumer 一次只取一則訊息(
max_messages=1),租約PUBSUB_MAX_LEASE_SECONDS預設 3300 秒、排空逾時PUBSUB_DRAIN_TIMEOUT_SECONDS預設 3300 秒、關閉寬限 3600 秒。SIGTERM:新訊息 nack、在途任務排空、然後關閉 — 被中斷的部署由租約過期 + 60 秒迴圈回收。 - Self-gate:部署任務是內建的 self-gated 函式(
…run_edge_worker_deployment → outbox_id),對環境回滾免疫。consumer 對它跳過一般的 Redisjob:running/job:done認領 — 那個認領無主、無心跳,且撞認領會 ACK(銷毀)耐久 outbox 前面的訊息。合併工作由任務自己的 owner-fenced 認領 + DB 租約負責。 - 格式錯誤的訊息 → ack(丟棄)。任務前的基礎設施錯誤 → nack。任務失敗 → nack(超過最大投遞次數進 DLQ)。已結算部署的重複投遞在終態 DB 列上立即返回。
回收重發器 — 在 API 行程內
每個 60 秒輪次之前,迴圈會驗證 EDGE_WORKERS_PROVIDER;值無效時記錄 WARNING Edge Worker recovery skipped: <code> 並睡 60 秒,因此該輪不執行任何部署、控制或清理回收。每輪的控制面稽核日誌行包含 retired=%d 與 ack_retired=%d。
republish_recoverable_edge_worker_deployments:認領停滯 ≥300 秒的 pending 列與過期租約(上限 50),隔離無效 payload(曾 leased 或版本 deploying → uncertain+暫停,否則 failed;永不把 deployed 版本降級),每次發佈前重寫必要的 submitting 狀態,然後派送。確定的派送拒絕以 (status, lease_owner, next_attempt_at) 的 compare-and-set 結算 — 認領時蓋上秒精度 next_attempt_at(預設冷卻 15 秒,夾 1–300),過期的發佈者因此輸掉 CAS。LEGACY transport 下,重發是在 API 執行緒內同步跑完整個部署。
Worker 資料列已不存在(公司硬刪除)的 pending 或租約過期的部署列,會結算為 failed、last_error_code 為 worker_deleted(last_error 為 the Worker row no longer exists),而不是被永遠重新發佈;不寫稽核列也不更新版本,因為那些列已經不在了。該範圍的執行閘門關閉時(僅地端;雲端閘門恆為開啟),掃描不認領任何東西。上限(50,夾 1–100)、300 秒寬限與 15 秒冷卻(夾 1–300)不變。
Outbox:入列規則與地端掃描
- 部署去重。
enqueue_deploy接受選用的operation_settings_digest(64 個 hex,否則 409operation-settings digest is invalid)。去重 key 仍是deploy:{worker_id}:{version_id}:{base_revision},給了摘要時再加上:{operation_settings_digest};對同一 Worker、版本與基底修訂版、但摘要不同的兄弟部署,以 409operation-settings digest differs from the existing deployment拒絕。 - 維護閘門。
enqueue_deploy與不確定部署的修復都會檢查 provider 的維護閘門。雲端從不阻擋;地端閘門關閉時丟出 409edge_workers_maintenance或edge_workers_restore_pending,而在備份圍欄期間入列的部署,會以last_error_codeedge_host_fenced建立,圍欄結束前不發佈。 claim_deferred_deployments(僅地端):認領已到期、帶有last_error_codeedge_host_fenced的pending部署列,最舊的優先,使用FOR UPDATE SKIP LOCKED與 15 秒冷卻,沒有批次上限;mark_fence_republish_accepted在 transport 接受喚醒後清除該標記。對地端 provider 不接受的列,lease_deployment回答TERMINAL。- 不變的常數:
CONTROL_AUDIT_REQUEST_GRACE_SECONDS=300、CONTROL_AUDIT_LEASE_SECONDS=120、CONTROL_AUDIT_MAX_BATCH=50、ARTIFACT_CLEANUP_GRACE_SECONDS=900、ARTIFACT_CLEANUP_LEASE_SECONDS=300、ARTIFACT_CLEANUP_MAX_BATCH=100、RECOVERY_DISPATCH_GRACE_SECONDS=300。
Host 調解迴圈 — 每 15 秒的退役
每個 API replica 在工作執行緒中執行一輪 provider 調解,逾時 20 秒,然後睡 15 秒。行程內的鎖讓每個 replica 同時只跑一輪:發現前一輪執行緒仍在跑的 tick 會記錄 WARNING Edge Worker host reconcile skipped: the previous pass is still running,任何錯誤則記錄 WARNING Edge Worker host reconcile failed: <exc>。其中沒有任何可設定項。在雲端,這一輪在 10 秒開始預算與 3 次退役上限內,把已刪除部門以及已硬刪除公司的 Worker 的 script 放上墓碑(墓碑在退役後保留,之後自動移除);在地端,它重試待處理的 host 退役、確認遺失的備份圍欄,並讓資料庫不再擁有的 script 退役。界限、日誌與孤兒報告:退役。