vatt'ghern jaskier's ballads
本文 4 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

一個跑了三步的批次匯入工作流,在 embed API 回應到一半時 process 被 OOM killer 收掉。重啟之後,它沒有從頭把前三步重跑一遍——它讀了一張 PostgreSQL 的資料表,發現第三步的 output 已經 checkpoint 過了,於是直接從第四步接上去。沒有 Redis,沒有 Temporal,那張接住 crash 的表就坐在你平常 SELECT 的同一個 schema 裡。

durable execution 搬進 PostgreSQL——pg_durable 用一張 schema 接住 crash

完這篇,你會知道 durable execution 這個詞到底在講什麼、它解決哪一類「重跑代價很高」的問題,以及 Microsoft 開源的 pg_durable 怎麼只靠 PostgreSQL 的資料表加一個 background worker,就把「crash 後從上次進度接續」這件事做成擴充。如果你知道 Temporal 或 DBOS 在做什麼、但沒看過把這套東西塞進資料庫內部的版本,這篇從零把 in-database durable execution 的機制拆給你看:狀態存在哪、checkpoint 在哪一刻發生、它跟外部 orchestrator 的取捨在哪、以及哪些場景千萬別碰。

那個跑到一半被 kill 的批次工作——重跑代價為什麼是個真問題

先把案發現場攤平。假設你要做一個常見到不行的後端任務——把一批文件 embed 進向量庫。流程大概是這樣:撈一批還沒處理的文件 ID、對每份文件呼叫一個 embedding API、把回傳的向量 upsert 進 pgvector、最後把那批文件標記成已處理。四個步驟,串成一條。

單跑一次沒什麼。問題出在「一批」可能是十萬份文件,embedding API 每次往返一兩百毫秒,整條工作流要跑好幾個小時。在這幾個小時裡,任何一件平凡的事都可能發生——deploy 重啟了那個 worker process、Kubernetes 把 pod evict 掉、機器 OOM、上游 API 限流回 429 讓你的 retry 邏輯卡住、或者單純就是有人 Ctrl-C 了那個 script。

現在的關鍵問題是:重啟之後,你站在哪?

如果你的工作流沒有把進度持久化,答案是「回到原點」。你已經花掉三小時、燒掉幾萬次 embedding API 呼叫(那是要付錢的),但因為 process 死掉時記憶體裡的進度也跟著沒了,你只能從第一份文件重新撈、重新 embed、重新 upsert。更糟的是,如果 upsert 不是 idempotent 的,重跑還會製造重複資料。

工程師對這個問題不是沒有對策——對策恰恰是問題所在。傳統做法是手動把進度寫進一張 status 表:每處理完一批就 UPDATE 一下 processed 旗標,重啟時先 SELECT 出「還沒處理的」再繼續。這能動,但它把一個本來是「一條工作流」的東西拆散成好幾塊——工作流邏輯散落在 SQL、worker、queue、dashboard、status 表之間。pg_durable 的 README 用一句話點出這種碎裂:「the workflow logic ends up spread across SQL, workers, queues, dashboards, and status tables。」你要自己想清楚哪些 state 要存、什麼時候存、重啟時怎麼從存下的 state 重建記憶體裡的執行進度。這套手工容錯,每個團隊都重寫一遍,每次都漏掉一兩個 edge case。

這套手工容錯還有個更隱蔽的問題:它把「正確性」綁在工程師的記憶力上。哪些 state 該存、存的時機在 commit 之前還是之後、重啟時 SELECT 的條件要不要考慮「上次 crash 剛好卡在 UPDATE 與 COMMIT 之間」這種 race——每一條都是一個可能寫錯的判斷。寫對了,下一個接手的人改了 status 表的某個欄位語意,又可能默默破壞 recovery 的假設。這種「散落且互相依賴」的容錯邏輯,是後端系統裡最難測、最容易在半夜噴掉的那一類程式碼。

durable execution 就是在回答這個問題:能不能讓「容錯地接續執行」變成 runtime 的內建能力,而不是每個工作流各自手刻的 status 表?讓「切 step、存進度、重啟接續」這套機制只實作一次、被驗證一次,然後所有工作流共用——而不是每個團隊在每個專案裡各自重新發明、各自踩同一批坑。

