Skip to Content
核心概念觸發器排程觸發器

排程觸發器與 run-due-triggers

Schedule trigger 不等待資料列寫入,而是由平台的 root scheduler 定期掃描「現在已到期」的定義並建立普通 trigger runs。Actions、run observation 與 retry 都沿用事件型 trigger;差別只在 run 如何被建立。

自 schedule protocol V2(backend 5.8.1)起,每一次觸發都有一個持久的 identity — record 上的日期值,或 table-level trigger 的時鐘視窗 — 在 run 插入的那一刻就寫進 occurrence ledger。本頁大多數規則都由這件事推導出來:一個 identity 最多觸發一次、失敗的 run 仍佔住它的 identity、而 scheduler 不再靠 run log 判斷什麼到期。

四種 schedule

date_column_reached

{ "name": "到期提醒", "on": "schedule", "schedule": { "type": "date_column_reached", "column": "到期時間", "offset_minutes": -1440 }, "when": [ { "column": "狀態", "op": "neq", "value": "完成" } ], "actions": [ { "type": "notify", "chatroom_id": "11111111-1111-4111-8111-111111111111", "message": "案件明天到期:$row.案件名稱" } ] }

column 必須是 date 或 datetime。每個符合 when 且時間已到的 record 建立一個 run,payload 含該列 snapshot。空白儲存格不會符合任何 when 運算子(neq 也一樣),所以這個範例也會略過 狀態 為空白的案件。

offset_minutes 會相對於 cell 值平移實際觸發時刻。當 column_value + offset_minutes 早於或等於 tick 時間時,該列即到期:

欄位型別限制意義
offset_minutesinteger−43200 到 43200(±30 天)負值在 column 時刻之前觸發(T-minus 提醒;-1440 就是 T-24h)。正值在其之後觸發(寬限期;+15 是 no-show 掃描)。

布林值會被明確拒絕 — 否則 True == 1 會混過去 — 因此送 checkbox 或字串會得到 400,而不是被默默忽略:date_column_reached offset_minutes must be an integer in [-43200, 43200] (negative = before the column instant, positive = after)。

Identity 以「值」為單位。 觸發 identity 是 (trigger, record, 正規化後的日期值) — date 欄位是 YYYY-MM-DD,datetime 欄位是 YYYY-MM-DD HH:MM:

  • 同一個值最多觸發一次,不論那次 run 成功或失敗。
  • 該 record 在這個 trigger 下從未見過的值會重新武裝它:把到期日往後改,提醒就會為新日期再觸發一次。
  • 改回已經觸發過的值不會再觸發。
  • offset 不屬於 identity。修改 offset_minutes 只改變值何時到期,不會重播 trigger 已經消耗過的值。

在已有歷史資料的表上新增 T-minus offset 會大量觸發。 每一筆早已過期、且目前的值從未觸發過的歷史列都會各產生一個 run——一次 tick 在第一個含到期列的 cursor 頁(record_limit 列,預設 1000)後停止,之後的 tick 逐頁繼續——系統沒有 skip-if-overdue 保護。新增 offset trigger 時,務必同時加上排除已結案列的 when(例如 Status != 'Done')再儲存。

兩段提醒 = 兩個 trigger。 offset_minutes 無法在單一 trigger 內表達階梯式提醒(同時 T-24h 與 T-1h),因為一個值在同一個 trigger 下只觸發一次。每一階請各寫一個 trigger,各自維護自己的 identity ledger。

要重新觸發,就改日期。 落在 failed 的 run 仍然佔住它的值;下一次 tick 不會再重建它。復原方式是 moderator 的 retry 端點(同一個 run、同一個 identity),或是真正的新值。

date_column_reached 沒有可宣告的 timezone 欄位,但若已設定公司業務時區,求值會使用該時區。公司時區為 null 時,仍沿用 naive UTC 解析:去掉尾端 Z,捨棄 cell 的明確 offset 而不換算,只有日期的 cell 錨定在 00:00 UTC。不能把這項舊限制套用到已啟用公司時區的情況。daily 與 cron 還可明確指定 schedule 時區,優先於公司設定。

