Delay、Trigger、Context 與狀態保存
時間會讓訊息累積,狀態會跨過多則訊息;兩者都需要明確上限與重啟語意。你將用 Delay 控制節奏、Trigger 建立可取消的時間窗,並在 node/flow/global context 中只保存必要且有界的資料。
為何需要本章
「偵測到動作後兩分鐘關燈」同時包含計時、重複事件、取消與重啟問題;「今天已通知幾次」則包含狀態範圍與保存問題。若只是接一個 Delay,每次動作都可能排入一則日後訊息;若把每筆事件都 push 到 global array,記憶體會持續增加。可靠設計應先問:同一房間只需要一個倒數嗎?新事件應重設、排隊還是丟棄?重啟後應恢復、重新計算還是安全終止?
本章依 Node-RED 5.0.2 的 Delay、Trigger 與執行環境 context 實作。所有手動測試只接 Debug,不接 HA Action;即使文字提到燈具或通知,也只是設計情境。第 12 章與第 13 章才會把時間與事件節點放進受控的 HA 流程。
localfilesystem context,也不會自動保存節點內正在倒數的 timer。核心觀念
- Delay:可固定/動態/隨機延遲,或限制訊息速率。排隊、丟棄與第二輸出等模式是不同政策;等待中的訊息占用資源。
- Trigger:可先送一個值,等待後送另一個值;新訊息可延長倒數,也能按全部或指定 msg property 分開維護 timer,並可 reset。
- node context:只屬於單一節點 instance,適合該節點的計數、上次值或小型狀態。
- flow context:同一 flow tab 內節點共享,適合一條自動化的協作狀態。Subflow 與 parent scope 有額外邊界,留到第 17 章。
- global context:所有 flow 可見,耦合與外洩面最大;只在真正跨 flow 且有明確 owner 時使用。
- context store:範圍回答「誰看得到」,store 回答「存在哪裡」。memory 與
localfilesystem不應混為一談。
Context 是 key/value 狀態,不是事件資料庫。保存「目前連續失敗次數」通常合理;保存所有感測歷史、完整 state object、HTTP req/res 或 access token 不合理。需要歷史時使用有保留政策的外部資料系統,且限制查詢與欄位。
手動驗證 Context 計數範例
- 檢查範例邊界。
閱讀 03-context-counter.json。它只有 Inject、Change、Debug;Change 用 JSONata 更新 flow context 的
count,再把值放到msg.payload。 - 匯入獨立測試 tab。
確認沒有 HA server、網路節點、credential 或自動排程後才匯入。保持 Inject 的 repeat 空白,只用按鈕手動觸發。
- 驗證第一次與連續觸發。
第一次應輸出 1,接著為 2、3。這證明同一 flow context 在多則訊息間可讀寫,不證明它能跨重啟保留。
- 測試獨立範圍。
複製整個 Change/Debug 到另一個 tab,重新命名 key 或保持相同 key,觀察 flow scope 不跨 tab 共享。若改成 global,先理解它會擴大可見範圍,再於測試後刪除 key。
- 執行重啟契約測試。
先記錄目前 count,再以你環境核准的維護流程重新啟動 Node-RED。若未配置 durable store,預期 memory 值消失;若已配置 named store,仍要實測其行為,不要只看欄位名稱推定。
JSONata expression 與 Change 規則都可能同時讀寫 context;多人或高頻並行輸入時,read-modify-write 的競爭需要額外設計。本範例是單一手動 Inject,不宣稱可當高併發計數器。
Delay 與 Trigger:排隊或重設必須二選一
Delay 5.0.2 runtime 具有固定延遲、以 msg.delay 覆寫的動態延遲、隨機延遲與多種 rate limit 路徑。delay 模式可接收帶 reset 的訊息清除等待,也可用 flush 送出等待訊息;rate 模式可 queue 或 drop,並受 runtime nodeMaxMessageBufferLength 約束。這些是控制介面,不應讓不可信輸入任意設定 delay/rate/reset/flush。
例如通知節流可選「每分鐘送一則,其餘排隊」或「只保留最新概念而丟棄中間更新」。排隊適合每則都重要且總量有上限的任務;drop 適合可被下一個值取代的遙測。緊急告警不應因共用 Delay queue 被延後。rate limit 只能控制節奏,不代表下游成功,也不能取代 retry/backoff。
Trigger 5.0.2 可按所有訊息共用一個 timer,或按指定 msg property(常見為 topic)建立多個 timer。延長 delay 開啟後,新訊息會重新開始等待;msg.reset 可取消相應 timer。這更接近「最後一次動作後才送出關閉候選」,但真正關燈前仍應重新查狀態並檢查 occupancy,而不是把兩分鐘前的訊息當成現在事實。
| 需求 | 較適合 | 必要保護 |
|---|---|---|
| 每則都延後相同時間 | Delay | 限制排隊長度與輸入速率 |
| 控制 API 呼叫節奏 | Delay rate limit | timeout、錯誤分支與 queue 政策 |
| 最後一則之後才輸出 | Trigger + extend | 按房間分 topic、提供 reset |
| 立即送開始、稍後送結束 | Trigger | 定義重複輸入與重啟語意 |
按 topic 分 timer 時,topic 必須有界。若攻擊者或異常來源可持續產生新 topic,Trigger 的 topic map 會持續增加 pending timers。先用 Switch allowlist 或把未知來源映射到固定隔離 topic。
node、flow、global:選最小可見範圍
範圍選擇可用一句話判斷:「哪些節點需要讀它?」只有目前節點就用 node;同一 tab 內協作才用 flow;跨 tab 且有明確共同資料才考慮 global。越大的範圍越難追蹤 owner、初始化與刪除時機,也越容易被不相關 flow 覆寫。
| 狀態 | 建議 scope | 理由 | 上限/清理 |
|---|---|---|---|
| 某節點的連續異常次數 | node | 不需分享 | 設定最大值,恢復時歸零 |
| 同一房間 flow 的通知抑制旗標 | flow | 幾個節點協作 | 布林或小型 timestamp,明確 reset |
| 全站維護模式 | global(審慎) | 多 flow 需要讀 | 單一 owner、預設安全值、變更紀錄 |
| 所有感測歷史 | 不放 context | 無界且查詢需求不同 | 使用受管資料系統與保留政策 |
命名應包含用途而非環境秘密,例如 notificationCount、maintenanceMode。避免籠統的 data、temp,也不要把住址、使用者姓名、device ID 放進 key。刪除 flow 或搬移節點可能改變 node/flow scope 身分;升級與重構後要重跑初始化測試。
在 Change typed input 或 Function API 中可指定 named store;如果指定不存在的 store,runtime 會記錄 unknown store 並回退到預設 store。這種回退不應被視為成功的 durability,部署前應確認 settings 與 runtime log,並驗證實際重啟結果。
memory 與 localfilesystem 的耐久性邊界
Node-RED 5.0.2 runtime 在沒有配置 contextStorage plugin 時使用 memory store。memory 快且適合暫態值,process 重新啟動後不能期待保留。要使用 localfilesystem,必須在 runtime settings 明確配置;本章不直接修改 Add-on 共用 settings,因為設定錯誤會影響所有 flow,應先備份並在維護時段審查。
localfilesystem 預設啟用 memory cache;固定原始碼的預設 flushInterval 是 30 秒,意義是「兩次磁碟寫入的最小間隔」,用來降低底層儲存磨耗。set 會先改 cache、標記 pending write,再由 timer flush;正常 close 會嘗試 flush。由此可知,每次 assignment 並不是立即 durable。突然斷電或 process crash 可能遺失尚未 flush 的近期更新。
磁碟保存也不是資料庫交易或備份。序列化遇到 circular reference(循環參照)會記錄警告;即時物件、函式與不適合 JSON 的值不應存入。底層空間滿、權限、檔案損壞與備份還原都可能失敗。對安全關鍵狀態,應讓 Home Assistant 或專用資料服務成為權威來源,流程啟動時重新查證,而非盲信舊 context。
// 概念設定片段,僅供審查;不要未備份就套用
contextStorage: {
default: "memoryOnly",
memoryOnly: { module: "memory" },
durable: { module: "localfilesystem" }
}
若環境核准這類設定,可把高頻暫態計數留在 memory,少量確需跨重啟的小型狀態指定 durable store。修改後應檢查 runtime 啟動 log、Context sidebar 的 store,以及「寫入 → 等待足夠 flush → 正常重啟 → 驗證」和「非正常中斷不保證」兩種契約。不要降低 flush interval 來假裝零遺失;那會增加寫入頻率,仍沒有交易保證。
有界狀態、啟動政策與手動測試矩陣
每個長時間 flow 都要有四個上限:最大 pending 訊息數、最大 timer/topic 數、context value 最大大小、狀態最長存活時間。上限要在輸入端 enforce,而不是只靠主機記憶體。若超限,送到 Catch/Status/診斷分支,並在清除 payload 後記錄計數,不要把完整家庭事件寫入 log。
| 測試 | 操作 | 應觀察 |
|---|---|---|
| 單次 | 手動 Inject 一則 | 起始值、等待時間、結束值 |
| 重複 | 等待期間再 Inject | 是排隊、drop 還是 extend |
| 取消 | 送固定 topic 的 reset | 只清除預期 timer,沒有結束輸出 |
| 並行 | 兩個固定 topic 交錯 | timer 是否隔離,topic map 有界 |
| 重啟 | 倒數期間核准重啟 | pending timer 不假定恢復;啟動後走安全狀態 |
| context | 寫入後重啟 | memory 消失;configured file store 依契約驗證 |
| 超量 | 以小型、受控 burst 測試上限 | 可觀察地拒絕,不造成無界 queue |
以玄關照明為例,安全啟動政策可以是「重啟後不補送舊的關燈指令;等待下一次事件,再查目前 occupancy 與燈狀態」。以通知計數為例,可以在每日邊界重設且設最大值,而不是無限制加一。夏令時間、時區與排程留到 HA time 節點章節;Delay 的毫秒等待不應被寫成日曆排程。
部署前使用 disabled Action 或純 Debug 模擬。測試通過後,任何真正有副作用的下游節點都要再加狀態查詢、allowlist、錯誤處理與人工復原方式。不要用大量 Inject 壓測正式 Add-on;資源測試應在隔離環境且有停止條件。
故障排除
- 每次動作都晚一點觸發,累積很多次:你可能用 Delay 排隊,需求其實是 Trigger extend。先切斷副作用輸出、清除 queue,再用手動重複案例驗證重設語意。
- Trigger reset 沒有效果:檢查是否按 topic 分流,以及 reset 訊息的 property 是否與原訊息完全一致。不要讓未知 topic 任意建立 timer。
- 重啟後 count 歸零:確認目前 store;未配置 contextStorage 時是 memory。若需求確實要跨重啟,經備份與審查後配置 named localfilesystem store,再做重啟測試。
- 剛寫入 file store,斷電後仍遺失:這符合 cache/flush 邊界;assignment 不是立即磁碟 durable。不要承諾零遺失,改由權威系統重建安全關鍵狀態。
- Context sidebar 看得到但 Function 讀不到:確認 node/flow/global scope、tab、key 與 named store 是否相同;檢查 runtime 的 unknown-store warning。
- 記憶體或儲存持續增長:檢查 Delay queue、Trigger topic、Join pending 與 context array。停止來源,設定上限與 TTL,清理前先備份必要的去識別診斷資料。
固定來源
- Node-RED 5.0.2 固定提交 61bd08d:
core/function/89-delay.*、89-trigger.*、@node-red/runtime/lib/nodes/context。 - Node-RED 官方文件:Working with context。
- Node-RED 官方文件:Saving context data to the file system。
- 本站安全 Flow:03-context-counter.json。
設定檔案儲存後,請依「寫入 → 等待 flush → 正常重啟 → 驗證」的順序測試;突然中斷仍可能遺失尚未寫入磁碟的近期更新。
常見問題
短時間內重複事件造成多次通知,應先用 Delay 還是 Trigger?
Subflow 內的 flow.get("exampleKey") 會讀到 parent tab 嗎?
flow.get("$parent.exampleKey") 存取 parent flow context,並記錄 owner、預設值與清理政策;否則保留在 Subflow 內,避免隱藏耦合。完整架構留到第 17 章。