下面這個 widget 先把「重跑代價」這件事量化給你看。一條六步的工作流,假設它在第 N 步 crash。把滑桿拖到不同的 N,看兩種世界的差別:沒有 durable execution 時,crash 等於整條重跑(浪費掉 N 步已完成的工作);有 durable execution 時,每步之間都 checkpoint,重啟只從上一個 checkpoint 接續,浪費趨近於零。

拖動滑桿改變 crash 發生的步數 · 6 步工作流

N = 4
non-durable 重跑浪費:4 durable 接續浪費:0
六步工作流 crash NON-DURABLE 從頭重跑 DURABLE 從上個 checkpoint 接續 橙色長度=白做的工作;durable 的橙色幾乎為零,因為每步之間都已 checkpoint。
六步各佔 100 個 viewBox 單位。non-durable 重跑浪費=crash 前已完成的步數;durable 浪費=0(最多重跑當前那一步未 checkpoint 的部分)。

六步各佔 100 個 viewBox 單位

durable execution 讓 crash 後重跑浪費從 N 步降為零。

把滑桿拖到 N=6 最能說明問題:在沒有 durable execution 的世界,一條跑到第六步才掛掉的工作流要從第一步整條重來,前五步全部白做。durable execution 把這個 N 壓成 0——這就是它存在的理由。接下來我們把「durable execution」這個概念本身拆開。

durable execution 是什麼——把工作流的進度當成可持久化的狀態

放下 pg_durable 一下,先把概念講清楚,因為下面所有東西都建立在它上面。

一個普通的 function 跑起來,它的執行進度——目前跑到第幾行、local 變數的值、call stack——全部活在記憶體裡。process 一死,這些進度跟著蒸發,沒有任何辦法「從中間接回去」,你只能重新呼叫一次,從頭跑。

durable execution 的核心想法是:把這個「執行進度」從易失的記憶體搬到持久化的儲存上。具體做法是把工作流切成一連串明確的 step,每完成一個 step,就把「這個 step 完成了、它的 output 是什麼」寫進持久化儲存——這個寫入動作就叫 checkpoint。當 process 死掉重啟,runtime 不從頭跑,而是先去讀那份持久化的執行紀錄,看到「step 1、2、3 已經完成,output 分別是這些」,於是直接跳到 step 4 接續。

用一句話講:durable execution=把工作流的進度做成 durable state,重啟時 replay 已存的進度而不是重跑。

這裡有兩個容易混淆的點要先標記清楚。

第一,durable execution 不是「把整個工作流包進一個資料庫 transaction」。一條跑好幾小時、中間還要呼叫外部 API 的工作流,根本不可能塞進一個 transaction——你不會想 hold 一個 lock 三小時,外部 API 呼叫也不在資料庫的 transaction 邊界裡。durable execution 的粒度是 per-step:每個 step 各自完成、各自 checkpoint,step 與 step 之間是獨立的持久化邊界,不是一個大 transaction。

第二,durable execution 要求 step 之間的執行是可重放的(replayable)。重啟時 runtime 要能根據已存的 output「重建」到 crash 前的執行位置。這要求 step 的結果是確定性的,或者把不確定的部分(時間戳、隨機數、外部 API 回應)本身也 checkpoint 起來,replay 時讀回存下的值而不是重新求值。pg_durable 底層的 duroxide runtime 提供的正是「deterministic replay」——這四個字是整套機制能成立的地基。

還有一個工程後果值得先放進腦子:因為 step 之間是 replay 邊界,step 應該盡量設計成 idempotent。replay 的本質是「已 checkpoint 的 step 不重做、未 checkpoint 的 step 從頭重做」,但「未 checkpoint」可能意味著那個 step 其實已經對外造成了副作用——例如 embedding API 已經被呼叫、錢已經付了,只是回應還沒寫進 checkpoint 就 crash 了。runtime 重啟時會把這個 step 當成「沒做過」再跑一次。如果這個 step 是 idempotent 的(重複呼叫結果一樣、不會重複扣款),重做沒問題;如果不是,你會得到重複的副作用。durable execution 幫你接住了「進度」,但「副作用的 exactly-once」仍然要靠 step 本身的設計,這是任何 durable execution 系統都共有的邊界,不是 pg_durable 獨有的。