interval

{ "on": "schedule", "schedule": { "type": "interval", "every_seconds": 3600 }, "actions": [{ "type": "webhook", "url": "https://ops.example/hourly" }] }

every_seconds 是 60 到 2,592,000 秒。這是 table-level run,沒有來源 record,因此 record_id 為 null,不要在 action template 假設一定有 $row 值。儲存 trigger 的那一刻就是它的第一個視窗,所以新的 interval schedule 會在下一次 tick 立即觸發;之後的視窗是 anchor + n × every_seconds。

daily

{ "on": "schedule", "schedule": { "type": "daily", "at": "01:30", "timezone": "Asia/Taipei" }, "actions": [{ "type": "webhook", "url": "https://ops.example/daily" }] }

at 是 24 小時制的 HH:MM。timezone 是可選的 IANA 時區名稱:有設定時,at 會被解讀為該時區的當地牆上時間;沒設定時 at 使用公司業務時區,公司設定為 null 才沿用 UTC。驗證透過 zoneinfo fail-closed:

  • 非字串是 400 daily schedule timezone must be an IANA name string (e.g. 'Asia/Taipei')。
  • 無法解析的時區是 400 daily schedule timezone '<tz>' is not a known IANA zone. use an IANA name like 'Asia/Taipei' or 'America/New_York'。

daily trigger 錨定在當地當天的開始,因此在當地時間 10:00 儲存一個 daily 01:30 的 trigger,仍會立刻先觸發一次(今天的時段已經過去),隔天 01:30 再觸發一次 — 與先前的首次觸發行為相同。

cron

{ "on": "schedule", "schedule": { "type": "cron", "expression": "0 9 * * 1-5", "timezone": "Asia/Taipei" }, "actions": [{ "type": "webhook", "url": "https://ops.example/weekday-morning" }] }

expression 是嚴格五欄位的 cron 字串 — minute、hour、day-of-month、month、day-of-week — 不接受其他形式:

接受拒絕(400 cron schedule invalid: …)
十進位數值、*、逗號清單、閉區間範圍(1-5)、正整數步進(*/15、8-18/2)秒或第六個欄位、@daily 之類的 macro、月份/星期名稱、day-of-week 7、?、L、W、#、反向範圍、對單一數值加步進

範圍是 minute 0–59、hour 0–23、day-of-month 1–31、month 1–12、day-of-week 0–6,星期日 = 0。day-of-month 與 day-of-week 同時受限時,任一符合即觸發(POSIX/Vixie 語意),所以 0 9 15 * 5 是「每月 15 日,以及每個星期五」。欄位間重複的空白會在儲存時被收斂;authored 的清單與範圍則原樣儲存。IaC differ 不會收斂空白,所以文件裡請寫單一空白的表示式——含連續空白的行在每次 apply 後都會重新 plan 成 update。

timezone 與 daily 完全相同:可選 IANA 名稱,未設定時使用公司業務時區(公司設定為 null 才沿用 UTC),明確的 "UTC" 會被正規化掉。精度是一分鐘,與 tick 契約一致。春季前移當天不存在的當地分鐘永遠不會觸發;秋季回撥當天出現兩次的分鐘只觸發一次(第一次出現)。

cron 錨定在儲存的那一刻,永遠不會補跑在那之前已經過去的視窗 — 在 10:00 建立的 0 9 * * * trigger,第一次觸發是明天 09:00。

cron 是 recordless,而且比 interval/daily 更嚴格:

  • when 會被整個拒絕:cron_when_not_allowed: cron schedules are recordless and take no when predicates。
  • actions 中任何位置的 $row token,以及以 $row 為目標的 update_record,都是 400 triggers[<i>]: cron schedules are recordless — '$row' tokens and row-targeted actions are invalid. use date_column_reached for row-scoped scheduling。delete_record action 與 invoke_command 的 $row inputs 會更早失敗,訊息是下方引用的 interval/daily 版本。
  • materialize_slots 仍只允許在 daily 上。

