排程觸發器與 run-due-triggers
Schedule trigger 不等待資料列寫入,而是由平台的 root scheduler 定期掃描「現在已到期」的定義並建立普通 trigger runs。Actions、run observation 與 retry 都沿用事件型 trigger;差別只在 run 如何被建立。
自 schedule protocol V2(backend 5.8.0)起,每一次觸發都有一個持久的 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。
offset_minutes 會相對於 cell 值平移實際觸發時刻。當 column_value + offset_minutes 早於或等於 tick 時間時,該列即到期:
| 欄位 | 型別 | 限制 | 意義 |
|---|---|---|---|
offset_minutes | integer | −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,第一次 tick 會一次爆發。 每一筆早已過期、且目前的值從未觸發過的歷史列都會立刻各產生一個 run,系統沒有 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 沒有時區概念,cell 一律以 naive UTC 解析:結尾的 Z 會被去掉,cell 內明確的 UTC offset(例如 2026-07-25T09:00+08:00)會被捨棄而不是換算,只有日期的 cell 則錨定在 00:00 UTC。因此台北租戶的「T-24h」除非自己把時差寫進 offset_minutes,否則會差一個 UTC 偏移量。只有 daily 與 cron 有 timezone 欄位。
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 維持 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 文件可以收斂。
timezone 與 daily 完全相同:可選 IANA 名稱,未設定為 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 中任何位置的
$rowtoken,以及以列為目標的 action(對$row的update_record/delete_record),都是 400cron schedules are recordless — '$row' tokens and row-targeted actions are invalid。 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 行並以不帶 id 的方式撰寫新 kind;plan 會是一個 delete 加一個 create。
同時只跑一個 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 | 最大 datetime | fire_at 永遠在未來 — 該列實質上永遠不會觸發 |
| 負 offset | 最小 datetime | 已過期,因此立刻觸發 — 與未平移的古老日期結果相同 |
offset_minutes: 0 不可能溢位,所以上面依正負號的判斷已窮盡所有情況。單一租戶的垃圾邊界日期,再也無法讓全平台的排程自動化停擺。
Schedule 上的 when
when predicate 篩的是資料列,因此只有 date_column_reached 會評估它。在 interval 或 daily schedule 上,when 在設定階段會被接受,但之後永遠不會被評估 — 因為根本沒有 record 可以評估;schedule preview 會以警告 when_ignored_for_recordless_schedule 回報這件事。在 cron 上則是儲存時直接拒絕。
Table-level run 帶 record_id: null 與空的 data snapshot,因此 notify、webhook、api_call、create_record template 裡的 $row.<col> 對 interval/daily run 會渲染成空字串。有兩種 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。
scheduler 不再從 run log 推導任何東西:trigger 的上一個視窗與已消耗的值存放在它的 state row 與 occurrence ledger,刪除 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。
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_reached | scheduler 下一次會讀的那一頁 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/cron | last_consumed_window_at(從未觸發為 null;失敗的視窗仍算已消耗)、due_at(共用視窗計算選出的邊界,沒有時為 null)、due_now,以及 active_run(目前有 pending/running 的 run 佔住這個 trigger — 此時 due_now 為 false)。 |
invalid | scheduler 會跳過的儲存 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 與每一條 trigger 路由相同的 CustomTableModeratorRequired:表不存在或已在垃圾桶回 404,不同租戶或無權限回 403。
輪詢到終端狀態
run 列存在不等於 run 已結束。生命週期是 pending → running → done 或 failed,而終端狀態只有 done 與 failed:
| 狀態 | 意義 |
|---|---|
pending | 已入列、尚未被取走 — 過渡狀態 |
running | worker 正在執行 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 流程。