這套想法不是 pg_durable 發明的。Temporal、DBOS、AWS Step Functions、Azure Durable Functions 都是 durable execution 的實作。它們的共通點是:有一個 runtime 負責切 step、checkpoint、replay;有一個持久化後端存執行紀錄。它們的差異,在於那個持久化後端跟你的應用資料的關係——這正是 pg_durable 切進來的角度。

狀態存在哪——duroxide.* 與 df.* 兩層 schema

傳統的 durable execution 平台(以 Temporal 為代表)是一個獨立的服務:你的應用透過 SDK 連到 Temporal server,Temporal 把工作流狀態存在它自己的後端(通常是 Cassandra 或一個專屬的 PostgreSQL/MySQL 實例)。你的業務資料在你的資料庫,工作流狀態在 Temporal 的資料庫,兩邊是分開的。

pg_durable 的選擇剛好相反:它把工作流狀態存進你已經在用的那個 PostgreSQL,跟業務資料同一個資料庫實例。它用 pgrx——一個讓你用 Rust 寫 PostgreSQL 擴充的框架——把整套 runtime 編譯成 extension,跑在資料庫 process 內部。執行狀態就活在資料表裡,分成兩層 schema。

df.* schema 是給你用的那一層。`CREATE EXTENSION pg_durable` 之後,你會看到 `df.instances`(每一筆是一個正在跑或已完成的工作流實例)、`df.nodes`(每一筆是工作流裡的一個 step / 執行圖上的一個節點)、`df.vars`(step 之間傳遞的具名變數,例如 `|=>` 命名出來的那些)。重點是這些是普通的 PostgreSQL 資料表——你可以在工作流跑到一半或跑完之後,直接 `SELECT` 出它的狀態與結果,不需要連到任何外部 dashboard。

duroxide.* schema 是 runtime 自己的內部記帳,由底層的 duroxide-pg 這個 state provider 擁有,存的是 orchestration runtime 的低階狀態——history、work queue、sub-orchestration 的關聯等等。一般情況下你不會直接碰它,但它跟 df.* 一樣只是 PostgreSQL 的表。

下面這張圖把「一個 crash 之後 runtime 靠哪幾張表把自己拼回來」攤開——這是理解 in-database durable execution 的核心:所有重啟需要的 state,都是同一個資料庫裡的 row。

durable 狀態分布的五張表 · 2 層 schema

SCHEMA: df.* —— 你 SELECT 得到的那層 df.instances 一列=一個工作流實例 instance_id · status running / completed / failed df.nodes 一列=執行圖一個 step 已完成的 step + 其 output replay 從這裡接續 df.vars step 之間傳遞的具名變數 |=> 命名出來的 $batch 等 受 RLS 保護 SCHEMA: duroxide.* —— runtime 內部記帳(duroxide-pg 擁有) duroxide history orchestration 的低階事件序列 deterministic replay 的依據 duroxide work queue background worker 撿工作的佇列 timers / sub-orchestration crash 重啟=SELECT 這五張表,拼回上次的執行位置——全部在同一個資料庫實例內。 df.* 上的 RLS 讓每個 user 只看得到自己的 instances 與 nodes;worker 角色須為 superuser 以 bypass RLS。
df.* 是面向使用者的 DSL 表,duroxide.* 是 runtime 自己的底層狀態。兩層都是普通 PostgreSQL 表,這是 in-database 路線跟外部 orchestrator 最根本的差別。

df.* 是面向使用者的 DSL 表,duroxide.* 是 runtime 自己的底層狀態

crash 重啟所需的全部狀態分布在同一個 PostgreSQL 實例的五張普通資料表裡。