無法辨識的型別回 invalid schedule type '<x>'. valid types: date_column_reached, interval, daily, cron。

顯式預設值合法,但不會被存下來

offset_minutes: 0 與 timezone: "UTC"(daily 與 cron 皆然)都做 omit-when-default 正規化:寫入時會被接受,然後被丟棄,所以持久化的 schedule 根本沒有這個 key,GET .../triggers 也不會回顯它們。沒有 key 就代表預設值。

這對 IaC 收斂很重要。Differ 在比較之前會對 authored 文件套用同一套省略規則,因此一份合法地寫死 offset_minutes: 0 或 timezone: "UTC" 的 JSONL trigger 行會 plan 成 noop(也仍可透過結構相等被 adopt),而不是每次 apply 之後又永遠重新 plan 出 action: "update"。在文件裡寫出顯式預設值是安全的。

布林值 的 offset_minutes 刻意不被正規化掉;它會一路保留下來,成為它本來就該是的 apply-time 驗證錯誤。

Export 會把 schedule 以正規化後的子字典整包輸出。只有 schedule 的 column 子 ref 會做 column token 翻譯;offset_minutes、timezone 與 expression 是與環境無關的純值,因此既有的匯出 bundle 與正規化後的形式位元組相同。

更改 schedule

每個 schedule trigger 都擁有一個以 trigger id 為鍵的持久 state row。哪些修改會保留這個 identity:

修改效果
其他任何欄位(name、when、actions)identity 不受影響
date_column_reached:更改 column 或 offset_minutes輪替掃描的 cursor 重新開始;已消耗的值仍然是已消耗
interval/daily/cron:更改 every_seconds、at、expression 或 timezone視窗從新的 anchor 重新起算;已經在執行的 run 依其凍結的定義跑完
在同一個 trigger id 下更改 schedule.type拒絕:400 schedule_kind_change_requires_new_trigger_id: trigger '<id>' changes its schedule type in place. retire the old trigger id and create the new schedule kind under a new trigger id
移除 trigger它的 state 與 ledger 會保留(退役);再次加入同一個 id 會讓它們復活

kind-change 規則存在的原因是:date cursor 與時鐘視窗無法共用同一個 identity。在 IaC 中,請把舊 trigger 行標為 state: "absent"(plan:delete),並以新的 ref 在另一行宣告新 kind(plan:create)。移除某一行不會刪除任何東西、trigger id 由伺服器產生,而沿用舊 ref 會保留舊 id,apply 就會以 kind-change 錯誤失敗。

同時只跑一個 run,只補跑一個視窗

Table-level schedule(interval、daily、cron)受兩條規則管理。

每個 trigger 最多一個 active run。 當該 trigger 的某個 run 仍在 pending 或 running 時,後續視窗不會被插入或排隊。那個 run 進入 done 或 failed 之後,下一次 tick 才計算最新的一個到期視窗,並最多為它插入一個 run。因此緩慢的 action 會延後下一個視窗,而不是把 run 疊起來;若無法接受,請縮短 action 或拆分 trigger。

停機會被合併。 scheduler 暫停之後,只會補跑最新一個符合的視窗 — 一個 run,而不是每個錯過的視窗各一個,也永遠不會形成補跑佇列。每小時一次的 interval 停機五小時只會產生一個 run。

終端 failed 的 run 會消耗它的視窗;tick 不會重建它。重試該 run(見 trigger runs)會重新武裝「同時只有一個 run」的 fence,並依建立時凍結的定義重新執行同一個 run;當同一 trigger 仍有另一個 run 在執行時,重試會回 409。

本節內容都不適用於 date_column_reached:同一 trigger 的不同 record 可以平行執行,只有相同的 (trigger, record, 值) 才會被 fence。

DST 與 local-date 視窗鍵

讓 zoned schedule 保持確定性的是兩個各自獨立的機制。