這個設計帶來一個外部 orchestrator 很難給的東西:工作流狀態跟你的業務資料在同一個 transaction 邊界與同一份備份裡。你 pg_dump 一次,工作流的進度跟業務資料一起被備份;你做 point-in-time recovery,兩者一起回到同一個時間點。工作流的 output 你可以直接 JOIN 業務表去查,不用跨服務對帳。「close to data」這四個字是這整個路線的賣點。

順帶把「為什麼 checkpoint 要落在每一步之間、而不是更細或更粗」也想清楚。checkpoint 太粗(例如整條工作流跑完才寫一次)等於沒有 durable execution——crash 就全沒了。太細(例如每個 SQL row 都 checkpoint 一次)則 checkpoint 的寫入開銷會吃掉工作流大半的時間,因為每次 checkpoint 都是一次資料庫寫入。pg_durable 選的粒度是「一個 DSL step」——這是個務實的折衷:step 是工作流裡語意上的最小可重做單位,也通常是外部副作用(一次 API 呼叫、一批 UPDATE)的自然邊界。你切 step 的方式,直接決定了 crash 時最多浪費多少工作、以及平時要付多少 checkpoint 開銷,這是用 pg_durable 時最該想清楚的設計決定。

權限模型值得提一句,因為它影響你怎麼把 pg_durable 接進 multi-user 的應用。`CREATE EXTENSION` 之後不會自動把權限授給 `PUBLIC`,admin 要顯式呼叫 `SELECT df.grant_usage('app_role')` 才開放。df.* 上掛了 row-level security,每個 user 只能看到並管理自己的 durable function instance 與 node。而那個在背景跑工作的 worker 角色(由 GUC `pg_durable.worker_role` 設定,預設 `azuresu`)必須是 superuser——因為它得 bypass RLS 才能管理所有 user 的 instance。

怎麼用——|=> 命名、~> 串接,與 background worker 的 checkpoint 迴圈

到這裡你知道狀態存在哪了。現在看 pg_durable 實際長什麼樣,以及那個 background worker 在每一步之間做了什麼。

pg_durable 的 DSL 不是另開一個檔案寫 YAML,而是用 SQL 字串加上幾個 operator 把 step 串起來,整條餵給 `df.start()`。最小的例子就是開頭那個批次匯入:

SELECT df.start(
    'SELECT id FROM documents WHERE processed = false LIMIT 100' |=> 'batch'
    ~> 'UPDATE documents SET processed = true WHERE id = ANY($batch)'
);

拆開看這幾個 operator——它們是這套 DSL 的全部語法重心:

  • |=>(named step):把左邊那個 SQL step 的結果命名。上例把 `SELECT id ...` 的結果命名成 `batch`,後面就能用 `$batch` 引用。命名出來的變數存進 `df.vars`。
  • ~>(sequencing):把兩個 step 串成「先做這個、再做那個」的順序。上例是先撈出 batch、再用 `$batch` 去 UPDATE。每個 `~>` 之間,就是一個 checkpoint 邊界。
  • df.if() / df.join() / df.loop():控制流。`df.if()` 做條件分支、`df.join()` 把 fan-out 出去的多個獨立 step 結果合併(例如平行跑幾個 query 再 join)、`df.loop()` 做重複。這三個讓工作流不只是直線,而是真的執行圖。

`df.start()` 回傳一個 instance ID,並開始執行。注意它「開始執行」的方式不是同步把整條跑完才 return——它在 `df.instances` 寫一筆 running 的 row,把工作丟進 duroxide 的 work queue,然後立刻 return 那個 instance ID。真正的執行交給 background worker。

這個 background worker 是 pgrx 註冊的一個常駐 process(這也是為什麼要把 `pg_durable` 加進 `shared_preload_libraries` 並重啟資料庫——background worker 必須在資料庫啟動時就掛上去)。它的迴圈大致是:從 work queue 撿一個待執行的 instance、執行下一個 step、把 step 的 output 與「這個 node 完成了」寫進 `df.nodes`(這就是 checkpoint)、更新 instance 狀態、再撿下一個。關鍵在於 checkpoint 落在每一步之間——README 的講法是 runtime「executes and checkpoints as it goes」。