在到期判斷處,daily 的目標時刻是以當地時間搭配 fold=0 建構的:春季前移時「不存在」的牆上時間、以及秋季回撥時「出現兩次」的牆上時間,都會解析到轉換前的 offset;因此轉換當天既不會跳過當日時段,也不會觸發兩次。cron 對重複出現的分鐘採同樣的 fold-0 規則,而對不存在的分鐘則直接跳過(cron 分鐘不會被重新解讀成較早的 offset)。

在入列處,zoned daily schedule 的去重鍵是當地日期(該時區的 %Y%m%d),不是 UTC 日期,所以跨越 UTC 午夜的兩次 tick 不可能產生兩個不同的鍵。interval 與 cron 的鍵精度到分鐘(%Y%m%dT%H%M)。這些鍵是傳輸層的保護;持久的保護是 occurrence ledger。

邊界日期會飽和,而不是拋錯

當 date/datetime cell 落在可表示範圍的邊緣 — 9999-12-31 23:59 搭配正 offset、0001-01-01 00:00 搭配負 offset — 平移後的時刻會溢位。在那裡拋錯會在 commit 之前中止整個 cron tick,影響所有表與所有租戶。因此觸發時刻改為確定性地飽和:

溢位方向夾到實際結果
正 offset最大 datetimefire_at 永遠在未來 — 該列實質上永遠不會觸發
負 offset最小 datetime已過期,因此立刻觸發 — 與未平移的古老日期結果相同

offset_minutes: 0 不可能溢位,所以上面依正負號的判斷已窮盡所有情況。單一租戶的垃圾邊界日期,再也無法讓全平台的排程自動化停擺。

Schedule 上的 when

when predicate 篩的是資料列,因此只有 date_column_reached 會評估它。它遵循 trigger when 的規則——包括 decimal 欄位必須用純十進位字串("12.50"),因為 JSON 數字可以存檔但永遠不會命中,該列會被計為 when_filter_mismatch。在 interval 或 daily schedule 上,when 在設定階段會被接受,但之後永遠不會被評估 — 因為根本沒有 record 可以評估;schedule preview 會以警告 when_ignored_for_recordless_schedule 回報這件事。在 cron 上則是儲存時直接拒絕。

Table-level run 帶 record_id: null 與空的 data snapshot,因此在 interval/daily run 上,notify 文字與 api_call 的 URL、header、body template 裡的 $row.<col> 會渲染成空字串,create_record/update_record data 中剛好等於 "$row.<col>" 的值會寫入 null,webhook 則直接 POST 空的 snapshot。有兩種 action 反而會明確拒絕:invoke_command 的 inputs(invoke_command input '<name>': "$row" tokens require record context — interval/daily schedule triggers fire with no record)與 delete_record(delete_record requires record context — interval/daily schedule triggers fire with no record)。cron 則在所有 action 上都拒絕 $row。

決定什麼到期的是 state row 與 occurrence ledger。run log 只作為混合版本的後備資料來讀取:最新的 table-level run 只能把時鐘視窗往後推、沒有 occurrence 的進行中 run(V2 之前寫入)會被當成 fence,而 V2 之前的日期 run 會在 occurrence 被認領前把該列標為已消耗。因此刪除 run log 不會重新武裝 schedule。run log 仍是觀察與重試的介面。

Root scheduler tick

Ops audience。 POST /root/custom-tables/run-due-triggers 是平台 scheduler/operator 端點,不是租戶前端 API。它需要 X-ADMIN-TOKEN 的 BaseRoot 權限;沒有的話 router dependency 會回 403,detail 為 BaseRoot access denied: Invalid X-ADMIN-TOKEN。請由受控排程器呼叫,不要把 root credential 交給瀏覽器或一般整合服務。

每次 tick 可用 table_limit(1..2000,預設 200)與 record_limit(1..10000,預設 1000)限制掃描量,回傳:

{ "tables_scanned": 200, "tables_with_schedules": 12, "runs_created": 37, "trigger_runs_requeued": 0, "staged_changes_requeued": 3 }

觸發精度取決於外部 tick 頻率(至少每分鐘一次),不是精確到秒的牆上時間。offset_minutes、daily at 與 cron 分鐘都繼承這個粒度;不要在上面建立秒級 SLA 邏輯。

Tick 重疊是安全的。候選 table(任何 triggers config 非 null 的 live table — 已丟到垃圾桶的表其 schedule 保持沉寂)以 SELECT … FOR UPDATE SKIP LOCKED 認領;在那之下,occurrence ledger 讓每個 identity 即使在兩次 tick 互相競爭時也只能被認領一次:重複的認領是冪等的 no-op,不會回滾同一次 tick 插入的其他新 run。

record_limit 是一頁 cursor page 的大小。每個 date_column_reached trigger 都保有自己的 cursor,遍歷依 created_at, id 排序的 live records;每次 tick 讀取 cursor 之後的下一頁(最多繞回開頭一次),在該頁內判定已觸發的列,並把 cursor 推進到最後檢視的那一列 — 即使沒有任何列到期。整頁都沒有到期列時,tick 會一口氣最多再推進 10 頁,並停在第一頁有到期列的地方。因此每一筆 live 列都會被輪替到 — 大表不再默默餓死新加入的列。沒有任何頁含到期列時,完整輪替一次需要 ceil(live_records / (10 × record_limit)) 次 tick;若每一頁都有到期列,則最多需要 ceil(live_records / record_limit) 次 tick,因為含到期列的頁會結束該次 tick。

trigger_runs_requeued 是針對「入列遺失」或「worker 崩潰」的 run 的復原掃描,staged_changes_requeued 是核准閘門的復原掃描;兩者每次 tick 都會執行,與 schedule 無關。即使某個掃描拋錯,tick 仍然成功並對它回報 0。

Tick 建立 pending runs 並重新排入工作佇列;它不等待 actions 完成。監控應追蹤 tick 自身成功率、runs_created 趨勢,以及後續 triggerRuns.list 的 failed/stuck 數量。當 runs_created 是 0 而你需要知道為什麼時,請用下方的 schedule preview,而不是把 tick 解讀成「cron 沒在跑」。

Schedule preview

GET .../tables/{table_id}/triggers/schedule-preview 是三種 scope 都有的 moderator 專用、唯讀診斷(triggers.schedulePreview)。它在請求當下執行 scheduler 自己的 evaluator,對每個 schedule trigger 回傳一個 arm — 永遠不會建立 run、認領 occurrence、推進 cursor 或派送工作,也永遠不會回傳 record id 或 cell 值。

{ "table_id": "22222222-2222-4222-8222-222222222222", "evaluated_at": "2026-08-27T05:02:13", "live_records": 2, "record_limit": 1000, "triggers": [ { "kind": "date_column_reached", "trigger_id": "trg_666666666666", "config_status": "valid", "records_scanned": 2, "already_fired": 1, "missing_schedule_value": 0, "invalid_schedule_value": 0, "scheduled_for_future": 1, "when_filter_mismatch": 0, "due_now": 0, "unscanned": 0, "truncated": false, "warnings": [] }, { "kind": "interval", "trigger_id": "trg_777777777777", "config_status": "valid", "last_consumed_window_at": "2026-08-27T05:00:00", "due_at": null, "due_now": false, "active_run": false, "warnings": ["when_ignored_for_recordless_schedule"] }, { "kind": "invalid", "trigger_id": "trg_888888888888", "stored_schedule_type": "monthly", "config_status": "invalid", "error_codes": ["unsupported_schedule_type"], "warnings": [] } ] }

回應是以 kind 區分的 discriminated union:

Arm它告訴你什麼
date_column_reachedscheduler 下一次會讀的那一頁 cursor page 內互斥的計數,依 scheduler 的順序分類:missing_schedule_value → invalid_schedule_value → already_fired → scheduled_for_future → when_filter_mismatch → due_now。不變量:records_scanned 是這六項之和,live_records = records_scanned + unscanned,truncated = unscanned > 0。
interval/daily/cronlast_consumed_window_at(從未觸發為 null;失敗的視窗仍算已消耗)、due_at(共用視窗計算選出的邊界,沒有時為 null)、due_now,以及 active_run(目前有 pending/running 的 run 佔住這個 trigger — 此時 due_now 為 false)。
invalidscheduler 會跳過的儲存 trigger,附 stored_schedule_type 與一個以上穩定的 error_codes:trigger_id_missing、trigger_id_duplicate、schedule_object_missing、stored_schedule_invalid、unsupported_schedule_type、schedule_column_missing、schedule_column_wrong_type、offset_minutes_invalid、interval_seconds_invalid、daily_time_invalid、cron_expression_invalid、cron_when_not_allowed、when_column_missing、when_predicate_invalid、timezone_invalid、schedule_state_missing。一個損毀的 trigger 永遠不會遮蔽其他正常的 trigger。

所有內容都是穩定的英文代碼或計數;在地化文案、排序與顏色由呼叫端負責。把 due_now 讀成「在 evaluated_at 這一刻符合資格」,而不是「已執行」— 同時進行的 tick 或仍在執行的 fence 仍可能讓真正的 scheduler 延後 — 也永遠不要只憑零筆到期列就推論「cron 沒在跑」。它的 dependency 是 CustomTableModeratorRequired,與每一種 trigger 寫入、run 列表及重試相同(GET .../triggers 使用更嚴格的 trigger 設定讀取閘門):表不存在或已在垃圾桶回 404 CustomTable Not found,自後端 5.21.0 起其他公司的表也是;你自己公司、所屬聊天室已被刪除的聊天室資料表回 404 Chatroom Not found;你所屬公司內非 moderator 的呼叫者回 403;而你身為 moderator、但位於路徑以外範圍的表,在部門路徑回 404 Table not found in this department,在公司路徑回 … in this company,在聊天室路徑回 … in this chatroom。

輪詢到終端狀態

run 列存在不等於 run 已結束。生命週期是 pending → running → done 或 failed,而終端狀態只有 done 與 failed:

狀態意義
pending已入列、尚未被取走 — 過渡狀態
runningworker 正在執行 actions — 過渡狀態
done終端,actions 完成
failed終端,附帶 error 字串
stuck不是儲存的狀態 — 這是列表過濾值,代表 failed 或 updated_at 超過 300 秒仍在 running(worker 掛掉)

等待排程 trigger 結果的呼叫者必須輪詢 GET .../trigger-runs,直到 status 為 done 或 failed。在 pending/running 的列上讀 action_results 只會拿到不完整或空的紀錄,看起來像假的失敗。

失敗可分成兩類。若 table 在入列與執行之間消失,run 會落在 failed,error 剛好是 trigger config no longer exists(刪除 trigger 對 schedule run 已不會造成這種失敗 — 它們帶著凍結的定義)。其他例外一律記為 failed,error 是例外訊息截斷至 1000 字元;worker 本身不會崩潰。而在 action_results 內,單一 action 的失敗收據截斷在 300 字元:{"type": <action type>, "ok": false, "detail": "…"}。

卡在 running 超過 300 秒的 run 會被視為 worker 崩潰:它變成可重試,並出現在 status=stuck 過濾結果中。對 done 的 run,或仍存活(未逾時)的 running run 呼叫 retry 是 409 run is <status>; only failed/pending (or stale-running) runs can be retried。

任何 trigger 都無法與 channel SCP 規則共存;任一側 authoring 都會被 400 拒絕,理由是 trigger 沒有 acting chatroom,因此無法滿足 channel floor。

產生可預約時段

daily schedule 也是 materialize_slots action 的唯一宿主 — 它會把班表資料掃成滾動 horizon 內具體、可預約的時段列。詳見產生可預約時段。

端點參數與 root auth 詳見 root.runDueTriggers。設定 trigger 仍透過 triggers.set 的 full-replace 流程。

Last updated on