於是 crash recovery 的故事變得很簡單:worker process(或整個資料庫)掛掉重啟後,新的 worker 從 work queue 撿回那些還沒做完的 instance,去 `df.nodes` 看哪些 step 已經 checkpoint 過、output 是什麼,replay 到 crash 前的位置,然後從下一個還沒做的 step 接續——「execution resumes from the last durable checkpoint instead of making you reconstruct state by hand」。你不用手刻 status 表、不用想 replay 邏輯,這些是 runtime 的內建行為。

這裡有個常被忽略但很關鍵的細節:work queue 與 checkpoint 都是 PostgreSQL 的 row,意味著 worker 撿工作、執行 step、寫 checkpoint 這一連串動作,可以包在資料庫的 transaction 語意裡。「step 的業務寫入」與「這個 step 已完成的 checkpoint 記號」能在同一個 commit 裡落地——這正是手刻 status 表最容易出錯的地方(業務寫入 commit 了、status 旗標還沒更新就 crash,重啟後分不清這步到底做完沒)。把工作流狀態跟業務資料放進同一個資料庫,讓「進度記號」與「實際效果」能原子地一起持久化,是 in-database 路線在正確性上一個不明顯但實在的好處。

對於要跨出 SQL 的 step,pg_durable 留了逃生口:`df.http()` 讓一個 step 去呼叫外部 HTTP endpoint。這是它接住「向量 pipeline 要呼叫 embedding API」「維運任務要打 Slack 通知」這類需求的方式——把外部呼叫包成一個 step,它的回應一樣被 checkpoint,replay 時讀回存下的回應而不是重打一次。但這也劃出了表達力的天花板:能自然表達成 SQL step 或 HTTP 呼叫的工作流它接得住,要跑一段任意 Rust/Go/Python 業務邏輯的,就得想辦法包成 SQL function 或自架一個 HTTP endpoint 給它打——這個扭曲程度,後面談取捨時會再回來。

稍微完整一點的工作流會用上控制流。下面這段把「撈批次、平行 embed 兩個來源、join 起來、條件性通知」寫出來,讓你看到 `df.if` / `df.join` / `df.loop` 在語法裡的位置(這是示意寫法,operator 是真的,欄位是編出來的):

SELECT df.start(
  'SELECT id FROM documents WHERE processed = false LIMIT 100' |=> 'batch'
  ~> df.loop('batch',
       'SELECT embed($item) AS vec'  |=> 'embedding'
       ~> 'INSERT INTO vectors(doc_id, vec) VALUES ($item, $embedding)'
     )
  ~> df.join(
       'SELECT count(*) FROM vectors'       |=> 'n_vec',
       'SELECT count(*) FROM documents'     |=> 'n_doc'
     )
  ~> df.if('$n_vec = $n_doc',
       // then:全部完成,標記並通知
       'UPDATE documents SET processed = true WHERE id = ANY($batch)'
       ~> 'SELECT notify_done($n_vec)',
       // else:有缺口,留給下一輪 retry
       'SELECT log_gap($n_doc - $n_vec)'
     )
);

這段每一個 ~> 都是一道 checkpoint 邊界。`df.loop` 裡每跑完一個 item 也 checkpoint,所以一條 embed 十萬份文件的迴圈跑到第八萬份掛掉,重啟是從第八萬份接,不是從第一份。下面這個 tab widget 把一個 instance 的生命週期攤成四個階段,讓你看 `df.nodes` 與 `df.instances` 的 row 在 start、checkpoint、crash-resume、complete 各階段怎麼變。

df.start() 寫一筆 running 的 instance,丟進 work queue 後立刻 return instance_id。此刻 df.nodes 還是空的。

df.instances
 instance_id | status   | created_at
 a1f3...     | running  | 12:00:01

df.nodes
 (空——還沒有任何 step checkpoint)

background worker 撿起 instance,跑完 step 1(撈批次),把 output 寫進 df.nodes——這一行就是 checkpoint。

df.nodes
 node_id | instance_id | step    | status | output
 n1      | a1f3...     | batch   | done   | {12,17,23,...}

df.vars
 instance_id | name  | value
 a1f3...     | batch | {12,17,23,...}

step 2 跑到一半 process 被 kill。重啟後新 worker 撿回 instance,SELECT df.nodes 看到 n1 已 done,於是 replay 到 step 2 重做,不碰 step 1。

// 重啟後 worker 讀回的狀態
df.nodes
 node_id | step  | status   // n1 已 checkpoint,跳過
 n1      | batch | done

// 從 step 2 接續,沒有重撈 batch
 n2      | update| running

所有 step checkpoint 完成,instance 狀態翻成 completed。你直接 SELECT 就拿到結果,不需要外部 dashboard。

df.instances
 instance_id | status    | completed_at
 a1f3...     | completed | 12:08:44

df.nodes
 node_id | step   | status
 n1      | batch  | done
 n2      | update | done

四個階段看下來,重點就一句:每一刻的「執行進度」都不是記憶體裡的東西,而是 `df.nodes` 裡可以 SELECT 出來的 row。階段 3 的 crash-resume 之所以不重做 step 1,純粹是因為 `df.nodes` 裡 n1 那行寫著 done——recovery 不是魔法,是一次 SELECT。

跟外部 orchestrator 的取捨——in-database 換掉了什麼基礎設施

講完機制,該談取捨了。pg_durable 不是要取代 Temporal——它是在一個特定的軸線上做了相反的選擇,這個軸線值得你親眼看一下。把下面的分隔線從左拖到右,左邊是用外部 orchestrator 跑 durable execution 通常要養的那一堆東西,右邊是 in-database 版本剩下的東西。

外部 ORCHESTRATOR 路線 你的應用 + SDK Temporal server 專屬狀態後端 (C*/PG) worker fleet 你的業務 PostgreSQL 五個要部署、監控、對帳的元件 IN-DATABASE 路線 一個 PostgreSQL + pg_durable 擴充 · 業務資料 · df.* / duroxide.* 狀態 · background worker · DSL 工作流定義 no Redis · no Temporal · no 外部服務

互動圖表

in-database 路線把五個獨立服務壓成一個 PostgreSQL 擴充,無須 Redis 或 Temporal。

這張圖把 in-database 的賣點講白了:少了一整層獨立服務的部署、監控、版本升級與跨服務對帳。對一個團隊規模不大、本來就跑著 PostgreSQL、工作流量不到需要獨立 orchestrator 的應用來說,這個減法很有吸引力——README 直接點名「no Redis, no Temporal, no external services」。

但減法是有代價的,而且代價就藏在「跑在資料庫內部」這件事本身。外部 orchestrator 的存在不只是歷史包袱——它把工作流的計算負載隔離在資料庫之外。pg_durable 的 background worker 跟你的 OLTP query 共享同一個資料庫實例的 CPU、記憶體、connection、I/O。一條 CPU 密集或大量 fan-out 的工作流,會跟你的線上交易搶資源。外部 orchestrator 用「多一層基礎設施」換來的,正是這層隔離與獨立 scale 的能力。pg_durable 用「省掉那層基礎設施」換來的,是放棄這層隔離。

另一個取捨在 step 的表達能力。pg_durable 的 step 是 SQL(外加 `df.http()` 之類的逃生口呼叫外部 HTTP)。Temporal 的 activity 是任意程式碼——你的 Go / Java / Python function。如果你的工作流每一步都是「呼叫這個微服務、跑這段業務邏輯、再呼叫那個」,那些邏輯不會自然地表達成 SQL step,硬塞進 SQL function 或 HTTP endpoint 反而比用一個吃任意程式碼的 orchestrator 更扭曲。

第三個取捨是版本演進。長時間執行的工作流有個經典難題:一個實例可能跑好幾天,期間你部署了新版本,改了工作流的 step 順序或某個 step 的邏輯。對一個正在 replay 舊實例的 runtime 來說,「已 checkpoint 的舊 step」與「新版本定義的 step」對不上,replay 可能失敗或行為錯亂。Temporal 這類成熟平台為此發展出整套 versioning 與 determinism 檢查的機制。pg_durable 把工作流定義寫在 SQL DSL 裡、狀態寫在表裡,這個演進問題不會因為搬進資料庫就消失——你仍然要想清楚「正在飛的實例」遇到工作流定義變更時怎麼辦。評估任何 durable execution 方案時,這條長尾的維運成本,往往比第一天的部署便利更決定它能不能長期跑得住。

把三個取捨擺在一起看,它們其實指向同一個權衡軸:pg_durable 用「貼著資料、少一層基礎設施」換掉了「計算隔離、任意程式碼、以及成熟平台累積的 versioning 工具」。這不是優劣,是定位——它把 durable execution 的入門成本壓到「裝個擴充」的等級,代價是把一部分原本由獨立服務承擔的複雜度,搬回到你的資料庫與你的工作流設計裡。

哪些場景不該用——in-database durable execution 的邊界

最後把「不適合」的場景講清楚,因為一個工具的價值,一半在於知道什麼時候別用它。README 自己列了一組 non-goals,下面這四個是最常見的誤用方向。點任一張卡片看為什麼。

四種不該套 pg_durable 的場景

四種不該套 pg_durable 的場景 單一 INSERT ... SELECT no-benefit 次毫秒 同步請求 latency 禁裝擴充 的環境 no-ext 跨異質 系統工作流 spread

點任一場景看為什麼不適合

單一 INSERT ... SELECT

durable execution 的價值來自「多步之間可 checkpoint」。如果整個工作本身就是一條原子 SQL——撈出來、轉換、寫回去全在一個 statement——它要嘛成功要嘛 rollback,沒有「跑到一半」的中間狀態需要被接住。套上 pg_durable 只是多一層記帳開銷,買不到任何容錯。

次毫秒同步請求

pg_durable 明確為背景、長時間執行設計。df.start() 把工作丟進 work queue 交給 background worker,這套排程加上每步 checkpoint 的寫入,本身就有毫秒級 overhead。放在一個要求次毫秒回應的同步 API 熱路徑上,這層 overhead 是淨損失——這類請求要的是快,不是可重啟。

禁裝擴充的環境

pg_durable 是用 pgrx 寫的擴充,且需要把自己加進 shared_preload_libraries 註冊 background worker。部分託管 PostgreSQL 不允許安裝任意擴充,或不開放 background worker——在這些環境整套機制根本掛不上去,你只能回到外部 orchestrator 或自己手刻 status 表。

跨異質系統的工作流

當工作流的大部分其實活在 Postgres 之外——橫跨多個微服務、message broker、第三方 API、各種異質系統——把編排中樞塞進其中一個資料庫是錯位的。這種拓樸正是外部 orchestrator 的主場:它本來就站在所有系統之外做協調,不偏袒任何一個資料庫。

互動圖表

單一 SQL 語句、毫秒級同步呼叫、不能裝擴充的環境,都不適合用 pg_durable。

把這四個場景反過來讀,就是 pg_durable 的甜蜜點:多步、會跑一段時間、中間有外部呼叫或可能 crash、而且工作流的資料重心本來就在這個 PostgreSQL 裡。README 點名的幾類正好落在這裡——向量 pipeline(chunk、embed、upsert)、ingest 工作流(stage、dedup、transform、publish)、需要等人核可的維運任務、fan-out 後 join 的平行聚合。這些任務的共通特徵,就是「重跑代價高」與「資料已經在庫裡」同時成立。

也別把 pg_durable 讀成「Temporal 殺手」。它是 durable execution 光譜上一個刻意偏向 in-database 的點:用放棄計算隔離與任意程式碼表達力,換掉一整層獨立基礎設施。當你的工作流剛好落在它的甜蜜點,這個交換非常划算;當它落在四個 non-goal 任何一個附近,這個交換就開始虧。工程判斷不在於哪個工具比較強,而在於你的工作流站在這條光譜的哪一段。

Take-away:durable execution 就是把工作流的進度做成可 SELECT 的 durable state;pg_durable 把那份 state 直接放進你的 PostgreSQL,crash recovery 不是魔法,是 background worker 對 df.nodes 的一次 SELECT。